agent: the managed daemon's subcommand helpers are no stale Codex copy (#1461)

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 <sub>
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).
This commit is contained in:
ggbdpq authored and yetone committed 2026-10-10 14:07:55 +08:00
1 parent c98160c045
commit 74c48ac060
2 files changed
+71 -3

No files matched your search

+43
View File
@@ -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)
}
}
+28 -3
View File
@@ -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 <sub> (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)