Skip to content

feat: recover subagents from the run coordinator - #5282

Open
kyleseaman wants to merge 1 commit into
feat/run-coordinator-outboxfrom
feat/run-coordinator-recovery
Open

feat: recover subagents from the run coordinator#5282
kyleseaman wants to merge 1 commit into
feat/run-coordinator-outboxfrom
feat/run-coordinator-recovery

Conversation

@kyleseaman

@kyleseaman kyleseaman commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Stacked change: PR 6 of 7

Stack: #5277#5278#5279#5280#5281#5282#5283
Base: #5281
Next: #5283

Problem / Motivation

Startup recovery understands legacy run files, but durable coordinator rows and leases need a fenced takeover path with safe orphan-process handling. Cancellation can also overlap coordinator admission, leaving terminal ownership ambiguous unless the submission and command claim settle definitively.

Why it matters

Restart recovery must retain terminal output and pending delivery without killing an unrelated process after PID reuse, allowing two owners to complete one run, losing accepted work, or reporting both a local result and a later recovered interruption.

What changed (motivation → approach → change)

  • Adds a one-way legacy-state importer and coordinator recovery for expired leases, interrupted runs, and pending outbox events.
  • Excludes locally active and queued run IDs from both legacy-folder import and recovery claims, preserving the coordinator identity of accepted local work.
  • Retries the legacy importer during periodic recovery after a transient startup failure, so legacy state cannot remain stranded until another restart.
  • Preserves recovered interruptions as a neutral fourth per-member outcome, exposes retained partial results to the parent, and counts them in the aggregate non-success tally so wave totals remain consistent.
  • Persists PID, start identity, and ownership behind the active execution fence; agent-writable legacy process fields never authorize termination.
  • Atomically withholds terminal outbox delivery while protected child identity remains, including across the recovery lease between reaper sweeps.
  • Continues draining unrelated eligible completion events when one protected child cannot be reaped.
  • Clears that identity under the current execution fence after normal dedicated-session teardown succeeds; teardown or clear failure leaves delivery pending for recovery.
  • Allows stale process-write replay only for the identical stored identity; a replacement process must observe and write from the committed version.
  • Uses descriptor-pinned, no-follow, nonblocking reads for legacy state and quarantines malformed, linked, hardlinked, oversized, or redaction-changing inputs.
  • Pins recovery tombstone clearance to one no-follow run-directory descriptor on supporting hosts, so a concurrent directory replacement cannot redirect unlink or verification; fallback platforms resolve the path once.
  • Reads retained transcripts through a pinned canonical trusted root while validating the caller's declared path spelling, including homes reached through a symlinked prefix.
  • Treats a stable Linux/Windows process-identity mismatch as PID reuse: recovery never signals the replacement process and converges the original run to interrupted, while an unreadable or formatting-sensitive POSIX identity remains deferred.
  • Strongly retains accepted-run admission until it reaches a definite durable or failed outcome, so task cancellation cannot cancel a queued coordinator write or abandon accepted execution.
  • Treats every post-submit command-claim exception as uncertainty: a stable same-owner fence is adopted when available, otherwise durable recovery remains the sole terminal owner.
  • Makes user Stop drain admission, resolve an ambiguous claim to a fence, and commit exactly one STOPPED result; a definite submit failure clears uncertainty and keeps the legacy reporter authoritative.
  • Preserves a settled submit result across retained-task handoff, and yields Stop to a terminal or live newer recovery owner instead of retrying an already-taken claim forever.
  • Drains retained submission, fallback, and terminal-report chains during shutdown and re-admits any unresolved owner when the outer shutdown boundary is cancelled.
  • Offloads recovery tombstone clearance from the event loop during cancelled-shutdown re-admission and final shutdown settlement.
  • Preserves live batch routing, queued consumption debt, and fixed diagnostics across fallback and recovery.
  • Extends SQLite, shadow, security-posture, and system documentation for the recovery path.

Tests

  • test/test_run_coordinator_recovery.py covers legacy import, linked and swapped directory rejection, nonblocking file opens, repeated importer recovery, fenced takeover, process identity, pending delivery recovery, the cleanup-lease sweep that must keep terminal delivery blocked, per-run drain isolation when cleanup fails, and swapped-directory tombstone clearance.
  • Coordinator contract and SQLite tests cover migrations, stale owners, claim uncertainty, and restart convergence.
  • Wiring regressions cover delayed and failed submission, generic claim errors, ambiguous-claim Stop, single STOPPED delivery, callback-safe retained-task handoff, shutdown drain, tombstone clearance, and fallback delivery.
  • The current stack-tip focused coordinator suite passes: 507 passed, 1 valid platform skip.
  • Black, subprocess encoding, isort, flake8, Linux-parity mypy, docs, harness-parity, brand, and public-tree scrub gates pass at the stack tip.

Manual verification

N/A — restart, import, process identity, cancellation, shutdown, and recovery delivery are covered by deterministic automated tests.

Related Issues

No linked issue: this stack implements the locally reviewed durable run coordinator RFC.

Checklist

  • At most two commits (one is the norm), with a Conventional Commits title (feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)
  • Existing tests pass and new tests added for new functionality
  • Self-review completed; code follows project style guidelines
  • Documentation updated (if applicable)
  • No secrets, credentials, or internal references in the diff

@github-actions

github-actions Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — 🔴 changes requested (blocking)

GPT 5.6 found at least one blocking issue that must be resolved before merging d8856837349109821082db4c48137985ddaa8ac4.

This comment is updated in place on each push.

BLOCKING -- src/kiro_crew/run_coordinator/recovery.py:203 -- Restart cleanup uses an incompatible process-identity contract
platform_compat.kill_process_tree_pinned, process[0], process[1],
Valid Windows orphan -> durable token never matches raw start time -> delivery remains blocked; POSIX PID reuse between verification and termination -> unrelated process group is killed.
Anchor: residual/security
Fix: Decode the Windows token before calling the pinned helper, and defer POSIX termination when identity cannot be checked atomically.

BLOCKING -- src/kiro_crew/subagent_manager/monitoring.py:67 -- Native rows enter the mutable legacy kill path
await self._manager._reconcile_orphans()
Coordinator-backed child alters legacy PID fields -> startup legacy reconciliation -> arbitrary live PID is killed before durable identity verification.
Anchor: residual/security
Fix: Restrict legacy reconciliation to run IDs absent from the coordinator.

BLOCKING -- src/kiro_crew/dashboard/handlers/messaging.py:783 -- Windows recovery discards retained transcripts (origin: validation)
root_fd = open_dir_chain_nofollow(
Windows restart -> status disk fallback invokes unsupported descriptor-relative traversal -> existing result is reported as _No result._.
Anchor: residual/crash-data-loss-corruption
Fix: Use the existing cross-platform no-link reader with canonical-root containment when pinned traversal is unsupported.

[GPT-REVIEWED] d885683
[BLOCK-MERGE] d885683
False positive or not applicable? A repository writer can comment:
/ai-review override gpt d8856837349109821082db4c48137985ddaa8ac4: <one-sentence reason>

@kyleseaman
kyleseaman force-pushed the feat/run-coordinator-recovery branch from 0f18778 to 065a15d Compare August 23, 2026 12:42
@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running and removed readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention labels Aug 23, 2026
@github-actions

github-actions Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — 🟡 CONCERNS

Premise-level review of d8856837349109821082db4c48137985ddaa8ac4 — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

All regions of the patch and the relevant repo mechanisms are now checked. Producing the review.

First-Principles-Verdict: CONCERNS

The recovery machinery is RFC-derived and earns its place, but three website fixes and an ACP-spawn test ride along undeclared in a 20-bullet description that never mentions them.

What this change ships

Intent: after a gateway restart, recover subagent runs durably — import legacy folders, take over expired leases, kill only identity-verified orphans, deliver exactly one terminal outcome. ADDITION (implements the in-repo rfc-durable-run-coordinator.md).

  1. Restart recovers unfinished runs as "interrupted" with partial results retained — justified (RFC-derived)
  2. Orphan children killed only on exact durable process-identity match, SEL-audited first — justified
  3. New "interrupted" member outcome in digests, counted as non-success in wave totals — justified
  4. Terminal delivery withheld while a protected child identity remains — justified
  5. Stop/cancel/shutdown drain retained admission to exactly one terminal owner — justified
  6. spawn_status transcripts read via pinned no-follow walk; non-dir-fd platforms lose disk transcripts — justified, declared
  7. Legacy import + SQLite schema v4/v5 process-identity columns — justified
  8. Theme overlays/audio now torn down when a remote Crew is activated — undeclared, rides along
  9. Artifact widget iframes re-report height after load so healthy artifacts stop being flagged broken — undeclared, rides along
  10. macOS pass_fds assertion split + forced-darwin ACP spawn test — undeclared, rides along
    (Capped: black-baseline pruning also reformats 5 files, ~1,700 diff lines in test_slack_gateway.py/conftest.py alone.)

Watch

  • Items 8–10 touch themes, artifacts, and the ACP client — subsystems this PR's description ("recover subagents from the run coordinator") never names. Each fixes a nameable harm, so none is deletable on its own merits, but a reviewer of this PR will not look at them; they belong in their own PR or at minimum in the description.
  • The baseline-pruning reformat is welcome per AGENTS.md but is asked for "in its own commit"; here it buries the behavioral diff.

Subtractions

  • Drop import_legacy from the RunCoordinator Protocol plus its Shadow/SQLite forwarders (models.py:310, shadow.py:159, sqlite.py:564): grepped import_legacy\b in src/ — 7 hits, all definitions, forwarders, or its own batch loop; zero production callers of the singular. Keep import_legacy_batch as the only surface and let memory.py loop privately.
  • Drop the delivery_claim_factory constructor parameter on SlotQueueRepository — one construction site exists (dashboard/state.py:3182, grepped SlotQueueRepository(), passing the only value ever passed; move _PendingSubagentDeliveries into slot_queue_repository.py and construct it directly.

[FIRST-PRINCIPLES-REVIEWED] d885683

@github-actions

github-actions Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Design-level review of d8856837349109821082db4c48137985ddaa8ac4 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

Design-Verdict: CONCERNS

Sound fenced-recovery design with fail-safe biases throughout, but the shadow-phase dual-ownership machinery is intricate scaffolding that must not become permanent.

Watch

  • Exactly-once terminal ownership is enforced by an implicit state machine — _coordinator_shadow_generation counters, _coordinator_claim_uncertain / _coordinator_shadow_submission_durable / _legacy_delivery_tombstone flags on SubagentInfo, and done-callbacks in _await_retained_shadow_submit that spawn further tracked settlement tasks — plus parallel held_error_tombstone_ids plumbing through the Slack digest path. Each piece is justified by the legacy-stays-authoritative shadow phase, but the guarantee lives only in the wiring tests, not in a structural invariant; if the stack's endgame does not flip authority and delete the legacy reporter, this becomes the hardest-to-modify permanent surface in the subsystem. Confirm the retirement is actually scheduled.

Suggestions

  • Latch legacy import off after a clean pass: monitoring.py passes the importer on every reaper sweep, so import_all re-walks and re-parses every retained run folder (up to the 7-day prune window) forever, returning UNCHANGED — the description's "retries after a transient startup failure" implies a latch the code doesn't have; add one and the steady-state O(folders) disk scan disappears.

(Note: the website/ and test_acp_client.py hunks in the merge-base range come from ancestor commits closing their own issues — stack-base drift, not this PR's change; not flagged.)

[DESIGN-REVIEWED] d885683

@kyleseaman
kyleseaman force-pushed the feat/run-coordinator-recovery branch from 065a15d to 2ee02da Compare August 23, 2026 13:27
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Aug 23, 2026
@kyleseaman
kyleseaman force-pushed the feat/run-coordinator-recovery branch 2 times, most recently from d02032a to f82ba18 Compare August 23, 2026 14:34
@kyleseaman

Copy link
Copy Markdown
Collaborator Author
  • fixed — retained submission and late fallback had no production producer

    The shadow submit path now creates, shields, and retains the real submission task; its completion callback schedules the late legacy reporter only when durable settlement proves no run was stored. Shutdown drains both owners. The producer, failure handoff, and shutdown ownership are pinned by wiring regressions.

bolichen97
bolichen97 previously approved these changes Aug 27, 2026

@bolichen97 bolichen97 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM

@kyleseaman

kyleseaman commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator Author
  • fixed — replacement could not adopt a claim committed by a stalled predecessor

    The current generation now reconciles an inconclusive claim response with a bounded stable lookup, adopts only the same owner’s unexpired command/run fence, and rechecks its generation after the await. Foreign, expired, or missing receipts remain owned by durable recovery. The exact race is pinned by test_replacement_adopts_claim_committed_by_stale_shadow_attempt in 052211726.

bolichen97
bolichen97 previously approved these changes Aug 28, 2026
bolichen97
bolichen97 previously approved these changes Aug 28, 2026
@kyleseaman

Copy link
Copy Markdown
Collaborator Author
  • fixed — replacement inherited stale claim uncertainty after acquiring its own fence (reviewed at 66acbbef679283b862e6a1c413faa9c6c1e5a51d)

    Shadow attempts now carry a monotonic generation. A replacement clears only predecessor uncertainty, and stale callbacks cannot mutate its fence or uncertainty after any await. The replacement proceeds with the fence it owns in 052211726.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author
  • fixed — failed tombstone clearance consumed finalization ownership (reviewed at ed404a2a1cf04de2e474e037705d832755df28f3)

    Clearance now fails closed and is verified. A failed or unverifiable deletion retains settlement and its terminal claim for retry, leaving the run tombstone-free only after a proven recovery handoff. Regression coverage passes in 052211726.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author
  • fixed — exhausted synchronous routing could hide an undelivered fallback from restart recovery (reviewed at 1d24dfced536075faaaa600b10f79bf9503b5ef8)

    The fallback writes its error tombstone only after actual parent acceptance or later consumption. Exhausted synchronous delivery stays unreported and tombstone-free so restart reconciliation can recover it in 052211726.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author
  • fixed — queued or digest-held acceptance dropped fallback tombstone debt (reviewed at ded3b1d315e573dc466132f70c8def23cf2be70f)

    Deferred delivery now carries explicit legacy tombstone debt, including the error kind, through the consumption ledger. Consumption restores failure retention without suppressing restart recovery in 052211726.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author
  • fixed — fallback lock could wedge settlement and tombstone before parent acceptance (reviewed at fdf06b2bfc43a7f5e77357abd5ffd7427dc743e0)

    Terminal ownership is now arbitrated through the existing atomic report claim instead of a lifecycle lock, and tombstone creation follows confirmed delivery or consumption. Deterministic settlement tests pass in 052211726.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author
  • fixed — fallback reporting lacked durable task ownership (reviewed at 9eb774a23cf02e1a84afe59556fcb6dcf3cdc08d)

    The real submit and fallback reporter tasks are shielded, strongly referenced, drained during orderly shutdown, and re-admitted to restart reconciliation if the outer shutdown boundary cancels them. Delivery precedes tombstoning in 052211726.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author
  • fixed — stale shutdown settlement retained local debt after another owner won (reviewed at 32afbce5049f65372c6e739df987f8a4ff80c047)

    Settlement now revalidates generation, durable fence, launched recovery, terminal flags, and user-Stop ownership around every yielding boundary. Once another owner wins, stale local debt is released without terminalizing in 052211726.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author
  • fixed — legacy release performed blocking filesystem persistence on the gateway loop

    Compatibility PID and state mirrors now run off the event loop, while protected coordinator identity persistence still completes before child execution. The no-blocking-call suite is part of the 273-test exact-head verification for 052211726.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author
  • Fixed span=e9c52491a479 — Terminal rows bypass protected orphan cleanup

Recovery now claims expired terminal rows that retain an owned child identity, verifies and terminates that child, then clears only the protected process identity under the recovery fence. The committed terminal outcome and outbox payload remain unchanged. If cleanup cannot be proven, the recovery pass retains the identity and does not drain delivery.

Evidence: regression coverage exercises successful cleanup and failed-cleanup delivery deferral; the shared coordinator contract passes for both memory and SQLite backends. The focused coordinator/authority suite passed 303 tests with one platform skip, and formatting, subprocess encoding, isort, flake8, Linux-parity mypy, and docs lint passed.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author

/ai-review override gpt 5f224d6: Removing the POSIX deferral would weaken the documented fail-closed process-identity boundary because the mandatory durable audit creates a probe-to-signal window in which a recycled PID could be killed; default POSIX recovery intentionally retries until identity-safe termination is available.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author

span=3ebfd78990ae — rebutted. The suggested early-return removal would turn a previously verified PID into an unpinned destructive signal after the mandatory off-loop durable audit, allowing PID reuse to retarget termination. The repository specification and regression test_recovery_defers_default_termination_without_a_process_identity_pin deliberately require fail-closed POSIX deferral; Windows uses a held process handle, and an explicitly injected terminator remains available where the caller can provide equivalent identity safety.

@github-actions

Copy link
Copy Markdown
Contributor

Human judgment recorded

@kyleseaman marked the gpt AI finding as false positive, not applicable, or explicitly accepted for 5f224d63456395ad33c2f8266bcfa4ba7057aa5c.

Removing the POSIX deferral would weaken the documented fail-closed process-identity boundary because the mandatory durable audit creates a probe-to-signal window in which a recycled PID could be killed; default POSIX recovery intentionally retries until identity-safe termination is available.

This decision applies only to this commit. A new push requires a new judgment.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author

span=3b96d9d7798e — Fixed in b2510389fb151dcdce8a7e1f9fbfe0e0bfe0f982. An ambiguous legacy admission now leaves the live record nonterminal and cancellable instead of prematurely setting done; operator Stop can reacquire or adopt the retained claim and commit STOPPED. A regression covers the case where admission has already returned uncertain before Stop arrives.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author

span=40e50041bffe — Fixed in e637fdc682ae7cb026b68dbbdd771465be8e886b. The recovery test now uses the repository's requires_symlinks capability marker, so Windows hosts without SeCreateSymbolicLinkPrivilege skip the test instead of aborting the suite. The focused regression and collection check pass locally.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author
  • fixed span=e9fb8da2b635

Periodic recovery now retries the legacy importer after transient startup failure, with a deterministic regression passing on 58827ed.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author
  • fixed span=38c5ade19417

Legacy import now pins the trusted root, each run directory, and every optional file with descriptor-relative no-follow/nonblocking opens before metadata or reads; retained transcript reads use the same trusted-root discipline. Regression tests cover directory swaps, linked parents, hardlinks, off-loop descriptor cleanup, and linked display-root spellings on 003a681.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author
  • fixed span=e51cd2330ac2

Recovered interrupted members retain their exact neutral per-member outcome, but now count in the wave aggregate's non-success bucket so ok + err + stopped == total. A deterministic two-member regression covers the recovered-interruption close path in 1b107877dcb9174aee3fc055ab5549a778284b38.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author
  • Fixed — span=38c76f4dfc8c

Tombstone verification follows a swappable run directory.

Evidence: recovery now opens the run directory once with no-follow semantics, unlinks and verifies through the same descriptor, and has a deterministic swapped-directory regression covering the race.

@bolichen97 bolichen97 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review: recover subagents from the run coordinator

Reviewed origin/feat/run-coordinator-outbox...origin/feat/run-coordinator-recovery (41 files, +6763/−793) at 8289db460. Eleven angles; findings verified by executing the real RunRecovery, LegacyRunImporter, MemoryRunCoordinator/SQLiteRunCoordinator and the manager's reap paths, including real child processes and cross-tree differentials.

The headline, found independently by five of the eleven angles: this PR replaces the legacy orphan reconciler with a durable one, and the durable one cannot kill anything on macOS or Linux — while the legacy one it replaced now has zero production callers. Net effect on the two platforms most people run: a crashed gateway's surviving child runs forever, its run never converges, orphan folders accumulate without bound, and the parent is never told.

Blocking

1. run_coordinator/recovery.py:184if self._terminate_process is None and not platform_compat.IS_WINDOWS: continue makes POSIX skip the entire run: no kill, no complete(), no clear_recovered_process(). The only production construction is subagent.py:1704 RunRecovery(self._coordinator, self._outbox_delivery) — no keyword args — so _terminate_process is always None in production. Executed on this darwin host with a real sleep 600 child, matching process_start_id, process_owned=True and an expired lease: the run stayed observed_state=running across 3–5 sweeps with terminated=0 / interrupted=0 / delivered=0, lease_epoch climbing 2→3→4→5→6 and updated_at 111→231→351 while the child stayed alive. Each re-claim rewrites the whole runs table (see finding 10), every 60 s, forever. Worse: if the run had already committed its real completion but teardown failed so process_stopped was never set, claim_outbox returns [] and six reconcile passes yield delivered=0 — the finished subagent's actual result is permanently undeliverable. The log line claims the child "cannot be identity-pinned through termination", but platform_compat.kill_process_tree_pinned does support POSIX (:3036 delegates straight to kill_process_tree, i.e. exactly what the deleted _kill_orphan_pid did) — so the branch refuses on the one platform where the pinned kill is available and permits it only on Windows. The single test of the production default is pytest.skipped off Windows; every other termination test injects terminate_process=.

2. subagent_manager/monitoring.py:118 — redirecting the startup task from _reconcile_orphans() to _reconcile_startup() leaves _reconcile_orphans_impl with no production caller, silently deleting the legacy folder-based orphan kill, the gateway_restart tombstone write, and the owner Slack-DM digest. grep -rn '_reconcile_orphans' src/ finds only the impl and the facade delegate — no caller. That dead path owned _reap_orphan_process_impl, which SIGKILLed a surviving child using the folder's own pid+pid_recorded_at; the replacement cannot do it because the importer never imports those fields (docs/system-specs/modules/subagent.md:1035: "Legacy pid, pid_start_id, and process_owned fields are never imported and never authorize a signal") and recovery.py:184 declines to kill on POSIX anyway. _notify_orphan and _send_orphan_slack_dm are called only from inside the dead function, so the DM digest — the one fallback that does not depend on the agent-writable parent_session and could still have reached the operator — is dead code. And because no orphan folder ever receives a tombstone now, prune_stale_tombstones (still called at :579) never matches them: after N crashes, <data_home>/subagents/ accumulates N folders plus their session files forever, and list_orphans() re-reports them on every sweep. The tests still pass because they call manager._reconcile_orphans() directly, so the dead call site is invisible.

3. run_coordinator/legacy.py:99_open_base pins the RAW data_home() with a per-component O_NOFOLLOW walk and swallows the refusal with no log, so any symlinked ancestor silently disables 100% of legacy recovery, indistinguishable from "nothing to import". Executed with the same orphan folder reached two ways: real path → RecoveryReport(imported=1, interrupted=1, delivered=1); via a symlinked home → RecoveryReport(imported=0, existing=0, corrupt=0), get_run('crashed1') is None, and with logging.basicConfig(level=DEBUG) zero log recordscorrupt is not even incremented, so the report looks like an empty tree. This is not exotic: pinned_fs.pin_parent's own docstring says refusing every symlinked ancestor "would also break paths under /tmp on macOS" (which is why the looser open_dir_pinned exists), macOS /tmp and /var are symlinks and /var/folders is where tempfile.gettempdir() lives, and the repo ships test/test_subagent_result_path_spelling.py whose entire premise is a symlinked data home. Executed at 150 folders: /tmp → 0 imported; the identical tree under /private/tmp → all 150. Note canonical_run_coordinator_dir() calls .resolve() while _base() uses the unresolved data_home(), so the ledger is at the canonical path and the legacy tree is looked for at the non-canonical one. On Windows it is worse: supports_pinned_tree_walk() is always False (no os.O_DIRECTORY), so legacy import is a permanent unlogged no-op there — and with _reconcile_orphans now dead, no pre-coordinator run is ever recovered on Windows and nothing says so. A child agent that renames ~/.kiro/crew/subagents aside and puts a symlink in its place therefore turns off recovery for every sibling run.

4. run_coordinator/legacy.py:221 — the importer discards state.json's parent_session, so a recovered run's completion event carries destination="" and is routed to nobody while still being acknowledged as delivered. Executed: a legacy folder with parent_session="dashboard:main", a result and no tombstone → imported=1, interrupted=1, delivered=1, but run.parent_session == '' and the outbox event's destination == '' / payload.parent_session_key == ''. Downstream dashboard_slot_key('') is falsy, so the gateway's on_done skips both the slot branch and the channel branch and returns having delivered nothing — then _deliver_outbox_event_impl sets _reported_to_parent = True and returns True, so drain_once acks and mark_delivered(fence) removes the durable event. The base _notify_orphan_impl read state.get("parent_session", "") and injected the interruption there. The parent LLM waits forever for a subagent whose interruption was discarded, and the only durable record of it is consumed. Dropping the value as agent-writable is defensible; manufacturing a delivery for it anyway is not — refuse or route, don't ack into the void.

5. subagent_manager/terminal.py:136_record_process_identity_impl passes pid_start_id or "" with a hardcoded process_owned=True, which record_process REJECTS, so an unreadable process identity aborts the whole subagent run. Executed: record_process(run, fence, version, 4242, "", True)rejected/invalid_transition (memory.py:836 refuses process_owned and not process_start_id), and _coordinator_record_process_impl turns that into RuntimeError("coordinator process record refused: invalid_transition") at run.py:924, outside any try/except — so _run_inner fails and the subagent never receives its prompt. platform_compat.process_start_time returns None whenever the identity is unreadable: macOS/BSD when ps exceeds the 2 s _START_TIME_PS_TIMEOUT under load, when trusted_system_bin('ps') misses, or when output doesn't strict-decode; Linux under hidepid=2; Windows when OpenProcess is denied. Its own docstring states "an unreadable value must fail SAFE: None means 'identity unconfirmed', which every caller treats as 'do not kill'" — this new caller escalates that benign signal into total loss of dedicated subagent spawning on such a host. The base code logged at debug and continued. Degrading to process_owned=False (as the session-sharing path at run.py:800 already does) keeps the run alive without granting kill authority.

6. subagent_manager/terminal.py:996_force_reap_impl (user Stop and reaper force-kill) calls _run_terminal_report without process_stopped, so _clear_terminal_process_impl never runs and the TERMINAL row keeps its protected child identity — which makes claim_outbox refuse the event for delivery, batch drain AND acknowledgement. Executed: RUNNING → record_process(pid=4242, 'start-token', owned=True)complete(STOPPED) → row is TERMINAL with process_owned still True; then drain_once(event_id=…)[], drain_once(limit=16)[], acknowledge(event_id)None, 0 deliveries; after clear_recovered_process the same drain immediately returns DELIVERED. So _report_terminal_impl's loop spins on a 1 s sleep and the Stop notification only reaches the parent after the 90 s lease expiry plus a 60 s reaper pass lets recovery clear the identity — and never at all if the child survived teardown, per finding 1. Because the guard also blocks acknowledge(), a queued announce can be re-delivered later as a duplicate.

7. run_coordinator/memory.py:517 — a run admitted through the keyed command authority whose execution command is still PENDING when the gateway dies is permanently invisible to claim_recovery, because the 60 s grace applies only to source_version == "legacy-shadow-v1" and the authority submits with source_version="". Executed: two runs submitted, one keyed (what admit_reserved passes at subagent_command_authority.py:465 — it never sets source_version) and one shadow, gateway crashing between submit() and claim_command(). Over 20 consecutive reconcile() sweeps at the production 60 s cadence: the shadow run converges on sweep 1; the keyed run is still accepted / outcome=None after 20 minutes and was never delivered. Direct claim_recovery probes at +10 s, +130 s, +1 day and +30 days return ['shadow'] only. No production code claims stale PENDING execution commands (claim_commands has zero callers in src/), so the row leaks in coordinator.db forever and the parent never gets a terminal event. This falsifies the invariant added by this PR at docs/system-specs/modules/subagent.md:1052: "A durable keyed submission that has not yet been claimed remains eligible for normal execution after restart."

8. run_coordinator/recovery.py:240reconcile always calls completion_for with the default RunOutcome.INTERRUPTED, so a run the user explicitly Stopped is reported after a restart as "interrupted by gateway restart" with user_stopped: false and run.error discarded; the RunOutcome.STOPPED branch is unreachable from production. Executed: admit + start, user presses Stop (which durably records only a CommandOperation.CANCEL control command), gateway crashes before complete() writes STOPPED, lease expires, reconcile() runs → run.desired_state AFTER user Stop: DesiredState.RUN, delivered outcome: interrupted, delivered user_stopped: False, delivered error: interrupted by gateway restart, cancel control status still: PENDING. Nothing in src/ ever writes DesiredState.CANCEL (all three construction sites hardcode RUN and no path mutates it), and reconcile never inspects the PENDING CANCEL command, so the STOPPED branch could not be selected even if the check existed. Downstream, gateway.py:6555 tests info.outcome == OUTCOME_INTERRUPTED before info.user_stopped, so the user sees "⚠ interrupted by gateway restart" instead of "⏹ stopped by user", and _recovered_outcome makes :6632 skip failure accounting — silently violating the neutral-stop contract every other Stop path honours.

Should fix

9. run_coordinator/legacy.py:166_read_object double-closes the descriptor: fd = -1 sits AFTER the with os.fdopen(fd) block, so any exception from stream.read lets the with close it and then finally: os.close(fd) closes it again. Two executed repros. (a) With read() raising OSError(EIO) — a failing disk, ESTALE on a network home, MemoryError on the 1 MiB read, KeyboardInterrupt at shutdown — the original error is destroyed and replaced by OSError: [Errno 9] Bad file descriptor, and import_all just counts the folder "corrupt". (b) Modelling the real race, the proxy's __exit__ closed fd n and an unrelated open() was then handed the same number (fd 4, verified); the stray close succeeded silently and the unrelated open file was clobbered — writing to it raised [Errno 9]. _request runs via asyncio.to_thread in a real threadpool worker concurrently with the event loop opening sockets, pipes and SQLite connections, so the reused number can be any of those. _has_result uses the correct unconditional-close shape; only _read_object mixes fdopen ownership with a manual close. Fix: set fd = -1 before the with, or use contextlib.closing.

10. run_coordinator/legacy.py:283 + monitoring.py:479import_all awaits one import_legacy per folder, each a full-database DELETE+re-INSERT, so the import is O(N²) — and the reaper re-runs it at full cost every 60 s forever. Measured against the real SQLiteRunCoordinator: 200 folders → 0.81 s; 500 → 2.99 s; 1000 → 9.61 s with 500,500 run-row INSERTs; 2000 → 28.32 s with 2,001,000 INSERTs (exactly N(N+1)/2). Because _invoke persists unconditionally, a re-run that imports nothing costs the same: at 500 folders pass 2/3 = 4.92 s/4.84 s with existing=500, imported=0; at 1000 folders 19.25 s/16.43 s; and an independent measurement at 4000 folders (1.3 s of attacker work: mkdir + a 70-byte write) took 93.6 s, i.e. 23.4 ms/folder. import_all is wired into both startup (:118) and the 60 s reaper (:479), and _MIGRATIONS/_save_memory never delete a runs row, so a user with 1000 pre-coordinator folders burns 16–19 s of every 60 s window permanently. Amplification is also confirmed: every tombstone-less folder is imported as RUNNING, then turned into an INTERRUPTED completion with an outbox event, so N attacker directories become N ledger rows + N outbox events + N delivery attempts. Extrapolated, ~2.5 k folders puts one sweep over _REAPER_INTERVAL; since every call serialises in the bounded coordinator_executor(), live runs' 30 s renew() calls queue behind the sweep, their 90 s leases expire, and claim_recovery then interrupts still-running subagents.

11. run_coordinator/legacy.py:217conversation_key is the one imported field that is neither redacted nor length-capped, and agent is redacted but uncapped. Executed: "conversation_key": "subagent:AKIAIOSFODNN7EXAMPLE" is imported and stored verbatim into the conversation_key TEXT NOT NULL column, while every sibling field is redacted (task +1000 cap, agent redacted, error +2000 cap, run_id via a redaction-equality check). It is also a routing key elsewhere (run.py:320, continuation.py:63) — precisely the agent-writable class the comment at :262-268 says must not be trusted. Separately, "agent": "A"*900000 imported cleanly with len(agent) == 900000, and completion_for() produced a payload_json of 900,254 bytes, which complete() stores in outbox.payload_json and _deliver_outbox_event_impl puts through _redact(info.agent) into the dashboard frame. Because _save_memory rewrites all three tables on every call, that ~900 KB blob is re-written on every coordinator operation. Also unbounded: _folders applies no cap on folder count (finding 10), and _number only rejects non-finite values, so "started": -1e308 survives as created_at and completion_for emitted payload["elapsed"] == 1e+308 — which _info_from_outbox_impl parses into info.elapsed and publishes, while claim_recovery's sorted(key=(created_at, run_id)) pins that run to the front of every batch.

12. run_coordinator/recovery.py:142 — on macOS/BSD an identity mismatch returns (None, True) = retry forever with no attempt cap, deadline or escalation, so a genuinely reused PID (or a mere TZ/locale change) wedges the run immortally while Linux/Windows converge from the identical state. Executed: for one live pid, platform_compat.process_start_time returned 'Wed Sep 2 03:25:26 2026' under TZ=UTC and 'Wed Sep 2 12:25:26 2026' under TZ=Asia/Tokyo — so a gateway restarted under a different TZ/LC_TIME (launchd vs shell env) mismatches its own recorded identity for the same live child. Feeding that drifted value to production-default recovery left the run running across 5 sweeps (lease_epoch 2→6), never terminal, never delivered. The continue keeps the 90 s lease, so the row is simply re-claimed and re-warned every pass; nothing in the file can break the loop. Related: the default process_identity is process_start_time, which on Linux is /proc/<pid>/stat field 22 — ticks since boot — stored in a durable row with no boot-id fencing, and the repo's own platform_compat._own_identity_token refuses to hand out that bare value for exactly this reason ("a post-reboot process can repeat an earlier boot's (PID, ticks) pair") and adds _linux_boot_id(). Migration 5 adds no such column and nothing clears process_* at startup, so a post-reboot process can alias an old row and be classified as the recorded child; with IS_LINUX patched and a recorder installed, recovery reported terminated=1 against an unrelated live pid.

13. run_coordinator/memory.py:596_claim_commands' acquire_run_lease branch checks neither run.observed_state nor run.lease_expires_at, so it grants a fresh execution fence on an already-TERMINAL, already-delivered run and steals a live recovery owner's unexpired lease. Executed: a legacy-shadow run recovered past its grace; recovery commits INTERRUPTED, delivers the event, and holds the lease (owner=rec, epoch=1, lease_left=90 s), deliberately leaving the spawn command PENDING (recovery.py never touches it — docs :1205: "Recovery never claims or replays its execution command"). A subsequent claim_command("spawn:run-B", OwnerLease("gw2", now+90)) returns GRANTED with RunFence(lease_epoch=2) on a run the claimer itself sees as terminal / INTERRUPTED, and run.owner_id flips to gw2 while rec's lease still had 90 s. Reachable from _resolve_stopped_shadow_claim (subagent.py:2878), which retries exactly this claim_command in a loop: it sets info._coordinator_fence, returns True, and never reaches the _shadow_claim_taken_over check — so cancel_impl proceeds to _force_reap, complete(STOPPED) hits OUTCOME_CONFLICT, _report_terminal returns without delivering, and the user's Stop is silently swallowed. claim_recovery does guard on lease_expires_at > now; _claim_commands does not. Same family: import_legacy writes result_path onto an existing non-terminal run with no fence check and without incrementing version (executed: applied/transitioned, owner_id/lease_epoch unchanged, version still 4, so the fenced owner's complete(expected_version=4) still succeeds — no VERSION_CONFLICT), and since a live run's result_path is always '', the not existing.result_path guard is satisfied for every in-flight run on every 60 s sweep.

14. subagent_manager/terminal.py:210 — the lease heartbeat returns permanently on the first renew() that comes back False and never retries, so with a 90 s lease and a 30 s cadence just two consecutive failed renewals leave a genuinely live run holding no lease for the rest of its 30-minute deadline. Executed: renew at t+30 → True; after 95 s (two missed renewals) → False, and every subsequent renew → False forever, because memory.py:1018 refuses lease_expires_at <= now with no re-acquire path. terminal.py:210-212 logs and returns, popping the lease task. The run keeps executing with an expired lease, so claim_recovery takes it the moment it leaves _tasks (finding 13's shape), and nothing notices: complete(..., allow_expired=True) still returns applied/completed, so the lost lease is silent. One >90 s loop stall or a laptop suspend is enough, since time.time() advances across suspend while the reaper's asyncio.sleep deadline is monotonic. Related asymmetry: reconcile passes allow_expired=True to complete() but clear_recovered_process() validates without it (memory.py:875), so a sweep that outlives its own 90 s lease commits interruptions whose outbox events it can then no longer make claimable — executed with a 200 s probe batch: interrupted=1 delivered=0, run TERMINAL but still process_owned=True, claim_outbox[].

15. test/conftest.py:487 — a new autouse fixture monkeypatches kiro_crew.subagent.SQLiteRunCoordinatorMemoryRunCoordinator for the entire suite, and the only test asserting the production default was deleted. _isolate_default_run_coordinator has no opt-out, so every SubagentManager(...) in the suite builds an in-memory coordinator: nothing verifies that a real manager gets a durable store, nothing exercises claim_recovery/import_legacy through _offload, and no restart test actually persists anything. The deleted base test was the two-line assert isinstance(manager._coordinator, SQLiteRunCoordinator); grep -rn SQLiteRunCoordinator test/ now shows only the conftest patch and the direct unit tests. Concretely: change the default to MemoryRunCoordinator() (or let the SQLite constructor raise and fall back) and the whole suite stays green while every run and every pending outbox event is lost on restart — the exact durability this PR exists to add. The fixture's own docstring names "coordinator submission timeouts" as the motivation, i.e. it papers over the next finding.

Cut by the cap (all verified)

subagent.py:2578_SHADOW_SUBMIT_TIMEOUT_SECS = 1.0 and its wait_for were deleted; _await_retained_shadow_submit now does an unbounded await asyncio.shield(submit_task), so a wedged coordinator blocks _run indefinitely while holding its slot. Executed: _run still blocked after 3 s with _run_inner.await_count == 0 (the base bounded it at 1.0 s and proceeded); the deleted base test test_stalled_shadow_submission_does_not_block_legacy_execution asserted exactly that, and grep -rn '_SHADOW_SUBMIT_TIMEOUT' src/ test/ now returns nothing · subagent.py:2857_drain_retained_shadow_submits busy-spins forever once a retained submit task ends cancelled, because _settled/_settlement_done only pop the task when not done.cancelled() and gather(return_exceptions=True) returns CancelledError on every later pass; executed, the function never returns and starves the loop, and it is reachable because it gathers the raw submit task unshielded · run.py:331 — a new RuntimeError("coordinator execution fence is missing") converts the still-documented best-effort shadow mirror ("any coordinator failure is diagnostic rather than an execution failure") into a hard execution precondition, so a transient database is locked now fails a legacy-path subagent that previously ran fine · messaging.py:780_read_spawn_result's pinned read hard-requires supports_pinned_walk(), so on Windows spawn_status silently returns no transcript at all (executed with os.O_DIRECTORY deleted: POSIX returns the text, Windows-simulated returns None"_No result._"), and a result above MAX_FILE_BYTES is dropped rather than truncated — while the covering tests were marked @requires_pinned_walk (skipped) instead of covering the fallback · subagent_persistence.py:90agent_dir_for_display was changed from delegating to _agent_dir to calling only _validate_agent_id, dropping the resolved-path containment check, so it now returns a path that escapes the subagents root where it previously raised; executed with a symlinked evil1, _agent_dir raises "Path traversal blocked" while the display helper returns the path and its result.txt reads out-of-root content — and that string is handed to the parent agent to read at four sites. The base docstring said the validation "is delegated, so the two cannot drift apart" · messaging.py:996 — the new fourth outcome makes spawn_retry's old.outcome != "failed" gate reject exactly the runs a restart interrupted (409 "only failed agents can be retried"), i.e. the safest retry case, and the replacement info also loses cwd/_raw_task/max_turns/model · terminal.py:424 — the delivered tombstone is gated on outcome == "completed", so a legacy-imported run (which by construction has no tombstone) is never tombstoned after its interruption is delivered, leaving the folder unprunable and re-imported on every start · terminal.py:614if any(attempt.status is DELIVERED) was weakened to if attempts:, so the reporter now stops after a single PENDING attempt (destination raised, or acceptance refused) instead of retrying every _TERMINAL_RETRY_SECONDS; combined with the reaper's drain-to-empty loop being replaced by one non-looping drain_once(limit=256), a transient injection error now delays the parent by up to 60 s (and a >256 backlog by multiples of 60 s) where it previously retried in 1 s · memory.py:373import_legacy's existing-run branch looks the outbox up under (run_id, request.event_type) while the importer always sends event_type="", so existing_event is unconditionally None and every receipt falsely reports no pending delivery · _verified_live_process returns tuple[tuple[int,str]|None, bool] where ((pid, id), True) is representable nonsense, and the kill site reads it positionally as kill_process_tree_pinned(process[0], process[1], SIGKILL) — on the one call in the diff where confusing the two arguments kills the wrong process · a kill failing with PermissionError continues without clearing the identity, permanently excluding that run's completion from claim_outbox while re-emitting a critical=True "kill_authorized" SEL record every sweep for a kill that never happened · record_process never bounds process_id, so 2**31 is accepted and then makes pid_exists raise OverflowError (not an OSError, so uncaught) out of reconcile, aborting recovery for every other run and the final drain_once — executed, a healthy second run's committed completion was not delivered · reuse: _redact is duplicated in two new files where security.redact is already imported piecewise (now ~9 copies of a two-line security primitive, and legacy.py:140 uses _redact(run_id) != run_id as an authorization predicate); completion_for is a second subagent_completion payload builder that drops four keys _info_from_outbox_impl reads; _EVENT_TYPE is a fourth literal for one event name; the st_nlink > 1 or not S_ISREG or st_reparse_tag triple appears twice in one file while pinned_fs.refuse_hardlink_alias exists; and _DIR_OPEN_FLAGS captures dir_flags() at import time — the documented anti-pattern that module warns about, since the Windows-simulation tests delete the flag at runtime.

Conventions: terminal.py:643 edits a previously clean comment purely to append (Opus/Design/First-Principles review on #5326) — both a review-round marker and a PR number, forbidden verbatim by AGENTS.md and code-style.md. docs/system-specs/modules/subagent.md:214 says "schema v4 uses…" while _SCHEMA_VERSION = 5, and the test is named test_v1_database_migrates_to_v4_once while asserting == ("5",). recovery.py:97 re-inlines [:1000]/[:2000] and the "interrupted by gateway restart" string that legacy.py:36-37 already names as constants, and no run_coordinator/ row was added to code-style.md's owning-module index for any of the six new limits. Everything else is clean: black (pinned), flake8, isort, mypy --platform linux, docs-lint, brand, harness-parity, subprocess-encoding, check_agent_sdk_boundary, check_sync_io_in_async, check_loop_bound_locks, check_testpaths_coverage, check_lockdown_before_publish, check_changelog_history — and CHANGELOG is correctly untouched. Note check_black_formatting.py is red, on test/test_run_coordinator_sqlite.py from #5279, so this PR inherits a red gate it did not cause.


Execution-verified AI-assisted review (Claude Code). Eleven parallel angles ran against a local checkout at 8289db460 and each verified its own findings by execution — including real child processes under /tmp, cross-tree differentials, and measured latency curves; this summary is my consolidation of their reports, so treat the prioritisation as mine and the evidence as theirs. Nothing was modified in the tree and nothing was posted elsewhere. Verified clean, so please don't re-spend: the runs INSERT round-trips all 22 fields with no swapped pair; all 21 Protocol methods are forwarded by Shadow/SQLite/Memory with identical signatures; LegacyRunImport carries no process_id/process_start_id/process_owned, so no agent-writable field reaches a kill; the audit is correctly ordered before the kill on every path and an audit failure does gate it; and result_path is built from the _valid_id-validated run id.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author
  • Fixed — span=3ebfd78990ae

    Legacy import races locally active runs.

    Recovery now forwards its active/queued run-ID exclusion snapshot into legacy import, and the importer skips those folders before reading or submitting them. Both boundary regressions pass; the full recovery file is 33 passed with 1 platform skip, and the adjacent delivery/wiring/authority slice is 156 passed.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author
  • Fixed — span=3b96d9d7798e

    Definite submit failures become unrecoverable before delivery

    Definite missing-fence submissions now re-enter the existing tombstone-free legacy terminal settlement path instead of raising into the error-tombstone path. The regression tests cover both a null receipt and a delayed submit exception; the focused recovery/wiring/reap suite passes (116 passed, 1 skipped).

@kyleseaman

Copy link
Copy Markdown
Collaborator Author

Bolin review disposition for the current stack

All fifteen numbered findings from the September 2 review are fixed in the submitted recovery layer.

  1. Fixed.
  2. Fixed.
  3. Fixed.
  4. Fixed.
  5. Fixed.
  6. Fixed.
  7. Fixed.
  8. Fixed.
  9. Fixed.
  10. Fixed: legacy import is batched and no longer performs quadratic full-snapshot persistence.
  11. Fixed: imported routing fields are redacted and length-bounded.
  12. Fixed: durable process identity uses stable Linux boot identity, normalized POSIX identity, and Windows creation identity so mismatch and reboot cases converge safely.
  13. Fixed: terminal or live-leased runs cannot be reclaimed by execution commands, and legacy import cannot mutate a live run without authority.
  14. Fixed: heartbeat loss and expired recovery-clear paths retain explicit fenced ownership and converge safely.
  15. Fixed: production SQLite default coverage is restored rather than globally replaced by memory in the suite.

The remaining cut-by-cap correctness observations were included in the same recovery audit and covered by the current process-identity, import, lease, and shutdown regressions.

Current submitted head: d885683.

@bolichen97

Copy link
Copy Markdown
Collaborator

Open PR relationship audit

This is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion.

Relationship findings

  • PR #5277 is OVERLAPPING relative to this PR. The goals differ or the implementations can complement each other; this is not a duplicate claim. Recommended action for PR #5277: MERGE_DISCUSSION. Adds the recovery capability this PR's adapter deliberately omits. Files: src/kiro_crew/run_coordinator/memory.py.

No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

readiness: action required A blocking check or review needs attention

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants