Files
magpie/internal/agent/codex_daemon.go
T
yetone e611fc65d6 codex: magpie restarts the codex app-server a change left on the old model list when no codex session is on it, and the Agents page offers Restart when one is (luci on Discord)
luci (Codex CLI 0.162, magpie 0.1.1150): after Codex was wired back to
magpie, codex in a new terminal listed Codex's own models although
config.toml was magpie's.

Cause, reproduced with real codex 0.162.0 in a sandbox CODEX_HOME:
codex sessions attach to a background app-server (the managed daemon)
that is per CODEX_HOME, not per terminal (socket under
$CODEX_HOME/app-server-control). It is detached, has no idle exit and
builds its model list once, at start. A daemon started while Codex was
off magpie keeps the official list for every later session, in this
terminal or a new one, until it restarts. magpie writes only PATH to
shell rc files and doesn't unwire Codex on quit, so nothing
per-terminal is involved. luci's "a new terminal fixed it" isn't
explained by the daemon. Codex's hourly updater restarting it is a
guess, not proven.

What changed:
- provider.KeepCodexDaemonCurrent(ctx, home, changed) compares the
  daemon's start time with when magpie last changed what Codex reads at
  start. The start time is the mtime of a pid file in that home that
  names the running daemon's pid, so another home's daemon (ChatGPT.app,
  other sandboxes) is never acted on.
  - With no codex session attached, it runs
    `codex app-server daemon restart` with CODEX_HOME set to that home.
  - Otherwise it reports Behind and how many sessions are attached.
  - isCodexClient leaves out apps' and editors' own codex, exec-server,
    mcp-server and the daemon's own processes. It reads the whole ps line,
    since ps leaves a path's spaces unquoted ("Codex Computer
    Use.app/...").
- agent: Codex's Notice after a change runs this check first. It says
  either that magpie restarted the daemon, or that the daemon is behind
  and how to restart it (Agents page or the command).
- The GUI backend and `magpie serve` re-check every 15s. A session that
  was open at the change is usually closed later, and that is when the
  daemon can be restarted.
- Agents page: the stale "daemon" copy line has a Restart button that
  POSTs /api/codex/daemon/restart (timeout now 2 min, as a running turn
  is let finish). It reuses translated strings. On Windows, where
  processes aren't listed on each look, the row shows the last check.
- Restarts are serialized by one mutex.
- WSL's Codex is not handled automatically. Its row keeps the advice
  text, which names the restart command.

What was run:
- Real codex 0.162.0 in a sandbox CODEX_HOME, with a fake gateway, on
  luci's sequence: the daemon started on the official config, then
  magpie's config and catalog were written.
  - With a TUI open, /model listed GPT-6.x. The check said Behind with
    a session attached and did not restart.
  - With the TUI quit, the check restarted the daemon. A new codex in
    the same terminal listed Magpie Alpha and Beta.
- Earlier in the sandbox: a daemon restart under an idle attached 0.162
  TUI reconnects, and /model then shows magpie's models. A running turn
  is waited for up to 60s.
- New tests:
  - TestKeepCodexDaemonCurrent(Fails), TestIsCodexClient (with the
    space-in-path case from the real process list) and
    TestCodexNoticeKeepsTheDaemonCurrent. They don't build or fail on
    the old code. The space case failed before the ps-line fix.
  - The GUI test "the daemon's line restarts it" times out on the old
    app.js.
- Results:
  - agent-stale-copies.test.cjs: 60/60 in Chromium and WebKit, in en,
    zh, zh-TW, ja and de, at widths 980 and 440.
  - go test (temp HOME) for ./internal/agent, ./internal/provider,
    ./internal/gui and the root package: ok.
  - gofmt -l: clean.
  - GOOS=linux and GOOS=windows go vet -tags nogui: ok.
- The Windows path is checked only by vet and not tried on a Windows
  box.
2026-10-10 10:05:10 +08:00

123 lines
4.4 KiB
Go

package agent
import (
"context"
"log"
"os"
"path/filepath"
"sync"
"time"
"github.com/yetone/magpie/internal/provider"
)
// Codex's background app-server (the daemon codex sessions attach to)
// builds its model list once, as it starts, and runs on per CODEX_HOME,
// across terminals, with no idle exit. So a change magpie makes to what
// Codex reads at start — wiring it in, taking it out, a new catalog —
// reaches no codex session started after it until the daemon restarts:
// luci (Discord, Codex 0.162) took Codex off magpie, opened codex, which
// started the daemon on Codex's own models, and after wiring Codex in
// again every new codex showed the official list. With no codex session
// attached magpie restarts it itself, right after the change and every
// so often after (a session open then is one the user closes later);
// with one attached the Agents page offers the restart.
// codexRestartAll is what is said after a change while copies of Codex
// run that magpie leaves to the user.
const codexRestartAll = "Codex builds its model list at start-up — restart the Codex app, open codex sessions and the app-server they share (" + provider.CodexDaemonRestart + ") to see this."
// keepCodexDaemon is provider.KeepCodexDaemonCurrent; a var so tests can
// see what is asked of it without a codex.
var keepCodexDaemon = provider.KeepCodexDaemonCurrent
// codexDaemonSeen is the last check of this machine's daemon, for the
// Agents row where its processes aren't listed on each look (Windows,
// where listing them is a PowerShell run).
var codexDaemonSeen struct {
sync.Mutex
at time.Time
check provider.CodexDaemonCheck
}
// codexDaemonCheck checks the daemon of the Codex whose config is path
// against when magpie last changed what Codex reads at start, and
// restarts it when no codex session is on it.
func codexDaemonCheck(path string) provider.CodexDaemonCheck {
a := &Agent{ID: "codex", Path: path}
_, files, reads := a.startsWith()
changed := a.changedAt(files, reads)
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
c := keepCodexDaemon(ctx, filepath.Dir(path), changed)
if c.Err != nil {
log.Println("codex app-server:", c.Err)
}
codexDaemonSeen.Lock()
codexDaemonSeen.at, codexDaemonSeen.check = time.Now(), c
codexDaemonSeen.Unlock()
return c
}
// codexDaemonNotice is what is said of the daemon after a change: ok is
// false when there is none behind it.
func codexDaemonNotice(path string) (string, bool) {
c := codexDaemonCheck(path)
switch {
case c.Restarted:
return "magpie restarted the app-server codex sessions share, which none was on, so new codex sessions have this; restart the Codex app, if it is open, to see it there.", true
case c.Behind && c.Attached > 0:
return "Codex builds its model list at start-up, and the app-server open codex sessions share started before this: restart it (Restart on magpie's Agents page, or " + provider.CodexDaemonRestart + ") and the Codex app to see this.", true
}
return "", false
}
// codexDaemonEvery is how often KeepCodexDaemonCurrent looks.
const codexDaemonEvery = 15 * time.Second
// KeepCodexDaemonCurrent restarts this machine's Codex daemon whenever it
// is behind what Codex reads at start and no codex session is on it,
// looking every so often until ctx ends.
func KeepCodexDaemonCurrent(ctx context.Context) {
home, err := os.UserHomeDir()
if err != nil {
return
}
path := filepath.Join(home, ".codex", "config.toml")
t := time.NewTicker(codexDaemonEvery)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
}
if _, err := os.Stat(path); err == nil {
if c := codexDaemonCheck(path); c.Restarted {
log.Println("codex app-server: restarted, it had the model list from before", c.Since.Format(time.RFC3339))
}
}
}
}
// CodexDaemonRestarted forgets the daemon last found behind, once the user
// has restarted it.
func CodexDaemonRestarted() {
codexDaemonSeen.Lock()
codexDaemonSeen.check = provider.CodexDaemonCheck{}
codexDaemonSeen.Unlock()
}
// codexDaemonBehind is the daemon as last checked, while that check is
// recent: a copy left on the old list, for StaleCopies where processes
// aren't listed on each look.
func codexDaemonBehind() []StaleCopy {
codexDaemonSeen.Lock()
defer codexDaemonSeen.Unlock()
c := codexDaemonSeen.check
if !c.Behind || c.Restarted || time.Since(codexDaemonSeen.at) > 3*codexDaemonEvery {
return nil
}
return []StaleCopy{{Kind: "daemon", Since: c.Since}}
}