fix(persistence): preserve subagent cleanup and retention - #6713
Conversation
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
Design Review (Fable 5, fork) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound fix for a real transcript leak, but the deletion-authority machinery lands with fragile trust seams and a retention inversion humans should weigh. Watch
Suggestions
[DESIGN-REVIEWED] 78fb3a9 |
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of All verification is done. The core fix is real and well-anchored (the First-Principles-Verdict: CONCERNS The identity-capture fix is sound and boundary-derived, but it tows a retention-arbitration subsystem, and one production branch exists only so test monkeypatches stay green. What this change shipsIntent: stop failed subagent runs from leaking or wrongly deleting provider transcript files — a FIX.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 78fb3a9 |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed |
93911c6 to
f1f4125
Compare
f1f4125 to
4733d9f
Compare
|
fixed — The original |
|
fixed — The tombstone is now self-sufficient only for immutable session identity, not retention policy. Final SHA |
4733d9f to
e734613
Compare
|
fixed — Copying |
|
|
|
|
fixed — Final SHA |
|
fixed — Final SHA |
|
fixed — Final SHA |
|
rebutted — configurable 30-day cap: |
|
|
fixed — Final SHA |
|
fixed — Final SHA |
|
fixed — Readable |
|
|
fixed — Promotion now acquires |
|
|
Exact-SHA GPT/Opus pass; 491 tests and all gates pass. |
|
Exact-SHA GPT/Opus pass; 495 tests and all gates pass. |
|
Exact-SHA GPT/Opus pass; 497 tests and all gates pass. |
|
CI rerun requested: |
Capture live cleanup identity before fallible state writes, retain every session generation for bounded provider cleanup, and keep event-loop tombstone snapshots memory-only. Serialize promotion against durable prune claims, preserve retryable ownership, and use current readable owner state as retention authority with bounded fallback for incomplete or unreadable state.
|
This is ready for review – all AI analyzers pass |
Open PR relationship auditThis 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
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
|
Thanks @bolichen97 – will keep this open per your suggestion. Confirming that this is ready for review |
bolichen97
left a comment
There was a problem hiding this comment.
The security property this hinges on is right and is the part I checked hardest: provider-deletion authority comes only from the protected record under the file-gated trust/ root plus synchronous live gateway publication, while the agent-writable tombstone.json/state.json SID and cleanup_identities fields are demoted to display and retention hints — so a forged victim SID (or a planted legacy cleanup-identities.json in the run folder) cannot widen prune's deletion set, and there is a regression pinning exactly that. _protect_cleanup_identities_path correctly re-locks the existing file rather than only its parents, since tightening a parent does not retrofit a Windows file DACL, and it is fail-loud on both the read and the write path. _read_cleanup_identities_file treating only FileNotFoundError as empty is the right call — a parse or schema error propagating is what stops a later append from rewriting unreadable history as a fresh single generation. Concurrency checks out: publish_live_cleanup_identity deliberately skips _CLEANUP_IDENTITY_LOCK and relies on list.append atomicity so the event loop can publish before a queued to_thread is cancelled, _live_cleanup_identities uses a non-blocking acquire, prune runs under maintenance_executor() so its blocking _acquire_retention_locks is off-loop, and the two lock orders (retention -> cleanup-identity in prune, retention -> state in promotion) form no cycle. _remember_identity_off_loop shields and fully drains before re-raising, so restart cannot precede the authority record. Two things I looked at and accepted as deliberate rather than defects: _cleanup_session_files_sync now returning False for non-kiro providers keeps those run folders for 90 days instead of dropping them at 7 (metadata only, bounded, and the alternative is a permanently unreachable transcript), and the shared-path PID write becoming best-effort is the right trade since the old propagating behavior orphaned a live shared handle — except Exception still lets CancelledError through. One non-blocking nit for a follow-up: the subagent.py facade still declares _promote_conversation -> None while it now forwards RetentionPromotionResult, papered over with # type: ignore[func-returns-value]; the declared type is now inaccurate even though the call site guards on an explicit RETRYABLE.
Problem / Motivation
The live subagent client learns its session identity, provider, and (for Claude
Code) project CWD before the best-effort
state.jsonupdate records them. If thatstate write is skipped or fails and the run then exits abnormally, a tombstone
built only from state cannot identify or correctly route cleanup of the provider
transcript files.
Cleanup also needs two safety properties: current readable state—not a stale
tombstone snapshot—owns the mutable
keepretention decision, and unreadablestate must preserve both the session files and the tombstone folder that owns
their identity for a later retry.
Why it matters
A failed non-retained run can leak
.json/.jsonltranscript files containingtask context, tool calls, and partial output. Misrouting a Claude Code session
through the ACP cleanup default leaks the same files. Conversely, deleting when
retention intent is unknown can destroy resume material, while deleting only the
tombstone folder makes retained session files permanently invisible and
unreclaimable.
What changed (motivation → approach → change)
SubagentInfonow captures the live client'ssession_id, provider, and ClaudeCode CWD immediately after session acquisition, before the fail-closed resume
guard, context construction, and fallible state update. Abnormal-exit tombstones
receive each non-empty live cleanup value independently. Shared-runtime handles
capture identity inside
_create_shared_session; PID persistence is best-effort,so a disk error cannot orphan the live shared handle by triggering dedicated
fallback. Early exits with no live value retain the existing persisted-state
behavior; an empty in-memory value never overwrites usable data. The current
base's manager extraction keeps continuation arbitration in
subagent_manager/continuation.pyand session acquisition insubagent_manager/run.py;subagent.pyremains the unchanged state-owning facade,while persistence bridges live cleanup identity to its existing tombstone writer.
The generic tombstone writer also snapshots non-empty session ID, provider, and
CWD from readable state so gateway-restart and delivered tombstones retain
cleanup identity if state corrupts later. Remembered live values are merged after
that state fallback and therefore override it. Cancel recovery can acquire multiple
sessions under one run ID; persistence atomically records complete per-session
cleanup generations in an owner-only record below the file-gated
trust/root,outside the agent-writable run folder and independent of the tombstone's
orphan-exclusion role. Every read and write first applies fail-loud owner-only
restriction to all trust-store directories, including inheritable Windows DACLs,
and re-locks an existing record because tightening its parent does not retrofit
an older file ACL. Only a missing record reads as empty; I/O, parse, and schema
failures propagate so a later append cannot rewrite unreadable history as a fresh
single-generation record. Prune contains those failures per tombstone and continues
later entries without altering the corrupt record; shared-session setup logs them
best-effort and retains the live handle. Each generation records retention intent and the continuation
owner key before the later best-effort combined state update. Protected ownership
outranks empty or conflicting agent-writable keys, while the referenced owner's
current readable state remains authoritative; generation
keepis the missing-fieldfallback. Session acquisition publishes
the generation synchronously in memory before submitting durable work, so executor
saturation or cancellation cannot hide the live SID from terminal tombstones. The
in-memory fallback is append-only; the worker deduplicates only while serializing
the protected record off-loop and never replaces the live list, so an older writer
cannot discard a concurrent recovery SID. On the dedicated arm, durable generation
persistence follows the cancellation-drained provenance write. Both dedicated and
shared identity workers are shielded and fully drained before cancellation is
re-raised, so restart cannot precede protected authority. Cancellation cannot skip
required model fields while the already-published memory record still
feeds terminal tombstones. Current main's #7557 also moves the run PID, session
record, and shared-runtime PID writes through the same drained off-loop state
helper. The composition keeps cleanup CWD sourced from the identity captured at
acquisition, preserves the shared handle when storage fails, and binds generation
arguments inside its executor callable so cancellation probes reach the actual
drained
state.jsonwriter rather than the already-safe memory-first publication.Slow storage therefore cannot stall heartbeat or hide a just-acquired session from
a contending tombstone, and a transient SID1 generation-write failure cannot be
lost when SID2 later succeeds.
Shared-handle ownership and provider references are attached immediately after
handle creation, before any cancellable persistence await, so force-reap always
destroys the shared handle instead of resetting a nonexistent dedicated session.
Shared-path persistence errors are logged without abandoning the live handle or
triggering dedicated fallback. Event-loop tombstone snapshots are memory-only:
they acquire the identity lock non-blocking and use writer-published generations,
never reading the protected durable record. Executor-owned prune independently
merges that record after restart; successful prune or explicit folder deletion
evicts both the durable and in-memory fallback. Tombstones snapshot the list and
leave the latest in top-level compatibility fields for diagnostics and restart
hints, but neither spelling can authorize provider deletion. Tombstone-write and
restart-scan fallbacks populate a separate SID-less retention-hint set used only by
the SessionManager exemption. Prune's deletion set comes only from the protected
record and synchronous live gateway publication. Retention fallback selects a
protected generation matching the current readable-state SID, or the latest
protected generation when that SID is absent or mismatched; agent-writable
state/tombstone SID and owner fields cannot select a victim identity or suppress
trusted owner/
keepmetadata. Aforged tombstone list/top-level SID or legacy
cleanup-identities.jsoninside theagent-writable run folder therefore cannot add another run's SID to that set.
During stale pruning, readable dictionary state is required before immediate
cleanup. Tombstone identity, provider, and CWD may guide folder eligibility and
match a trusted generation, but cannot expand provider-deletion authority. Only a
literal current-state
keep is Trueis retained; strings such as"false"arenon-retention rather than truthy policy.
keep=Truepreserves the identity folderfor restart registry rebuild; release to
falseallows prune to retry a failedprovider-file deletion. Readable
keep=Truealways defers disk prune; release or the conversation TTL writes
falseand ownsdeletion. This arbitration is part of cleanup correctness, not an independent
retention feature: once durable identity makes provider files reachable, a stale
keep=Falseprune racing promotion could destroy newly reachable resume material.False-to-true promotion and prune share per-agent state transactions inside
the single gateway process. Continuation tombstones lock both child and original
owner in stable order, then re-read under lock, so unrelated agents never contend.
Promotion writes
truebefore that locked read, or prune keeps arbitration throughprovider cleanup and folder removal so a later promotion returns retryable rather
than racing deletion. On the event loop, promotion probes arbitration and the
per-agent off-loop-writer lock non-blocking. Contention returns retryable
conversation_busy, so a later retry writeskeep=Trueonly after every olderwriter completes. Off-loop promotion lets
update_stateacquire that non-reentrantlock normally, avoiding self-deadlock while preserving serialization. Transient
persistence errors likewise return retryable without dispatch; retry restores exact
pre-attempt SessionManager/TTL ownership. The coordinator returns the result
directly through the unchanged forwarding facade, so concurrent callers carry
independent outcomes without a thread-local side channel. A process crash leaves no
half-committed claim format: the next prune re-reads current owner state under
arbitration. If state and a rewritten tombstone both lack a top-level SID, prune
derives retention and owner from the latest valid durable cleanup generation.
Provider cleanup occurs before lock release; folder and protected-record removal
follow only when every trusted generation reports success. Unsupported providers
and transient deletion failures preserve both retry surfaces for up to 90 days,
preventing a permanently missing route from accumulating private run folders forever.
A legacy SID present only in agent-folder state/tombstone receives the same bounded
lookup window: it cannot authorize provider deletion, but allows a later trusted
migration to reclaim the transcript. At the hard ceiling, only run-local metadata is
reaped; untrusted identity is never used for provider-file deletion. Restart registry
rebuild also requires literal
keep is True, exact state SID/conversation-ownermatching against a protected/live generation, and sources provider/CWD from that
trusted record, preventing forged state from seeding TTL-release deletion. An
explicitly injected noncanonical state reader remains an application-owned trusted
integration seam; the canonical disk reader never takes that fallback. Continuation folders
follow the original conversation's readable
keepvalue directly:
falseor missing is non-retention, while unreadable owner statereceives bounded grace instead of inheriting the continuation's stale local
true.Registry rebuild, prune, and the conversation TTL sweep share
subagent_id_from_conversation_key; malformed keys are dropped per entry so theycannot abort later cleanup. Every completed plain run records
keep=False; readable legacy or failed-writerecords with no
keepkey are treated as non-retained and prune at the normalcutoff.
Unreadable state with tombstone SID also keeps the SessionManager deletion
exemption until prune owns cleanup. An acquisition-time
keep=Falsegenerationremains unknown because a later promotion may have landed only in the now-unreadable
state; only
keep=Truemay collapse uncertainty, since it can only preserve data.Missing, malformed, deeply nested,
Unicode-invalid, or non-object state receives an extra 24-hour grace anchored on
the normalized tombstone death time, preserving identity and session files beyond
the six-hour continuation TTL. Tombstone death is normalized without float
conversion; hostile values use validated file mtime (or the current sweep time when
clock rollback leaves no bounded timestamp) for cutoff and grace, and timestamps
exactly at the cutoff are eligible across platform clock resolutions.
Tests
The affected persistence suite proves the complete chain:
never contained them; a fail-closed
resume_failedbefore context constructionalso tombstones the fresh session identity; shared-session cleanup-generation and
PID write failures retain the live shared handle instead of spawning an orphan
fallback; and cancel recovery from SID1 to SID2 records and reclaims both sessions
even when SID1's protected-record write fails transiently, SID2 succeeds, shutdown
clears the tombstone, and simulated restart drops process memory. A cancellation-at-
to_threadregression proves the live shared SID and ownership are visible beforethe queued durable writer starts, then drives force-reap through shared-provider
shutdown and proves the handle is destroyed without dedicated reset; a rendezvous
test proves a concurrent publication cannot be overwritten by an older writer. A direct regression
makes the protected durable-record reader raise if tombstone creation calls it,
proving event-loop snapshots remain memory-only while prune still recovers durable
generations. A security regression writes the same forged victim SID to the
tombstone's top-level and generation-list fields plus the old agent-folder
cleanup-identities.jsonpath, then proves prune deletes only the run-ownedtranscript while preserving the unrelated victim transcript. Another
regression forces owner-only directory restriction to fail and proves both read
and write abort before the protected record changes. A malformed protected record
likewise raises and remains byte-for-byte untouched by a later append; a sorted
corrupt-first prune proves the valid later entry still reclaims. The persistence
fixture also asserts trust records stay under that test's own temp root. Shared
I/O/parse/schema failure cases retain the created handle. Shared and dedicated
cancellation tests hold the identity worker in flight and prove the run
cannot finish/restart until the protected write lands.
keep=Falsebut no SID cleans through tombstonemetadata; generic gateway-restart/delivered tombstones snapshot readable
SID/provider/CWD as compatibility and restart hints if state corrupts later,
without granting provider-deletion authority. Tombstone writing and the
executor-owned restart scan publish only a SID-less SessionManager hint; a
regression forges state/tombstone victim SIDs and proves the hint becomes live
while the provider-deletion set remains empty. The same scan matrix proves a
retained state row is rejected when SID or conversation owner mismatches the
protected generation, and that
keep="false"is not retained. Another regression makesread_tombstoneraise if the event-loop fallback calls it.keepprunes at the normal cutoff; explicitkeep=Truealways preserves the folder for restart recovery until release or conversation
TTL writes
false. A barrier-controlled race proves promotion never blocks theevent loop while prune owns arbitration: the first attempt returns retryable
conversation_busy, then a retry after prune completes cleanup returnsconversation_gone. An unsupported Claude Code cleanup regression proves prunepreserves both the run folder and protected identity record, then reaps both once
a cleanup implementation reports success. A legacy state/tombstone-only SID
regression similarly preserves the lookup without invoking cleanup, then proves
later protected publication makes reclamation safe. Parameterized hard-ceiling
regressions prove both legacy-no-authority and unsupported-provider retry folders
are reaped after 90 days without ever using untrusted SID data for provider cleanup.
A held upstream off-loop writer lock
is also retryable immediately; after release, promotion writes
keep=Trueand thestale writer cannot roll it back. A synchronous off-loop promotion regression also proves
update_stateowns its lock without recursive acquisition or deadlock. Transientpersistence errors and skipped state writes are
retryable and cannot dispatch; retry also preserves any SessionManager/TTL
ownership that existed before the attempt. Continuations use the original
owner's readable
keepdirectly; if the later combined identity/retention statewrite fails, the cleanup generation restores the missing owner key and intent,
and a subsequent readable owner release still wins. Unreadable owner intent receives
bounded 24-hour grace.
unreadable; the SessionManager fallback preserves tombstone-identified material
until prune's bounded 24-hour grace expires. A regression records acquisition-time
keep=False, promotes state totrue, corrupts state, and proves the stale falsegeneration remains unknown for grace rather than immediately deleting resume data.
Another forges a conflicting tombstone SID and proves latest protected
keep=Truestill wins without provider cleanup or folder removal. Readable legacy continuations use
their resolved state SID when the original owner is unreadable and the old
tombstone has no SID. Malformed continuation owner IDs are bounded as unknown
retention per entry; a sorted-first malformed record cannot abort pruning of a
valid stale record later in the same sweep. A deeply nested sorted-first
tombstone is likewise isolated so the valid next entry still prunes. String,
NaN, infinite, future, and oversized tombstone
death values use bounded file-mtime fallback rather than aborting or evading
prune; if both death time and mtime are future-dated after wall-clock rollback,
the current sweep time preserves unknown-retention grace instead of authorizing
immediate cleanup; exact cutoff equality remains eligible on coarse-resolution
filesystems.
keep=Trueafter tombstone creation retains thesession.
Validation evidence (the long persistence suites were not rerun during the latest recovery):
test/test_subagent_persistence.py test/test_subagent_continuable.py test/test_session_cleanup.py test/test_session_sharing.py test/test_continuable_followups.py test/test_jsondecodeerror_redundancy_ratchet.py: 211 passedtest/test_subagent_state_write_serialization.py, untouchedtest/test_subagent_coverage.py,test/test_subagent_context_group_plumbing.py,test/test_app_spawn_capability.py, the two upstream base-regression suites, and the exact ACP stale-turn test: 621 passedisort --check-only src/kiro_crew test,flake8 src/kiro_crew test, agent-SDK boundary, docs lint, andgit diff --check: passedmypy src/kiro_crew/: passed (1,264 source files)Manual verification
N/A — this backend persistence invariant is covered through live capture,
abnormal-exit tombstone creation, provider-specific metadata, and the real stale
prune path.
Screenshots / video
N/A — backend-only persistence change with no rendered UI delta.
Pattern harvest
Rule candidate: semgrep
Pattern: synchronous
threading.Lockacquisition in an async/event-loop call pathRationale: event-loop paths must use non-blocking acquisition or offload blocking work.
Related Issues
no linked issue: this implements item 7c of #6292 but does not close the
umbrella issue.
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)Contribution License Agreement
N/A — the repository template does not currently provide CLA attestation wording.