Skip to content

Harden the update flow - #243

Open
david-hummingbot wants to merge 43 commits into
mainfrom
fix/update-flow-hardening
Open

david-hummingbot wants to merge 43 commits into
mainfrom
fix/update-flow-hardening

Conversation

@david-hummingbot

@david-hummingbot david-hummingbot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Closes 32 risk scenarios in the update flow, plus 39 more — 14 found by installing the result and running an update through it, and 25 across six rounds of review. Draft for review of the approach.

Grouped by what they touch: C Condor, H hummingbot-api, S the shared git machinery (fires once per checkout), P cross-platform. V is the later pass — see Group V, which supersedes S2 and corrects what the original Also covers section claimed.

Rebased onto 30dd1443; two conflicts needed care rather than a side picked, both noted below.

Group C — Condor

# Scenario Fix
C1 npm run build emptied frontend/dist while uvicorn served from it. A reload mid-build returned 500, not 404 — a missing index.html raises inside FileResponse. A build that failed left no dashboard at all, while the run said the previous bundle would come back. It had already been deleted. Builds to dist.new and swaps, so exposure is one rename and dist is untouched unless the build wins. dist.old becomes a free rollback. The SPA fallback answers 503 with an explanation, and the failure message is now true. .gitignore covers both scratch dirs. make build-frontend swaps the same way (V36), and the 503 has a test (V37).
C2.1 A Restart Now button rendered directly beneath the engine's own "dependencies failed — fix it before restarting". Pressing it booted new code against the old venv. The run.state == "failed" arm is gone.
C2.2 make run turns remain-on-exit off once boot is confirmed, so a restart handed over with the pane set to close — a successor that failed to boot took its traceback with it. remain-on-exit is re-enabled across the handover and turned off again once the successor serves. A failed os.execv now logs and writes restart-failure.log.
C3 A Local admin could discover, preflight, resolve and apply an update entirely in the browser, then had to leave it and run make restart — the one step that makes any of it take effect. POST /updates/relaunch, and the dashboard applies it itself: short cancellable countdown, restart, reconnect, one reload. Telegram keeps its manual button. Never on a failed run. Every open tab reloads, not only the one that pressed the button, and only once the successor is answering — V30, V31.
C4 startup() migrates stored data on every boot and the moves are one-way, so a rollback leaves Condor looking for directories that no longer exist. Nothing said so. migration-incoming warning when the incoming commits touch condor/migrations.py.
C5 Nothing verified the update worked. python -m condor.doctor already existed, read-only and exiting non-zero on real failures — wired only into make install. A final non-fatal doctor step whose output lands in the journal, with a new WARNED state so "the update succeeded" cannot swallow "and here is what is still wrong". Pinned in V35.
C6 The default chat agent wrote its routines into the tracked repo-root routines/ — 13 files upstream actively maintains. Improving a shipped routine edited a file upstream also edits. Writes go to the gitignored local root; the shipped root stays as the read fallback, so the catalog is as full as it ever was. Migration lifts what the product already wrote there (untracked only).
C7 requires-python had a floor and no ceiling, so uv picked the newest interpreter and a fresh install died inside a transitive llvmlite build naming neither Condor nor the real constraint. >=3.12,<3.14 plus a .python-version.
C8 Nothing warned that the dashboard being read was the one about to be rebuilt. frontend-will-rebuild warning.
C9 The interrupted-run message described the state of the disk accurately and omitted that the process reading it had booted on the partial result. Names the steps that finished and says to re-run before anything else.
C10 A local user with role USER gets no Updates tab and no explanation. Names the role and the knob, not the tab.
C11.1 A customized playbook silently stopped receiving upstream changes, for ever. forked_from was written and never read back — content_digest was called in exactly two places, both at fork time. stale_forks() supplies the missing comparison, reported before the update ("this update changes 2 shipped files you have your own version of") and after ("upstream has since changed these"). Local still wins — this reports, it does not take anything back.
C11.2 The dashboard's save erased the stamp on the first edit, so there was nothing to compare even in principle. Writes carry the stamp across, as the three stores already did.
C11.3 A playbook upstream retires survives as a local-only phantom and keeps running. Reported as retired, pointing at the mute set.
C12 A hand-edited shipped file blocked the update, and discard destroyed the work while reporting "Restored 1 tracked file(s)" — a destructive act in the vocabulary of a repair. New keep-mine: moves the edit into .condor/agents where the product would have put it, resets the tracked file, and leaves the install in the supported state. Offered only where every conflicting path is forkable. Discard now names what was lost.
C13 Killed mid-swap leaves a good dist.new and dist.old and no dist — and the static mount and SPA fallback both sit inside one dist.is_dir() evaluated at startup, so Condor came up serving a bare 404 at /. Adopted at boot, preferring dist.new.
C14 Nothing checked free disk space, and an update writes a venv, a node_modules, a bundle and an image. low-disk warning naming the figure.
C15 npm ci deletes node_modules before installing, so a failure left no dependency tree and the build could not run either. Its own failure state, with the actual remedy.
C16 The run guard was asyncio.Lock and an in-process flag — a second Condor on the same checkout could fast-forward the same repo concurrently. Advisory lock file naming the holding pid.
C17 Both the engine and the banner explained the missing relaunch control as a safety trade: that re-execing races whatever started Condor into a second copy on the same port. It does not — request_restart() SIGTERMs, teardown() runs, and main() execs once the loop is gone, replacing the image in place. Comments corrected, and the accurate reading is what C3 is built on. Three places were missed and still told the old story — see V39.

Group H — hummingbot-api

# Scenario Fix
H1.1 CI publishes only on merges to the default branch, tagging only latest and the version. So on a feature branch the update silently offered, and applied, main's code, while the status card truthfully reported the checkout as up to date. If the published tag cannot correspond to this checkout, the image update is not offered and the reason is stated. Branch name is not the test: a fork has a main too, --abbrev-ref answers the literal string HEAD when detached, and unpushed commits mean the branch is main and the code is not.
H1.2 make build is the documented way to run your own code, and Condor offered to overwrite the result with the registry's. An image whose digest the registry does not hold is reported as built locally, not behind. A probe that cannot answer is neither — see V32.
H1.3 "source" mode was unreachable on every real install — no shipped compose file has a build: key. Removed. The git fast-forward stays, and the step now says what it is for: the shared files the container reads.
H2 A docker step killed at the 1800s ceiling said nothing about whether a half-rebuilt stack was safe to touch. Both compose steps are idempotent; the message now says so.

Group S — shared git machinery

# Scenario Fix
S1 rev-parse --abbrev-ref HEAD returns the literal string HEAD when detached, so origin/HEAD resolved to the default branch, nothing reported being ahead, and merge --ff-only quietly fast-forwarded a detached checkout onto main and left it detached. Nothing anywhere called symbolic-ref. A detached-head block that names the commit, before anything reads a branch name or fetches.
S2 The stash escape was one-way: the work survived, but recovering it needed a shell the Local user was never asked to open. Replayed after the fast-forward. The conflicting case turned out not to work as written — see V1, which supersedes this row.
S3 A crashed git leaves .git/index.lock behind and every later subcommand fails on it, with an error that sends the reader hunting a process that is not there. One line naming the file.

Group P — cross-platform

Targets Linux, macOS and WSL2. Native Windows is explicitly out of scope: os.execv, tmux, make and the nvm.sh sourcing are POSIX, and .venv/Scripts vs bin is unhandled throughout.

# Scenario Fix
P1 content_digest hashed raw bytes, so the same file digested differently on a CRLF checkout (sha256:6d9b716cc24c vs sha256:ee60f3851972) — which would make C11.1 cry wolf about trading-rule files on Windows. Newlines normalized before hashing, with the pre-normalization form still accepted so existing stamps do not all fire at once.
P2 No .gitattributes, so line endings were whatever each host produced — and an untouched tracked file could reach C12's dead end without anyone having edited it. * text=auto, with eol=lf pinned for the files that are hashed or executed.
P3 Timeouts are tuned for a Linux dev box; a /mnt/c checkout on WSL2 or Docker Desktop on macOS runs several times slower. Failures name the step and say the retry is idempotent.
P4 A vite build over WSL2's or Docker Desktop's memory cap is SIGKILLed — rc 137, no stderr worth reading, indistinguishable from any other build failure. Named, with the knob to raise.
P5 macOS (APFS) and Windows (NTFS) are case-insensitive, so two tracked paths differing only in case are one file there and a clone silently loses one. CI guard against case-collisions and stray CRLF.

Also covers

Two cases found while checking what happens to files an agent creates rather than edits, both the same shape as C11 and neither detectable by the stamp:

  • A shipped routine edited locally. A stamp lives in frontmatter and _stamp() deliberately refuses to invent it for a file that has none — but the library ships 19 .py routines, so an agent editing one shadowed it permanently with nothing able to notice.
  • A file the agent creates under a name upstream later ships. Local wins per item, upstream's version is unreachable, and no stamp ever existed to compare.

Both are reported for files under an agent's own slug. Two trees were missed entirely and are fixed in V6 and V7 — including, awkwardly, the shipped-routine case named just above, whose stock copy lives at the repo root rather than under the agent.

An identical unstamped copy is not reported — copying is not diverging — and a file with no shipped counterpart at all is the ordinary "the agent wrote something new" case, which was never a fork of anything.


Rebased onto main

Branched before #241–#244; rebased onto 30dd1443 (177 commits), and again onto f15e401d once #258 merged. Three conflicts are worth naming, because resolving any of them the obvious way loses something:

  • condor/web/routes/agents.py — main renamed strategies/strategy.md → loops/loop.md and added the SEC-693 server-pin gate to the same write C11.2 had changed to write_preserving_stamp. Main's side carries the security gate, so taking it is the instinct — and that drops C11.2 silently. All three kept.
  • condor/migrations.py — both branches added a v5 behind the marker .migrated-v5: main's FEAT-128 rename, and this branch's chat-routine lift. Whichever wrote the marker first would stop the other ever running. The lift is now v6.
  • frontend/src/components/RelaunchBanner.tsx (fix(web): reload open tabs after relaunch (CORR-434) #258) — main grew useReloadAfterRelaunch, which is the same true → false watcher V30 had added here privately, and edited the banner C3 had rewritten. Three hunks, all resolving toward this branch: main's side still describes the passive banner C3 replaces. The private watcher goes and the shared hook stays, so one reload path serves both. reloadOnce stays with it — the hook rides a 30s query, which is right for a tab that is only watching, while the tab that pressed the button polls every second.

Main's stock controllers (agents/<slug>/controllers/**, FEAT-126) need no new handling: they sit under an agent slug, so the C11 machinery already reaches them, .py and .yml included through the unstamped arm.

Group V — found by installing and running it

The 32 above were found by reading. V1–V14 were found by doing the documented install into an empty directory — Condor's make install + make run, then hummingbot-api's make setup + make deploy — pointing the two at each other, pushing a synthetic upstream commit, and applying the update from the dashboard.

V15 onward are review, in six rounds: of this branch, and then of those fixes in turn. Each row says which, because the pattern is worth seeing — a fix that covers one path and not its siblings accounts for most of them.

# Scenario Fix
V1 Supersedes S2. stash is offered as a way out of dirty-conflict, and that block means the incoming commits touch the file being parked — so the replay conflicting is the main line, not a corner. git stash pop handles that badly unattended: conflict markers written into the tracked file, index left unmerged, stash kept, exit non-zero. The run still reported succeeded, over a checkout holding a half-merged agent playbook. The remedy it printed — "resolve it by hand with git stash pop" — fails with needs merge when followed. The docstring said a conflict aborts; nothing aborted. apply plus an explicit drop, because apply can be undone and pop cannot. The undo is deliberately narrow: reset --hard is the obvious abort and is wrong here, since these checkouts are runtime working directories and unrelated uncommitted work is expected — the preflight gates on the intersection of dirty and incoming for exactly that reason. Only the stash's own paths go back, tracked ones to HEAD and untracked ones removed, and the abort is claimed only once the unmerged set is actually empty. Files stashed with -u live in a third parent commit stash show ignores, so they are asked for separately. A new stashed-work warning closes the other half: git status says nothing about stashes, so parked work used to sit there with every surface reporting a healthy install.
V2 H1.2 never fired. It rests on registry_has_digest — ask the registry whether it holds this digest — and that needs a digest to ask about. RepoDigests is populated for a build only under the containerd image store; under the classic overlay2 store, still the default on Docker Engine, docker build leaves it []. So local_image_digest returned None, _image_facet took the "never been pulled" arm, and preflight turned it into a hard block over the whole update — Condor's half included — coded registry-unreachable while the registry answered fine in the same call. No test covered local is None. "No registry digest" and "no image" were one answer to two questions. local_image_identity splits them: docker image inspect fails outright when the image is absent, so the id answers is it here and RepoDigests answers does the registry know it. Present-without-a-digest now reports as built here — the conclusion the containerd store reaches by the other route. Error codes also move onto the facet; preflight had been deriving them with "compose" in image.error, which is how this came to name a cause that had not happened.
V3 git fetch origin <branch> exits 128 both when the remote is unreachable and when the remote answered fine and has no branch of that name. Every failure read as the first, so an unpushed feature branch — the ordinary state of anyone mid-change — produced "the remote is unreachable", and the status card said "Failed to fetch from remote". Both send the reader to debug a network that was never the problem. incoming-unknown is the wrong shape besides: nothing is incoming, there is no upstream. no-upstream-branch names the branch, says it exists only here, and gives the two ways out — push it, or switch to one that tracks origin, naming that branch when the remote's default can be read. The block stays, because a branch with no upstream genuinely cannot be fast-forwarded; what changes is that the reason is true.
V4 The advisory lock never named its holder. _acquire_run_lock opened the file "w", which truncates at open — before flock is even attempted — so the process losing the race wiped the pid written by the one holding it. start then read an empty file and dropped the (pid N) the refusal exists to carry, and anyone who went and looked at update.lock found it empty too. Open "a+", and empty the file only once the lock is held — the point at which this process is entitled to write it.
V5 C5's new step cried wolf on every macOS update. _listening_binds falls back to lsof, and -sTCP:LISTEN makes lsof print the state as its own trailing token: NAME is TCP 127.0.0.1:8088 (LISTEN), so the last column is (LISTEN), not an address. Split on ( it yielded the empty string — and the empty string was in _is_public_bind's wildcard set. Any listener on the dashboard port therefore read as bound to all interfaces; in local mode that is a FAIL whose remedy is "unset WEB_HOST", a variable nobody set, on a port that was loopback all along. On a healthy install it was the only thing the new doctor step reported. Drop the state token before reading the address. The empty string also stops counting as a wildcard: a bind this code could not parse is unknown, and unknown is not evidence of exposure — otherwise the next parsing slip becomes a security warning again.
V6 Three whole trees were never scanned for stale forks. all_stale_forks walks iter_agent_slugs(), which is built on _is_agent_dir — and that rejects a leading underscore. So _shared, the library every agent reads under its own, was never passed in: published skills, routines and (since FEAT-126) controllers could all diverge from the shipped copies in silence. One entry added to the enumeration. The comparison itself always worked — stale_forks("_shared") returned the right answer with nothing calling it. Worth naming the asymmetry this leaves: locally_overridden walks raw paths, so the pre-update warning always covered _shared. An operator was told once, at the update that changed the file, and never again — including when the customization came after the change had landed.
V7 C6's own tree had no coverage at either end. FEAT-033 keeps the shipped routine library at the repo-root routines/ and FEAT-115 moved only the write target under the agent, so the chat's pair is (<local>/condor/routines, <repo>/routines) — the one layer whose stock root is not <stock>/<slug>/. stale_forks asked _homes for the pair, got <stock>/condor/routines, found nothing there on any install, and filed every chat routine under "an ordinary file the agent authored". locally_overridden missed it from the other end: it skipped any incoming path whose first segment was not agents/. So improving a shipped routine — the thing the local layer exists to allow — shadowed it in silence, before the update and after. Both ends ask the loader where stock actually is. The per-file comparison moves into _compare_layer so the second root reuses it rather than growing a copy that can drift, and rel_prefix keeps the reported path the one the reader would go looking for.
V8 StaleFork.rel is relative to one library's root, so every forked playbook rendered as a bare AGENT.md and the warning read (AGENT.md, AGENT.md, AGENT.md) — three files it could not tell apart. Nine agents ship, so an install with more than one customized is the ordinary case; and main retiring three agents makes a retired entry collide with a stale one under the same name. rel keeps its meaning, because the stores and the tests read it as library-relative. The qualified form is a new label, which is what the surfaces show.
V9 test_three_slow_servers_resolve_in_one_timeout_not_three asserted elapsed < DELAY * 1.5 — a wall clock, which measures the machine as much as the code. It passed alone and failed under a loaded full-suite run: red that says nothing about the property and trains people to re-run rather than look. Rendezvous instead of clock. Every fetch waits at a barrier until all three arrive, so a concurrent resolver releases the moment the last one gets there however loaded the host, and a serial one cannot reach the assertion at all — it fails on the rendezvous timeout rather than hanging. The deterministic assertion (max_concurrent == 3) was already sitting next to the flaky one and was always the thing carrying the test. Checked both ways: green as written, and assert 1 == 3 when the route's gather is replaced with a sequential loop.
V10 Reported live: a local-only install with a pending update where clicking the "Updates available" notification did nothing at all. Two files have to agree about one number and nothing checked that they did. .env decides who the dashboard is — local mode signs in as ADMIN_USER_ID, no password — and config.yml decides what that id may do. On the install in question .env resolved to 1883786161 (a duplicated key; all three parsers that read that file take the last assignment) while config.yml gave that id the role user, with admin sitting on id 1. Every admin route answered 403, the frontend dropped the Updates tab, and the notification's own ?tab=updates link fell back to Servers without a word. The update engine was working the whole time — nothing could reach it. And nothing was wrong enough: make doctor printed a green ✓ ADMIN_USER_ID 1883786161, a tick on the exact value that had no rights. Three parts. Doctor: Admin access names the id, the role it actually has and the key to change; ADMIN_USER_ID (duplicate) fires separately on a second assignment, because a duplicate is silently authoritative while the first line is the one people read. Dashboard: the fallback stays — the tab genuinely is not theirs — but it now says so instead of redirecting in silence, naming the role, the knob and make doctor. Message: get_user_role returns the enum, so C10's 403 rendered 'UserRole.USER' — a string that appears nowhere in config.yml, so the reader could not search for it.
V11 make install runs setup-chrome, which was python -c "import kaleido; kaleido.get_chrome_sync()" with stderr sent to /dev/null. Three things went wrong at once when it stalled: no timeout, so it waited for ever; no progress, so nothing separated slow from wedged; and the only line on screen was Setting up Chrome for chart rendering..., indefinitely. Measured in that state: 256 KB of a ~150 MB archive after thirty minutes, connection open and idle. The install could not finish and nothing said why — reached by following the documented setup, on the path where every update step in this branch got a timeout and a named failure. condor/setup_chrome.py, following condor.setup_llm's convention. The stall detector is the part that matters: a cold download on a slow link can legitimately run for minutes, so a ceiling generous enough not to cut that off is also generous enough to sit on a dead connection just as long — bytes arriving is what separates them. A stall names the proxy or firewall that a held-open idle connection usually means; the ceiling names the knob to raise (CONDOR_CHROME_TIMEOUT) and says the link was merely slow. Progress is printed as it goes. An already-installed browser now costs nothing instead of being re-fetched every install, and the real error is shown instead of discarded. Always exits zero: charts are optional and an optional renderer must not fail an install.
V12 compose_up shells docker compose up -d into HUMMINGBOT_API_DIR, so it never passes the guard make deploy has. Same exposure: container_name: is fixed for every service, so two checkouts are one stack to Docker — Compose matches by name, prints Recreate, and the update takes over the other checkout's containers and its postgres volume, silently. Condor points at a directory; Compose acts on a project, and those differ whenever two checkouts share a basename — which they do by default, both being hummingbot-api. Compose records the project's working directory on every container, so the question is answerable. A foreign-stack block before the update, and the same question again immediately before compose_up — the confirm screen can be minutes old and what is at stake is a database. Nothing running, an unlabelled container, or an unreachable Docker are all "no conflict".
V13 A live dashboard reported "No local digest for hummingbot/hummingbot-api:latest — it has never been pulled by tag". The image had been on disk for days and the container was running; Docker Desktop was restarting. Only one probe in that path needs the daemon, which is why nothing else gave it away — measured with the socket gone: compose config rc=0 (parses locally), buildx imagetools inspect rc=0 (asks the registry), image inspect rc≠0. So a service definition and an available digest both resolve, and the single failure lands on the branch that concludes the image was never pulled. V2 did not help: splitting "no registry digest" from "no image" gives two answers about the image, and this is not one. Ask whether the daemon is there before concluding anything about what it holds. One extra command, only on the error path — a test pins that the happy path never runs it — and the available digest the registry already gave us is kept rather than discarded.
V14 remote_default_branch turned refs/remotes/origin/HEAD into a name with rsplit("/", 1)[-1] — right for main, wrong for every branch with a slash: fix/x came back as x. image_tracks_this_checkout compares against it, so on a repo with a slashed default the comparison never matched and the image was skipped with a reason that read like a deliberate finding. V3 prints the name as advice, so it was also telling people to git switch to a branch that does not exist. Strip the known prefix, and return None when the ref is not under it rather than inventing a name from what is left.
V15 Review, P1. FEAT-115 moved the chat's routine write target to <local>/condor/routines, but assistant_routines sends the chat through discover_routines, which reads the root library and the shared one and never that. The agent wrote routines it could not then see — and the v6 migration, which lifts previously untracked routines out of routines/, moved existing visible ones into the same unread directory. The chat's own layer shadows the general library, exactly as an agent's does. FEAT-033's carve-out is kept: the shipped catalog is still read beside it.
V16 Review, P1. has_update_stash looked for our message anywhere in the stash list while apply/drop default to stash@{0}, and the stack reorders on every push. With an operator stash made after ours, theirs was applied and dropped — their work restored into the tree unasked, ours left parked, and the step reported "Restored the work that was stashed before the update." Resolve our entry by name to a stash@{n} and address every command at it. Nothing between the apply and the drop can renumber it.
V17 Review, P1. Edited through the product and in the checkout is two customizations. keep-mine moved the checkout's copy onto the product's with shutil.move, which replaces it — one destroyed, while the message said the version was kept. Refused, naming both and where they are. Only the operator can say which they meant.
V18 Review, P1. The update lock is taken before check() and released only by the task created after it, so a git or docker that cannot start left it held by a process that was not updating — locking out every other process on the checkout until it exited. The setup between the lock and the task is wrapped; any failure releases before it propagates.
V19 Review, P1. The relaunch banner treated a 403 like a dropped connection — reasonable for a restart, since the socket usually closes before the reply. For a non-admin seat the restart never happens, so the poll finds the same server answering, reloads, and the countdown starts again: a reload loop that seat can never end. A 403 is a refusal, not silence: the banner stops and says the seat lacks the admin role. The half of V10 the Updates tab already had.
V20 Review, P2. keep-mine is offered for an untracked-conflict too, and checkout HEAD -- <path> fails on a path HEAD never had — so the resolution reported failure after moving the file. Only tracked paths are reset. Moving an untracked one was the resolution.
V21 Review, P2. The CRLF guard read the working tree, where * text=auto promises nothing — it governs what git stores. A checkout with core.autocrlf=true (the Windows default, inherited by a WSL2 clone under /mnt/c) materializes CRLF for exactly the files this is meant to pass, failing every ordinary .py and .tsx. Reads the index via git ls-files --eol, which is what text=auto actually governs. Checked both ways: passes on a clean checkout, and still catches a blob forced in as i/crlf.
V22 Review, P2. keep-mine moves a batch, and the "destination already exists" check ran inside the move loop — so a clash found half way through returned "nothing was moved" with the earlier files already gone. The checkout was left showing a deletion nobody made, and the update still blocked. Every destination is validated before anything moves, and the refusal names only the paths that actually clash.
V23 Review, P1. A staged addition kept its index entry. Resetting only paths present in HEAD is right for an untracked file and wrong for a staged one: checkout HEAD -- cannot touch a path HEAD never had, so the entry survived the move, and the incoming commit adding that same path still had something to collide with — reported as a success. Staged additions get rm --cached; tracked ones still reset to HEAD.
V24 Review, P1. V16 fixed the lookup and left the advice. The abort message still said stash@{0} and a bare git stash pop. Ours is not necessarily on top, so following the printed remedy restored somebody else's work — the exact mistake V16 had just stopped the code from making. The message carries the ref that was actually resolved.
V25 Self-review. V15 stamped the chat's routines as an agent's. Discovering its local layer with agent_slug=CHAT_SLUG sets source="agent:condor", and that stamp is load-bearing: the MCP tool renames an agent: routine to <slug>/<name> and labels it agent-scoped with the specialist slug — which is empty for the chat. The chat's local layer is the writable half of the general library, so it discovers without a slug and keeps global: the bare name and scope the rest of that catalog has.
V26 Self-review. V12 refused the operator's own update through a symlink. Compose records the resolved working directory and os.path.normpath does not resolve symlinks, so reaching the same directory through one — macOS /tmp, any symlinked project root — compared unequal to itself. foreign-stack fired, naming that same directory back at them. realpath on both sides.
V27 Self-review. V11's stall detector killed healthy downloads. With no resolvable download root the byte counter is pinned at 0, so last_change never moves and the stall branch aborts a transfer that is progressing — then blames a proxy for it. A stall verdict needs a size to watch; without one, only the ceiling applies.
V28 Review, P1. V15 made chat routines listable but not runnable. The layer went into assistant_routines alone, while RoutineStore._resolve_routine starts a run through get_routine → discover_routines, which did not know about it. A newly authored routine failed to start, and a local override of a shipped name showed its own description in the catalog while running the shipped code — you read one thing and ran another. The layer moves into discover_routines, where the catalog, get_routine and the resolver behind a run all see the same thing; the special case is gone with it. Tests now pin resolution and not only the catalog, which is the gap that let this through.
V29 Found while fixing V28. discover_routines' mtime shortcut restores an unchanged root file by stem out of the previous merged dict. Sound while root shadowed shared; unsound the moment a local layer could shadow root, because the entry restored for a root stem may be the override — so a deleted override kept being served and the shipped routine stayed unreachable. It surfaced as cross-test pollution: test_primitives_catalog failed in the suite and passed alone. The root library gets a cache of its own. fresh_start still honours _routines_cache = None, which is the invalidation every existing caller uses.
V30 A tab that did not press the button never reloaded. C3's reload lived inside the countdown, so only the tab that started the relaunch picked up the rebuilt bundle. Every other one — a second admin's, one that pressed Cancel, one whose seat cannot restart at all — watched required go false, unmounted the banner, and carried on serving the bundle it booted with, with nothing left on screen to say so. The same went for every tab when the relaunch came from Telegram or make restart, since neither goes near the browser. The watcher sits above the early return, because the transition it waits for is the one that stops the strip being rendered. Now main's own useReloadAfterRelaunch — see Rebased onto main.
V31 And the tab that did press it reloaded too early. It reloaded on the first response it could get, but SIGTERM only starts the shutdown: through the drain and teardown() the dying process answers 200 for seconds. So the reload landed the new bundle — already swapped onto disk by C1's build step — against the old API, which is the one thing this was supposed to avoid. required is the flag that cannot lie: set once per process and never cleared, so only the successor answers false. Both reload paths share one guard, since either may get there first.
V32 Review. H1.2 guessed when it could not tell. registry_has_digest returns None for "could not ask" — typically a Docker Hub rate limit — and that fell through to behind=1. The two wrong answers are not symmetric: call it a pull and Condor offers to overwrite a make build whose only copy is on that host; call it a build and a real update hides behind a silent "up to date". Neither. The probe failing is a fact about the probe: image-origin-unknown blocks with the reason, which is what an error on the image facet already does, and retrying is the remedy.
V33 Review. The facet tests reached the network. _facet stubbed three updater calls and left registry_has_digest real, so it shelled out to docker buildx imagetools inspect — 13.4s for a single test. It agreed with its own assertion only because the registry answered one of the two ways that reach the same branch; had it ever answered not found, the test would have failed. Stubbed, with all three branches pinned separately. The file went from 13.4s to 1.0s.
V34 Review. POST /updates/relaunch was missing from the gate table. That table exists to be exhaustive — "every one of them must answer 403" — and the route it omitted is the one that restarts the process. The gate itself was never wrong. _stub_pipeline had the same shape of gap: run_doctor and stash_pop ran for real, the latter against this very checkout. The route joins the table. Both pipeline calls are stubbed; that file went from 6.6s to 1.6s.
V35 Review. C5's WARNED arm had no test. run_doctor appeared in no test file at all, so nothing held the three properties that make it safe. Pinned: warned, still succeeded, still owed the relaunch. Failing the run on the doctor's verdict would report code already on disk as never having landed, and withhold the one thing that reaches the fix for whatever it is complaining about.
V36 Review. C1 was only half fixed. The atomic swap went into the updater; make build-frontend still ran a bare npm run build, and vite's emptyOutDir empties the served directory in place. run and restart stop Condor first, but the target is public and a Condor started any other way — run-fg, a supervisor, a second checkout — is still serving out of dist. The Makefile swaps through dist.new exactly as the updater does, with a test reading the target so it cannot drift back.
V37 Review. C1's 503 had no HTTP test. TestClient had been sitting imported and unused in the bundle-build tests ever since the 503 was written. A test that takes index.html away, asks for a page, and asserts 503 with the explanation and no-cache — using that import.
V38 Review. V19's denied banner still offered "Restart now". It posts the same request from the same seat and is refused the same way, underneath a sentence explaining that it will be. The button is gone in denied. What that seat has to do instead is in the message, and neither half of it is a button.
V39 Review. C17 was over-claimed. Three places still told the old story — that exec'ing races a second Condor onto the port: the engine's own module docstring, the Telegram handler, and the restart-flow tests. This branch's relaunch route refutes it in as many words, so the codebase argued both sides of the same question. All three now say what the code does, and why the run stops short anyway: the moment to interrupt a running bot is not the pipeline's to pick.

How V10 and V11 were verified

V11 was checked against the real download on all three paths: a successful fetch (131 MB in 37s, with progress), an already-installed browser (instant skip, nothing re-fetched), and a hard failure forced through a dead proxy — which now prints Connection refused instead of discarding it, and still exits 0 so the install continues. The stall path is covered by tests, being the one that cannot be forced on demand.

For V10, the live install was left untouched. The state was reproduced in a throwaway install on a spare port: as a non-admin the tab is replaced by "Updates is admin-only … Give this user role: admin in config.yml", and make doctor fails with the id, the role and the key. Granting the role brings the tab back and GET /updates answers 200.

Not fixed here

One thing the clean-room install surfaced that is not this branch's to fix:

  • Two hummingbot-api checkouts share one compose project. The directory basename is the project name and container_name is hardcoded, so make deploy in a second checkout adopts the first's containers and its postgres volume — it printed Recreate, not Create. Belongs in hummingbot-api, and is fixed there in fix: connector config visibility and XRPL market pricing hummingbot-api#227; V12 covers the path an update takes, which does not go through make deploy.

@david-hummingbot
david-hummingbot force-pushed the fix/update-flow-hardening branch 2 times, most recently from a766dfb to 7a40573 Compare September 29, 2026 03:25
@david-hummingbot
david-hummingbot marked this pull request as ready for review September 29, 2026 12:05
@greptile-apps

greptile-apps Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

[High risk] Hardens the update flow with schema changes and migration logic.

The PR does not appear safe to merge until the two frontend build paths cannot interfere with each other's bundle swap.

Findings

  1. P1 Concurrent builds can remove bundle ▶
  2. P2 Test disrupts live dashboard ▶
  3. P2 Fast reconnect poll cannot authenticate ▶

Summary

This PR hardens Condor's update preflight, conflict recovery, image handling, dashboard builds, health checks, and relaunch flow. Since the previous review, it also:

  • Makes the public Makefile frontend build swap bundles instead of emptying the served directory.
  • Handles inconclusive image-origin checks and expands relaunch and update-flow tests.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[In-process update] --> C[frontend/dist.new and dist.old]
  B[make build-frontend] --> C
  C --> D[frontend/dist served by Condor]
Loading

Reviews (9) · Last reviewed commit: "Fix seven findings from review"

Comment thread routines/base.py
Comment thread utils/updater.py Outdated
Comment thread utils/updater.py Outdated
Comment thread frontend/src/components/RelaunchBanner.tsx Outdated
Comment thread condor/updates/run.py Outdated
Comment thread utils/updater.py Outdated
Comment thread tests/test_repo_portability.py Outdated
Comment thread utils/updater.py Outdated
Comment thread utils/updater.py
Comment thread utils/updater.py
Comment thread routines/base.py Outdated
@david-hummingbot
david-hummingbot force-pushed the fix/update-flow-hardening branch from 1fe7e97 to 3fdf58e Compare September 30, 2026 05:02
Comment thread frontend/src/components/RelaunchBanner.tsx Outdated
Comment on lines +125 to +130
if (res.ok) {
const body = (await res.json().catch(() => null)) as {
required?: boolean;
} | null;
if (body?.required === false) {
reloadOnce();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Fast reconnect poll cannot authenticate

The new successor check requires a successful response body, but this poll calls /meta/relaunch without a bearer token. That endpoint requires authentication, so the one-second poll cannot receive required: false. The initiating tab must instead wait for the authenticated shared query’s next 30-second poll to reload.

Knowledge Base Used: Frontend application

The manifest declared a floor and no ceiling, so uv picked the newest
interpreter on the host. The dependency tree does have a ceiling -- pandas-ta
pulls numba, which pulls llvmlite -- but it is only discovered after resolution,
inside a transitive build, in a message naming neither Condor nor the real
constraint. On a host whose newest Python is 3.14 (a stock Anaconda is enough)
`make install` simply fails.

Measured in the install sandbox: `uv sync --python 3.13` completes and gives
Python 3.13.15, while `--python 3.14` dies building llvmlite with
`TypeError: Popen.__init__() got an unexpected keyword argument 'dry_run'`.
So <3.14 is the honest ceiling -- 3.13 is genuinely supported -- and stating it
turns an unreadable transitive build error into an accurate resolver message.

.python-version makes the choice explicit for a fresh clone rather than leaving
it to whatever happens to be first on PATH.
There was no .gitattributes, so line endings were whatever each host's git
config produced. That is not cosmetic here: content_digest() hashes a stock
agent file's raw bytes to stamp a fork with the revision it diverged from, so
the same file yields a different digest on a CRLF checkout than on an LF one
(measured: sha256:6d9b716cc24c against sha256:ee60f3851972).

It also made an untouched file able to block an update. With core.autocrlf
applied at clone time but not persisted into the clone's config -- which some
GUI clients and a later global-config change both produce -- tracked files show
as modified with no user edit, and if upstream also touched one, repo_blocks()
raises dirty-conflict on a file nobody edited. (The persisted case is clean; I
checked rather than assumed.)

*.sh and Makefile get eol=lf because a CRLF copy does not merely hash
differently, it fails to execute.

This lands before the digest change that depends on it.
content_digest() hashed raw bytes, which made a fork's stamp mean different
things on different platforms: the same file digests to sha256:6d9b716cc24c on
an LF checkout and sha256:ee60f3851972 on a CRLF one. A forked_from written on
one host therefore compares as *changed* on another, and the staleness check
added later in this branch would report every fork as stale for a reason that
has nothing to do with upstream -- a warning that cries wolf about files
carrying trading rules.

The preceding .gitattributes keeps the tracked tree LF, which fixes the common
case. This fixes the comparison itself, so it also holds where git's config does
not reach: a .condor/ local root carried between machines, or a stamp written
before the attributes landed.

Legacy stamps are unaffected on an LF checkout, where the normalized and raw
digests are identical. The one case that can differ -- a stamp written from a
CRLF working tree -- is handled where the comparison happens, in the staleness
check.
fork_path() stamps a forked file with forked_from -- the digest of the stock
file it came from -- and that stamp is the only record of what a local
customization diverged from. The three stores render their frontmatter through
carry_fork_stamp() and keep it. The two web routes did not.

They write a body the browser round-tripped from a GET, and that GET had read
the *stock* file, which carries no stamp. So fork_if_stock() wrote the stamp and
the very next line erased it:

    after fork_if_stock:  forked_from: sha256:ce2f8b03fc63
    after the route's write:  NO STAMP

Editing an agent through the dashboard is the primary way anyone edits these
files, so in practice the stamp almost never survived its first save -- which
leaves the staleness check nothing to compare and is why this lands first.

The dance goes in layering.write_preserving_stamp() rather than inline at both
call sites, so a third write path cannot reintroduce the bug by forgetting it.
fork_path() records forked_from and nothing ever read it back. content_digest()
was called in exactly two places, both inside fork_path at fork time, so the
stamp was written and never compared -- not in condor/updates/, not in the
frontend, nowhere.

That is the mirror image of the problem FEAT-115 solved. The old failure was
loud: your edit blocked the update. The new one is silent: the update succeeds,
upstream's rewrite lands in the shipped tree, and the agent goes on reading your
copy for ever. Upstream churns exactly these files, and they carry trading rules
and risk limits, so a limit tightened upstream never arrives.

stale_forks() supplies the missing comparison, in one walk with two outcomes:
the counterpart moved (stale), or the counterpart is gone (retired -- upstream
withdrew a playbook and reads resolve local-before-stock, so it keeps running).

It only reports. Local still wins: that is the deliberate promise of FEAT-115,
and an update that quietly took upstream's version back would be the old loud
failure wearing a new coat. Items with no stamp are skipped, because a file the
operator authored from scratch was never a fork of anything.

A stamp written from a CRLF tree holds the pre-normalization hash, so the
legacy form is checked too -- otherwise the preceding digest change would report
every such fork stale at once.
stale_forks() had nobody to tell. This raises it where the question is actually
being asked -- "what will this update do to my install" -- as a non-blocking
warning naming the paths.

Non-blocking is the point. Owning a customization is what FEAT-115 shipped; the
missing half was the sentence saying upstream has moved on without it. The
previous audit asked for exactly this: treat a locally-edited playbook as owned
and let the update leave it alone with a clear "your version, upstream moved
on" notice. The owning half shipped; this is the notice.

Two shapes, because they need different advice: files upstream changed (your
version stays in use, those changes will not reach your agents) and files
upstream removed (they keep running -- mute a retired playbook rather than
deleting it, which is what stock_delete_error already establishes).

The path list is capped at eight. An install that forked thirty playbooks needs
a count and a few examples, not thirty lines in a Telegram message.

Both surfaces render warnings by message, so neither needed a change.
Two gaps in the shared git machinery, so both components inherit both.

S1: rev-parse --abbrev-ref HEAD returns the literal string "HEAD" when the
checkout is detached, and everything downstream treated that as a branch name.
origin/HEAD then resolves to the remote's default branch, so ahead_count found
nothing ahead and raised no diverged block, and `git merge --ff-only
origin/HEAD` quietly succeeded -- fast-forwarding a detached checkout onto main
and leaving it detached, 299 commits of someone else's history applied with no
message saying anything had happened. Nothing anywhere called symbolic-ref.

is_detached() asks the question --abbrev-ref cannot answer, and repo_blocks()
refuses before it reads a branch name or fetches. The message names the commit,
because the operator needs somewhere to go back to.

remote_default_branch() comes along now because it is the other half of the same
symbolic-ref gap, and the image-versus-branch work later in this branch needs it
rather than hardcoding "main". It returns None when the ref is absent, which
callers must read as "cannot tell".

S3: a crashed git leaves .git/index.lock behind and every later subcommand fails
on it. The raw error says "Another git process seems to be running", which sends
the reader looking for a process that is not there; one line naming the file
turns that into a fix. Appended only when the output actually mentions the lock.
hummingbot-api's Makefile ships `docker build -t hummingbot/hummingbot-api:latest .`
as the documented way to run your own code, and Condor offered to overwrite the
result with the registry's:

    image: current='sha256:1b2e7341'  available='sha256:6341fe50'  behind=1
    plan : Pull the published hummingbot-api image

The obvious discriminator does not work. "A built image has no RepoDigests" is
true of the old graphdriver store and false of the containerd one, which records
a canonical digest for a build as well as a pull -- so on Docker 29.x the built
image has a RepoDigest like any other, and a fix keyed on the list being empty
never fires at all. .Comment is no help either: both read
buildkit.dockerfile.v0, because the published image was itself built by buildkit
in CI.

What does work is asking the registry whether it holds *this* digest. A pulled
digest resolves; a built one comes back "not found", because it was never
pushed. Verified against the real registry in the install sandbox: pull, then
build over the same tag, and the facet goes from behind=1 to "Built locally".

registry_has_digest() returns None rather than False when it could not ask, and
only False suppresses the "behind" reading -- an unreachable registry is not
evidence that an image was built locally. The probe runs only when the digests
already differ, so the common up-to-date path costs nothing.
hummingbot-api is described by two facets and only one of them was honest. The
repo facet compares HEAD against origin/<branch>, correctly. The image facet
compared the local digest against whatever the registry's tag resolved to -- and
CI publishes only on merges to the default branch, tagging only `latest` and the
version (the registry holds exactly `latest`, `1.0.1`, and one stale one-off).
So on a feature branch the update silently offered, and applied, the default
branch's code, while the status card went on truthfully reporting the checkout
as up to date.

The report suggested warning. Deciding is simpler and stronger: if the published
tag cannot correspond to this checkout, do not offer to move the container at
all. PRs merge straight to the default branch and there is no per-branch tag, so
there is no second mapping to make -- either this checkout is the one the tag is
built from, or the operator owns the image.

Branch name alone is not the test. image_tracks_this_checkout() requires four
things, each of which is a way the name gets it wrong: the remote must be the
canonical repository (a fork has a `main` too), HEAD must not be detached
(--abbrev-ref answers the literal string "HEAD"), the branch must be the
remote's default (read via symbolic-ref, not hardcoded), and the checkout must
not be ahead (unpushed commits mean the branch is `main` and the code is not).

The skip is stated, not silent -- a silent skip would be the same class of
defect as the silent pull it replaces. The running digest stays visible, the
offer disappears, and the detail says why and whose image it is. The plan drops
the image steps to match, because a plan that lists a pull the run then skips is
its own small lie. The checkout is still fast-forwarded, and the step now says
what for: it supplies the shared files the container reads.

"source" mode goes with it. _steps_for had a "Rebuild from source" branch and
compose_mode/compose_build existed to serve it, but no shipped compose file has
a build: key, so it was unreachable on every real install -- a mode nobody
could enter and therefore nobody tested. An operator who hand-adds one is
running their own image, which is precisely the case the checkout gate now
covers by leaving it alone.
vite.config.ts overrides neither outDir nor emptyOutDir, so vite's defaults
applied and `npm run build` emptied frontend/dist while uvicorn was serving out
of it. Measured against a live instance with index.html moved aside:

    baseline:        200
    index.html gone: 500      <- not 404: FileResponse raises on a missing path
    deep route:      500
    asset 404 check: 404      <- the mount itself is healthy
    restored:        200

Three consequences from one emptied directory. A reload during the build got a
500 that nothing upstream can tell from a crash. A build that *failed* left the
install with no dashboard at all, while the run reported that "the dashboard
would come back on the previous bundle" -- a sentence that was false, because
vite had already deleted it. And a restart on a destroyed dist came up with no
static mount and a bare 404 at /, because the mount and the SPA fallback both
sit inside a single dist.is_dir() evaluated once at create_app() time.

The build now writes dist.new and swaps, so exposure is one rename instead of
minutes, dist is untouched unless the build succeeds, and dist.old is a free
instant rollback. Verified against the real toolchain in the install sandbox:
the pinned vite accepts --outDir/--emptyOutDir, two builds leave dist plus a
populated dist.old, and the tree stays clean.

.gitignore gets both scratch names in this commit, not a later one: `dist/` on
line 13 matches neither, and an unignored scratch dir would make every mid-build
tree dirty -- which is itself something repo_blocks() can refuse an update over.

The remaining window is the instant between the two renames, and a process
killed inside it leaves a good dist.new and dist.old with no dist. That is what
_adopt_orphaned_bundle() is for: at boot, put one back, preferring dist.new
because its build had already succeeded.

The SPA fallback now answers 503 with an explanation rather than an opaque 500,
the failure message says what is actually true, and npm ci / OOM-kill / timeout
failures are named separately because they look identical in the raw output and
have different remedies. The preflight warns when a rebuild is coming, since
nothing told the person reading the dashboard that it was the thing being
replaced.
Two defects that compounded: the flow offered a restart it had just finished
arguing against, and if that restart died the reason was unrecoverable.

The keyboard's condition was `run.state == "failed" or relaunch_pending()`, so
any failure produced a Restart Now button -- including one whose own message
says not to. The admin read "Code was pulled but dependencies failed. Fix it
before restarting" with Restart Now directly beneath it, and pressing it booted
the new code against the old venv. The `or` arm is gone; the condition that was
always right is untouched.

The second half is why every other failure here was hard to diagnose. The
Makefile turns remain-on-exit on for the startup probe and back off once the
boot is confirmed, and that is correct -- a crash three hours later must still
take the session down, or `make status` would report a dead Condor as running.
But it means an in-process restart hands over with the pane set to close, so a
successor that fails to boot takes its own traceback with it:

    WITH remain-on-exit OFF (what `make run` leaves):  session GONE
    WITH remain-on-exit ON:   Pane is dead (status 1) -- readable

So the same dance is repeated around the handover: on immediately before the
exec, off again from main() once the successor is serving. The Makefile's own
toggle is unchanged.

And the exec itself is guarded. os.execv failing is the one way a restart ends
with nothing running and nothing said -- the loop is gone and the pane is about
to close, so a log line can vanish with it. The traceback now also goes to
restart-failure.log in the runtime root, where the next boot and the operator
can both find it.
A Local-mode admin could discover, preflight, resolve conflicts and apply an
update entirely in the browser, and then had to leave the browser for any of it
to take effect: the panel's advice was to run `make restart`. Telegram had a
Restart Now button the whole time.

The asymmetry was explained, in both the engine and the banner, as a safety
trade -- that re-execing races whatever started Condor into a second copy on the
same port. The code does not do that. request_restart() raises SIGTERM rather
than exec'ing, so teardown() runs and main() execs only once the loop is gone;
the exec then replaces the process image in place, keeping the pid, the parent
and the controlling terminal. Nothing exits, so nothing can observe a child
dying and start a replacement. The listening socket is non-inheritable (PEP 446)
and released by the exec -- measured three exec generations deep, rebinding to
the same port each time with the pid unchanged:

    gen1: bound :18099  pid=45185  inheritable=False
    gen2: bound :18099  pid=45185  inheritable=False
    gen3: bound :18099  pid=45185  inheritable=False

So the browser now does what a router does when its firmware updates: counts
down, restarts, and comes back on its own. The countdown is cancellable because
two admins may be watching and one may want to pick the moment, and a cancel
leaves the old banner and a manual button. A failed run never triggers it --
that is the mistake the previous commit removed from the other surface.

The reload at the end is not optional: the build swaps in a bundle with new
hashed asset names, so a browser still holding the old index.html would be a
different build from the API answering it. Reconnection is a plain fetch loop
rather than the app's socket, because it has to work before any of the app's
channels are back.

The POST goes through condor.updates.request_relaunch() rather than importing
utils directly -- the routes are a view over the engine, and a test pins that.
The 202 may never arrive, since the process handling it is the one being
replaced; the client treats that as "probably restarting" and lets the
reconnect decide.
A hand-edited playbook was a dead end. FEAT-115 redirects writes made through
the product into the gitignored local root, but a text editor knows nothing
about that, and these are markdown files sitting in a git checkout -- exactly
what people open in one. The edit raised dirty-conflict, and both escapes lost
something:

    before discard: # Scout v1 + TWO HOURS OF MY WORK
    resolve('condor','discard') -> True | "Restored 1 tracked file(s)."
    after  discard: # Scout v1

A destructive act reported in the vocabulary of a repair. The other escape,
stash, kept the work but behind a shell command the Local user was never asked
to open.

keep-mine moves the edit into .condor/agents -- where FEAT-115 would have put it
had the write gone through the product -- and resets the tracked file to HEAD.
The customization survives and keeps being used, the shipped copy fast-forwards
normally, and the install ends up in the state the design supports instead of a
dead end.

Offered only when every conflicting path is forkable. On a mixed set it would
read as "keep all of this" while quietly resetting the paths it could not move,
and a path with no local layer -- main.py, a compose file, anything in
hummingbot-api -- would be "kept" somewhere nothing reads, which is a quieter
way of losing it than discarding. The check is repeated in resolve() rather than
trusted from the button, since that screen may be minutes old.

The stash escape stops being one-way as well. The whole point of parking work
rather than discarding it is getting it back, and a clean pop is the common case
once the fast-forward has landed, so the run now pops it and says what happened.
The original caution was right about the *failure* -- a conflicting pop leaves a
half-merged tree plus a stash entry nobody was told about -- so a conflict warns
instead of failing the update, and says where the work still is. A stash we did
not make is left alone.

That needs a step state for "ran, did not fail the update, but read this":
WARNED, taught to both renderers. The discard message now names what was lost.
Every other agent's authored content lands in the gitignored local root. The
chat's did not: assistant_routines_dir() returned the repo-root routines/ for
both a falsy slug and "condor".

    assistant_routines_dir(None)                 -> <repo>/routines        # tracked
    assistant_routines_dir('condor')             -> <repo>/routines        # tracked
    assistant_routines_dir('hyperliquid_expert') -> <local>/.../routines   # ignored

That directory is tracked and upstream actively maintains it -- 13 files, 48
commits touching them across the last 60 -- and edit_routine overwrites in
place. So asking the chat agent to improve a shipped routine modified a file
upstream also modifies, and the next update whose commits touched it raised a
dirty-conflict.

The carve-out is not removed, because it is load-bearing for a different reason:
FEAT-033 found that threading the slug through naively relocates the general
*library* to agents/condor/routines, which does not exist, so the whole catalog
empties without a single error. Only the **write target** moves; the shipped
root stays as the read fallback, so reads are unchanged and the catalog is as
full as it ever was. The test that pinned this now asserts the property it was
actually protecting -- a populated catalog -- rather than the implementation
detail that writes and reads shared a directory.

A v5 migration lifts what the product already wrote there. Untracked files only:
a tracked file is either shipped or a modification of something shipped, and
moving it would take the operator's edit out of the tree the update is about to
touch. keep-mine exists for exactly that case and it is theirs to choose.
Anything already present at the destination is left alone, so the lift is
idempotent.
Five things that each reuse machinery already here.

Verify the update worked (C5). make doctor / python -m condor.doctor already
exists, is read-only, and exits non-zero on real failures -- and was wired only
into make install. Nothing in the update path ran it, so a broken boot was
indistinguishable from a good one until someone opened the dashboard. The
contrast is the argument: the hummingbot-api path gets a health gate whose step
is literally "wait for the API to answer", while Condor -- the component running
the update -- got none. Never fatal: the code is already on disk, so failing the
run on the doctor's verdict would report a completed update as a failed one.

Warn on incoming migrations (C4). startup() migrates this deployment's data on
every boot and the moves are one-way, so restoring old code onto migrated data
leaves Condor looking for directories that no longer exist -- and FEAT-115
widened that to the agent tree, so a rollback now also strands forked playbooks,
skills and mutes. Keyed off what the fast-forward would actually write, via
incoming_paths: paths_changed() answers True for an unresolvable range by
design, so asking it against an empty commit would fire this on every update.
The frontend-rebuild warning moved onto the same footing.

Precheck disk (C14). shutil.disk_usage appeared nowhere in the update path, and
an update writes a synced venv, a fresh node_modules, a new bundle and a pulled
image. Running out part-way is the worst input to everything else here.

Say what an interrupted run left behind (C9). The old message described the
state of the disk accurately and omitted the part that matters: the process
reading it booted on the partial result. The fast-forward lands before deps are
synced and before the dashboard is rebuilt, so that Condor may be running new
code against a stale venv. It now names the steps that finished and says to
re-run before anything else.

Name the role, not the tab (C10). Local mode admits both user and admin at
login while every admin route demands admin, so a hand-edited config.yml boots,
logs in, and silently has no Updates tab. Best effort, because a gate must never
crash or fail open because a courtesy could not be paid.

And two guards against silent breakage elsewhere: an advisory lock file so a
second Condor on the same checkout cannot fast-forward the same repo
concurrently (_lock and _current are per-process, and a stray `make run` in
another pane is exactly what someone tries when a restart looks stuck), and
tests that fail if two tracked paths ever differ only in case or a CRLF file
lands -- both of which are invisible on Linux and break a clone on macOS or
Windows. The second one immediately found *.jpeg missing from .gitattributes.
_plan() gained an unstash and a doctor step; _steps_for(), which renders the
plan on the confirm screen, did not. The two are meant to be the same list --
the whole reason the run's steps are built up front is so the confirm screen
and the progress screen agree, and a step that turns out to be unnecessary is
marked skipped rather than quietly missing.

Caught by running a real update through the live API and watching the confirm
screen promise four steps while the progress screen ran six.
…o stamp

Two gaps in the staleness work, both found by asking what happens to a file an
agent creates rather than edits.

The warning was retrospective. stale_forks() compares a fork against the stock
file *on disk*, and that only moves once the fast-forward has landed -- so it
reported the divergence on the preflight *after* the update that caused it,
when there was nothing left to decide:

    === BEFORE the update ===   stale forks : none
    === AFTER  the update ===   stale forks : ['AGENT.md']

locally_overridden() intersects the incoming path set with what this install
already has its own copy of, so the preflight now says "this update changes 2
shipped files that you have your own version of" while the decision is still
open. The retrospective check stays -- they answer different questions, and the
silent-forever case is the one that needed both.

The detector also only walked *.md. A stamp lives in frontmatter, and _stamp()
deliberately refuses to invent frontmatter for a file that has none, because
that would change what the file is and break the machinery that imports it. So
everything else forked down was invisible to it -- and the library ships
nineteen .py routines. An agent editing a shipped routine shadowed it for ever
with nothing able to notice.

Same shape, different cause: a file the agent *creates* under a name upstream
later ships collides identically. Local wins per item, upstream's version is
unreachable, and no stamp ever existed to compare.

So the walk now covers every file and classifies by what it can prove: a stamp
that no longer matches is stale, a stamp with no counterpart is retired, and no
stamp with a differing counterpart is shadowing. An identical unstamped copy is
not reported -- copying is not diverging -- and a file with no counterpart at
all is exactly the ordinary "the agent wrote something new" case, which was
never a fork of anything.

The shadowing message is separate because the advice is: rename yours if you
meant to keep both.
The 1800s ceiling exists so a hung pull cannot wedge the update forever, but
hitting it returned a bare "Timed out after 1800s: docker compose pull" and left
the operator to guess whether a half-rebuilt stack was safe to touch.

It is. compose pull and compose up -d are both idempotent, so the answer is to
run the update again -- which is the one thing the message did not say.

The frontend build already got this treatment; the docker steps did not, and
they are the ones most likely to hit a ceiling: on Docker Desktop for macOS, or
on WSL2 with the daemon behind a VM, a cold pull legitimately takes several
times longer than it does on Linux.
react-hooks/set-state-in-effect, twice, both mine: setPhase("counting") ran
synchronously in an effect body, and the countdown effect called relaunchNow()
directly when it hit zero, which does the same thing one frame later.

The first was an effect doing work that belongs in initial state. The banner now
splits: the outer component is only a gate on `required`, and the strip it
mounts opens in "counting" because that is its useState default. Nothing has to
set it after the first render, and the `started` ref that existed only to make
the effect fire once is gone with it.

The second was a real cascade: reaching zero re-rendered to set the phase, which
re-ran the effect. The timer now owns it -- one timeout whose callback either
decrements or fires the relaunch, so every setState happens across an async
boundary rather than during the effect.

No behaviour change: same countdown, same cancel, same reconnect-and-reload.
`StaleFork.rel` is relative to one library's root, so every forked playbook
rendered as a bare `AGENT.md` and the warning read "(AGENT.md, AGENT.md,
AGENT.md)" — three files it could not tell apart. Nine agents ship, so an
install that has customized more than one is the ordinary case, not an edge
one, and main retiring three agents makes a retired entry collide with a stale
one under the same name.

`rel` keeps its meaning, because the stores and the tests read it as
library-relative. The qualified form is a new `label`, which is what the
surfaces show.
`all_stale_forks` walked `iter_agent_slugs()`, which is built on
`_is_agent_dir` — and that rejects a leading underscore. So `_shared`, the
library every agent reads *under* its own, was never passed in: published
skills, routines and (since FEAT-126) controllers could all diverge from the
shipped copies in silence.

The comparison itself already worked — `stale_forks("_shared")` returned the
right answer all along, nothing ever called it. Only the enumeration was
short.

The asymmetry this leaves behind is worth naming: `locally_overridden` walks
raw paths, so the *pre-update* warning has always covered `_shared`. An
operator was told once, at the update that changed the file, and then never
again — including when the customization came after the change had landed.
FEAT-033 keeps the shipped routine library at the repo-root `routines/` and
FEAT-115 moved only the *write* target under the agent, so the chat's pair is
`(<local>/condor/routines, <repo>/routines)` — the one layer whose stock root
is not `<stock>/<slug>/`.

`stale_forks` could not see that. It asks `_homes` for the pair, gets
`<stock>/condor/routines` as the stock side, finds nothing there on any
install, and files every chat routine under "an ordinary file the agent
authored". `locally_overridden` missed it from the other end: it skipped any
incoming path whose first segment was not `agents/`. So improving a shipped
routine — the thing the local layer exists to allow — shadowed it in silence,
before the update and after it.

Both ends now ask the loader where stock is. The per-file comparison moves
into `_compare_layer` so the second root reuses it rather than growing a
second copy that can drift, and `rel_prefix` keeps the reported path the one
the reader would go looking for.
`_acquire_run_lock` opened the file `"w"`, which truncates at open — before
`flock` is even attempted. So the process that lost the race wiped the pid
written by the process holding it, `start` then read an empty file, and the
`(pid N)` the refusal exists to carry was dropped from every message. Anyone
who went and looked at `update.lock` afterwards found it empty too.

Open `"a+"` and empty the file after the lock is held, which is the point at
which this process is entitled to write it.
`_listening_binds` falls back to `lsof`, and `-sTCP:LISTEN` makes lsof print
the connection state as its own trailing token: NAME is
`TCP 127.0.0.1:8088 (LISTEN)`, so the last column is `(LISTEN)`, not an
address. Splitting it on "(" produced the empty string — and the empty string
was in `_is_public_bind`'s wildcard set.

So on every host without `ss` (macOS, WSL without iproute2) any listener on
the dashboard port read as bound to all interfaces. In local mode that is a
FAIL, and its remedy is "unset WEB_HOST" — a variable the operator never set,
on a port that was on loopback all along.

That matters more now than it did: this branch runs the doctor at the end of
every update, so the one thing the new WARNED step reported on a healthy
macOS install was this phantom.

The empty string also stops counting as a wildcard. A bind this code could
not parse is unknown, and unknown is not evidence of exposure — otherwise the
next parsing slip becomes a security warning again.
`git fetch origin <branch>` exits 128 both when the remote is unreachable and
when the remote answered fine and has no branch of that name. The guard read
every failure as the first, so an unpushed feature branch — the ordinary
state of anyone mid-change — produced "the remote is unreachable", and the
status card said "Failed to fetch from remote". Both send the reader to debug
a network that was never the problem, and `incoming-unknown` is the wrong
shape besides: nothing is incoming, there is no upstream at all.

`no-upstream-branch` names the branch, says it exists only here, and gives
the two ways out — push it, or switch to one that tracks origin, naming that
branch when the remote's default can be read. The block stays, because a
branch with no upstream genuinely cannot be fast-forwarded; what changes is
that the reason is true.
H1.2 rests on `registry_has_digest`: ask the registry whether it holds *this*
digest, and an image it has never seen is the operator's own build. That
check needs a digest to ask about, and it never got one.

`RepoDigests` is populated for a build only under the containerd image store.
Under the classic overlay2 store — still the default on Docker Engine, which
is where this runs — `docker build` leaves it `[]`. So `local_image_digest`
returned None, `_image_facet` took the "never been pulled" arm, and preflight
turned that into a hard block over the whole update, Condor's half included,
coded `registry-unreachable` while the registry was answering fine in the
same call.

"No registry digest" and "no image" were the same answer to two different
questions. `local_image_identity` separates them: `docker image inspect`
fails outright when the image is absent, so the id answers "is it here" and
`RepoDigests` answers "does the registry know it". Present-without-a-digest
is now reported as built here — the same conclusion the containerd store
reaches by the other route.

Error codes move onto the facet while we are here. Preflight was deriving
them with `"compose" in image.error` and filing everything else under
`registry-unreachable`, which is how this failure came to name a cause that
had not happened.
`stash` is one of the ways out of `dirty-conflict`, and that block means the
incoming commits touch the very file being parked — so the replay conflicting
is the main line, not a corner case. `git stash pop` handles that badly for
an unattended caller: it writes conflict markers into the tracked file,
leaves the index unmerged, keeps the stash and exits non-zero.

What that produced: an update reported as succeeded over a checkout holding a
half-merged agent playbook, and a remedy — "resolve it by hand with git stash
pop" — that errors with "needs merge" when followed. The docstring said a
conflict aborts. Nothing aborted.

`apply` and an explicit drop, because apply can be undone and pop cannot. The
undo is deliberately narrow: `reset --hard` is the obvious abort and is wrong
here, since these checkouts are runtime working directories and unrelated
uncommitted work is expected — the preflight gates on the intersection of
dirty and incoming for exactly that reason. So only the stash's own paths go
back, tracked ones to HEAD and untracked ones removed, and the abort is only
claimed once the unmerged set is actually empty. Files stashed with `-u` live
in a third parent commit that `stash show` does not look at, so they are
asked for separately.

The other half was that nothing mentioned it afterwards. `git status` says
nothing about stashes, the finished run scrolls away, and the next preflight
reads the working tree — so parked work sat there with every surface
reporting a healthy install. `stashed-work` says it is there and what the
next update will do with it.
`test_three_slow_servers_resolve_in_one_timeout_not_three` asserted
`elapsed < DELAY * 1.5`, which measures the machine as much as the code. It
passed run on its own and failed under a loaded full suite — red that says
nothing about the property and trains people to re-run rather than look.

The deterministic assertion was already sitting next to it: `max_concurrent`
can only reach 3 if all three fetches were in flight at once, and a serial
resolver can only ever reach 1. So the timing check was not carrying the
test, only its flakiness.

Every fetch now waits at a barrier until all three arrive. A concurrent
resolver releases the moment the last one gets there, however loaded the
machine; a serial one cannot reach the assertion at all and fails on the
rendezvous timeout rather than hanging. Checked both ways: passing as
written, and `assert 1 == 3` when the route's `gather` is replaced with a
sequential loop.
Reported live: a local-only install with a pending update, where clicking the
"Updates available" notification did nothing at all.

Two files have to agree about one number and nothing checked that they did.
`.env` decides who the dashboard *is* — local mode signs in as
`ADMIN_USER_ID` with no password — while `config.yml` decides what that id
may *do*. On the install in question `.env` resolved to `1883786161` and
`config.yml` gave that id the role `user`, with the admin role sitting on id
`1`. So every admin route answered 403, the frontend dropped the Updates tab,
and the notification's `?tab=updates` link fell back to Servers without a
word. The update engine was working the whole time; nothing could reach it.

Nothing was wrong enough to notice. The old check asked only whether the
variable was set, so `make doctor` printed a green `ADMIN_USER_ID 1883786161`
— a tick on the exact value that had no rights.

Two checks, because they are two different mistakes. `Admin access` names the
id, the role it actually has and the key to change. `ADMIN_USER_ID
(duplicate)` fires on a second assignment in `.env`, which is worth saying on
its own: all three parsers that read that file take the last one, so a
duplicate is silently authoritative while the first line is the one people
read.
The silent fallback was defensible on its own — a deep link to `?tab=updates`
from a seat that is not an admin renders Servers rather than an empty page.
It stops being defensible once the product sends that link itself: the hourly
update check posts an "Updates available" notification whose link is
`?tab=updates`, addressed to `ADMIN_USER_ID`, which in local mode is the id
the dashboard signs in as whether or not `config.yml` grants it the role.

So the reader is told there is an update, handed a link, clicks it, and lands
on Servers with the notice still sitting in the bell. Nothing happened and
nothing said why. That is how this was reported: "clicking the update message
doesn't do anything".

Still falls back — the tab genuinely is not theirs — but now says so, names
the role and the knob, and points at `make doctor`, which after the previous
commit names the id it signs in as.
`get_user_role` returns the enum, so the C10 message interpolated it as
"this user's role is 'UserRole.USER'". That string appears nowhere in
config.yml: the reader cannot search for it, and the value they are being
told to change looks nothing like the one on disk. Seen on a real install
while chasing an update notification that did nothing.
The journal outlives the process by design — that is what lets a run
interrupted by a restart still be judged at the next boot — and the panel
shows the last run until someone presses Done. So it can be displaying a
failure from last week, and without a time a week-old one and a two-minute-old
one read identically.

That is not hypothetical: a stale failed run in a test install was read as a
live dependency problem, and nothing on the screen could settle it either
way.

`ended` is the moment that matters, falling back to `started` for a run that
never reached one. Relative inline, because the question being asked is "is
this still relevant"; exact on hover, for when it is not. Reuses
`formatRelativeTime` rather than adding a second way to render an age.
`make install` ran `python -c "import kaleido; kaleido.get_chrome_sync()"`
with stderr sent to /dev/null. Three things went wrong at once when that
stalled: no timeout, so it waited for ever; no progress, so nothing separated
"slow" from "wedged"; and the only line on screen was "Setting up Chrome for
chart rendering...", indefinitely. Measured in that state: 256 KB of a
~150 MB archive after thirty minutes, connection open and idle. The install
could not finish and nothing said why.

Every step in the *update* path got a timeout and a named failure in this
branch. This is the install path with neither, reached by following the
documented setup.

The stall detector is the part that matters. A cold download on a slow link
can legitimately run for minutes, so a ceiling generous enough not to cut
that off is also generous enough to sit on a dead connection just as long —
bytes arriving is what separates them. A stall names the proxy or firewall a
held-open idle connection usually means; the ceiling names the knob to raise
and says the link was merely slow.

Also: an already-installed browser now costs nothing instead of being
re-fetched on every install, and the real error is printed instead of
discarded. Still always exits zero — charts are optional, and an optional
renderer must not fail an install.
`remote_default_branch` turned `refs/remotes/origin/HEAD` into a branch name
with `rsplit("/", 1)[-1]`, which is right for `main` and wrong for every
branch with a slash in it — `fix/x` came back as `x`.

Two consequences, both quiet. `image_tracks_this_checkout` compares the
current branch against this, so on a repo whose default is slashed the
comparison never matched and the image was skipped with a reason that read
like a deliberate finding. And `no-upstream-branch`, added earlier in this
branch, prints the name as advice — so it was telling people to switch to a
branch that does not exist. That is how this was noticed: the suggestion came
back `git switch update-flow-hardening` for a default of
`fix/update-flow-hardening`.

Strip the known prefix instead, and return None when the ref is not under it
rather than inventing a name from whatever is left.
`compose_up` runs `docker compose up -d` in `HUMMINGBOT_API_DIR` directly, so
it never passes the guard hummingbot-api's Makefile has on `make deploy`.

The exposure is the same one. `container_name:` is fixed for every service
there, so two checkouts are one stack as far as Docker is concerned: Compose
matches the existing containers by name, prints `Recreate`, and the second
checkout takes over the first's containers *and its postgres volume*, without
an error or a warning. Condor points at a *directory*; Compose acts on a
*project*, and those are not the same thing when two checkouts share a
basename — which they do by default, both being called `hummingbot-api`.

Compose records the project's working directory on every container it
creates, so the question is answerable: a `foreign-stack` block before the
update starts, and the same question again immediately before `compose_up`,
because the screen the operator confirmed on may be minutes old and what is
at stake is somebody's database.

Nothing running, an unlabelled container, or a Docker that cannot be reached
are all "no conflict" — none of them is evidence of one.
Reported from a live dashboard: "No local digest for
hummingbot/hummingbot-api:latest — it has never been pulled by tag". The
image had been on disk for days and the container was running. Docker
Desktop was restarting.

Only one probe in this path needs the daemon, which is why nothing else gave
it away. Measured with the socket gone:

  docker compose config              rc=0   parses locally, never opens it
  docker buildx imagetools inspect   rc=0   asks the registry directly
  docker image inspect               rc!=0  the one that needs the daemon

So `compose config` supplies a service, the registry supplies an *available*
digest, and the single failure lands on the branch that concludes the image
was never pulled. Everything around it looks healthy, which is exactly what
makes the conclusion convincing and wrong.

Splitting "no registry digest" from "no image" earlier in this branch did not
help here: both of those are answers about the image, and this is not. Ask
the daemon whether it is there before concluding anything about what it
holds — one extra command, only on the error path, and the available digest
the registry already gave us is kept rather than thrown away.
All seven of Greptile's comments reproduce. Three destroy or misreport work.

**The chat could not see the routines it wrote.** FEAT-115 moved the chat's
write target to `<local>/condor/routines`, but `assistant_routines` sends the
chat through `discover_routines`, which reads the root library and the shared
one and never that. An authored routine never reached the catalog — and the
v6 migration, which lifts previously untracked routines out of `routines/`,
moved existing ones into the same unread directory. The chat's own layer now
shadows the general library, exactly as an agent's does.

**The wrong stash was applied and dropped.** `has_update_stash` looked for
our message anywhere in the list while `apply`/`drop` default to `stash@{0}`,
and the stack reorders on every push. With an operator stash made after ours:
theirs was applied *and dropped*, their work restored into the tree unasked,
ours left parked, and the step reported "Restored the work that was stashed".
Addressed by ref now, resolved by name.

**`keep-mine` overwrote an existing local copy.** Edited through the product
*and* in the checkout is two customizations; `shutil.move` replaced the first
with the second and said both were kept. It refuses now and names both, since
only the operator can say which they meant.

The rest:

- the update lock was acquired before `check()` and released only by the task
  created after it, so a git or docker that could not start left the lock held
  by a process that was not updating;
- the relaunch banner treated a 403 like a dropped connection, so a non-admin
  seat polled, found the server still up, reloaded, and counted down again —
  a reload loop it could never end. It stops and says why, which is the half
  of V10 the Updates tab already got;
- `keep-mine` on an *untracked* file reported failure after succeeding, because
  `checkout HEAD --` cannot reset a path HEAD never had. Only tracked paths are
  reset; moving an untracked one *was* the resolution;
- the CRLF guard read the working tree, where `text=auto` promises nothing — a
  checkout with `core.autocrlf=true` fails it on ordinary clean files. It reads
  the index, which is what `text=auto` actually governs. Verified both ways: it
  passes clean and still catches a blob forced in as `i/crlf`.
All three of the new review comments reproduce, and all three are in what
the last commit changed.

**A batch move could leave the checkout changed after refusing.** The
"destination already exists" check ran inside the move loop, so a clash found
half way through returned "nothing was moved" with the earlier files already
gone — the checkout left showing a deletion nobody made, and the update still
blocked. Every destination is checked before anything moves, and the refusal
names only the paths that actually clash.

**A staged addition kept its index entry.** Resetting only paths present in
HEAD was right for an untracked file and wrong for a staged one: `checkout
HEAD --` cannot touch a path HEAD never had, so the entry survived the move
and the incoming commit adding the same path still had something to collide
with — reported as a success. Those get `rm --cached` instead.

**The abort advice named the wrong stash.** The lookup was fixed to resolve
our entry by ref, and then the recovery line still said `stash@{0}` and bare
`git stash pop`. Ours is not necessarily on top — anything pushed later sits
above it — so following that advice restored somebody else's work, which is
the exact mistake the lookup had just stopped making. The message uses the
ref that was resolved.
A pass over the diff before the next review round, aimed at the shape the
last two rounds kept producing: a fix that covers one path and not its
siblings. All three are in code added by this branch.

**Chat routines came back stamped as an agent's.** Discovering the chat's
local layer with `agent_slug=CHAT_SLUG` sets `source="agent:condor"`, and
that stamp is load-bearing: the MCP tool renames an `agent:` routine to
`<slug>/<name>` and labels it agent-scoped with the specialist slug, which is
empty for the chat. The chat's local layer is the writable half of the
*general* library, so it discovers without a slug and keeps `global` — the
bare name and scope the rest of that catalog has.

**A symlinked checkout read as somebody else's stack.** Compose records the
*resolved* working directory; `os.path.normpath` does not resolve symlinks.
Reaching the same directory through one — macOS `/tmp`, or any symlinked
project root — compared unequal to itself, so `foreign-stack` refused the
operator's own update while naming that same directory back at them.
`realpath` on both sides.

**The Chrome stall detector fired on a healthy download.** With no resolvable
download root the byte counter is pinned at 0, so `last_change` never moves
and the stall branch kills a transfer that is progressing — then blames a
proxy for it. A stall verdict needs a size to watch; without one only the
ceiling applies.

Also: the relaunch banner now uses `isForbidden` from `admin-api`, which
exists for exactly this question, instead of a second 403 check of its own.
Greptile, on the previous commit: adding the chat's local layer to
`assistant_routines` alone put its routines in the catalog and left them
unreachable. `RoutineStore._resolve_routine` starts a run through
`get_routine`, which reads `discover_routines` — and that did not know about
the layer. So a newly authored routine failed to start, and a local override
of a shipped name showed its own description in the catalog while running the
shipped code. The second is the worse half: you read one thing and run
another.

The layer belongs in `discover_routines`, where every reader of the general
library — the catalog, `get_routine`, the resolver behind a run — sees the
same thing. The special case in `assistant_routines` is gone with it.

That surfaced a second defect, in the cache rather than the layering. The
mtime shortcut restores an unchanged root file *by stem* out of the previous
merged dict, which was sound while root shadowed shared and stopped being so
the moment a local layer could shadow root: the entry restored for a root
stem may be the override, so a deleted override kept being served and the
shipped routine stayed unreachable. The root library now has a cache of its
own, and `fresh_start` still honours `_routines_cache = None` because that is
the invalidation every existing caller uses.

Tests pin resolution rather than only the catalog, which is the gap that let
the first defect through.
Two tabs can be watching the same update, and only one of them can have
started the relaunch. The other watched `required` go false, unmounted the
banner, and carried on running the bundle it booted with — which is also
what every tab did when the relaunch came from Telegram's button or a
`make restart` on the host, since neither goes near the browser. The
watcher for that has to sit above the early return, because the
transition it waits for is the same one that stops rendering the strip.

The initiating tab had the opposite problem: it reloaded on the first
response it could get. SIGTERM only starts the shutdown, and through the
drain and `teardown()` the dying process answers 200 — so the reload
landed on the old API, serving the new bundle the build step had already
swapped onto disk. `required` is the flag that cannot lie: set once per
process and never cleared, so only the successor answers false.

Both paths now share one guard, since either may get there first.
main grew `useReloadAfterRelaunch` while this branch was open, which is the
same true → false watcher this banner had been carrying privately. Two of
them would reload the same tab for the same reason, so the private one
goes and the shared one stays; its own test covers the cases this file's
test was duplicating.

`reloadOnce` stays. The hook rides the 30s query, which is right for a tab
that is only watching, but the tab that pressed the button is staring at a
spinner and polls every second — and either can now get there first.
@david-hummingbot
david-hummingbot force-pushed the fix/update-flow-hardening branch from 3fdf58e to 0ce7e6d Compare September 30, 2026 08:47
**An image of unknown origin is neither behind nor built here.** The two
wrong answers are not symmetric — call it a pull and we offer to overwrite
an operator's `make build`, the only copy of which is on that host; call it
a build and we hide a real update. `registry_has_digest` returns None for
"could not ask", typically a rate limit, and that fell through to behind=1.
It now blocks with the reason, which is what an `error` on the image facet
already does, and retrying is the remedy.

**The facet tests reached the network.** `_facet` stubbed three updater
calls and left `registry_has_digest` real, so it shelled out to `docker
buildx imagetools inspect`: thirteen seconds for one test, and it agreed
with its own assertion only because the registry answered one of the two
ways that reach the same branch. Had it ever answered "not found" the test
would have failed. Stubbed, and all three branches are now pinned
separately. The file went from 13.4s to 1.0s.

**`/updates/relaunch` was missing from the gate table.** That table exists
to be exhaustive, and the route it omitted is the one that restarts the
process. The gate itself was never wrong. `_stub_pipeline` had the same
shape of gap: `run_doctor` and `stash_pop` ran for real, the latter against
this very checkout.

**A warned doctor still owes a relaunch.** By the time it runs the code is
on disk; failing the run on its verdict would report all of that as not
having happened, and withhold the one thing that reaches the fix for
whatever it is complaining about. Pinned.

**`make build-frontend` emptied the directory it was serving from.** `run`
and `restart` stop Condor first, but the target is public and a Condor
started another way is still serving. It swaps through dist.new like the
updater does.

**A missing bundle answers 503, and now there is a test saying so.** The
`TestClient` import had been sitting unused since the 503 was written.

**The denied banner offered a button that could not work.** It posts the
same request from the same seat and is refused the same way, under a
sentence explaining that it will be.

**Three places still told the old story** — that exec'ing races a second
Condor onto the port. The code has not done that for some time, and this
branch's own relaunch route says so in as many words.
Comment thread Makefile
Comment on lines +93 to +97
rm -rf dist.new && \
npm run build -- --outDir dist.new --emptyOutDir && \
rm -rf dist.old && \
{ [ -d dist ] && mv dist dist.old || true; } && \
mv dist.new dist \

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Concurrent builds can remove bundle

If make build-frontend runs while the in-process updater is building, both processes delete and reuse the same dist.new and dist.old directories. The Makefile target does not take the update lock, so one build can remove the other's output or interleave the renames, leaving the running dashboard without a complete bundle. The in-process build uses the same directories in utils/updater.py.

Comment on lines +178 to +181
moved = index.exists()
aside = dist / "index.html.pytest-aside"
if moved:
index.rename(aside)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Test disrupts live dashboard

If Condor is serving the dashboard from this checkout while the backend tests run, this test renames the real frontend/dist/index.html. Browser requests then receive 503 until the test restores it. A concurrent build can also interfere with cleanup. Testing against a temporary bundle would avoid changing the served files.

@david-hummingbot david-hummingbot changed the title Harden the update flow: 32 scenarios from the risk report Harden the update flow Oct 1, 2026
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.

2 participants