Harden the update flow - #243
david-hummingbot wants to merge 43 commits into
Conversation
a766dfb to
7a40573
Compare
|
1fe7e97 to
3fdf58e
Compare
| if (res.ok) { | ||
| const body = (await res.json().catch(() => null)) as { | ||
| required?: boolean; | ||
| } | null; | ||
| if (body?.required === false) { | ||
| reloadOnce(); |
There was a problem hiding this comment.
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.
3fdf58e to
0ce7e6d
Compare
**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.
| 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 \ |
There was a problem hiding this comment.
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.
| moved = index.exists() | ||
| aside = dist / "index.html.pytest-aside" | ||
| if moved: | ||
| index.rename(aside) |
There was a problem hiding this comment.
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.
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
npm run buildemptiedfrontend/distwhile uvicorn served from it. A reload mid-build returned 500, not 404 — a missingindex.htmlraises insideFileResponse. A build that failed left no dashboard at all, while the run said the previous bundle would come back. It had already been deleted.dist.newand swaps, so exposure is one rename anddistis untouched unless the build wins.dist.oldbecomes a free rollback. The SPA fallback answers 503 with an explanation, and the failure message is now true..gitignorecovers both scratch dirs.make build-frontendswaps the same way (V36), and the 503 has a test (V37).run.state == "failed"arm is gone.make runturnsremain-on-exitoff 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-exitis re-enabled across the handover and turned off again once the successor serves. A failedos.execvnow logs and writesrestart-failure.log.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.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-incomingwarning when the incoming commits touchcondor/migrations.py.python -m condor.doctoralready existed, read-only and exiting non-zero on real failures — wired only intomake install.WARNEDstate so "the update succeeded" cannot swallow "and here is what is still wrong". Pinned in V35.routines/— 13 files upstream actively maintains. Improving a shipped routine edited a file upstream also edits.requires-pythonhad a floor and no ceiling, souvpicked the newest interpreter and a fresh install died inside a transitivellvmlitebuild naming neither Condor nor the real constraint.>=3.12,<3.14plus a.python-version.frontend-will-rebuildwarning.USERgets no Updates tab and no explanation.forked_fromwas written and never read back —content_digestwas 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.discarddestroyed the work while reporting "Restored 1 tracked file(s)" — a destructive act in the vocabulary of a repair..condor/agentswhere 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.dist.newanddist.oldand nodist— and the static mount and SPA fallback both sit inside onedist.is_dir()evaluated at startup, so Condor came up serving a bare 404 at/.dist.new.node_modules, a bundle and an image.low-diskwarning naming the figure.npm cideletesnode_modulesbefore installing, so a failure left no dependency tree and the build could not run either.asyncio.Lockand an in-process flag — a second Condor on the same checkout could fast-forward the same repo concurrently.request_restart()SIGTERMs,teardown()runs, andmain()execs once the loop is gone, replacing the image in place.Group H — hummingbot-api
latestand 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.maintoo,--abbrev-refanswers the literal stringHEADwhen detached, and unpushed commits mean the branch ismainand the code is not.make buildis the documented way to run your own code, and Condor offered to overwrite the result with the registry's."source"mode was unreachable on every real install — no shipped compose file has abuild:key.Group S — shared git machinery
rev-parse --abbrev-ref HEADreturns the literal stringHEADwhen detached, soorigin/HEADresolved to the default branch, nothing reported being ahead, andmerge --ff-onlyquietly fast-forwarded a detached checkout onto main and left it detached. Nothing anywhere calledsymbolic-ref.detached-headblock that names the commit, before anything reads a branch name or fetches..git/index.lockbehind and every later subcommand fails on it, with an error that sends the reader hunting a process that is not there.Group P — cross-platform
Targets Linux, macOS and WSL2. Native Windows is explicitly out of scope:
os.execv, tmux,makeand thenvm.shsourcing are POSIX, and.venv/Scriptsvsbinis unhandled throughout.content_digesthashed raw bytes, so the same file digested differently on a CRLF checkout (sha256:6d9b716cc24cvssha256:ee60f3851972) — which would make C11.1 cry wolf about trading-rule files on Windows..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, witheol=lfpinned for the files that are hashed or executed./mnt/ccheckout on WSL2 or Docker Desktop on macOS runs several times slower.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:
_stamp()deliberately refuses to invent it for a file that has none — but the library ships 19.pyroutines, so an agent editing one shadowed it permanently with nothing able to notice.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 ontof15e401donce #258 merged. Three conflicts are worth naming, because resolving any of them the obvious way loses something:condor/web/routes/agents.py— main renamedstrategies/strategy.md→loops/loop.mdand added the SEC-693 server-pin gate to the same write C11.2 had changed towrite_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 grewuseReloadAfterRelaunch, which is the sametrue → falsewatcher 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.reloadOncestays 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,.pyand.ymlincluded 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'smake 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.
stashis offered as a way out ofdirty-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 pophandles 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 withgit stash pop" — fails withneeds mergewhen followed. The docstring said a conflict aborts; nothing aborted.applyplus an explicit drop, because apply can be undone and pop cannot. The undo is deliberately narrow:reset --hardis 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-ulive in a third parent commitstash showignores, so they are asked for separately. A newstashed-workwarning closes the other half:git statussays nothing about stashes, so parked work used to sit there with every surface reporting a healthy install.registry_has_digest— ask the registry whether it holds this digest — and that needs a digest to ask about.RepoDigestsis populated for a build only under the containerd image store; under the classic overlay2 store, still the default on Docker Engine,docker buildleaves it[]. Solocal_image_digestreturned None,_image_facettook the "never been pulled" arm, and preflight turned it into a hard block over the whole update — Condor's half included — codedregistry-unreachablewhile the registry answered fine in the same call. No test coveredlocal is None.local_image_identitysplits them:docker image inspectfails outright when the image is absent, so the id answers is it here andRepoDigestsanswers 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.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-unknownis the wrong shape besides: nothing is incoming, there is no upstream.no-upstream-branchnames 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._acquire_run_lockopened the file"w", which truncates at open — beforeflockis even attempted — so the process losing the race wiped the pid written by the one holding it.startthen read an empty file and dropped the(pid N)the refusal exists to carry, and anyone who went and looked atupdate.lockfound it empty too."a+", and empty the file only once the lock is held — the point at which this process is entitled to write it._listening_bindsfalls back tolsof, and-sTCP:LISTENmakes lsof print the state as its own trailing token: NAME isTCP 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 "unsetWEB_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.all_stale_forkswalksiter_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.stale_forks("_shared")returned the right answer with nothing calling it. Worth naming the asymmetry this leaves:locally_overriddenwalks 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.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_forksasked_homesfor 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_overriddenmissed it from the other end: it skipped any incoming path whose first segment was notagents/. So improving a shipped routine — the thing the local layer exists to allow — shadowed it in silence, before the update and after._compare_layerso the second root reuses it rather than growing a copy that can drift, andrel_prefixkeeps the reported path the one the reader would go looking for.StaleFork.relis relative to one library's root, so every forked playbook rendered as a bareAGENT.mdand 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.relkeeps its meaning, because the stores and the tests read it as library-relative. The qualified form is a newlabel, which is what the surfaces show.test_three_slow_servers_resolve_in_one_timeout_not_threeassertedelapsed < 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.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, andassert 1 == 3when the route'sgatheris replaced with a sequential loop..envdecides who the dashboard is — local mode signs in asADMIN_USER_ID, no password — andconfig.ymldecides what that id may do. On the install in question.envresolved to1883786161(a duplicated key; all three parsers that read that file take the last assignment) whileconfig.ymlgave that id the roleuser, withadminsitting on id1. Every admin route answered 403, the frontend dropped the Updates tab, and the notification's own?tab=updateslink 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 doctorprinted a green✓ ADMIN_USER_ID 1883786161, a tick on the exact value that had no rights.Admin accessnames 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 andmake doctor. Message:get_user_rolereturns the enum, so C10's 403 rendered'UserRole.USER'— a string that appears nowhere inconfig.yml, so the reader could not search for it.make installrunssetup-chrome, which waspython -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 wasSetting 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, followingcondor.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.compose_upshellsdocker compose up -dintoHUMMINGBOT_API_DIR, so it never passes the guardmake deployhas. Same exposure:container_name:is fixed for every service, so two checkouts are one stack to Docker — Compose matches by name, printsRecreate, 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 beinghummingbot-api.foreign-stackblock before the update, and the same question again immediately beforecompose_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".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 configrc=0 (parses locally),buildx imagetools inspectrc=0 (asks the registry),image inspectrc≠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.remote_default_branchturnedrefs/remotes/origin/HEADinto a name withrsplit("/", 1)[-1]— right formain, wrong for every branch with a slash:fix/xcame back asx.image_tracks_this_checkoutcompares 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 togit switchto a branch that does not exist.Nonewhen the ref is not under it rather than inventing a name from what is left.<local>/condor/routines, butassistant_routinessends the chat throughdiscover_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 ofroutines/, moved existing visible ones into the same unread directory.has_update_stashlooked for our message anywhere in the stash list whileapply/dropdefault tostash@{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."stash@{n}and address every command at it. Nothing between the apply and the drop can renumber it.keep-minemoved the checkout's copy onto the product's withshutil.move, which replaces it — one destroyed, while the message said the version was kept.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.keep-mineis offered for anuntracked-conflicttoo, andcheckout HEAD -- <path>fails on a path HEAD never had — so the resolution reported failure after moving the file.* text=autopromises nothing — it governs what git stores. A checkout withcore.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.pyand.tsx.git ls-files --eol, which is whattext=autoactually governs. Checked both ways: passes on a clean checkout, and still catches a blob forced in asi/crlf.keep-minemoves 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.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.rm --cached; tracked ones still reset to HEAD.stash@{0}and a baregit 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.agent_slug=CHAT_SLUGsetssource="agent:condor", and that stamp is load-bearing: the MCP tool renames anagent:routine to<slug>/<name>and labels it agent-scoped with the specialist slug — which is empty for the chat.global: the bare name and scope the rest of that catalog has.os.path.normpathdoes not resolve symlinks, so reaching the same directory through one — macOS/tmp, any symlinked project root — compared unequal to itself.foreign-stackfired, naming that same directory back at them.realpathon both sides.last_changenever moves and the stall branch aborts a transfer that is progressing — then blames a proxy for it.assistant_routinesalone, whileRoutineStore._resolve_routinestarts a run throughget_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.discover_routines, where the catalog,get_routineand 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.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_catalogfailed in the suite and passed alone.fresh_startstill honours_routines_cache = None, which is the invalidation every existing caller uses.requiredgo 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 ormake restart, since neither goes near the browser.useReloadAfterRelaunch— see Rebased onto main.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.requiredis 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.registry_has_digestreturnsNonefor "could not ask" — typically a Docker Hub rate limit — and that fell through tobehind=1. The two wrong answers are not symmetric: call it a pull and Condor offers to overwrite amake buildwhose only copy is on that host; call it a build and a real update hides behind a silent "up to date".image-origin-unknownblocks with the reason, which is what anerroron the image facet already does, and retrying is the remedy._facetstubbed three updater calls and leftregistry_has_digestreal, so it shelled out todocker 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 answerednot found, the test would have failed.POST /updates/relaunchwas 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_pipelinehad the same shape of gap:run_doctorandstash_popran for real, the latter against this very checkout.WARNEDarm had no test.run_doctorappeared in no test file at all, so nothing held the three properties that make it safe.make build-frontendstill ran a barenpm run build, and vite'semptyOutDirempties the served directory in place.runandrestartstop 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 ofdist.dist.newexactly as the updater does, with a test reading the target so it cannot drift back.TestClienthad been sitting imported and unused in the bundle-build tests ever since the 503 was written.index.htmlaway, asks for a page, and asserts 503 with the explanation andno-cache— using that import.denied. What that seat has to do instead is in the message, and neither half of it is a button.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 refusedinstead 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: admininconfig.yml", andmake doctorfails with the id, the role and the key. Granting the role brings the tab back andGET /updatesanswers 200.Not fixed here
One thing the clean-room install surfaced that is not this branch's to fix:
container_nameis hardcoded, somake deployin a second checkout adopts the first's containers and its postgres volume — it printedRecreate, notCreate. 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 throughmake deploy.