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.
139 lines
4.3 KiB
Go
139 lines
4.3 KiB
Go
package gui
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/yetone/magpie/internal/agent"
|
|
"github.com/yetone/magpie/internal/catalog"
|
|
"github.com/yetone/magpie/internal/gateway"
|
|
"github.com/yetone/magpie/internal/provider"
|
|
"github.com/yetone/magpie/internal/update"
|
|
)
|
|
|
|
// served is the gateway this process serves, nil while another magpie
|
|
// has it.
|
|
var served atomic.Pointer[gateway.Server]
|
|
|
|
// backendCtx ends the gateway this process serves (stopServing).
|
|
var backendCtx, endBackend = context.WithCancel(context.Background())
|
|
|
|
// serving is the gateway this process serves, until it has finished.
|
|
var serving sync.WaitGroup
|
|
|
|
// stopServing stops this process's gateway taking requests and returns
|
|
// once those in flight have finished.
|
|
func stopServing() {
|
|
endBackend()
|
|
serving.Wait()
|
|
}
|
|
|
|
// startBackend starts what serves the page and the agents: the gateway,
|
|
// unless another magpie has it (then that one serves and this one only
|
|
// shows its status, and gw is nil — until that one is gone: a magpie left
|
|
// running from before an update, a magpie serve in a terminal), and the
|
|
// model lists kept warm.
|
|
func startBackend() (gw *gateway.Server) {
|
|
gateway.Window = true // the routing this process serves is shown on its page
|
|
gw = serveGateway()
|
|
go watchGateway(backendCtx, gatewayWatch)
|
|
// Codex's background app-server, restarted when it has the list from
|
|
// before a change and no codex session is on it (luci)
|
|
go agent.KeepCodexDaemonCurrent(backendCtx)
|
|
// Model lists are fetched, never compiled in: whatever the agents can see
|
|
// comes from the models.dev catalog plus each vendor's own /models answer.
|
|
// Keep both halves warm without making the user click anything.
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
if catalog.Stale() {
|
|
if err := catalog.Sync(ctx); err != nil {
|
|
log.Println("catalog:", err)
|
|
}
|
|
}
|
|
cancel()
|
|
// A signed-in agent's list exists only at the vendor; fill it in the
|
|
// first time so the picker never shows a stale snapshot.
|
|
provider.FetchNew(20 * time.Second)
|
|
// lists an older magpie wrote into agents' files, without what
|
|
// it has learnt since (context windows, providers added)
|
|
agent.SyncCatalog()
|
|
// and fetched again each day it stays open, for new models' prices
|
|
catalog.KeepFresh()
|
|
}()
|
|
return gw
|
|
}
|
|
|
|
// gatewayWatch is how often a magpie without the gateway looks for it gone.
|
|
const gatewayWatch = 15 * time.Second
|
|
|
|
// watchGateway takes the gateway up once the magpie that had it is gone,
|
|
// looking every so often until ctx ends: a watch that outlived it would
|
|
// start a gateway after this one has stopped serving.
|
|
func watchGateway(ctx context.Context, every time.Duration) {
|
|
t := time.NewTicker(every)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
}
|
|
gatewayMu.Lock()
|
|
if ctx.Err() == nil && served.Load() == nil && !gateway.Running() {
|
|
serveGatewayLocked()
|
|
}
|
|
gatewayMu.Unlock()
|
|
}
|
|
}
|
|
|
|
// gatewayMu keeps one start of the gateway here at a time: the watch's,
|
|
// a restart's, a take-over's (takeover.go).
|
|
var gatewayMu sync.Mutex
|
|
|
|
// servedRun ends the gateway this process serves, and is closed once it
|
|
// has, for a restart; under gatewayMu.
|
|
var servedRun struct {
|
|
stop context.CancelFunc
|
|
done chan struct{}
|
|
}
|
|
|
|
// serveGateway starts the gateway here when no magpie has it: the one
|
|
// started, or nil.
|
|
func serveGateway() *gateway.Server {
|
|
gatewayMu.Lock()
|
|
defer gatewayMu.Unlock()
|
|
return serveGatewayLocked()
|
|
}
|
|
|
|
func serveGatewayLocked() *gateway.Server {
|
|
// handing over, the one there is this one's predecessor, which lets go
|
|
// once this one listens beside it
|
|
if !gateway.Handover {
|
|
if o := gateway.ServedBy(); o.Running {
|
|
if update.Newer(gateway.Version, o.Version) {
|
|
log.Printf("gateway: magpie %s serves %s, older than this one (%s); agents' requests go through it until it quits", o.Version, gateway.URL(), gateway.Version)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
gw := gateway.New()
|
|
served.Store(gw)
|
|
serving.Add(1)
|
|
ctx, stop := context.WithCancel(backendCtx)
|
|
done := make(chan struct{})
|
|
servedRun.stop, servedRun.done = stop, done
|
|
go func() {
|
|
defer serving.Done()
|
|
defer close(done)
|
|
defer stop()
|
|
if err := gw.ListenAndServe(ctx); err != nil {
|
|
log.Println("gateway:", err)
|
|
served.CompareAndSwap(gw, nil) // another took the port first
|
|
}
|
|
}()
|
|
return gw
|
|
}
|