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.
123 lines
4.4 KiB
Go
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}}
|
|
}
|