Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions cmd/gortex/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -1250,6 +1250,20 @@ func renderDaemonHeader(w io.Writer, st daemon.StatusResponse) {
})
t.AppendRow(table.Row{"daemon", st.Version})
t.AppendRow(table.Row{"pid", st.PID})
// Upgrade-skew facts belong next to the daemon version they qualify.
// The cli row appears only when this binary's build differs from the
// daemon's — the same compare runProxy warns on at connect time — so
// a matching pair keeps the table exactly as terse as before. The
// binary row surfaces the daemon's own on-disk drift probe and only
// when it actually ran: an unchecked binary is unknown, not fresh,
// and must not be reported as either.
local := canonicalVersion()
if warn := daemonSkewWarning(st.Version, local); warn != "" {
t.AppendRow(table.Row{"cli", local + " (differs from daemon)"})
}
if st.BinaryChecked && st.BinaryStale {
t.AppendRow(table.Row{"binary", "stale — on-disk image newer than running image; run 'gortex daemon restart'"})
}
t.AppendRow(table.Row{"socket", st.SocketPath})
t.AppendRow(table.Row{"uptime", formatDuration(time.Duration(st.UptimeSeconds) * time.Second)})
switch {
Expand Down
97 changes: 97 additions & 0 deletions cmd/gortex/daemon_status_render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,3 +258,100 @@ func TestRenderDaemonHeader_ReadyAndEnriched_NoWarmupLabelChange(t *testing.T) {
assert.Contains(t, out, "ready (warmup 5m0s)")
assert.NotContains(t, out, "enrichment in progress")
}

// stubBuildVersion rewrites the ldflags-injected build identity for the
// duration of a subtest — the same `-X main.version` / `-X main.commit`
// seam goreleaser populates — so canonicalVersion() reports a chosen
// build and renderDaemonHeader's skew compare can be driven end-to-end.
func stubBuildVersion(t *testing.T, v, c string) {
t.Helper()
oldV, oldC := version, commit
version, commit = v, c
t.Cleanup(func() { version, commit = oldV, oldC })
}

// TestRenderDaemonHeader_SkewRow — the local-version row appears only
// when daemonSkewWarning(st.Version, canonicalVersion()) is non-empty,
// the same compare runProxy applies at connect time. Matching versions
// and dev builds (no injected identity, the v0.0.0-dev sentinel) must
// keep the table exactly as terse as it was.
func TestRenderDaemonHeader_SkewRow(t *testing.T) {
t.Run("skewed versions append the cli row", func(t *testing.T) {
stubBuildVersion(t, "0.63.3", "deadbee")
st := daemon.StatusResponse{Version: "v0.63.4+abc1234"}
// Precondition: the row is gated on this exact compare, so prove
// the gate is live before asserting the render honors it.
if daemonSkewWarning(st.Version, canonicalVersion()) == "" {
t.Fatalf("expected daemonSkewWarning(%q, %q) to be non-empty",
st.Version, canonicalVersion())
}
var buf bytes.Buffer
renderDaemonHeader(&buf, st)
out := buf.String()
assert.Contains(t, out, "cli")
assert.Contains(t, out, "v0.63.3+deadbee (differs from daemon)")
})

t.Run("matching versions omit the row", func(t *testing.T) {
stubBuildVersion(t, "0.63.4", "abc1234")
st := daemon.StatusResponse{Version: "v0.63.4+abc1234"}
if daemonSkewWarning(st.Version, canonicalVersion()) != "" {
t.Fatalf("expected daemonSkewWarning(%q, %q) to be empty",
st.Version, canonicalVersion())
}
var buf bytes.Buffer
renderDaemonHeader(&buf, st)
assert.NotContains(t, buf.String(), "cli")
})

t.Run("dev build omits the row", func(t *testing.T) {
// Plain `go build` identity: canonicalVersion() reports the
// v0.0.0-dev sentinel, which daemonSkewWarning deliberately
// ignores so dev binaries never nag about skew.
stubBuildVersion(t, "0.0.0", "")
assert.Equal(t, "v0.0.0-dev", canonicalVersion())
st := daemon.StatusResponse{Version: "v0.63.4+abc1234"}
var buf bytes.Buffer
renderDaemonHeader(&buf, st)
assert.NotContains(t, buf.String(), "cli")
})
}

// TestRenderDaemonHeader_BinaryRow — the daemon's self-reported
// on-disk-binary drift row appears only when the drift probe ran
// (BinaryChecked) and found the running image stale. An unchecked binary
// must never render as stale — unknown is not stale.
func TestRenderDaemonHeader_BinaryRow(t *testing.T) {
t.Run("stale binary appends the binary row", func(t *testing.T) {
st := daemon.StatusResponse{
Version: "v0.63.4+abc1234",
BinaryChecked: true,
BinaryStale: true,
}
var buf bytes.Buffer
renderDaemonHeader(&buf, st)
out := buf.String()
assert.Contains(t, out, "binary")
assert.Contains(t, out, "stale — on-disk image newer than running image")
})

t.Run("fresh binary omits the row", func(t *testing.T) {
st := daemon.StatusResponse{
Version: "v0.63.4+abc1234",
BinaryChecked: true,
}
var buf bytes.Buffer
renderDaemonHeader(&buf, st)
assert.NotContains(t, buf.String(), "binary")
})

t.Run("unchecked binary omits the row even if BinaryStale is set", func(t *testing.T) {
st := daemon.StatusResponse{
Version: "v0.63.4+abc1234",
BinaryStale: true,
}
var buf bytes.Buffer
renderDaemonHeader(&buf, st)
assert.NotContains(t, buf.String(), "binary")
})
}
33 changes: 25 additions & 8 deletions cmd/gortex/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,15 @@ func init() {
// integration cannot check: that the `gortex` the editor will launch is the
// one on PATH, and that the daemon it proxies to actually answers a handshake.
type DoctorEnvironment struct {
BinaryOnPath bool `json:"binary_on_path"`
BinaryPath string `json:"binary_path,omitempty"`
BinaryError string `json:"binary_error,omitempty"`
DaemonRunning bool `json:"daemon_running"`
DaemonSocket string `json:"daemon_socket,omitempty"`
DaemonVersion string `json:"daemon_version,omitempty"`
DaemonError string `json:"daemon_error,omitempty"`
BinaryOnPath bool `json:"binary_on_path"`
BinaryPath string `json:"binary_path,omitempty"`
BinaryError string `json:"binary_error,omitempty"`
CLIVersion string `json:"cli_version,omitempty"`
DaemonRunning bool `json:"daemon_running"`
DaemonSocket string `json:"daemon_socket,omitempty"`
DaemonVersion string `json:"daemon_version,omitempty"`
DaemonError string `json:"daemon_error,omitempty"`
VersionSkewWarning string `json:"version_skew_warning,omitempty"`
}

// DoctorAgentReport is one agent's slice of the doctor output.
Expand Down Expand Up @@ -211,6 +213,7 @@ func doctorExit(r doctorRuntime) error {
// best-effort and never fail the command — doctor is a read-only diagnostic.
func doctorEnvironment() DoctorEnvironment {
out := DoctorEnvironment{DaemonSocket: daemon.SocketPath()}
out.CLIVersion = canonicalVersion()
if p, err := exec.LookPath("gortex"); err == nil {
out.BinaryOnPath = true
out.BinaryPath = p
Expand All @@ -231,6 +234,10 @@ func doctorEnvironment() DoctorEnvironment {
defer c.Close()
out.DaemonRunning = true
out.DaemonVersion = c.Ack.DaemonVersion
// The same skew compare `gortex mcp` warns on at connect time and
// `daemon status` renders next to the daemon version — doctor is
// what a confused user runs first, so it must agree with both.
out.VersionSkewWarning = daemonSkewWarning(out.DaemonVersion, out.CLIVersion)
return out
}

Expand Down Expand Up @@ -331,7 +338,17 @@ func printDoctorEnvironment(w io.Writer, env DoctorEnvironment) {
if ver == "" {
ver = "ok"
}
fmt.Fprintf(w, " %s daemon handshake: %s (%s)\n", glyphCheck, ver, doctorPath(env.DaemonSocket))
// A skewed pair downgrades the handshake row to a warning and
// appends the same one-line remedy the proxy prints — doctor,
// `daemon status`, and the MCP proxy must give one verdict.
glyph := glyphCheck
if env.VersionSkewWarning != "" {
glyph = glyphWarn
}
fmt.Fprintf(w, " %s daemon handshake: %s (%s)\n", glyph, ver, doctorPath(env.DaemonSocket))
if env.VersionSkewWarning != "" {
fmt.Fprintf(w, " %s\n", env.VersionSkewWarning)
}
} else {
fmt.Fprintf(w, " %s daemon handshake: %s\n", glyphCross, env.DaemonError)
}
Expand Down
55 changes: 55 additions & 0 deletions cmd/gortex/doctor_env_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package main

import (
"bytes"
"testing"
)

// TestPrintDoctorEnvironment_VersionSkew — the daemon handshake row
// carries ✓ when CLI and daemon agree and ! plus the skew remedy line
// when they don't, reusing daemonSkewWarning so doctor, `daemon
// status`, and the MCP proxy render one verdict.
func TestPrintDoctorEnvironment_VersionSkew(t *testing.T) {
t.Run("matching versions keep the check row", func(t *testing.T) {
env := DoctorEnvironment{
DaemonRunning: true,
DaemonVersion: "v0.63.4+5f5fce2",
CLIVersion: "v0.63.4+5f5fce2",
DaemonSocket: "/tmp/gortex.sock",
}
if warn := daemonSkewWarning(env.DaemonVersion, env.CLIVersion); warn != "" {
t.Fatalf("precondition: expected no skew warning for matching versions, got %q", warn)
}
var buf bytes.Buffer
printDoctorEnvironment(&buf, env)
out := buf.String()
if !bytes.Contains([]byte(out), []byte("✓ daemon handshake: v0.63.4+5f5fce2")) {
t.Fatalf("expected check-marked handshake row, got:\n%s", out)
}
if bytes.Contains([]byte(out), []byte("warning:")) {
t.Fatalf("unexpected skew warning in matching-version render:\n%s", out)
}
})

t.Run("skewed versions warn with the remedy", func(t *testing.T) {
env := DoctorEnvironment{
DaemonRunning: true,
DaemonVersion: "v0.63.4+5f9ce2a",
CLIVersion: "v0.63.5+abcdef1",
DaemonSocket: "/tmp/gortex.sock",
}
env.VersionSkewWarning = daemonSkewWarning(env.DaemonVersion, env.CLIVersion)
if env.VersionSkewWarning == "" {
t.Fatal("precondition: daemonSkewWarning must fire for skewed versions")
}
var buf bytes.Buffer
printDoctorEnvironment(&buf, env)
out := buf.String()
if !bytes.Contains([]byte(out), []byte("! daemon handshake: v0.63.4+5f9ce2a")) {
t.Fatalf("expected warn-marked handshake row, got:\n%s", out)
}
if !bytes.Contains([]byte(out), []byte("run 'gortex daemon restart' to upgrade the daemon")) {
t.Fatalf("expected the skew remedy line, got:\n%s", out)
}
})
}
44 changes: 44 additions & 0 deletions cmd/gortex/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (

"github.com/zzet/gortex/internal/daemon"
gortexmcp "github.com/zzet/gortex/internal/mcp"
semver "github.com/zzet/gortex/internal/version"
)

// coldStartTools is the static core catalogue the proxy answers a cold-start
Expand Down Expand Up @@ -119,6 +120,9 @@ func runProxy(ctx context.Context, surface *gortexmcp.ToolSurface) (ran bool, er
}

logProxyConnection(os.Stderr, client, false)
if warn := daemonSkewWarning(client.Ack.DaemonVersion, canonicalVersion()); warn != "" {
fmt.Fprintln(os.Stderr, "[gortex mcp] "+warn)
}
if surface != nil && surface.Active() {
fmt.Fprintf(os.Stderr, "[gortex mcp] tool surface restricted (preset %q)\n", surface.Preset())
}
Expand All @@ -140,6 +144,46 @@ func runProxy(ctx context.Context, surface *gortexmcp.ToolSurface) (ran bool, er
return true, nil
}

// daemonSkewWarning returns a one-line stderr warning when the daemon
// reports a different build than this binary, or "" when they match,
// the daemon did not report a version, or either side is a dev build
// (no injected identity — comparing against a dev build would noise
// every dev run, and a dev-built daemon cannot be "upgraded" by a
// restart, so the remedy advice would be wrong for it too).
// The remedy is direction-aware: an older daemon should be restarted
// (a restart respawns it from this newer binary), while a newer daemon
// means this binary is the stale side and upgrading it is the fix —
// restarting would downgrade the daemon to this older build. The
// upgrade remedy is 'gortex upgrade' rather than a package-manager
// specific command: it upgrades the way gortex was installed (brew,
// scoop, go install, or the install script) and restarts the daemon
// around the binary swap. When
// either side does not parse as semver, or only the build metadata
// differs (which SemVer precedence ignores), a generic remedy that
// covers both directions is emitted. Implements the documented intent
// in docs/versioning.md: the daemon exposes DaemonVersion so "clients
// can feature-gate or warn on mismatch"; this warns and continues —
// never gates.
func daemonSkewWarning(daemonVer, localVer string) string {
Comment thread
madeinoz67 marked this conversation as resolved.
if daemonVer == "" || localVer == "" || localVer == "v0.0.0-dev" || daemonVer == "v0.0.0-dev" || daemonVer == localVer {
return ""
}
base := fmt.Sprintf("warning: daemon %s != binary %s", daemonVer, localVer)
d, dErr := semver.Parse(daemonVer)
l, lErr := semver.Parse(localVer)
if dErr != nil || lErr != nil {
return base + " — run 'gortex daemon restart' or 'gortex upgrade'"
}
switch semver.Compare(d, l) {
case -1: // daemon older — restarting respawns it from this newer binary
return base + " — run 'gortex daemon restart' to upgrade the daemon"
case 1: // daemon newer — this binary is the stale side
return base + " — this binary is older than the running daemon — run 'gortex upgrade'"
default: // same precedence, different build metadata
return base + " — run 'gortex daemon restart' or 'gortex upgrade'"
}
}

func newProxyLogicalSessionID() string {
var raw [16]byte
if _, err := rand.Read(raw[:]); err == nil {
Expand Down
27 changes: 27 additions & 0 deletions cmd/gortex/proxy_skew_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package main

import "testing"

func TestDaemonSkewWarning(t *testing.T) {
cases := []struct{ name, daemonV, localV, want string }{
{"equal versions", "v0.63.4+5f5fce2", "v0.63.4+5f5fce2", ""},
{"daemon older", "v0.63.4+5f9ce2a", "v0.63.5+abcdef1",
"warning: daemon v0.63.4+5f9ce2a != binary v0.63.5+abcdef1 — run 'gortex daemon restart' to upgrade the daemon"},
{"daemon newer", "v0.63.5+abcdef1", "v0.63.4+5f9ce2a",
"warning: daemon v0.63.5+abcdef1 != binary v0.63.4+5f9ce2a — this binary is older than the running daemon — run 'gortex upgrade'"},
{"same version different build", "v0.63.4+aaaaaaa", "v0.63.4+bbbbbbb",
"warning: daemon v0.63.4+aaaaaaa != binary v0.63.4+bbbbbbb — run 'gortex daemon restart' or 'gortex upgrade'"},
{"unparseable daemon version", "strawberry", "v0.63.5+abcdef1",
"warning: daemon strawberry != binary v0.63.5+abcdef1 — run 'gortex daemon restart' or 'gortex upgrade'"},
{"daemon version empty", "", "v0.63.5+abcdef1", ""},
{"local dev build", "v0.63.4+5f9ce2a", "v0.0.0-dev", ""},
{"daemon dev build", "v0.0.0-dev", "v0.63.5+abcdef1", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := daemonSkewWarning(tc.daemonV, tc.localV); got != tc.want {
t.Fatalf("daemonSkewWarning(%q, %q) = %q, want %q", tc.daemonV, tc.localV, got, tc.want)
}
})
}
}
14 changes: 14 additions & 0 deletions internal/daemon/proto.go
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,20 @@ type StatusResponse struct {
// every alive (spec, workspace) subprocess. Empty when no LSP
// router is wired (`semantic.enabled: false` in `.gortex.yaml`).
LSPRouter *LSPRouterStatus `json:"lsp_router,omitempty"`

// BinaryStale is true when the file at the daemon's os.Executable()
// path no longer matches the image the daemon is running (size or
// mtime differ) — the signature of a package-manager upgrade that
// replaced the binary under a running daemon. Stat failures leave
// this false and BinaryChecked false (unknown, not fresh).
BinaryStale bool `json:"binary_stale,omitempty"`
// BinaryChecked reports whether the binary-drift probe actually ran:
// false means the daemon could not establish the on-disk state (no
// captured start identity, or the status-time stat failed) and the
// binary is unknown — not fresh.
BinaryChecked bool `json:"binary_checked,omitempty"`
// BinaryReplacedAtUnix is the on-disk file's mtime when stale.
BinaryReplacedAtUnix int64 `json:"binary_replaced_at_unix,omitempty"`
}

// LSPRouterStatus reflects one daemon's LSP-router state for the
Expand Down
Loading
Loading