Skip to content

Commit 2babb87

Browse files
author
SqlRush
committed
Add daemon detached start control
1 parent c71d46d commit 2babb87

5 files changed

Lines changed: 287 additions & 11 deletions

File tree

cmd/claude/main.go

Lines changed: 171 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"io"
1010
"net/http"
1111
"os"
12+
"os/exec"
1213
"path/filepath"
1314
"sort"
1415
"strings"
@@ -65,10 +66,20 @@ type daemonOptions struct {
6566
}
6667

6768
type daemonControlOptions struct {
68-
StatePath string
69-
Status bool
70-
Stop bool
71-
Tick bool
69+
StatePath string
70+
Status bool
71+
Stop bool
72+
Tick bool
73+
Start bool
74+
Restart bool
75+
HeartbeatInterval time.Duration
76+
}
77+
78+
type daemonProcessStartOptions struct {
79+
WorkingDirectory string
80+
SessionID contracts.ID
81+
StatePath string
82+
Heartbeat time.Duration
7283
}
7384

7485
type repeatedStringFlag []string
@@ -95,6 +106,9 @@ func run(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int
95106
daemonStatus := flags.Bool("daemon-status", false, "print daemon status and exit")
96107
daemonStop := flags.Bool("daemon-stop", false, "stop the daemon recorded in daemon state")
97108
daemonTick := flags.Bool("daemon-tick", false, "trigger one daemon schedule tick")
109+
daemonStart := flags.Bool("daemon-start", false, "start daemon in the background")
110+
daemonRestart := flags.Bool("daemon-restart", false, "restart daemon in the background")
111+
daemonSession := flags.String("daemon-session", "", "daemon session id")
98112
daemonStatePath := flags.String("daemon-state", "", "daemon state path")
99113
cwd := flags.String("cwd", "", "working directory")
100114
printMode := flags.Bool("print", false, "print response and exit")
@@ -154,12 +168,18 @@ func run(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int
154168
fmt.Fprintf(stderr, "ccgo: %v\n", err)
155169
return 1
156170
}
157-
if *daemonStatus || *daemonStop || *daemonTick {
171+
if strings.TrimSpace(*daemonSession) != "" {
172+
state.SetSessionID(contracts.ID(strings.TrimSpace(*daemonSession)))
173+
}
174+
if *daemonStatus || *daemonStop || *daemonTick || *daemonStart || *daemonRestart {
158175
return runDaemonControl(context.Background(), state, daemonControlOptions{
159-
StatePath: strings.TrimSpace(*daemonStatePath),
160-
Status: *daemonStatus,
161-
Stop: *daemonStop,
162-
Tick: *daemonTick,
176+
StatePath: strings.TrimSpace(*daemonStatePath),
177+
Status: *daemonStatus,
178+
Stop: *daemonStop,
179+
Tick: *daemonTick,
180+
Start: *daemonStart,
181+
Restart: *daemonRestart,
182+
HeartbeatInterval: *daemonHeartbeat,
163183
}, stdout, stderr)
164184
}
165185
if *daemonMode || *daemonOnce {
@@ -265,9 +285,11 @@ func runChromeNativeHost(stdin io.Reader, stdout io.Writer, stderr io.Writer) in
265285
}
266286
}
267287

288+
var startDaemonProcess = startDaemonProcessDefault
289+
268290
func runDaemonControl(ctx context.Context, state *bootstrap.State, options daemonControlOptions, stdout io.Writer, stderr io.Writer) int {
269291
actionCount := 0
270-
for _, enabled := range []bool{options.Status, options.Stop, options.Tick} {
292+
for _, enabled := range []bool{options.Status, options.Stop, options.Tick, options.Start, options.Restart} {
271293
if enabled {
272294
actionCount++
273295
}
@@ -276,6 +298,13 @@ func runDaemonControl(ctx context.Context, state *bootstrap.State, options daemo
276298
fmt.Fprintf(stderr, "ccgo daemon: daemon control actions are mutually exclusive\n")
277299
return 2
278300
}
301+
if options.Start || options.Restart {
302+
if err := startOrRestartDaemon(ctx, state, options, stdout); err != nil {
303+
fmt.Fprintf(stderr, "ccgo daemon: %v\n", err)
304+
return 1
305+
}
306+
return 0
307+
}
279308
statePath, err := resolveDaemonStatePath(state, options.StatePath)
280309
if err != nil {
281310
fmt.Fprintf(stderr, "ccgo daemon: %v\n", err)
@@ -306,6 +335,138 @@ func runDaemonControl(ctx context.Context, state *bootstrap.State, options daemo
306335
return 2
307336
}
308337

338+
func startOrRestartDaemon(ctx context.Context, state *bootstrap.State, options daemonControlOptions, stdout io.Writer) error {
339+
if options.HeartbeatInterval <= 0 {
340+
return errors.New("daemon heartbeat interval must be positive")
341+
}
342+
runner, err := state.ConversationRunner()
343+
if err != nil {
344+
return err
345+
}
346+
if runner.SessionPath == "" && runner.SessionID != "" {
347+
runner.SessionPath = session.TranscriptPath(runner.WorkingDirectory, runner.SessionID)
348+
}
349+
existingPath, err := resolveDaemonStatePath(state, options.StatePath)
350+
if err != nil {
351+
return err
352+
}
353+
existingState, err := daemonpkg.LoadState(existingPath)
354+
if err != nil {
355+
return err
356+
}
357+
existingRuntime := daemonpkg.RuntimeStateAt(existingState, time.Now().UTC(), 2*time.Minute)
358+
if options.Restart && existingState.GeneratedAt != "" && existingRuntime == daemonpkg.RuntimeRunning && strings.TrimSpace(existingState.Endpoint) != "" {
359+
if err := stopDaemon(ctx, io.Discard, existingPath); err != nil {
360+
return err
361+
}
362+
} else if options.Start && existingRuntime == daemonpkg.RuntimeRunning && existingState.GeneratedAt != "" {
363+
return writeAlreadyRunningDaemon(stdout, existingPath, existingState)
364+
}
365+
366+
sessionID := contracts.NewID()
367+
sessionPath := session.TranscriptPath(runner.WorkingDirectory, sessionID)
368+
statePath := daemonpkg.SessionStatePath(sessionPath, sessionID)
369+
pid, err := startDaemonProcess(ctx, daemonProcessStartOptions{
370+
WorkingDirectory: runner.WorkingDirectory,
371+
SessionID: sessionID,
372+
StatePath: statePath,
373+
Heartbeat: options.HeartbeatInterval,
374+
})
375+
if err != nil {
376+
return err
377+
}
378+
startCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
379+
defer cancel()
380+
started := waitForDaemonRuntimeState(startCtx, statePath, daemonpkg.RuntimeRunning)
381+
if started.RuntimeState != daemonpkg.RuntimeRunning {
382+
return fmt.Errorf("daemon start did not report running state: %s", statePath)
383+
}
384+
lines := []string{
385+
"ccgo daemon started",
386+
"session_id=" + string(sessionID),
387+
"state_path=" + statePath,
388+
"runtime_state=" + daemonpkg.RuntimeRunning,
389+
}
390+
if started.PID > 0 {
391+
lines = append(lines, fmt.Sprintf("pid=%d", started.PID))
392+
} else if pid > 0 {
393+
lines = append(lines, fmt.Sprintf("pid=%d", pid))
394+
}
395+
if started.Endpoint != "" {
396+
lines = append(lines, "endpoint="+started.Endpoint)
397+
}
398+
if started.GeneratedAt != "" {
399+
lines = append(lines, "generated_at="+started.GeneratedAt)
400+
}
401+
_, err = fmt.Fprintln(stdout, strings.Join(lines, "\n"))
402+
return err
403+
}
404+
405+
func writeAlreadyRunningDaemon(stdout io.Writer, statePath string, state daemonpkg.State) error {
406+
lines := []string{
407+
"ccgo daemon already running",
408+
"state_path=" + statePath,
409+
"runtime_state=" + daemonpkg.RuntimeRunning,
410+
}
411+
if state.PID > 0 {
412+
lines = append(lines, fmt.Sprintf("pid=%d", state.PID))
413+
}
414+
if state.Endpoint != "" {
415+
lines = append(lines, "endpoint="+state.Endpoint)
416+
}
417+
_, err := fmt.Fprintln(stdout, strings.Join(lines, "\n"))
418+
return err
419+
}
420+
421+
func startDaemonProcessDefault(ctx context.Context, options daemonProcessStartOptions) (int, error) {
422+
executable, err := os.Executable()
423+
if err != nil {
424+
return 0, err
425+
}
426+
if strings.TrimSpace(options.WorkingDirectory) == "" {
427+
return 0, errors.New("daemon working directory is unavailable")
428+
}
429+
if options.SessionID == "" {
430+
return 0, errors.New("daemon session id is unavailable")
431+
}
432+
logPath := options.StatePath + ".log"
433+
if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil {
434+
return 0, err
435+
}
436+
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
437+
if err != nil {
438+
return 0, err
439+
}
440+
defer logFile.Close()
441+
args := []string{
442+
"--cwd", options.WorkingDirectory,
443+
"--daemon",
444+
"--daemon-session", string(options.SessionID),
445+
"--daemon-heartbeat", options.Heartbeat.String(),
446+
}
447+
cmd := exec.Command(executable, args...)
448+
cmd.Dir = options.WorkingDirectory
449+
cmd.Env = os.Environ()
450+
cmd.Stdin = nil
451+
cmd.Stdout = logFile
452+
cmd.Stderr = logFile
453+
if ctx != nil {
454+
select {
455+
case <-ctx.Done():
456+
return 0, ctx.Err()
457+
default:
458+
}
459+
}
460+
if err := cmd.Start(); err != nil {
461+
return 0, err
462+
}
463+
pid := cmd.Process.Pid
464+
if err := cmd.Process.Release(); err != nil {
465+
return 0, err
466+
}
467+
return pid, nil
468+
}
469+
309470
func resolveDaemonStatePath(state *bootstrap.State, explicit string) (string, error) {
310471
if strings.TrimSpace(explicit) != "" {
311472
return filepath.Clean(strings.TrimSpace(explicit)), nil

cmd/claude/main_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,110 @@ func TestRunDaemonOnceWritesState(t *testing.T) {
186186
}
187187
}
188188

189+
func TestRunDaemonOnceUsesInjectedSessionID(t *testing.T) {
190+
t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir())
191+
cwd := t.TempDir()
192+
var stdout, stderr bytes.Buffer
193+
code := run([]string{"--cwd", cwd, "--daemon-session", "sess_fixed_daemon", "--daemon-once"}, strings.NewReader(""), &stdout, &stderr)
194+
if code != 0 {
195+
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
196+
}
197+
statePath := daemonStatePathFromOutput(t, stdout.String())
198+
if !strings.Contains(statePath, "sess_fixed_daemon") {
199+
t.Fatalf("state path = %q", statePath)
200+
}
201+
state, err := daemonpkg.LoadState(statePath)
202+
if err != nil {
203+
t.Fatal(err)
204+
}
205+
if state.SessionID != "sess_fixed_daemon" {
206+
t.Fatalf("daemon state session = %q", state.SessionID)
207+
}
208+
}
209+
210+
func TestRunDaemonStartLaunchesDetachedDaemon(t *testing.T) {
211+
t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir())
212+
cwd := t.TempDir()
213+
oldStartDaemonProcess := startDaemonProcess
214+
defer func() { startDaemonProcess = oldStartDaemonProcess }()
215+
launches := 0
216+
startDaemonProcess = func(_ context.Context, options daemonProcessStartOptions) (int, error) {
217+
launches++
218+
if options.SessionID == "" || options.StatePath == "" || options.WorkingDirectory == "" {
219+
t.Fatalf("start options = %#v", options)
220+
}
221+
state := daemonpkg.BuildState(options.SessionID, options.WorkingDirectory, daemonpkg.RuntimeRunning, 4242, "http://127.0.0.1:4242", time.Now().UTC(), nil)
222+
if err := daemonpkg.WriteState(options.StatePath, state); err != nil {
223+
t.Fatal(err)
224+
}
225+
return 4242, nil
226+
}
227+
var stdout, stderr bytes.Buffer
228+
code := run([]string{"--cwd", cwd, "--daemon-start", "--daemon-heartbeat", "20ms"}, strings.NewReader(""), &stdout, &stderr)
229+
if code != 0 {
230+
t.Fatalf("daemon start exit = %d stderr=%s", code, stderr.String())
231+
}
232+
if launches != 1 || !strings.Contains(stdout.String(), "ccgo daemon started") || !strings.Contains(stdout.String(), "endpoint=http://127.0.0.1:4242") {
233+
t.Fatalf("launches=%d stdout=%q", launches, stdout.String())
234+
}
235+
var secondOut, secondErr bytes.Buffer
236+
code = run([]string{"--cwd", cwd, "--daemon-start"}, strings.NewReader(""), &secondOut, &secondErr)
237+
if code != 0 {
238+
t.Fatalf("second daemon start exit = %d stderr=%s", code, secondErr.String())
239+
}
240+
if launches != 1 || !strings.Contains(secondOut.String(), "ccgo daemon already running") {
241+
t.Fatalf("launches=%d second stdout=%q", launches, secondOut.String())
242+
}
243+
}
244+
245+
func TestRunDaemonRestartStopsRunningDaemonBeforeStart(t *testing.T) {
246+
t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir())
247+
cwd := t.TempDir()
248+
resolvedCWD, err := filepath.EvalSymlinks(cwd)
249+
if err != nil {
250+
t.Fatal(err)
251+
}
252+
oldSessionID := contracts.ID("sess_restart_old")
253+
oldStatePath := daemonpkg.SessionStatePath(session.TranscriptPath(resolvedCWD, oldSessionID), oldSessionID)
254+
var stopped bool
255+
var server *httptest.Server
256+
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
257+
if r.URL.Path != "/stop" {
258+
t.Fatalf("path = %s", r.URL.Path)
259+
}
260+
stopped = true
261+
disabled := daemonpkg.BuildState(oldSessionID, resolvedCWD, daemonpkg.RuntimeDisabled, 1111, server.URL, time.Now().UTC(), nil)
262+
if err := daemonpkg.WriteState(oldStatePath, disabled); err != nil {
263+
t.Fatal(err)
264+
}
265+
_ = json.NewEncoder(w).Encode(daemonpkg.StopResponse{OK: true, RuntimeState: daemonpkg.RuntimeDisabled})
266+
}))
267+
defer server.Close()
268+
running := daemonpkg.BuildState(oldSessionID, resolvedCWD, daemonpkg.RuntimeRunning, 1111, server.URL, time.Now().UTC(), nil)
269+
if err := daemonpkg.WriteState(oldStatePath, running); err != nil {
270+
t.Fatal(err)
271+
}
272+
oldStartDaemonProcess := startDaemonProcess
273+
defer func() { startDaemonProcess = oldStartDaemonProcess }()
274+
launches := 0
275+
startDaemonProcess = func(_ context.Context, options daemonProcessStartOptions) (int, error) {
276+
launches++
277+
state := daemonpkg.BuildState(options.SessionID, options.WorkingDirectory, daemonpkg.RuntimeRunning, 5151, "http://127.0.0.1:5151", time.Now().UTC(), nil)
278+
if err := daemonpkg.WriteState(options.StatePath, state); err != nil {
279+
t.Fatal(err)
280+
}
281+
return 5151, nil
282+
}
283+
var stdout, stderr bytes.Buffer
284+
code := run([]string{"--cwd", cwd, "--daemon-restart"}, strings.NewReader(""), &stdout, &stderr)
285+
if code != 0 {
286+
t.Fatalf("daemon restart exit = %d stderr=%s", code, stderr.String())
287+
}
288+
if !stopped || launches != 1 || !strings.Contains(stdout.String(), "ccgo daemon started") {
289+
t.Fatalf("stopped=%v launches=%d stdout=%q", stopped, launches, stdout.String())
290+
}
291+
}
292+
189293
func TestRunDaemonDueSchedulesNoopsWithoutSchedules(t *testing.T) {
190294
runner := conversation.Runner{
191295
SessionID: "sess_daemon_due",

0 commit comments

Comments
 (0)