Skip to content

fix: fleet restart adopts rebuilt base images through the canonical lifecycle path (#1860) - #1912

Merged
obasilakis merged 3 commits into
devfrom
feature/1860-fleet-restart-adoption
Jul 31, 2026
Merged

fix: fleet restart adopts rebuilt base images through the canonical lifecycle path (#1860)#1912
obasilakis merged 3 commits into
devfrom
feature/1860-fleet-restart-adoption

Conversation

@webmixgamer

Copy link
Copy Markdown
Contributor

Summary

Changes

Test Plan

  • New tests pass: pytest tests/unit/test_1860_fleet_restart_adoption.py -v (16/16)
  • Full unit suite: 5856 passed; 1 failure verified pre-existing on clean dev (local venv fastapi skew in test_1069, unrelated)
  • Live validation on a real 4-agent instance, all three acceptance criteria:
    • base rebuild → fleet restart → 3/3 running agents recreated image_drift, container image IDs verified against new trinity-agent-base:latest; trinity-system skipped; workspace marker file survived; stats 200 + clone_status: ok post-recreate
    • no-drift fleet restart → plain restart, recreated: false, container IDs unchanged
    • config-drift (cpu override) → only the drifted agent recreated config_drift
    • concurrent second call → 409 fleet_restart_in_progress; agent-scoped MCP key → 403 human-only; audit row inspected with per-agent recreate map

Blast-radius decision (AC3)

Sequential loop kept deliberately: admin-initiated rare op, zero concurrent-recreate load, no dependency on unshipped #1817. A client timeout (nginx 60s / Cloudflare ~100s at ≳3-6 recreating agents) does NOT mean failure — the loop continues server-side and the audit row is the durable record (endpoint docstring documents this + the system_prefix/filter_status chunking workaround). Fleet-wide breaker reset on cold starts is intended stop→start semantics. Note for operators: fleet restart now applies pending config drift — container IDs change on what previously looked like "just a restart".

Fixes #1860

🤖 Generated with Claude Code

…ts adopt rebuilt base images (#1860)

POST /api/ops/fleet/restart stopped/started agents with raw Docker calls,
bypassing start_agent_internal — no config-drift predicate ran and a rebuilt
trinity-agent-base was never adopted on "Restart All" (#1809's cold-start
gate never fired).

- lifecycle.restart_agent_internal(): the canonical stop→cold-start helper
  (explicit stop is load-bearing for the #1809 image predicate; future home
  of #1817's per-agent start lock)
- restart_fleet routes through it; per-agent recreated/recreate_reason
  surfaced via explicit allowlist copy, summary.recreated count
- skips ephemeral ghosts (config predicates aren't ephemeral-gated — a
  recreate would destroy a volume-less ghost workspace, ent#69)
- reject_agent_principal beside assert_admin (the endpoint now replaces
  containers — Invariant #8 escalation rule, #1816 precedent)
- single-flight Redis SETNX lock ops:fleet_restart (409 on contention,
  own-lease refresh, compare-and-delete release, fail-open) — guards the
  client-timeout→retry overlap (#799/#1817 wedge class)
- partial-safe fleet_restart audit entry with a per-agent recreate map
  (restores the entry dropped in 0ec3a7f); sync cleanup ordered before the
  awaited audit so a shutdown CancelledError can't leave the lock held
- actionable containerless-recovery errors (#1559), context-stats cache
  invalidation, 16 mocked unit tests, flow/architecture docs, learnings,
  CSO diff report (PASS)

Fixes #1860

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread src/backend/routers/ops.py Fixed
webmixgamer and others added 2 commits July 31, 2026 12:11
…odeQL py/stack-trace-exposure)

Per-agent failure rows now carry HTTPException .detail (platform-authored)
or the exception class name only; the full message + traceback go to the
backend log (exc_info). The #1559 containerless recovery hint is preserved.
Tests strengthened into leak regression guards (raw message asserted absent);
flow-doc line refs re-verified.

Refs #1860

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

@obasilakis obasilakis 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.

Reviewed via /review (structural pass on the merge-base diff).

No critical findings. Auth is strictly tightened and correctly scoped — verified User.agent_name is set only for scope == "agent" (dependencies.py:360), so system-scoped keys keep access and no platform automation breaks. The CodeQL exposure fix holds: the entire reachable call graph from start_agent_internal has exactly two detail= sites and both are static, so nothing dynamic can ride HTTPException.detail through. container_start import removal verified against zero remaining references. Test coverage is genuinely thorough (both exception shapes, leak regression guard, full lock lifecycle + both fail-open paths, all four skips, audit contents).

Three informational notes, none blocking:

  1. Lease refresh isn't ownership-checked (ops.py:296-300) — release is compare-and-delete, but expire fires unconditionally on lock_held. If one agent's restart ever outlives the 900s TTL, the lock expires, a second caller acquires, and this loop then extends their lease while both run — the interleaving the lock exists to prevent, masked by the guard. Low probability (the refresh only has to cover a single agent), but a GET-then-EXPIRE or Lua CAS would close it.
  2. Lock acquired outside the try/finallylist_all_agents_fast() sits between acquire (:262-280) and try: (:293). Unreachable today (it swallows everything and returns []), but any statement added there later leaks a 900s fleet-restart lockout.
  3. AC3 in substance, not in wording — every agent now cold-starts, so skip_injection is always False and each restart pays wait_for_agent_ready (60s cap) + credential retries + skill injection. The PR decides the consequences (sequential loop, timeout semantics, audit-as-record); the injection cost itself is implicit rather than labelled.

Happy for 1 and 2 to be a follow-up rather than a respin.

@obasilakis
obasilakis merged commit 8f20f67 into dev Jul 31, 2026
22 checks passed
@obasilakis
obasilakis deleted the feature/1860-fleet-restart-adoption branch July 31, 2026 12:00
dolho added a commit that referenced this pull request Aug 3, 2026
…onses (#1917)

PR #1912 fixed `restart_fleet` after CodeQL flagged py/stack-trace-exposure,
and deliberately left the sibling sites alone under the minimal-changes rule.
This closes them with the same pattern — response carries platform-authored
text or the exception CLASS NAME; the full message + traceback go to the
backend log via exc_info=True.

Sites (located by content, not the issue's line numbers — dev has moved):

  routers/ops.py           fleet health probe, stop_fleet per-agent,
                           _stop_agent_container (feeds emergency_stop),
                           ops costs
  routers/system_agent.py  health probe (open CodeQL alert #231), workspace
                           cleanup, template copy, the re-initialize and
                           restart 500s, and the terminal WebSocket error frame
  routers/agents.py        start / stop / logs HTTPException details

The health probe had `str(e)[:50]`. Truncation bounds a leak, it does not
remove one: docker and httpx messages lead with the host, socket path or URL,
so the first 50 characters are exactly the sensitive part.

Two sites were not in the issue and are the same defect, found by grepping the
named files rather than trusting the list: system_agent's re-initialize/restart
500 details, and its terminal WebSocket error frame — the only one here that
reaches a browser rather than an admin API client.

Guards: 8 tests, all 8 failing against pre-fix code. Each drives the real
router function with a sentinel in the exception message and asserts BOTH that
the sentinel is gone and that the class name survives — a response that dropped
the error entirely would pass a sentinel-only check while making the failure
undiagnosable. Plus a static ban on str(e) in the two routers where every
occurrence was this defect (agents.py is deliberately excluded: it has many
str(e) uses that never reach a response, so a blanket ban there would be false).

The fleet-health test initially patched a `get_agent_context_info` that does
not exist — `raising=False` made that silent, the probe never raised, and the
test passed against the UNPATCHED router. The pre-fix run is what caught it;
it now patches `get_agent_client` and asserts the probe actually ran. Same
vacuous-negative class as #1932/#1951.

Related to #1917

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Aug 3, 2026
…onses (#1917) (#1959)

PR #1912 fixed `restart_fleet` after CodeQL flagged py/stack-trace-exposure,
and deliberately left the sibling sites alone under the minimal-changes rule.
This closes them with the same pattern — response carries platform-authored
text or the exception CLASS NAME; the full message + traceback go to the
backend log via exc_info=True.

Sites (located by content, not the issue's line numbers — dev has moved):

  routers/ops.py           fleet health probe, stop_fleet per-agent,
                           _stop_agent_container (feeds emergency_stop),
                           ops costs
  routers/system_agent.py  health probe (open CodeQL alert #231), workspace
                           cleanup, template copy, the re-initialize and
                           restart 500s, and the terminal WebSocket error frame
  routers/agents.py        start / stop / logs HTTPException details

The health probe had `str(e)[:50]`. Truncation bounds a leak, it does not
remove one: docker and httpx messages lead with the host, socket path or URL,
so the first 50 characters are exactly the sensitive part.

Two sites were not in the issue and are the same defect, found by grepping the
named files rather than trusting the list: system_agent's re-initialize/restart
500 details, and its terminal WebSocket error frame — the only one here that
reaches a browser rather than an admin API client.

Guards: 8 tests, all 8 failing against pre-fix code. Each drives the real
router function with a sentinel in the exception message and asserts BOTH that
the sentinel is gone and that the class name survives — a response that dropped
the error entirely would pass a sentinel-only check while making the failure
undiagnosable. Plus a static ban on str(e) in the two routers where every
occurrence was this defect (agents.py is deliberately excluded: it has many
str(e) uses that never reach a response, so a blanket ban there would be false).

The fleet-health test initially patched a `get_agent_context_info` that does
not exist — `raising=False` made that silent, the probe never raised, and the
test passed against the UNPATCHED router. The pre-fix run is what caught it;
it now patches `get_agent_client` and asserts the probe actually ran. Same
vacuous-negative class as #1932/#1951.

Related to #1917

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

3 participants