Traced it. The root cause is narrower and more fixable than it looks, and part of the answer you asked for already exists — it was just unreachable by your app. Branch: deploy-build-probe.
Why live was wrong
You have it exactly right about the signals. X-Carlos-Version is set in edge.Reload from registry.Route.Version (internal/edge/edge.go, via versionHeader), refreshed on the edge's 2s poll. Route.Version is stamped by adoptHost. So the header describes what adoption INTENDED from the moment the symlink moves. The repo already says so in three places, including deploy.go itself: "it goes green while the old binary is still serving."
carlos deploy's watch, with nothing else configured, compares that header and a 2xx. Nothing else gates exit 0. (Your suggestion 4 is already the case, for what it is worth — a watch TIMEOUT does exit non-zero. The problem is only that a SUCCESS means less than it looks.)
Suggestion 1 already exists — and your app could not have it
An app can declare a build probe: a path whose response body names the running build. When one is set, the deploy watch polls the app's own process instead of the header, and live means the process cycled. /api/version serving 7507a33 is precisely the shape it wants.
The defect is that SetAppBuildProbe had exactly one caller — the app CLAIM door. Set at creation or never. Every app made before the flag existed, and every app whose author did not think to pass it, was permanently stuck on header-watching. The fix for the bug was out of reach of the apps that had the bug, which is why you correctly concluded the platform offered nothing.
Fixed on the branch:
carlos apps build-probe --app docs /api/version
and a deploy with no probe now says so while it waits, instead of silently watching the weaker signal:
watching the X-Carlos-Version header: it reports the release ADOPTED, not the build running a probe proves the process actually cycled — carlos apps build-probe --app docs /api/version
Why the instance did not cycle, and why it stayed stale for hours
Two mechanisms, and the second explains 84c906c.
The stamp is not gated on a confirmed restart, for two of the three backings. adoptHost runs: stage → capability converge → move the symlink → restart → SetVersions. The restart callback has three arms (makeRestartRoute, internal/host/agent.go):
- a named unit (
route.unit, e.g.console.service) returns its failure, andadoptHostthen rolls the symlink back and skips the stamp — the header stays honest. This arm was built for issue #110, which is your bug in its first incarnation. - a
carlos-app@<host>tenant and an exec child both log and return nil. Deliberately: a fire-and-forgettry-restartis a no-op on a stopped unit, and one tenant's failure must not poison the release for every other host in the pass. Right for the pass, wrong for the stamp —SetVersionsproceeds either way.
Also: the plain hourly carlos adopt pass passes no restart callback at all for hibernating routes, on the assumption that "the next wake execs the new binary anyway". True for an asleep instance. Not true for one that is awake.
The backstop is a 90-second convergence sweep, and it can fail silently. Service.sweepOnce compares the live symlink against spawnVersion (the build the child was actually exec'd with) and cycles after ConvergeGrace = 90s. That is what your carlos restart did by hand. But ConvergeDecision returns "do not cycle" forever, with no log line, in two cases: a persistent symlink read error, and either version reading empty — and New() defaults liveVer to a function returning ("", nil) for any backend that does not implement LiveVersion, which disables convergence permanently and silently. One of those is the likeliest reason docs sat on a build from two releases earlier rather than converging within 90 seconds.
On your suggestion 2
carlos instances list's VERSION is worse than "the adopted release": it is StagedVersion, read from the last box status snapshot, published every 5 minutes and dropped entirely once a box goes stale (3× that). So it can be up to ~15 minutes behind the registry column the edge is already serving, or blank. Splitting it into adopted/running is the right shape, but the running half does not exist anywhere outside the activator's own memory — spawnVersion has no accessor, no status field, no API. Publishing it is a real change and I have not made it.
What I have not touched
The fire-and-forget arms. Making arms 2 and 3 gate the stamp the way arm 1 does would be the honest fix, and it is a change to release adoption on every box in the fleet — the sort of thing that wants your eye and a rollout plan, not an overnight branch. Same for publishing spawnVersion. Both are written up in the branch description.
The small one
carlos deploy --label and --notes now work. runShipAPI already took both; deploy passed "".
Leaving this open on the two items above.