From 74c48ac0604d27ac679a531913d30367a52873e5 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Sat, 10 Oct 2026 13:44:57 +0800 Subject: [PATCH] agent: the managed daemon's subcommand helpers are no stale Codex copy (#1461) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex keeps a long-lived helper next to the managed app-server (codex app-server daemon pid-update-loop). It matches the codex startsWith pattern, carries no --managed-daemon, sits under no app bundle or Application Support folder, so codexCopyKind called it "cli": a helper that started before magpie's last change to what Codex reads counted as "A Codex in a terminal" forever, a daemon restart didn't end it, and the only way to clear the row was killing a process the user can't name. codexCopyKind now answers "helper" for an app-server daemon command line, and StaleCopies skips it: it reads no list and there is no Codex to quit. The daemon itself (--managed-daemon) is told apart before, and a terminal Codex still counts. Red/green: TestCodexDaemonHelperIsNoStaleCopy runs the helper alone from before magpie's change — FAIL on the old code (one stale "cli" copy), passes now; a terminal codex from before still counts 1. Generated-by: GLM-5.3-Flash (ZCode) Reviewed and landed by the maintainer (cherry-pick of b40a1dd0 from ggbdpq:fix/codex-daemon-helper-1461, lychjck's report). Run on main at c98160c0 with this commit on top, macOS arm64: - On this Mac (ps, read only): the installed codex-cli 0.162.0 binary has the subcommand `app-server daemon pid-update-loop` ("[internal] Run the detached pid-backed standalone updater loop"): an updater, it reads no model list. The managed daemons running here are `codex app-server --listen unix:// --managed-daemon` and stay "daemon". No pid-update-loop helper was running on this Mac at review time, so the reporter's live row was not reproduced here; the fixture is the command line from the report. provider's isManagedDaemon already names the same helper as no daemon. - TestCodexDaemonHelperIsNoStaleCopy with main's connectinfo.go: FAIL, "the daemon's helper counted a stale copy: [{Kind:cli ...}], want none"; passes with the change. - gofmt -l: clean. go vet -tags nogui ./internal/agent for darwin, linux and windows; GOOS=linux go vet -tags nogui ./... and GOOS=windows go vet ./...: clean. go build, GOOS=linux go build -tags nogui, GOOS=windows go build: ok. - go test -tags nogui -count=1 ./... under a scratch HOME with GOPATH, GOMODCACHE and GOCACHE pinned: 44 packages ok, none failed. - Not run: GUI tests (no GUI change), Windows or Linux hardware (the stale-copy check doesn't run on Windows; Linux uses the same ps path). --- internal/agent/connect_stale_test.go | 43 ++++++++++++++++++++++++++++ internal/agent/connectinfo.go | 31 ++++++++++++++++++-- 2 files changed, 71 insertions(+), 3 deletions(-) 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)