Skip to content

fix(agents): make SSH-port allocation atomic so concurrent creates cannot collide (#2215) - #2235

Merged
vybe merged 11 commits into
devfrom
vybe/issue-2215
Aug 16, 2026
Merged

fix(agents): make SSH-port allocation atomic so concurrent creates cannot collide (#2215)#2235
vybe merged 11 commits into
devfrom
vybe/issue-2215

Conversation

@trinity-ability

@trinity-ability trinity-ability commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Fixes #2215

What changed and why

get_next_available_port() was an unlocked check-then-act. It read existing agent SSH ports from container labels, filtered them through is_port_available() — which binds inside the backend container's own network namespace, so host-published agent ports are invisible to it in production — and returned. The caller then ran containers.run up to ~100 lines and several seconds later.

At first boot with --workers 2 the two first-run seeders hold different SETNX locks (cornelius:provision / system_seed:provision), so worker A's Cornelius create (slow anonymous GitHub clone) runs concurrently with worker B's default-system deploy. Both compute the same max(existing_ports) + 1; the second containers.run fails Bind for 0.0.0.0:2227 failed: port is already allocated. The ent#313 reclaim removes the leaked container, but the agent is never created — and the system-seed flag policy then permanently latches default_system_seeded on the resulting partial, freezing the default fleet incomplete.

Four changes, in the order they defend:

D1 — atomic allocation (services/docker_service.py). A per-port Redis reservation, SET port_alloc:{port} "1" NX EX 600, is the last gate — after the label scan and after is_port_available, so reservations never leak onto candidates the scan walks past. SETNX contention means a concurrent allocator holds that candidate, so the scan continues; only a raised Redis error fails open (warn, return the candidate unreserved, stop attempting reservations). No release and no refresh: once the container exists its trinity.ssh-port label is the durable truth (Invariant #11), so the reservation is transient state bridging check→run, never a port registry — which also means there is no lease anything could release, so the #1919 compare-and-delete bar is N/A by construction. The label scan is now strict (_existing_agent_ports_strict): a Docker listing fault raises instead of degrading to list_all_agents_fast's [], which would compute start_port = 2222 and confidently reserve an existing agent's port. exclude= is merged once, before both scan loops, so it binds the forward scan and the 2222–2500 fallback.

D2 — bounded bind-conflict retry (services/agent_service/crud.py). D1 is fail-open, so a collision can still surface at containers.run — Redis down or restarted, an expired reservation, a foreign host process. _run_agent_container_with_port_retry wraps _create_agent_container for ≤3 attempts. On a bind-classified failure, in strict order: record the failed port → clean up the leaked Created husk (on every bind-classified attempt, the final one included) → if attempts remain, get_next_available_port(exclude=attempted_ports) and retry. Any cleanup doubt — ownership row present, lookup failure, unprovable provenance, removal failure — aborts and re-raises the original bind error, never the cleanup error: proceeding after a failed removal makes attempt N+1 409 against our own husk, which the reclaim then reads as "not ours" and strands. Gated on the port having been auto-allocated, captured before the mutation, so a caller-pinned port never silently moves.

D2b — reclaim classifier precedence (crud.py, 3 additive lines). See "Flagged deviations" below.

D3/D4 — pass-level seed lock + Cornelius alert (services/system_seed_service.py). One first_run_seed:provision lock (SETNX, uuid token, TTL 900s, compare-and-delete release in a finally, fail-open) over the whole first-run pass; the loser skips the entire pass without touching any flag. Ports were never the only cross-seeder hazard — SQLite BUSY under concurrent boot writes is anticipated in this very file, the 60s Docker read timeout is in ent#313's own docstring, and any transient during the burst produces the same permanently-latched partial. AC#3's genuine gap was that a failed Cornelius create was log-only; the orchestrator now captures the result and emits system-seed-cornelius-failed on create_failed/create_blocked and from the belt-except, credential-sanitized and URL-userinfo-redacted at the exit point.

Recreate-gap pre-reservation (services/agent_service/lifecycle.py, 2 lines). recreate_container_with_updated_config reuses the labeled port across its remove→create gap, during which the port is invisible to the allocator and unreserved — and the most-recreated agent tends to hold the fleet's max port, exactly what a concurrent allocation computes as max+1. reserve_port_for_recreate(port) (SET without NX — it is that agent's own port — fail-open) re-asserts it before the old container's removal.

No schema change, no migration on either track, no new env var, no compose change, no new endpoint or MCP tool.

AC#1 guarantee boundary — read this before building on it

With Redis up, two concurrent creations structurally cannot be assigned the same port: the SETNX is the atomic arbiter.

With Redis down, a same-port assignment is convergent-under-retry, not prevented: it is detected at bind time and resolved within the same create call by D2. Every lock in this codebase deliberately fails open and they share one client, so D1, both inner seeder locks and the D3 pass lock all degrade together — D2 alone stands in that mode.

This boundary is in the allocator's docstring verbatim so nobody later reads "atomic" as unconditional and deletes D2 as redundant.

Flagged deviations — please do not re-litigate these blind

  1. The D2b guard edits ent#313 reclaim code. The plan's dossier said "reclaim untouched"; this is a deliberate, pre-authorized 3-line deviation (plan §8 audit row 10, demanded independently by both /autoplan voices at HIGH). _is_container_name_conflict's text fallback matches the bare substring "already in use" — and Docker's userland-proxy bind failure, "… listen tcp 0.0.0.0:X: bind: address already in use" (the standard phrasing on Docker Desktop, this repo's primary dev platform), contains it. So a bind failure reaching _reclaim_failed_creation_container with container=None was misclassified as a name conflict, the reclaim declined, the Created husk squatted the name, every retry 409'd — and for Cornelius _agent_is_present counts the husk, so the next boot 409-converges the durable cornelius_seeded flag onto a dead container: the exact unrecoverable latch Cornelius seeder marks itself seeded on a volume-conflict 409 — Cornelius never created, never retried #1790 exists to prevent. A live pre-existing bug, squarely in this issue's blast radius. The change is a condition amend only: if _is_container_name_conflict(exc) and not _is_port_bind_conflict(exc). All three fail-closed gates (409, ownership row, floor-ts provenance) are preserved byte-for-byte, and _rollback_failed_creation is untouched. Tightening _is_container_name_conflict's substring instead was considered and rejected as riskier and non-additive.

  2. Port allocation MOVED inside the docker try/rollback fence during review. The plan had the allocation staying where it was (pre-try). It could not: D1's allocator now fails loud on a Docker listing fault, and a raise before that try strands the agent_git_config working-branch reservation _resolve_template already wrote — after which every later create of that name fails agent_git_config already exists, permanently, Cornelius's next-boot retry included. So the allocation sits immediately before containers.run, inside the fence. Second benefit: the 600s reservation TTL no longer has to cover config staging, the MCP-key mint, env build, the ent#15 snapshot prepopulate and the volume builds. Verified that nothing between the old and new call sites reads config.port — it is consumed only inside _create_agent_container (label + ports map, rebuilt per call), and there is no port column anywhere in agent_ownership.

  3. Two defensive shapes that look like over-caution and are not. (a) _is_port_bind_conflict wraps str(exc) in its own try — docker.errors.APIError.__str__ dereferences response attributes and can itself raise on a partial or stubbed response object, and this classifier runs inside the never-raises reclaim, where a raise would replace the creation error being reported (the ent#313 isinstance regression, one door over). (b) Cleanup uses a duck-typed docker_client.containers.get rather than the named get_agent_container helper: that helper flattens a lookup failure into "absent", and here absent means "safe to retry" while failure must abort.

Verification

Empirically proven on real Docker (dev stack, port-squat harness):

  • Squatted 2233 → the agent landed on 2234, with label == API == published bind agreeing, and /health healthy.
  • Squatting 2235/2236/2237 exercised the retry ladder: attempts 2/3, then 3/3, then a clean 500 with no 4th attempt — and the husk was removed on every attempt, the last one included.
  • port_alloc:* keys carry TTL ≤ 600 and decay as expected.
  • The config-drift recreate held port 2232 across remove→create, proven by its reservation TTL rising 309 → 590, which only reserve_port_for_recreate's no-NX SET … EX 600 can do.

WAVE-4 FULL /verify-local: branch PASS. The harness result.json reported fail at agent-exercise, root-caused across 4 reproductions including a fresh VM to a Docker Desktop 4.36.0 / Engine 27.3.1 bug: docker network connect against a running container with an ephemeral HostPort: "0" silently drops the host binding (gvisor write unixgram: destination address required). The app was healthy in-container throughout; the equivalent checks were driven manually and passed. Not branch-related.

Honest gaps — two claims rest on unit coverage, not end-to-end:

Tests (-p no:randomly, affected-file neighbourhood):

tests/unit/test_2215_port_allocation.py
tests/unit/test_2215_create_port_retry.py
tests/unit/test_ent124_default_system_seed.py
tests/unit/test_ent313_failed_creation_container_reclaim.py
tests/unit/test_1484_create_agent_characterization.py
tests/unit/test_agent_readiness_probe.py
→ 155 passed

tests/lint_sys_modules.py → exit 0, no new violations (both new files use monkeypatch.setitem).

origin/dev merged in (2 conflicts, both resolved as a union)

dev moved 5 commits after this branch was cut, so origin/dev is merged in rather than left conflicting. Neither side's behaviour changed:

Post-merge re-run of the same six files: 155 passed. The now-shared docker_service.py neighbours (test_2196_roster_availability.py, test_docker_service_list_fast.py, test_1560_agent_redis_key_parity.py, test_1560_breaker_cleared_on_lifecycle.py, test_ent107_cornelius_seed.py): 77 passed.

Pre-existing reds on dev — not from this PR

The full unit suite under -p no:randomly is 8 failed / 10466 passed, and all 8 reproduce on origin/dev itself:

pytest-randomly is installed in this venv, so always compare with -p no:randomly.

backend-unit-test should still go green on this PR, and that is not the check being lax: it is a base-vs-head differential (the suite under three seeds on both the base tip and the head merge commit, failing only on a test ID that is newly failing on head). All 8 fail identically on both sides, so they diff away. Worth knowing while reading dev's own history: on a push those diff jobs are skipped by design (if: github.event_name != 'push'), so a green backend-unit-test on a dev push means the invariant lint ran — not that the suite did.

Follow-ups (listed, deliberately not filed)

  1. Dev-tip test rot — the 8 failures above: ent#96's hardcoded dates need to be relative, and bug(github-pat): a backslash in the token raises re.error mid-rotation; .env quote-escaping is write-only #2017's TestKnownGaps strict-xfails need retiring.
  2. A failed create strands agent-<name>-workspace. The volume outlives the reclaimed container, so an immediate same-name retry 409s until the bug: Docker volumes are never deleted — volume_remove has zero callers; agent purge leaks workspace/public/shared volumes forever #1581 orphan-volume sweep reclaims it (~15 min, 3 unattached strikes). This partially blunts the "Cornelius retries on next boot" benefit when the failure lands late in creation.
  3. The two inner seeder locks remain pre-bug: fleet-restart lock — lease refresh isn't ownership-checked, and acquisition sits outside the try/finally (#1912 review follow-up) #1919 blind-delete. Only the new pass lock got token + compare-and-delete hygiene; cornelius:provision and system_seed:provision still DELETE unconditionally, so a TTL lapse can release a sibling's live lease. Out of scope here, kept as belts.
  4. _release_pass_lock's GET→DEL is non-atomic. Correct compare-and-delete needs a Lua CAS (the redis_breaker_util script-cache pattern); the current form narrows the window rather than closing it.
  5. The Docker Desktop agent-exercise blocker makes /verify-local unable to complete its final phase on macOS Desktop until 4.36.0/27.3.1 is worked around or upgraded past.

Review pipeline

/autoplan (strategy + engineering voices, both approve-with-changes) + gemini-critic (approve) on the plan; /review on the branch (deviation 2 originated there); WAVE-4 FULL /verify-local; /validate-pr pre-push. No new endpoints, WS channels, MCP tools, auth-boundary changes, or credential paths — /cso --diff skipped per the pipeline table for a non-auth bug fix.

🤖 Generated with Claude Code

trinity-ability and others added 11 commits August 16, 2026 11:51
…on (#2215)

get_next_available_port() was an unlocked check-then-act: two concurrent
creators (the fresh-install seeders under --workers 2) computed the same
max+1 and the second containers.run failed 'port is already allocated'.

- _existing_agent_ports_strict(): the allocator's label scan now RAISES on
  a Docker listing fault instead of degrading to the empty set (which would
  allocate 2222 over an existing fleet); demo mode (no client) keeps the
  empty set.
- exclude= parameter, merged before BOTH scan loops (forward + fallback).
- per-candidate SETNX reservation port_alloc:{port} EX 600 as the LAST gate:
  contention => next candidate; a raised Redis error fails open (returns the
  candidate unreserved — crud's D2 bind-retry converges the collision).
  TTL-only self-heal, no release: the container label becomes the durable
  truth (Invariant #11).
- reserve_port_for_recreate(): SET no-NX, fail-open — re-asserts a
  recreating agent's own port across the remove->create gap.
- is_port_available(): netns-blindness documented (weak filter, kept).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…2215)

The D1 port reservation is fail-open (Redis down => today's racy allocation),
so a same-port assignment must converge at bind time instead of failing the
create — which, during first-run seeding, permanently latches a 'partial'
default fleet (the observed #2215 incident shape).

- auto_allocated_port captured BEFORE the config.port mutation (captured
  after, the retry gate is always-False and ships dead).
- _is_port_bind_conflict: narrow two-phrase duck-typed classifier
  ('port is already allocated' / 'address already in use'; never the generic
  'conflict'), cycle-guarded cause/context walk.
- _run_agent_container_with_port_retry: <=3 attempts, auto-allocated ports
  only; per-attempt order = record failed port -> cleanup husk -> reallocate
  with exclude= -> retry; WARNING per retry.
- _cleanup_bind_failed_container: runs on EVERY bind-classified attempt (the
  final one included, so no husk reaches the generic reclaim); ownership +
  provenance gated; ANY doubt aborts retries and re-raises the ORIGINAL bind
  error (never the cleanup error). Direct containers.get with duck-typed 404
  so a lookup FAILURE aborts instead of reading as 'absent'.
- D2b: the ent#313 reclaim's no-handle branch classifies bind-conflict BEFORE
  the name-conflict decline — Docker Desktop's bind phrasing contains the
  name-conflict substring, and the old order stranded the Created husk (and
  let Cornelius's 409-convergence burn the durable seed flag on a dead
  container). All three fail-closed gates preserved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
recreate_container_with_updated_config reuses the old container's labeled
SSH port, but between container_remove and containers_run that port is
invisible to the allocator's label scan AND unreserved — a concurrent
creation computing max+1 lands exactly there when the recreating agent
holds the fleet's max port. reserve_port_for_recreate (SET no-NX, EX 600,
fail-open) closes the gap with the same port_alloc:{port} keyspace.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ilure (#2215)

D3: the two seeders held DIFFERENT SETNX locks, so worker A's slow Cornelius
clone ran concurrently with worker B's default-system deploy — the concurrent
burst that exposes the port race and any other boot transient (SQLite BUSY,
60s Docker read timeout), each of which latches 'partial' permanently.
ensure_first_run_seeded() now takes ONE pass-level lock
(first_run_seed:provision, SETNX, TTL 900s, #1919 token hygiene: uuid token +
lock_token_matches compare-and-delete in finally). The loser skips the WHOLE
pass and writes no flags; fail-open on Redis down; inner seeder locks kept
unchanged as belts; the pre-setup deferral flow releases promptly.

D4 (AC#3): a failed Cornelius creation was log-only. The orchestrator now
captures the seeder result and raises a system-seed-cornelius-failed
operator-queue alert on create_failed/create_blocked AND from the belt-except
(a raise despite never-raises). Reserved-prefix deterministic id; context =
sanitized message + logs pointer; flag stays unset so next boot retries
(#1790 asymmetry untouched).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- test_2215_port_allocation.py (16): deterministic same-snapshot regression
  (two sequential calls -> distinct ports), threaded race belt (8 workers,
  bounded barrier, fakeredis), reservation-as-last-gate, SETNX contention,
  exclude in both scan loops, EX 600 on every reservation, Redis-exception
  fail-open (single attempt), client resolved once per call, Docker-fault
  raises, demo mode, Redis-down convergence-via-exclude,
  reserve_port_for_recreate (no-NX overwrite + fail-open).
- test_2215_create_port_retry.py (25): classifier vocabulary + the documented
  Desktop-phrasing overlap pin, per-attempt cleanup->realloc order, final-
  attempt cleanup, abort-and-reraise-ORIGINAL on any cleanup doubt (ownership
  row / lookup failure / provenance / removal failure), lookup-404 = clear,
  non-bind + pinned-port never retry, <=3 attempts with accumulating exclude,
  D2b reclaim path (bind reclaims under gates; pure name-conflict still
  declines; gates stay fail-closed), AST wiring pins (wrapper awaited, raw
  create not; auto_allocated_port captured BEFORE the mutation).
- test_ent124_default_system_seed.py (+11): pass-lock loser skips the WHOLE
  pass with no flag writes and leaves a foreign lease; winner runs both
  seeders and compare-and-deletes only its own token; Redis-error fail-open;
  cornelius create_failed/create_blocked/belt-except ->
  system-seed-cornelius-failed row; success/skip actions -> no row.
- test_1560_breaker_cleared_on_lifecycle.py: source-shape guard retargeted at
  the #2215 retry wrapper (the orchestrator's container-create phase call).

Also: _is_port_bind_conflict str(exc) made defensive — APIError.__str__ can
itself raise on a partial response object, inside the never-raises reclaim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- architecture.md: Redis-keyspace list gains port_alloc:{port} (transient
  SSH-port reservation, SETNX + TTL 600s, no release — the container label
  becomes the durable truth; SET no-NX by recreate; deliberately not
  agent:*) and first_run_seed:provision (pass-level seed lock, token +
  compare-and-delete, TTL 900s, fail-open); docker_service /
  system_seed_service / cornelius_agent_service service entries gain the
  matching clauses incl. the precise Redis-down guarantee boundary.
- requirements/roadmap.md 16.5.1: the pass-level lock over both seeders;
  honest-status now covers a failed Cornelius seed
  (system-seed-cornelius-failed).
- feature-flows/cornelius-default-agent.md: the operator alert + the
  pass-level lock in Error Handling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…#2215)

Review finding on the #2215 F4 "fail-loud allocator" decision: making
`get_next_available_port()` raise on a Docker listing fault (instead of
degrading to the empty set and handing out 2222) moved the failure from
INSIDE the docker try-block to BEFORE it. `_resolve_template` has already
written the `agent_git_config` working-branch reservation for a `github:`
template by then, and only `_rollback_failed_creation` (inside the try's
except) deletes it — so a listing fault at allocation stranded the row, after
which every later create of that name fails `reserve_and_generate_instance_id:
agent_git_config already exists` (Cornelius's next-boot retry included: a
permanent seed failure by a new route). Pinned by a probe test that failed on
the previous commit (raw RuntimeError, no rollback).

Move the `auto_allocated_port` capture + allocation into the try, immediately
before `_run_agent_container_with_port_retry`. `config.port` is consumed only
by `_create_agent_container` (label + ports map), so nothing between the old
and new sites needs it; the move also shrinks the reserved window (the ent#15
snapshot prepopulate and the volume builds now run BEFORE the port is
reserved), which the docker_service TTL comment now states accurately.

- crud.py: allocation inside the docker try; comment explains both reasons
- docker_service.py: TTL-adequacy comment updated for the new window
- test_1484 case 22: allocator raise -> git-config + MCP-key rollback, no run
- feature-flows/agent-lifecycle.md: allocator step/table lines refreshed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…2215)

/cso finding on the new `system-seed-cornelius-failed` operator alert: its
`context.message` carried the seeder's `create_failed` text verbatim, which
is `str(exc)` from a `github:` create — one that resolves the platform PAT
when an admin has configured it — and git/GitHub errors can embed PAT-bearing
remote URLs (learnings 2026-07-14). The deploy report already redacts at its
exit point (`system_service._failure_reason`); the operator queue is the same
durable, UI-rendered class. Apply `sanitize_text(redact_url_userinfo(...))`
at the alert's exit point; the URL itself survives, only userinfo/secrets go.

Pinned by a new ent124 test feeding a PAT-bearing clone URL through the
alert path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e lifecycle stub

`test_agent_readiness_probe.py` loads `lifecycle.py` in isolation with a
hand-enumerated `services.docker_service` stub; #2215 added
`reserve_port_for_recreate` to lifecycle's from-import, so every test in the
file errored at setup (`ImportError: cannot import name
'reserve_port_for_recreate'`) — the same shape as the #1559/#1809/#1854
entries already in that stub. Add the name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
House convention (#2226 registered its own in the same commit that added them).
Both entries sit beside test_ent313_failed_creation_container_reclaim.py — the
create-path neighbourhood they extend.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two textual conflicts, both resolved as a union — neither side's behaviour
changed:

* docker_service.py — the `typing` import line only. #2196 added `Dict` (its
  tri-state readers), #2215 added `Set` (the allocator's port set); the merged
  line carries both. Everything else auto-merged: #2196's
  agent_container_states/agent_container_state sit above #2215's
  _existing_agent_ports_strict/_try_reserve_port/reserve_port_for_recreate with
  no overlap.
* architecture.md — both sides appended a clause to the same `docker_service.py`
  catalog bullet. Kept #2196's tri-state clause first (it landed first), then
  #2215's allocator clause, plus one sentence naming why the two resolve a
  Docker fault in OPPOSITE directions: a roster read must degrade to
  "unchanged", an allocator must never degrade to "allocate over the fleet".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vybe
vybe marked this pull request as ready for review August 16, 2026 18:04

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Validated per /validate-pr. Approved with the deviations read, not waved through.

The defect and the defence-in-depth

The root cause is stated correctly: get_next_available_port() was an unlocked check-then-act whose only real filter — is_port_available — binds inside the backend container's own netns, so host-published agent ports are invisible to it in production. The two first-run seeders hold different SETNX locks, so they were never serialised against each other, and the resulting partial then permanently latches default_system_seeded. Ports were the symptom; the missing pass-level lock was the disease, and D3 is the right place to fix it.

The guarantee boundary is stated honestly and belongs in the docstring. With Redis up the SETNX is the atomic arbiter; with Redis down a same-port assignment is convergent-under-retry, not prevented. Since every lock here fails open on one shared client, D1/D3 and both inner locks degrade together and D2 alone stands in that mode — which is exactly why D2 must not later be deleted as redundant. Good that this is in the source and not only the PR body.

Reviewed in detail

  • D1 ordering is right: the reservation is the last gate, after the label scan and is_port_available, so a host-bound port never holds a 600s reservation. exclude merged once before both loops binds the 2222–2500 fallback too. SETNX contention → keep scanning; only a raised Redis error fails open.
  • _existing_agent_ports_strict raising instead of reusing list_all_agents_fast is the correct inversion: a [] degrade computes start_port = 2222 and confidently reserves over the live fleet, which no bounded retry can outrun. Note this makes recreate_missing_container and the system-agent bootstrap fail loud too — intended, and consistent with #2196's stated rule that a roster read degrades to unchanged while an allocator must never degrade to allocate over the fleet.
  • _cleanup_bind_failed_container fails closed on every doubt (ownership row, lookup failure, unprovable provenance, removal failure) and re-raises the original bind error, not the cleanup error — correct, since proceeding after a failed removal makes attempt N+1 409 against our own husk which the reclaim then reads as "not ours". The duck-typed containers.get over get_agent_container is justified: that helper flattens failure into absent, and here absent means safe-to-retry.
  • Husk cleanup on the final attempt too — the detail that keeps a bind husk from reaching the generic ent#313 reclaim still on the name.
  • auto_allocated_port captured before the mutation — captured after, the gate is always-False and the whole retry ships dead. Worth the comment it got.
  • D3 release is compare-and-delete with a unique token per #1919, in a finally, fail-open on acquire. _notify_cornelius_failure sanitizes + redacts URL userinfo at the exit point, which is right for a durable UI-rendered surface fed by str(exc) from a PAT-resolving github: create.

Flagged deviations — reviewed, both accepted

  1. D2b touching ent#313 reclaim. Accepted. _is_container_name_conflict's text fallback matches the bare substring "already in use", which Docker Desktop's bind failure contains — so a bind failure with container=None was misclassified, the reclaim declined, and for Cornelius _agent_is_present counts the husk, 409-converging cornelius_seeded onto a dead container. That is a live pre-existing bug squarely in this issue's blast radius, and the change is a condition amend only: all three fail-closed gates (409, ownership row, floor-ts provenance) are byte-identical and _rollback_failed_creation is untouched. Tightening the substring instead would have been riskier and non-additive — agreed.
  2. Allocation moved inside the docker try/rollback fence. Accepted, and it is a correction rather than a liberty: with D1 now raising, a pre-try raise strands the agent_git_config working-branch reservation _resolve_template already wrote, after which every later create of that name fails permanently — Cornelius's own next-boot retry included. Verified the claim that nothing between the old and new call sites reads config.port.
  3. The two defensive shapes (str(exc) in its own try; duck-typed lookup) are not over-caution — the first sits inside a never-raises reclaim where a raise would replace the error being reported.

Verification

All 24 non-skipped checks green, including all six pytest legs and regression diff. The seed-99999 pair that initially showed red were cancelled at an identical timestamp, not failed; I re-ran them and both pass. prod-image-smoke, verify-non-root, gitleaks, both CodeQL analyses green.

Scan: the one ghp_-shaped string is ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ab, a synthetic fixture in the test that asserts it is redacted — not a finding. No host paths, no mode/symlink changes, no new env var, no new top-level src/backend/ package, no schema change (so schema-parity/Alembic correctly N/A). Merges clean into current dev after #2234/#2237/#2233.

Honest gaps, noted not blocking

The agent_git_config stranding scenario and the "exactly one #2069 gitignore-merge task" claim rest on unit coverage because the dev .env GITHUB_PAT is expired; D2b's "address already in use" branch is unit-only on this daemon. Both are disclosed rather than papered over, and the empirical port-squat harness results (2233 → 2234, the 2/3 → 3/3 retry ladder with no 4th attempt, husk removed every attempt, recreate holding 2232 with TTL 309 → 590) cover the load-bearing paths on real Docker.

@vybe
vybe merged commit 9c0abb0 into dev Aug 16, 2026
30 of 32 checks passed
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