diff --git a/internal/agent/connect_stale_test.go b/internal/agent/connect_stale_test.go index 418609f5..2f14e05f 100644 --- a/internal/agent/connect_stale_test.go +++ b/internal/agent/connect_stale_test.go @@ -128,3 +128,46 @@ func TestCodexStaleCopiesSayWhichIsLeft(t *testing.T) { t.Fatalf("Stale: %d, want 6 (one app, not one per process)", n) } } + +// lychjck's #1461: the managed daemon keeps a long-lived helper next to it +// (codex app-server daemon pid-update-loop) that a daemon restart doesn't +// end and that reads no list. It matches the codex startsWith pattern and +// no --managed-daemon, so it was counted as a "cli" copy from before +// magpie's change: "A Codex in a terminal" that doesn't exist, forever, +// and only killing the process cleared the row. A daemon helper is no +// copy to reopen. +func TestCodexDaemonHelperIsNoStaleCopy(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) + t.Setenv("CODEX_HOME", "") + dir := filepath.Join(home, ".codex") + os.MkdirAll(dir, 0o755) + cfg := filepath.Join(dir, "config.toml") + os.WriteFile(cfg, []byte("model = \"gpt-6.1-sol\"\nopenai_base_url = \"http://127.0.0.1:3425/backend-api/codex\"\n"), 0o600) + then := time.Now().Add(-2 * time.Hour) + os.Chtimes(cfg, then, then) + + was := running + t.Cleanup(func() { running = was }) + running = func(string) []runningProc { + return []runningProc{ + // the helper, from before magpie's change + {3 * time.Hour, "/opt/homebrew/bin/codex app-server daemon pid-update-loop"}, + } + } + if got := codex(home).StaleCopies(); len(got) != 0 { + t.Fatalf("the daemon's helper counted a stale copy: %+v, want none", got) + } + // a terminal Codex from before is still one + running = func(string) []runningProc { + return []runningProc{ + {3 * time.Hour, "/opt/homebrew/bin/codex"}, + } + } + if n := codex(home).Stale(); n != 1 { + t.Fatalf("a terminal codex: %d stale, want 1", n) + } +} diff --git a/internal/agent/connectinfo.go b/internal/agent/connectinfo.go index 14e35744..cca26bec 100644 --- a/internal/agent/connectinfo.go +++ b/internal/agent/connectinfo.go @@ -187,9 +187,10 @@ func (a *Agent) Stale() int { return len(a.StaleCopies()) } type StaleCopy struct { // Kind, for Codex: "app" (the ChatGPT or Codex desktop app, its // app-server and helpers), "ide" (an editor extension's), "daemon" - // (the background app-server the CLI leaves running) or "cli"; "" - // for other agents; "embedded" is another app's own Codex, named by - // App + // (the background app-server the CLI leaves running), "helper" (a + // subcommand the daemon spawns beside itself, no copy to reopen) or + // "cli"; "" for other agents; "embedded" is another app's own Codex, + // named by App Kind string `json:"kind,omitempty"` App string `json:"app,omitempty"` Since time.Time `json:"since"` @@ -236,6 +237,12 @@ func (a *Agent) StaleCopies() []StaleCopy { if a.ID == "codex" { var app string c.Kind, app = codexCopyKind(p.cmd) + if c.Kind == "helper" { + // a daemon subcommand's helper reads no list, a + // restart of the daemon doesn't end it, and there + // is no Codex to quit (#1461) + continue + } if c.Kind == "embedded" { c.App = app } @@ -273,6 +280,11 @@ func codexCopyKind(cmd string) (kind, app string) { // the outermost bundle: ChatGPT.app's codex runs from a // CodexCLI.app inside it return "app", exe[:strings.Index(exe, ".app/")+len(".app")] + case daemonHelper(cmd): + // a subcommand the managed daemon spawns beside itself (codex + // app-server daemon pid-update-loop): long-lived, reads no list, + // and outlives a daemon restart (#1461) + return "helper", "" case slices.Contains(strings.Fields(cmd), "app-server"): // an app that ships a Codex of its own and talks to its // app-server (Agents Anywhere's connector, from its folder in @@ -290,6 +302,19 @@ func codexCopyKind(cmd string) (kind, app string) { return "cli", "" } +// daemonHelper says the command line is a subcommand the managed daemon +// spawns beside itself: app-server daemon (pid-update-loop, …). The +// daemon itself carries --managed-daemon and is told apart above. +func daemonHelper(cmd string) bool { + f := strings.Fields(cmd) + for i, s := range f { + if s == "app-server" && i+2 < len(f) && f[i+1] == "daemon" { + return true + } + } + return false +} + // elapsed reads ps's etime, [[dd-]hh:]mm:ss. func elapsed(s string) (time.Duration, bool) { s = strings.TrimSpace(s)