Skip to content

feat(daemon): surface daemon upgrade skew and staleness to CLI clients - #611

Open
madeinoz67 wants to merge 9 commits into
zzet:mainfrom
madeinoz67:feat/daemon-upgrade-skew-609
Open

feat(daemon): surface daemon upgrade skew and staleness to CLI clients#611
madeinoz67 wants to merge 9 commits into
zzet:mainfrom
madeinoz67:feat/daemon-upgrade-skew-609

Conversation

@madeinoz67

@madeinoz67 madeinoz67 commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #609. When the homebrew cask upgrades gortex, a long-running daemon keeps serving from the old binary and every CLI invocation silently talks to the version it was just upgraded past. docs/versioning.md already promises that "the daemon exposes its running version in the handshake ACK as DaemonVersion and on the control surface's status response — clients can feature-gate or warn on mismatch" — this PR implements the client half of that documented intent: the MCP proxy now warns on mismatch, and daemon status shows both the CLI-vs-daemon version skew and the daemon's own on-disk-binary drift.

Three surfaces, one compare each:

  • MCP proxy connect-time warning (cmd/gortex/proxy.go): daemonSkewWarning(daemonVer, localVer) compares the handshake-reported daemon version against canonicalVersion() and prints one stderr line through the existing [gortex mcp] prefix — warn-and-continue, never gate. The remedy is direction-aware: daemon older ⇒ run 'gortex daemon restart' to upgrade the daemon (a restart respawns from the newer on-disk binary); binary older ⇒ run 'gortex upgrade' — it upgrades the way gortex was installed (brew, scoop, go install, or the install script) and restarts the daemon around the swap itself; unparseable/same-precedence-different-build ⇒ the generic restart-or-upgrade remedy. Dev builds stay silent on either side (v0.0.0-dev as the local OR the daemon identity) so plain go build runs — CLI or daemon — never nag or advise "upgrading" a dev process.
  • Daemon staleness self-report (internal/daemon/server.go): the daemon captures its own executable's path/size/mtime at startup and stats it again on every ControlStatus, populating additive StatusResponse fields (BinaryChecked/BinaryStale/BinaryReplacedAtUnix). A stat failure or an uncaptured identity reports unknown (both flags false) — unknown is never rendered as fresh. The on-disk binary changed warning is logged exactly once per detection (once-flag), so polling status --watch cannot spam the daemon log. Detection covers both hand-started and launchd daemons: it derives from the kernel's view of the running image (os.Executable), not from any launchd integration.
  • Status rendering (cmd/gortex/daemon.go): two conditional rows in the header table — cli (differs from daemon) when the same skew compare fires, and binary stale — on-disk image newer than running image; run 'gortex daemon restart' when the daemon reports drift. Matching versions and fresh binaries keep the table exactly as terse as before.
  • Doctor (cmd/gortex/doctor.go, review follow-up): the handshake row downgrades from ✓ to ! and prints the same remedy line — computed by the same daemonSkewWarning, so doctor, daemon status, and the MCP proxy give one verdict. DoctorEnvironment carries cli_version/version_skew_warning for --json consumers.

Changes

  • cmd/gortex/proxy.godaemonSkewWarning + connect-time stderr warning in the proxy path; direction-aware remedies (gortex upgrade, never package-manager-specific); dev-build silence.
  • internal/version/compare.go (review follow-up) — Compare orders two Versions by SemVer 2.0.0 precedence (build metadata ignored); fills a package gap — internal/version parsed semver but could not order it. The §11 precedence chain is pinned in compare_test.go.
  • internal/daemon/server.go, internal/daemon/proto.go — startup binary-identity capture, per-status drift probe, log-once warning, additive StatusResponse fields (no protocol-version bump; older clients ignore the new fields).
  • cmd/gortex/daemon.go — conditional cli and binary rows in renderDaemonHeader; cmd/gortex/daemon_status_render_test.go pins both rows and their negative cases.
  • cmd/gortex/doctor.go — skew-aware handshake row + DoctorEnvironment fields; cmd/gortex/doctor_env_test.go pins the ✓/! rows.

Testing

  • All tests pass (go test -race ./...) — 140 ok / 0 data races; six non-green packages, every one dispositioned against clean main at b55b9a0d (throwaway-worktree reruns) or by isolation re-run, none a regression from this PR: TestFormatModelCostSeparatesUnpricedFromZero (cmd/gortex) and TestCostAvoidedAll_IncludesDefaults (internal/savings) both fail identically on main (stale pricing-table rows); TestUninstallHookPostCommit_RemovesFileWhenStubOnly (internal/githooks) fails identically on main; internal/agents/opencode + internal/gitcmd are load flakes that pass on this branch in isolation and on main; internal/graph/store_sqlite timed out at 606s only under the pegged full-suite run; TestRouter_ReloadConfig_ConcurrentNoRace (internal/daemon) hangs deterministically on this host, identically on main (workers stuck in ServerClient.ProxyToolCtx HTTP getConn; skipped via -skip; needs its own issue).
  • New tests added for new functionality:
    • TestDaemonSkewWarning — 7 subtests: equal, daemon-older, daemon-newer, same-version-different-build, unparseable daemon version, empty daemon version, dev build.
    • TestComparePrecedence (internal/version) — the semver.org §11 chain (alpha < alpha.1 < alpha.beta < beta < beta.2 < beta.11 < rc.1 < release) pinned pairwise against Compare, plus build-metadata-ignored and numeric-vs-alphanumeric rows.
    • TestRenderDaemonHeader_SkewRow — 3 subtests: skewed appends the cli row, matching omits it, dev build omits it.
    • TestRenderDaemonHeader_BinaryRow — 3 subtests: stale appends the binary row, fresh omits it, unchecked omits it even if BinaryStale is set.
    • TestPrintDoctorEnvironment_VersionSkew — matching versions keep the ✓ row; skewed versions render ! plus the remedy line.
    • TestStatusBinaryDriftFreshThenStale / _SizeChange / TestStatusBinaryStatErrorReportsUnknown / TestStatusBinaryUncapturedIdentityReportsUnknown — full lifecycle through the real ControlStatus handler: fresh → mtime-drift stale → log-once pinned; size-only drift; stat-error ⇒ unknown; uncaptured identity ⇒ unknown.
    • Gates: go build ./... + go vet ./... clean; gofmt clean on all branch-touched files; go test -race ./cmd/gortex/ ./internal/version/ ./internal/daemon/ green except the two pre-existing dispositioned failures above.
  • Benchmarks — not performance-relevant (one string compare per proxy connect, one stat per status poll).

E2E dogfood (XDG-isolated scratch daemon; binary swapped beside it, simulating a cask upgrade)

$ go build -o $E2E/bin/gortexA ./cmd/gortex &amp;&amp; cp gortexA gortex &amp;&amp; gortex daemon start
$ gortex daemon status                     # baseline — versions equal, binary fresh
 daemon    v0.63.4
 pid       80988
 socket    /tmp/gortex-skew-e2e/xdg/cache/gortex/daemon.sock
 ...                                       # no cli row, no binary row  → PASS

$ go build -ldflags &#34;-X main.version=9.9.9&#34; -o gortexB ./cmd/gortex &amp;&amp; mv gortexB gortex
$ gortex daemon status                     # after the simulated cask upgrade
 daemon    v0.63.4
 pid       80988
 cli       v9.9.9 (differs from daemon)
 binary    stale — on-disk image newer than running image; run &#39;gortex daemon restart&#39;
 ...                                       # both rows present  → PASS

$ echo | gortex mcp 2&gt;&amp;1 &gt;/dev/null | head -5
[gortex mcp] proxying to daemon (session 97d174cdd5feb62e2ac45de54f274e40, default_repo=&#34;&#34;)
[gortex mcp] warning: daemon v0.63.4 != binary v9.9.9 — run &#39;gortex daemon restart&#39; to upgrade the daemon
                                            # skew warning on stderr, direction-aware remedy  → PASS

Teardown by PID kill (never daemon stop); the live homebrew daemon was healthy and untouched afterwards (v0.63.4+5f5fce2, 5 sessions, ready).

Review follow-ups (#4969353348)

All three proposals addressed (352131a, 205b2e2, 668dfa4):

  • remedy text names gortex upgrade — install-method-agnostic, and it bounces the daemon around the swap itself
  • semver precedence compare moved to internal/version.Compare
  • doctor surfaces the same skew verdict on the handshake row

Checklist

  • Code follows existing patterns in the codebase
  • No unnecessary abstractions added
  • Language extractor includes Meta[&#34;methods&#34;] for interfaces (if applicable) — N/A
  • Methods have EdgeMemberOf edges to their containing type (if applicable) — N/A

Known limitations

  • Restart remains manual. The remedy text tells the operator what to run; nothing auto-restarts the daemon. Automating it (restart-on-upgrade) needs the cask-side stop/restart hook, which is deliberately deferred to its own change per the brew upgrade leaves the old daemon running: no restart, no version-skew detection (macOS) #609 plan.
  • Dev builds are silent by design. A local v0.0.0-dev CLI identity never triggers the warning or the cli row — the spec's "degrade silently" posture, extended to the dev-sentinel case so contributor builds don't nag.
  • Detection is per-status-poll, not push. The staleness probe runs when status is requested; a daemon nobody polls still drifts silently. The proxy warning independently covers every MCP client connect.

@zzet zzet left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@madeinoz67 good work, thank you!

I found a non-blocking proposals, please let me know if you'd like to address them.

Comment thread cmd/gortex/proxy.go Outdated
Comment thread cmd/gortex/proxy.go Outdated
Comment thread cmd/gortex/proxy.go
Review #4969353348 (r3810847325): 'brew upgrade gortex' only works for
macOS brew installs — curl-installed and scoop users get advice that
fails. 'gortex upgrade' detects the install method and runs the matching
update, and it stops/starts the daemon around the swap itself.
Review #4969353348 (r3810812621): the comparators outgrew the proxy —
internal/version parsed semver but could not order it, so Compare fills
a package gap instead of living as cmd/gortex-private helpers. The
§11 precedence test moves with it (TestComparePrecedence); the skew
remedy stays in cmd/gortex as CLI messaging.
…robe

Review #4969353348 (r3810888879): doctor is the command a confused user
runs when versions feel wrong, so the handshake row now downgrades to a
warning and prints the same remedy line the MCP proxy emits — computed
by the same daemonSkewWarning, so the three surfaces cannot disagree.
DoctorEnvironment carries cli_version and version_skew_warning for
--json consumers; dev builds stay silent via the existing sentinel.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

brew upgrade leaves the old daemon running: no restart, no version-skew detection (macOS)

2 participants