carlos / platform Public

carlos deploy reports "live" while the old binary is still serving — every platform-side signal describes the adopted release, not the running process Issue open

carlos deploy prints live and returns 0 while the previous binary is still serving. The instance had not cycled, and every signal the platform offers agreed it had — because they all describe the adopted release rather than the running process.

Hit first-hand today deploying oficina/docs to docs.bds.oncarlos.com. Earlier sessions on the same app recorded the same behaviour on 2026-08-31 and 2026-09-01; today's is the one I can give you verbatim.

What happened

$ carlos deploy --account oficina --app docs --kind binary --target linux-arm64 --version 7507a33 docs-linux-arm64
deploying https://docs.bds.oncarlos.com
shipped docs 7507a33
promoted docs 7507a33 -> edge
live https://docs.bds.oncarlos.com

Exit 0. Immediately after, against the real host:

| probe | answer | |---|---| | x-carlos-version response header | 7507a33 | | GET /api/version (the app's own rastrillo.BuildVersion) | dev | | GET /static/editor.7b30ca1e00c791c0.js (asset only the new build has) | 404 | | carlos instances list VERSION | 84c906c |

So the router advertised the new release, the app answered as a build from two releases earlier, and an asset that only exists in the new binary was not being served. The old process was still handling requests.

84c906c is worth noticing on its own: that is not the release before this one. main had also moved through dfb9271 earlier in the day, deployed by someone else. The instance had silently missed that adoption too, so it had been serving a stale binary for hours with nothing reporting a problem.

What actually moved it

carlos channels --account oficina --app docs   # edge -> 7507a33, confirmed propagated
carlos restart --app docs --account oficina

Then, and only then:

GET /api/version                                 -> 7507a33
GET /static/editor.7b30ca1e00c791c0.js           -> 200

and the logs show the pair that actually settles it:

[app]      rastrillo: serving addr=/run/carlos/... version=7507a33
[platform] instance woke (version 7507a33)

Why this is worth fixing rather than documenting

live is a claim about the running process, and right now it is computed from something else. Every operator-facing signal has the same problem:

  • x-carlos-version is the router's view of the adopted release.
  • carlos instances list's VERSION column is the adopted release, not the running one.
  • carlos channels answers "what does this channel point at", which is a different question again — already noted in our own repo's guidance, but it is the third signal that cannot distinguish "pointed at it" from "serving it".

The result is that there is no way to notice a failed adoption without a probe the app itself provides. We only caught it because this app stamps rastrillo.BuildVersion through -ldflags and serves /api/version, and because our bundle filenames are content-addressed so a stale binary 404s. An app that does neither would have shown a clean, green, entirely wrong deploy — which is what a restart-before-promote no-op looked like on 2026-09-01.

Suggestions, in order of how much they'd help

  1. Make deploy's watch probe the process, not the router. It already watches the URL; if the watch compared something only the new build can answer, live would mean what it says. That needs a platform-provided endpoint rather than an app convention — apps cannot be relied on to expose one.
  2. Split the VERSION column in carlos instances list into adopted and running, or mark them when they disagree. A single column that silently means "adopted" is the specific thing that made a two-release-stale box look healthy.
  3. Make deploy cycle the instance, or say plainly that it does not and that carlos restart is a required step. Right now the word live says the opposite.
  4. Failing all of the above, have deploy exit non-zero when its watch cannot confirm, rather than printing live on a timeout.

Unrelated, while I am here

carlos deploy has no --label flag, though carlos ship does. Passing one to deploy fails with flag provided but not defined: -label and dumps usage. If a deploy is a ship plus a promote, the label probably wants to survive the merge of the two.

Environment

oficina/docs on docs.bds.oncarlos.com, channel edge, linux-arm64, deployed 2026-09-02 ~21:19 UTC. Binary built CGO_ENABLED=0 GOOS=linux GOARCH=arm64 with -ldflags "-s -w -X github.com/carlosframework/rastrillo.BuildVersion=7507a33"; stamp confirmed present in the binary with strings before shipping, so dev was genuinely the old process answering and not an unstamped build.

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, and adoptHost then 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-forget try-restart is 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 — SetVersions proceeds 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.

Triage, 2026-09-05. deploy-build-probe reviewed. CI green. Verdict: merge with one fix. The diagnosis holds up under checking and the fix is right; there is a single footgun in the new advisory.

Verified rather than taken on trust: SetAppBuildProbe did have exactly one non-test caller on main — the claim door — so "the fix for the bug was out of reach of the apps that had the bug" is literally true. The new door reuses the claim door's membership proof plus rate limit, audit, and an empty service-role set. Your suggestion-1 concern about an app pointing the probe at something it controls is in-tenancy and bounded: validBuildProbe is enforced at the write door and re-checked at the read, so the probe can only name a path on the app's own host. A 404ing probe fails loudly and exits 1 rather than reporting live, and the stale-cache worry is already handled — the watch appends a per-attempt query parameter on both legs and matches whole tokens, not substrings.

The one thing to fix before it lands: the new "set a build probe" advisory prints on static deploys too. A static route has no process and no /api/version, so an operator who follows the advice sets a probe that 404s, and every subsequent carlos deploy for that site then times out and exits 1. That turns a helpful line into the same class of bug this branch fixes. staticDeploy is already in scope two lines up — gating on it is the whole change.

Coverage: --label/--notes are wired correctly but untested, and the bug being fixed was exactly a silently-passed "", so that one deserves a line in the existing e2e. The advisory itself is untested too.

Merge-order warning: this branch and status-instances (#11) both rewrite the same closed-door assertion from 17 to 18. Whichever lands second conflicts, and a mechanical resolution will be red — the count has to become 19 with both reasons listed.

On the two you left open, and why I think you were right to:

The fire-and-forget arms are the real fix and they are a change to release adoption on every box. Worth saying plainly what the current state costs: with arms 2 and 3 returning nil, SetVersions proceeds regardless, so X-Carlos-Version is a claim the platform makes without evidence for two of three backings. Your 84c906c — two releases stale for hours — is that, and the silent ConvergeDecision return is why the 90-second backstop did not catch it. That second one may be the cheaper half: a "do not cycle" that returns forever with no log line is a defect on its own terms, independent of the adoption question, and New() defaulting liveVer to ("", nil) for any backend without LiveVersion disables convergence permanently and silently. A log line there is not a rollout.

Publishing spawnVersion would also close #11's missing AWAKE column, which is the same gap from the other side. Two issues, one field.

Leaving this open. The branch is worth landing on its own; the two items above want your eye and a rollout plan, as you said.

Evidence from ips (app ips, two instances, plus ips-app) on 2026-09-12, after setting carlos apps build-probe --app ips /api/version. Thank you for the post-claim verb; it was out of reach until it existed.

The probe waits for a restart that nothing performs on a busy instance. carlos logs -a ips --stream platform for the day's three releases (f02980fb, 5e446e0a, 2c59cc6e) shows each instance's version changing only at instance restarting (carlos restart), never on its own. The app has a mobile-push schedule firing every minute on every instance, so no instance ever idles long enough to adopt at the bake. With a probe set, a bare carlos deploy would wait out its whole timeout and fail on every release. So our release now runs the deploy in the background, waits for each host's edge to name the new version, restarts, and then lets the deploy's probe confirm (ips scripts/deploy.sh).

What would make that script unnecessary, any one of:

  1. Promote drains and recycles a warm instance, the same SIGTERM path hibernation uses. This is what #76 expected.
  2. carlos deploy --restart, which restarts each instance once its edge has adopted, then watches the probe.
  3. A default probe for rastrillo apps at claim. Every rastrillo binary answers /api/version with its build, which already fits the probe's whole-word rule.

Two smaller things from the same work:

  • The probe path takes no query, so it can't cache-bust. rastrillo's /api/version sends no Cache-Control (filed there), and we've seen a bare /api/version return the previous commit for minutes after a restart. Worth the probe's request asking the edge not to serve from cache.
  • The probe watches the app's own platform host (--host scopes the watch), so the second instance of a two-instance app isn't verified by deploy at all.

--- Filed from the ips repository (Turnkey's insolvency practice system, built on rastrillo v0.26.0 and deployed on CARLOS), where this was worked around. Paths without a repository prefix are in ips.

Cross-reference for the cache point above: rastrillo/rastrillo #41 asks for Cache-Control: no-store on /api/version and /healthz. The build-probe request itself is also restated in #141 (vet checking the stamp).