feat(claude-code): capture background subagent work durably (SubagentStop + task records) - #2032
Conversation
…-flight markers Claude Code's launch-time post-task hook fires seconds after a background subagent starts, before any real work happens, so capturing there only ever saved an empty stub. Launch events for run_in_background tasks now record an in-flight marker on session state (including the subagent_type/description, since SubagentStop payloads carry no tool_input to derive them from) and defer the real capture to SubagentStop, the true completion signal. SubagentStop (event.Final) is now the authoritative final capture: it loads session state first to guard against resurrecting an ended/swept session (SaveTaskStep's ensureSessionInitialized would otherwise recreate it unconditionally), bypasses the no-changes skip gate so read-only subagents still get a task step, clears the in-flight marker on every exit path, and triggers the same eager condense SessionEnd uses when the session had already ended before SubagentStop arrived. Foreground launches are unchanged.
… triggers CRITICAL: SaveTaskStep incremented neither StepCount nor FilesTouched for a transcript-only task step (empty ModifiedFiles/NewFiles/DeletedFiles), which is exactly what the Final path's no-changes bypass produces for a read-only background subagent — the PR's headline case. CondenseAndMarkFullyCondensed's StepCount<=0 shortcut then marked such a session FullyCondensed without ever reading the shadow branch, stranding the just-captured transcript to be destroyed as an orphan. SaveTaskStep now increments state.StepCount when the save succeeds, the step is not incremental, and all three file lists are empty, so the step registers with every condensation trigger that keys on StepCount/FilesTouched (CondenseAndMarkFullyCondensed, PostCommit, doctor's classifySession, the zombie sweep). Grepped all SaveTaskStep call sites: hooks_claudecode_posttodo.go always sets IsIncremental=true (excluded); saveSubagentSessionTaskStep's caller returns early when totalChanges == 0, so it can never reach SaveTaskStep with empty file lists. Only captureSubagentTaskStep's Final-path bypass can. Strengthened TestHandleLifecycleSubagentEnd_SubagentStop_PhaseEnded_ TriggersEagerCondense to prove real consumption (persistent checkpoint written, shadow branch deleted) rather than merely asserting FullyCondensed flipped true, which a skip path can also produce. Verified the strengthened assertions fail without the StepCount fix. Also: per-field marker fallback (SubagentType/TaskDescription/SubagentID each fall back independently instead of as an all-or-nothing pair); extracted session.State.FindInFlightTask next to Add/Remove; fixed the InFlightTask doc comment (turn-end/SessionEnd consumption is this PR, not a follow-up); and collapsed the redundant ctx/logCtx parameter pair down to logCtx alone across handleSubagentStopFinal, recordInFlightTaskLaunch, removeInFlightTaskMarker, and captureSubagentTaskStep. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…turn end Adds captureInFlightTasks, the turn-end/SessionEnd backstop for background subagents whose SubagentStop hasn't arrived yet: turn-end snapshots each in-flight task's code changes as a code-only incremental checkpoint (marker stays), and SessionEnd runs the same non-incremental, transcript-including Final capture SubagentStop would have (marker cleared) before the eager condense, so newly-captured steps get swept instead of stranded. Refactors marker clearing into claimInFlightTask, an atomic find-and-remove, so two Final events racing for the same task (a late SubagentStop vs. SessionEnd's sweep, or a duplicate delivery) capture exactly once. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…er background labels
Three reviewer-flagged issues in the in-flight-task capture backstop:
1. captureInFlightTasks only applies maxInFlightTasksPerCapture to the
turn-end incremental path now. The SessionEnd final path is uncapped:
there's no later turn-end to defer stragglers to, so clipping it meant
tasks 9+ permanently lost their transcripts — the exact motivating
failure.
2. captureInFlightTaskIncremental now dedups on transcript growth: it stats
the subagent transcript and skips the analyzer scan and shadow commit
entirely when the size matches session.InFlightTask's new
LastCapturedTranscriptBytes, persisting the new size after a successful
capture. Kills both the per-turn full-rescan cost and the
content-identical checkpoint noise for a task with no new progress.
3. strategy.FormatIncrementalSubject now renders a real label
("Background <type> task: <description> (<id>)") for
IncrementalTypeBackgroundProgress instead of silently discarding
subagentType/taskDescription; every other incrementalType (e.g.
"TodoWrite") is unchanged.
Also drops SubagentTranscriptPath from the incremental TaskStepContext: the
incremental write path never stores a transcript, so setting it implied
storage that doesn't happen.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wires SubagentStop into .claude/settings.json (same empty-matcher, availability-guarded sh -c shape as Stop), threading it through UninstallHooks and CheckHookConfig's drift detection so `entire disable` and `entire doctor` treat it like every other managed hook. Refactors InstallHooks into smaller helpers to keep it under the maintainability lint gate as the hook list grows. Also carries a small rider from Task 3's review: the turn-end incremental-capture zero-files skip path now persists LastCapturedTranscriptBytes too, so a read-only background subagent's unchanged transcript isn't rescanned on every turn-end. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An analyzer error extracting modified files from a subagent's transcript used to fall through into the zero-files branch, which persisted LastCapturedTranscriptBytes at that size — permanently skipping retries, since the growth dedup treats a persisted size as already accounted for. Now an analyzer error skips both the save and the size persistence, so the next turn-end retries the scan. Also trims InstallHooks's doc comment: the per-helper enumeration and decomposition rationale belonged in the prior commit message, not here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extends the existing subagent-checkpoint integration test file with the full background-subagent flow this PR introduces: pre-task -> post-task (background launch stub) records an in-flight marker with no task checkpoint; a real subagent-stop payload runs the authoritative final capture (real transcript analyzer extraction via the existing CreateSubagentTranscript builder) and clears the marker; and a turn-end (Stop) between the stub and subagent-stop produces an incremental checkpoint while the marker survives. Adds SimulatePostTask's run_in_background flag and a new SimulateSubagentStop hook helper so the harness can drive the full hook-payload -> capture pipeline end to end, since the drift/install tests deliberately don't exercise payload handling. mise run test:integration (all 494) and mise run test:e2e:canary (all 56 Vogon + 4 roger-roger) pass unchanged; Vogon does not emit subagent-stop and needed no changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… SubagentStop double-fire The BackgroundLaunch test's comment overclaimed: the capture path merges the real transcript analyzer's output with git-status detection, so the assertion pins what got stored/captured, not analyzer extraction specifically (unit tests cover that split in isolation). Also adds TestSubagentCheckpoints_ForegroundDoubleFire_CapturesOnce, resolving Open Item 2 from the subagent-stop-capture plan empirically: a foreground post-task capture followed by a SubagentStop for the same tool_use_id (Claude Code fires it for every completed Task, not just background ones) must not double-capture, since no in-flight marker exists to claim. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extends the checkpoint domain doc with a Task Steps (Subagent Checkpoints) section covering the three-point background capture design: launch stub records an in-flight marker, turn-end snapshots code changes incrementally (capped, growth-deduped), SubagentStop is the authoritative final capture (transcript included, claim-based dedup, late-arrival guard), and SessionEnd runs the same final capture uncapped before its eager condense. Adds a SubagentEnd background-completeness checklist item (Claude Code as the worked example) to the agent integration checklist, and updates the Event Mapping Reference / Event Field Requirements tables in the implementation guide to reflect the new subagent-stop hook and the Event.Final/ SubagentTranscript fields. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit ed5142e. Configure here.
There was a problem hiding this comment.
Pull request overview
This PR addresses a gap in Claude Code background Task capture by introducing a true-completion signal path (SubagentStop → SubagentEnd with Final=true) plus a turn-end “backstop” that snapshots in-flight background subagent progress until the final completion arrives. It extends the session state model to persist in-flight background task markers and updates hook installation/drift detection so repositories automatically register the new hook.
Changes:
- Add background-task lifecycle handling: launch stub records an in-flight marker; turn-end snapshots incremental progress;
SubagentStopperforms the authoritative final capture (with late-arrival safeguards). - Extend session state to persist
InFlightTaskmarkers and related helper methods (add/find/remove + transcript-size growth dedup support). - Wire up Claude Code’s
SubagentStophook end-to-end (hook parsing, hook installation, drift detection), plus unit/integration test coverage and architecture docs.
Reviewed changes
Copilot reviewed 22 out of 22 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| docs/architecture/sessions-and-checkpoints.md | Documents task-step (subagent checkpoint) behavior, including background launch stub, turn-end backstop, and final capture semantics. |
| docs/architecture/agent-integration-checklist.md | Adds guidance for agents with “launch stub vs true completion” subagent models to map a true-completion hook to SubagentEnd with Final=true. |
| docs/architecture/agent-guide.md | Updates lifecycle hook mapping and required event fields (Final, SubagentTranscript) for subagent completion. |
| cmd/entire/cli/strategy/messages.go | Introduces IncrementalTypeBackgroundProgress and a dedicated subject formatter for background-progress incrementals. |
| cmd/entire/cli/strategy/messages_test.go | Adds/extends tests covering background-progress incremental subject rendering. |
| cmd/entire/cli/strategy/manual_commit_git.go | Ensures transcript-only final task steps are visible to condensation by incrementing StepCount when appropriate. |
| cmd/entire/cli/session/state.go | Adds persisted InFlightTasks and helper methods to manage background-task markers and transcript-size dedup state. |
| cmd/entire/cli/session/state_test.go | Adds tests pinning JSON wire format and add/find/remove semantics for InFlightTasks. |
| cmd/entire/cli/lifecycle.go | Implements background launch marker recording, turn-end backstop snapshots, final capture via SubagentStop, and session-end sweeping of remaining in-flight tasks. |
| cmd/entire/cli/lifecycle_test.go | Adds extensive unit tests covering background launch deferral, final capture, dedup/claim behavior, turn-end incremental snapshots, and session-end finalization. |
| cmd/entire/cli/integration_test/subagent_checkpoints_test.go | Adds end-to-end integration coverage for background launch deferral, turn-end backstop, and foreground double-fire dedup. |
| cmd/entire/cli/integration_test/hooks.go | Adds integration hook simulation for SubagentStop and supports run_in_background in simulated post-task input. |
| cmd/entire/cli/integration_test/agent_test.go | Updates expected installed hook count to include SubagentStop. |
| cmd/entire/cli/hooks.go | Adds isBackgroundLaunch helper to detect run_in_background: true in Task tool input. |
| cmd/entire/cli/hooks_test.go | Adds unit tests for isBackgroundLaunch. |
| cmd/entire/cli/agent/event.go | Extends event model with SubagentTranscript and Final to distinguish launch stubs from true completion. |
| cmd/entire/cli/agent/claudecode/types.go | Adds Claude settings hook struct support for SubagentStop payloads. |
| cmd/entire/cli/agent/claudecode/lifecycle.go | Adds parsing for subagent-stop hook, emitting SubagentEnd with Final=true and an authoritative transcript path when provided. |
| cmd/entire/cli/agent/claudecode/lifecycle_test.go | Adds tests for SubagentStop parsing and verifies Final semantics. |
| cmd/entire/cli/agent/claudecode/hooks.go | Installs/uninstalls SubagentStop, refactors install flow, and updates drift detection to require the new hook. |
| cmd/entire/cli/agent/claudecode/hooks_test.go | Adds tests ensuring SubagentStop is installed/removed and that drift detection flags missing SubagentStop. |
| .claude/settings.json | Registers the SubagentStop hook for repo dogfooding/verification. |
Suppressed comments (1)
cmd/entire/cli/strategy/messages_test.go:368
- If you want these subtests to run in parallel (recommended for unit tests that don't touch process-global state), shadow the loop variable before calling t.Run; otherwise the closure will capture the last iteration's values.
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := FormatBackgroundProgressSubject(tt.subagentType, tt.taskDescription, tt.shortToolUseID)
if got != tt.want {
t.Errorf("FormatBackgroundProgressSubject(%q, %q, %q) = %q, want %q",
tt.subagentType, tt.taskDescription, tt.shortToolUseID, got, tt.want)
}
})
}
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…tate snapshot cap Two verified cursor bugbot findings on PR #2032: - captureSubagentTaskStep merged DetectFileChanges' whole-worktree git-status scan into every Final capture. Correct for foreground (the parent is blocked, so the worktree delta is the subagent's), but a background Final (SubagentStop/SessionEnd) can fire minutes to hours after launch, so the same scan swept in the parent's or another agent's later edits — attribution pollution onto this task's checkpoint. Added subagentCaptureOptions.analyzerFilesOnly, set only on the background Final path (a marker was claimed), which skips the LoadPreTaskState/DetectFileChanges merge and captures only event.ModifiedFiles plus analyzer-extracted files, matching the turn-end incremental and commit-snapshot paths. - captureInFlightTasks' turn-end cap selected a stable oldest-StartedAt prefix of at most 8 markers, and markers persist until Final — so task 9+ never got an incremental snapshot, contradicting the "picked up next turn-end" comment. Added InFlightTask.LastSnapshotAttempt, stamped on every selected marker in one batch mutation; selection now orders by least-recently-attempted so the cap rotates instead of starving anything beyond it. Also addresses a Copilot nit: added t.Parallel() to the new TestFormatBackgroundProgressSubject test in messages_test.go (pure formatter, no global state). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ctly to the subagent
Three fixes to the SubagentStop background-capture path:
1. Stop overloading StepCount for transcript-only task steps. StepCount is
the SaveStep counter with baseline semantics: ==0 drives IsFirstCheckpoint
(the first shadow checkpoint snapshots the user's whole pre-existing
uncommitted state) and ==1 anchors TranscriptIdentifierAtStart. A
transcript-only background Final landing before the session's first
SaveStep silently killed both. Replaced with a dedicated
session.State.TranscriptOnlyTaskSteps counter, consulted by every
'has condensable shadow work' consumer:
- CondenseAndMarkFullyCondensed's no-steps shortcut (and its
no-shadow-branch reset)
- resetCheckpointWindow (all three condensation reset sites)
- isWarnableStaleEndedSession (stale ENDED-session warning)
- countOtherActiveSessions (concurrent-session warning)
- cleanupShadowBranchIfUnused / canDeleteShadowBranch (shadow-branch
protection)
- prepare-commit-msg's empty-session fast path
- doctor's classifySession (ended-stuck predicate; CheckpointCount sums
both counters)
- stopSessionAndPrint's 'work will be captured' message
- session adopt's target-local bookkeeping reset (zeroes it)
Deliberately NOT consulted by: SaveStep's checkpoint numbering /
first-checkpoint / transcript-anchor reads, calculatePromptAttributionAtStart
(transcript-only steps snapshot no files, so baseTree stays the right
attribution reference), condensation's SaveStepCount metadata field (the
honest 'SaveStep ran' signal gating the finalize rewrite), and
sessionHasEvidenceOfWork (file-less steps must not qualify a session for
the committed-files fallback).
Merge-time consumers for PR #2029 (not on this branch): session_sweep.go's
isSweepableZombie and IsCondensableEndedSession must OR in
TranscriptOnlyTaskSteps when the branches meet.
2. No parent-transcript fallback on background Final captures: with
analyzerFilesOnly set and no resolvable subagent transcript, the analyzer
scan is skipped entirely (Warn) instead of scanning the PARENT transcript
from offset 0, which attributed the whole session's files to one
background task. Foreground path keeps its parent-scan fallback.
3. A Final analyzer error now fails the capture instead of saving a
clean-looking zero-file checkpoint: in analyzer-only mode the scan is the
only file source, so a transient I/O error would permanently misstate the
task as read-only. The claimed marker is lost by design (the accepted
claim-loss semantics documented at claimInFlightTask).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Observability: - handleSubagentStopFinal now distinguishes a state-load failure (Warn) from state-not-found (Info) instead of logging 'not found' on corrupt state - unclaimed-Final skip logs at Warn when the event names a SubagentID or a subagent transcript (foreground dedup, duplicate event, or a misintegrated agent setting Final without launch markers); Debug otherwise - recordInFlightTaskLaunch logs the consequence of ErrStateNotFound at Info (background task will not be captured) instead of swallowing it - parseSubagentStop warns on empty tool_use_id/session_id — the tripwire for the defensive-parse assumption - isBackgroundLaunch logs unmarshal failures at Debug (now takes a ctx) - turn-end clip log demoted Warn -> Info (expected, self-healing) - stampInFlightSnapshotAttempts logs the swallowed ErrStateNotFound at Debug - captureSubagentTaskStep warns when a payload-supplied subagent transcript path doesn't exist on disk Comment/docs accuracy: - sessions-and-checkpoints.md: turn-end cap selection is least-recently-attempted rotation (never-attempted first), not oldest first; named the analyzer-only attribution trade-offs on the final capture (side-effect files under-captured, deletions uncapturable) - LastCapturedTranscriptBytes + the growth-dedup comment: the baseline is the last scan that fully accounted for the transcript, whether or not a checkpoint was written - InFlightTask doc names its actual consumers and the claim-up-front semantics; RemoveInFlightTask drops the speculative-call narrative - dropped dead plan citations and (Step 2.4b) references; dropped before-this-refactor archaeology in claim comments - analyzer-only trade-off comment cites only captureInFlightTaskIncremental (the commit-snapshot path lives in the stacked child PR, not here) - agent-guide.md scopes Final to the two-signal subagent model (single-signal agents leave it false) - claudecode unverified/dogfood comments reworded to the durable fact (defensive parse; key-name log removable post-verification) Code polish: - hook_registry classifies subagent-stop as a subagent hook - claudecode lifecycle uses slices.Sort over sort.Strings Tests: - TestInstallHooks_SubagentStop_UpgradeInPlace: a pre-SubagentStop settings file is repaired by plain enable (adds exactly the SubagentStop entry, other hook types byte-untouched) - TestHandleLifecycleSessionEnd_InFlightTask_FinalCapture now pins the marker.AgentID -> transcript-resolution plumbing: a real subagent transcript at the resolvable path lands in the stored task metadata on the shadow branch, and the handler still sweeps a remaining marker itself Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pture # Conflicts: # cmd/entire/cli/doctor.go # cmd/entire/cli/lifecycle.go
Branch test-file diff vs origin/main goes from 2160 added / 28 removed lines to 1869 added / 28 removed — 291 test lines removed. Every named regression survives as a test function, subtest name, or table-row name with its naming comment preserved: - deleted SubagentStop_NoMarker_SkipsDuplicateCapture (covered by ClaimPreventsDoubleCapture's second Final call and end-to-end by TestSubagentCheckpoints_ForegroundDoubleFire_CapturesOnce) and the duplicate background-progress FormatIncrementalSubject table row - lifecycle_test.go: saveInFlightSession/saveInFlightTranscriptSession, finalSubagentEvent/turnEndEvent, writeSubagentTranscripts, and makeNInFlightTasks helpers replace repeated fixture literals - merged the launch-dispatch pair into LaunchDispatch subtests and the turn-end skip trio into the SkipPaths table - session: merged Remove/Find into TestState_InFlightTaskAccessors - claudecode: folded the SubagentStop parse/fresh-install/uninstall variants into their parent tests as subtests - integration: trimmed TurnEndBackstop_ThenSubagentStop's tail to marker-cleared + final-checkpoint-exists Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 01M0BY81WVTTKN9EK4F4W8J171
…ion phase handleSubagentStopFinal decided its eager-condense trigger using the session-state snapshot loaded at the top of the function, before claimInFlightTask and captureSubagentTaskStep ran. If a racing SessionEnd flipped the session to PhaseEnded during that capture window (both serialize on the per-session gate, so the interleaving reduces to ordering), the stale snapshot still read idle, the eager condense was skipped, and the just-written task step became post-condensation zombie shadow data. Reload session state after captureSubagentTaskStep and decide on the fresh phase instead. A reload error or a missing state falls back to the pre-capture phase rather than skipping the decision, since silently treating a read hiccup as "not ended" would reintroduce the same zombie class. The initial load remains valid only for the zombie guard (missing state) and logging. Adds TestHandleLifecycleSubagentEnd_SubagentStop_SessionEndsMidCapture_StillCondenses, which uses a callback on the mock analyzer's ExtractModifiedFilesFromOffset to flip the persisted session to PhaseEnded mid-capture, deterministically reproducing the race. Verified the test fails against the stale-check version and passes with the fix.
…gent work Renames session.InFlightTask -> session.TaskRecord and State.InFlightTasks -> State.TaskRecords (json key in_flight_tasks -> task_records), the first step of #2058's durable subagent-record model (see docs/superpowers/plans/2026-08-19-subagent-durable-records.md, Task 1). Adds DeclaredTranscriptPath, Files, TokenUsage (*agent.TokenUsage, matching the type already used elsewhere on session.State), and CompletedAt (zero = in-flight) to TaskRecord. Add/Remove/Find are renamed to AddTaskRecord/RemoveTaskRecord/FindTaskRecord; new CompleteTaskRecord sets CompletedAt (+ completion fields) and returns false if the record is absent or already completed, giving an exactly-once guard; new LiveTaskRecords() filters to still-in-flight records. claimInFlightTask becomes claimTaskRecord and now completes a record instead of removing it, since a completed record must persist for the future condensation materializer (Task 2) rather than being discarded. Every call site that used to read "marker present" as "still running" now reads LiveTaskRecords() instead of the raw slice (the turn-end/SessionEnd backstop's empty-check, its task selection, and the affected tests), and tests that asserted the marker was removed after a Final capture now assert it is no longer live while still present in TaskRecords. Deferred: LastCapturedTranscriptBytes and LastSnapshotAttempt are NOT dropped in this commit even though the plan schedules their removal here — their consumers (captureInFlightTaskIncremental, persistCapturedTranscriptSize, selectInFlightTasksForSnapshot, stampInFlightSnapshotAttempts) are still live production code until the backstop machinery is deleted in Task 4. Dropping them now would mean doing Task 4's deletion early to keep the build green, so they stay on TaskRecord with a comment noting the deferred removal. Developers running this unreleased branch's binary lose any live in_flight_tasks markers on upgrade due to the json key rename — acceptable, since the branch is unmerged and the data is ephemeral. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drops the declared-path/files/tokens parameters from CompleteTaskRecord —
they were unreachable by construction: the only wired caller (claimTaskRecord)
runs before capture and so only ever had nils to pass, a second call is
already rejected by the CompletedAt guard, and Task 3's producers will attach
extracted data via direct field mutation on the claimed record inside the
same MutateSessionState closure that calls CompleteTaskRecord. Slims the
signature to (toolUseID string, completedAt time.Time) bool and updates the
doc comment to describe that split.
Also fixes three stale/misleading comments flagged in review:
- claimTaskRecord: notes the returned record is the pre-completion snapshot
(CompletedAt still zero).
- persistCapturedTranscriptSize: the old comment described the retired
remove-model ("the only way it could be gone is a Final racing"); records
now persist completed rather than disappearing, so a racing Final leaves
the SAME record completed instead. Added an early return once completion
has landed, since writing this incremental-only field onto an
already-completed record is harmless but pointless (the whole function
dies in Task 4 regardless).
- LiveTaskRecords: the fast-path empty-session guard is not yet a consumer —
it is still keyed on TranscriptOnlyTaskSteps and gets rewired in Task 4.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…kpoints Implements Task 2 of the subagent-durable-records plan (#2058's core): the persistent checkpoint writer gains WriteOptions.Tasks []TaskPayload, and condensation's new materializeTaskRecords resolves each session.TaskRecord's transcript (declared path first, agent-layout fallback), runs it through the sanitize -> externalize -> redact pipeline (factored into prepareTaskTranscriptForStorage, kept separate from prepareTranscriptForStorage so a task transcript never pollutes the session's CheckpointTranscriptSize growth baseline), and writes tasks/<tool-use-id>/{agent-<agent-id>.jsonl, task.json} into the checkpoint tree. A record whose transcript can't be resolved or read still gets a task.json with the reason recorded, never silently dropped. Deletes the dead writer #2058 identified: the WriteOptions.IsTask/ToolUseID single-task-per-checkpoint route (and its AgentID/CheckpointUUID/ SubagentTranscriptPath/Incremental* fields) that no producer ever set, along with writeTaskCheckpointEntries/writeIncrementalTaskCheckpoint/ writeFinalTaskCheckpoint and the incrementalCheckpointData/taskCheckpointData types. applySessionWrite is the single write site shared by both persistent backends (GitStore and the git-refs store), so one materializer change covers both. Completed task records are removed from session state once materialized; in-flight records survive so the next condensation re-materializes their transcript-so-far. This is wired into resetCheckpointWindow, the shared post-write mutation site all three condensation callers already use. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Quality-review hardening on top of the durable-records materializer:
- TaskPayload.Transcript becomes redact.RedactedBytes (matching how
WriteOptions.Transcript itself expresses "no content" — a value type read
via Len(), not a pointer). No producer can hand the writer raw bytes.
- Poisoned records no longer wedge condensation: identifier validation moves
from the writer (which aborted the WHOLE checkpoint write on a bad record,
and since completed records are only removed after a successful write,
would re-fail every future condensation) into materializeTaskRecords, which
skips a record with an unsafe/empty ToolUseID or AgentID entirely — no
payload at all, not even a reason-only task.json, since an unsafe ToolUseID
has no safe tasks/<id>/ path to put one under. The writer keeps its
validation as a last-resort. A skipped-but-completed record still gets
removed by resetCheckpointWindow's batch cleanup, since it can never
materialize and would otherwise retry forever.
- Declared-path-vanished fallback: an unreadable DeclaredTranscriptPath now
also tries the agent-layout resolver before declaring the transcript
unavailable, matching the resolver's whole reason for existing.
- TranscriptUnavailableReason is now one of four stable categories
("transcript unreadable", "transcript path unresolvable", "transcript
empty", "transcript redaction failed") — never the underlying error, which
may embed an absolute local path and must not enter a pushed task.json; the
detail goes to logging.Warn instead.
- taskRecordMetadata's StartedAt/CompletedAt switch from omitempty to
omitzero (Go 1.26): omitempty never treats a struct as empty, so a zero
CompletedAt was always serialized — breaking "absent means in flight".
- Reciprocal comments: prepareTranscriptForStorage/redactSessionTranscript
point at their task-transcript twin, and cli.ResolveAgentTranscriptPath
points back at strategy.resolveTaskTranscriptPath so a layout change lands
in both.
New tests: a poisoned-ToolUseID record alongside a valid one (valid one
materializes, checkpoint write succeeds, poisoned one produces no payload and
is still removed once completed) and an empty declared-transcript-file case
extending the existing missing-path test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… task writes All four subagent producers now write durable task records (#2058's pointer model) instead of shadow task steps; condensation's materializer stores each record's transcript under the checkpoint's tasks/<toolUseID>/ subtree. - SubagentStop Final: completes the launch-recorded marker exactly once, LAST (strategy.CompleteTaskRecord) — a failed extraction leaves the record live so the SessionEnd sweep retries, replacing the old claim-loss semantics. - Foreground post-task: same path, record created-on-completion (no launch stub exists for foreground); SaveTaskStep's create-if-missing parent-state guarantee is preserved via EnsureSessionExists. - Factory Droid Workers: turn-end attribution upserts a COMPLETED record on the parent (multi-turn Workers merge files), declaring the Worker session's transcript path for the materializer. - SessionEnd: captureInFlightTasks(final=true) becomes completeLiveTaskRecords, still ahead of endSessionNow's eager condense. Completion merges task files into FilesTouched (carry-forward/PostCommit gating unchanged) and keeps bumping TranscriptOnlyTaskSteps for zero-file completions so condensation triggers still fire; the eager condense's no-shadow-branch shortcut now defers to CondenseSession's live-transcript path when transcript-only records exist, since records never mint a shadow branch. SaveTaskStep remains for incremental checkpoints only (post-todo and the turn-end background backstop, which Task 4 retires). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… helper - launchStubTaskRecord extracts the duplicated create-on-completion literal shared by CompleteTaskRecord and UpsertCompletedTaskRecord. - applyTaskRecordCompletion nil-guards the record lookup (error, not panic), documents that callers pre-merge Files, and no longer lets a completion with an empty declared transcript path erase an earlier turn's. - TestFactoryDroidWorkerSessionBecomesTaskCheckpoint gains a second-turn subtest proving the upsert MERGES files across Worker turns (mutation- verified: removing the merge fails it); the empty-transcript-ref guard is pinned at the producer level in lifecycle_test.go, since the droid stop hook always carries a transcript path. - Sweep stale claimTaskRecord/captureSubagentTaskStep comment references. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sk records The turn-end incremental snapshot machinery (captureInFlightTasks, selectInFlightTasksForSnapshot, stampInFlightSnapshotAttempts, the growth dedup, persistCapturedTranscriptSize, the per-capture cap, and the background_progress incremental rendering) is deleted: in-flight coverage now comes from condensation materializing each record's transcript-so-far, so turn-end no longer writes shadow-tree task checkpoints. SaveTaskStep's sole production caller is the Claude Code post-todo hook. Every trigger and consumer that keyed on the TranscriptOnlyTaskSteps counter is rewired onto the records ledger via State.HasTaskContent(), and the field plus TaskRecord.LastCapturedTranscriptBytes/LastSnapshotAttempt are deleted. Semantics shift honored throughout: shadow-branch existence no longer implies task content — a records-only session (no shadow branch, no StepCount) is condensable content. Condensation gates now let records-only sessions write: sessionHasNewContent, CondenseSession's nothing-to-condense gate (extracted as skipIfNothingToCondense), and skipIfPostRedactionEmpty all count task records. Record-bearing blind spots closed: doctor --force condenses (never discards) record-bearing sessions and classifies ended record-bearing sessions as stuck even without a shadow branch; CondenseSessionByID materializes records before clearing; the eager-condense empty-session shortcut counts live records; and listAllSessionStates' orphan cleanup never clears a record-bearing state. Shadow-branch pinning predicates drop task steps (records never live there). checkpoint list --pending re-provides [Task] rows from state.TaskRecords (live and completed-unmaterialized); the ephemeral IsTaskCheckpoint reading path stays for post-todo incremental shadow steps. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A hard-killed agent fires neither SubagentStop nor SessionEnd, leaving its background task record LIVE forever: the exited-owner sweep condensed the session but removeCompletedTaskRecords keeps live records, so the session carried pending task content indefinitely. The sweep now completes live records before endSessionNow — exactly as a clean SessionEnd does — after a fresh-load OwnerExited re-check, so the eager condense materializes and removes them. Defense-in-depth in classifySession: an ENDED, FullyCondensed session with a leftover live record (pre-fix state, or a sweep whose condense succeeded but a capture failed) is healthy — everything worth keeping is materialized and the record can never complete — so doctor never re-flags or --force-loops it. Also: sessionHasNewContent's records-only=true behavior gets its missing direct test; pending-list rows for live records render a Running verb (FormatSubagentRunningMessage) instead of Completed; SaveTaskStep rejects non-incremental steps explicitly (its non-incremental branch was production-dead — posttodo is the sole caller); oversized inline comments trimmed to the one-line bar. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 01M0F5P115H7MTSVF28MDPD0WR
The background-subagent section of sessions-and-checkpoints.md still documented the deleted turn-end incremental backstop (rotation, cap of 8, IncrementalTypeBackgroundProgress). Rewritten around the record model: pointer mid-turn, exactly-once completion, condensation-time materialization into tasks/<tool-use-id>/ inside the parent session's checkpoint, self-contained per checkpoint, and path-free unavailability reasons. The committed-layout tree and the strategy-role table gain the tasks/ subtree; the agent checklist and agent guide point at the new section; CLAUDE.md's strategy list gains one task-record bullet. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pture Both sides grew overlapping subagent work. Resolutions: - agent/event.go: our SubagentTranscript and main's SubagentTranscriptPath were duplicates of the same "agent-declared subagent transcript" concept. Unified onto main's SubagentTranscriptPath, so Codex's and Cursor's declarations survive alongside Claude Code's SubagentStop, which now populates the same field. Kept our Final flag (main has no equivalent; it disambiguates the launch-time stub from true completion). - lifecycle.go: kept our event.Final dispatch and durable task-record machinery, and adopted main's declaredSubagentTranscript helper as the single resolution path, so a declared-but-missing path warns and falls back to layout resolution. Also carried main's capture-degradation handling (logStatusDegrade plus the UntrackedScanSkipped new-file guard) and recordCaptureDegraded, which has live callers. - checkpoint/persistent.go: the dead per-write IsTask task writer stays deleted (#2058); writeTaskRecordEntries stays. Main's edits to those deleted functions are discarded, except its size cap (below). - strategy/manual_commit_condensation.go: carried main's subagent-transcript size cap into the task materializer, which had no size guard. The cap is measured against sanitized bytes and skips only the jsonl, recording the new taskTranscriptReasonTooLarge category in task.json; the path goes to the log, never to the pushed reason. - lifecycle_test.go: both branches added a mockAnalyzerAgent helper to the same file and git auto-merged both copies; unified on our superset. - integration_test/codex_subagent_test.go: main's new test asserted a shadow-branch write at subagent-stop, which #2058 replaced with record-then-materialize. Re-pointed it at the completed task record so it still pins that Codex's flat declared rollout path is honoured. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 01M0G5T6BBH7NGQE7KDRZHWFDS
…just the record Merging main brought its Codex subagent test, which asserted the rollout was stored on the shadow branch — the write path #2058 removes. Re-pointing it at the task record kept the declared-path guarantee but dropped the storage one the test is named for, so it now follows through to condensation and reads the rollout back out of the checkpoint's tasks/ subtree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 01M0G5YDMH1DZRBZCEEXZH7S55
…e decision A nil reload shared the read-error branch's silent fallback, so "the session was swept mid-capture" and "the state file could not be read" were indistinguishable in the logs. They have different consequences: the sweeper condenses as part of ending the session, so the swept case has nothing left to condense here, while a read error is a genuine degradation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 01M0G6AH96R9N6BB7GK0VKB90B
The comment justified skipping the subagent rescan partly on agents cleaning their transcripts up. They don't: #2058 counted 180 Claude Code subagent transcripts (121 older than two days) and 164 of 164 Codex rollouts still on disk. The cost rationale is the real and sufficient reason, so the false half goes; the durable-records materializer depends on those files persisting. Refs #2058 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 01M0G70QEEB1NAZF9K8NBS81PS
…anch prePush returned early for the git-refs primary, above the block that calls RewriteUnpushedV1WithOPF. Its only call site was the git-branch path, so a user on git-refs who enabled the OpenAI Privacy Filter shipped 8-layer content believing 9 layers had run — session transcripts and subagent task transcripts alike, for any content at all. RewriteQueuedCheckpointRefsWithOPF is the refs analogue. Everything backend-agnostic is reused as-is: collectTreeBlobs, the leaf/raw byte caps, the single BatchBytesWithPrivacyFilter shell-out per push, the error taxonomy, and the two up-front fail-closed gates. Only discovery and ref update differ. Discovery is the push-discovery queue, which already names exactly the refs this push will carry, so there is no merge-base, divergence, or bootstrap analysis; it Peeks rather than Drains because flushCheckpointRefsQueue owns draining and stale pruning. Each checkpoint ref is standalone, so a rebuilt commit keeps its own original parent instead of being re-parented onto a chain — which is why rebuildV1Commit, already generic, is renamed rebuildCheckpointCommit. Every commit is rebuilt before any ref moves, so a failure part-way through leaves all refs where they were, and each ref update is a CAS so a concurrent write is not clobbered by a stale rebuild. The two backends fail closed differently, deliberately. git-branch aborts the user's git push. prePushCheckpointRefs documents that a checkpoint-ref push failure must never do that, and unflushed refs simply stay queued — so here failing closed means withholding the flush and returning nil: nothing un-OPF'd ships, the refs wait for the next push, and the user's push proceeds. That is not silent: it warns to the log and to stderr via the package's stderrWriter. OPFSkip keeps today's behavior (flush as-is, 8-layer, untagged). Known gap, unchanged from the spec for this change: only each queued ref's tip commit is rewritten. A checkpoint ref that advanced more than once between pushes (a write plus a summary/attribution backfill) still carries un-OPF'd ancestor commits into the push. Their trees are subsets of the rewritten tip's cumulative tree, but they remain reachable. Closing it needs a per-ref unpushed boundary, which the queue does not record. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ust its tip Pushing a checkpoint ref sends its whole unpushed ancestry, so rewriting only the tip left un-OPF'd ancestor blobs reachable from the pushed ref. Checkpoint refs really do chain — refs_store's refBase parents each write on the prior tip — so a checkpoint that gets a session write and then a summary, attribution, or transcript backfill before the next push has two unpushed commits. The tip's tree is cumulative, so the content was all present in redacted form at the tip; the unredacted originals simply sat one commit back. The boundary is the trailer itself: walk first parents back from each queued ref's tip, rewriting every commit that does not carry Entire-OPF-Applied, and stop at the first one that does (or at the root). That needs no network call and no push-watermark bookkeeping, because the trailer IS the watermark. In steady state — OPF enabled all along — the tip's parent already carries it, so the walk stops immediately and this costs nothing over the previous tip-only behavior. That leaves the late-OPF-enable case, where a ref's un-trailered ancestry can run arbitrarily deep, and it is bounded exactly the way the v1 path bounds its equivalent: resolveBootstrapLimit and BootstrapTooLargeError, converting an unbounded history rewrite into the same fail-closed, clearly-remediated stop v1 gives on a large first push. The raw-bytes cap keeps accumulating across the whole flush (all refs, all commits) rather than per ref. BootstrapTooLargeError's message loses its "entire/checkpoints/v1" wording, which would have been actively misleading to a git-refs user; its remediation is identical for both backends. Rebuild replays each ref's chain ancestor-to-tip, the rewritten parent carrying into the next commit, with the deepest rewritten commit keeping the boundary parent — the trailered ancestor the walk stopped at, or zero at a root. The ref then CASes to the new tip, and every commit across every ref is still rebuilt before any ref moves. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The refs backend doc listed OPF-for-refs as deferred work and stated that pre-push OPF does not follow the primary; both are now false. The security doc described the fail-closed contract as "aborts the push", which holds only on git-branch — on git-refs the checkpoint refs are pushed separately, so the failure withholds them and leaves them queued rather than blocking the user's own push. Same guarantee, different mechanism, and the difference is what a reader needs to know. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 01M0G9KDMC1SGVCDV96NK665YA
These are pure formatters with no process-global state, so they meet the repo-wide t.Parallel() convention that CLAUDE.md states; the file lost two tests to the backstop deletion, which is what surfaced the omission. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 01M0GA8Q9M86A7EDK4KTT0521D
Copilot CLI's SubagentEnd carries no tool_use_id and no agent_id, so every one of its subagents keys on the empty string. The exactly-once claim then matched the first subagent's already-completed record and skipped the mutation for each later one, dropping its files from the session — work the pre-records producer merged on every event. Exactly-once needs an identity to be "once" about, so an event with no correlation ID takes the merging path multi-turn Droid Workers already use. Also parallelizes the message formatter subtests, which tparallel requires once their parents are parallel — my earlier commit added the parents without it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 01M0GAJWG8ZHNBFPY62KWC1ZMJ
Main's #2032/#2058 replaced the task-checkpoint writers with writeTaskRecordEntries, which consumes an already-redacted TaskPayload.Transcript (redact.RedactedBytes) and does no redaction of its own — the old writeFinalTaskCheckpoint path was dead code no producer ever reached. This branch's ErrScannerDegraded guard there, and the subtest covering it, go with it: the guarantee is now carried upstream by the condensation materializer plus the RedactedBytes type. The three live guards are untouched (ephemeral's subagent write, RedactBlobBytes, the finalize path), each still pinned by a test. Also composed: the status sweep's scanner guard with main's up-front StateStore creation, and this branch's scanner bullet in CLAUDE.md with main's expanded both-backends OPF bullet. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 01M0GFR6T6KGJY81RGDZ0RJNSW
… task data E2E Tests has failed on every push to main since ed9c31c (entireio#2032, "capture background subagent work durably"). One job, factoryai-droid, two tests, both at "expected task checkpoint within 30s" — and failing again on gotestsum's re-run, so deterministic rather than agent nondeterminism. Bisects cleanly: ed34d4d (entireio#2018) was green and is ed9c31c's only parent. It is not a bug in entireio#2032. waitForTaskCheckpoint polled the *shadow branches* for a tasks/ path, which is precisely what entireio#2032 stopped writing: a Worker's turn now lands as a completed session.TaskRecord on the parent, and the transcript is materialized into the parent's checkpoint under tasks/<id>/ at condensation time. entireio#2032 updated its own integration test to the inverse assertion ("a Worker's turn must write a task record, not shadow data") and said so in CLAUDE.md ("records never live on the shadow branch"); these two E2E tests were the only holdout, and they could not object during review because E2E Tests runs on push-to-main, never on a PR. So move them onto the shipped contract, in both directions: - waitForCompletedTaskRecord polls .git/entire-sessions/*.json for a record with a non-empty completed_at (omitempty drops the zero value, so presence is the completion signal). Same strength as the existence check it replaces. - assertNoShadowTaskData pins the inverse — no shadow branch carries a tasks/ path. Narrowed to task data on purpose: a parent session legitimately has shadow branches of its own, since shadow pinning keys on StepCount and says nothing about task content. TestFactoryTaskCheckpointExistsBeforeCommit becomes TestFactoryTaskRecordExistsBeforeCommit — there is no pre-commit checkpoint under the new model, but the durability intent the name carries is still tested: the Worker's work is captured before the user commits. Prompts are untouched, so Vogon's regexes are unaffected; both tests skip on non-Factory agents, so the canary is too. Whether dropping the pre-commit shadow copy weakens durability is entireio#2058's question, not this test's. These assert what shipped; they do not settle it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M0J4CJ6CCGRXYYVWBSB8Q9H1

https://entire.io/gh/entireio/cli/trails/1076
Implements #2058's durable pointer model for subagent work, with the subtree payload location (
tasks/inside the parent session's checkpoint).The incident
A real session dispatched ~29 background Claude Code subagents; entire captured nothing beyond a 2–8s launch stub for each one, even though the agents ran 5–18 minutes. The root cause: Claude Code's PostToolUse for a backgrounded Task tool fires at the launch acknowledgment, before the subagent has done any work — the real completion is delivered later, out of band, as
SubagentStop. Entire never registered a hook for that true-completion signal, so every background subagent's edits and transcript were invisible.Verified hook facts
SubagentStopfires per-agent at true completion, including after the parent's own turn already ended. Payload carriessession_id(parent),agent_id,agent_transcript_path(the subagent's own transcript),tool_use_id,transcript_path,cwd. Notification-only; default timeout 600s.SubagentStopis the only completion signal, and a finishing subagent does not fire the parent'sStop.<project>/<session-id>/subagents/agent-<agent_id>.jsonl; the launch-time PostToolUse payload'stool_response.agentIdmatches that filename (verified against 29 real transcripts).Parsing stays defensive: an absent
agent_transcript_pathdegrades to the agent-layout convention (ResolveAgentTranscriptPath) rather than failing, and the parser logs the raw payload's key names (never values) at Debug.Design: durable task records + condensation materializer
Task records (
session.TaskRecord, jsontask_records, on session state) are the durable ledger:ToolUseID,AgentID,StartedAt,SubagentType,TaskDescription,DeclaredTranscriptPath,Files,TokenUsage,CompletedAt(zero = live/in-flight). Mid-turn a record is a pointer, not a payload — the subagent transcript stays where the agent wrote it, and the record remembers how to find it. Producers:SubagentStop's payload carries none of them).strategy.CompleteTaskRecord— oneMutateSessionStateclosure: claimCompletedAt, attach files/tokens/declared path, merge into the session'sFilesTouched. Completion happens last, after successful extraction, so a failed capture leaves the record live for the SessionEnd sweep to retry; a racing duplicate completes nothing.UpsertCompletedTaskRecord): multi-turn workers merge files into one record instead of claiming exactly-once.Materializer: condensation resolves each record's transcript (declared path first, agent-layout fallback), runs the same sanitize → externalize → redact pipeline as the session transcript (
prepareTaskTranscriptForStorage), and writestasks/<toolUseID>/{agent-<agentID>.jsonl, task.json}inside the parent session's checkpoint onentire/checkpoints/v1— both backends, via the sharedapplySessionWrite. An unresolvable/unreadable/empty transcript still gets atask.jsonwith a stable, path-free unavailable reason. Poisoned records (empty/unsafe IDs) are skipped-with-warn, never allowed to wedge condensation.Self-contained checkpoints: live records store transcript-so-far on every condensation and survive for retry; completed records are removed only after a successful write (
removeCompletedTaskRecordsoffresetCheckpointWindow). A mid-task commit carries a partial transcript; a later checkpoint carries the full one.Trigger currency:
State.HasTaskContent()(len(TaskRecords) > 0) replaces the deletedTranscriptOnlyTaskStepscounter everywhere. Records-only sessions (no shadow branch, no steps, empty parent transcript) condense; records never live on the shadow tree, so shadow-branch existence no longer implies task content (shadow pinning keys onStepCountonly).Alignment with #2058
This PR implements #2058's core — a durable record that survives until condensation, plus a materializer that makes every checkpoint self-contained — choosing the subtree payload option (
tasks/inside the session checkpoint) over a separate task checkpoint kind. There is now exactly one producer path into stored task data (the materializer); the never-reachable per-writeIsTaskpersistent route and itsEntire-Metadata-Tasktrailer (#2058's "dead writer") are deleted.What was removed and why
Once every checkpoint is self-contained, the mid-turn backstops are redundant — they existed only because task data used to die if it wasn't snapshotted before the session ended:
persistCapturedTranscriptSize,IncrementalTypeBackgroundProgress/FormatBackgroundProgressSubject/FormatIncrementalSubject.IsTaskpersistent route +Entire-Metadata-Tasktrailer (dead code, see above).claimTaskRecord-as-removal: completion now marks, never deletes — the materializer needs the record.SaveTaskStepis now formally incremental-only (errors on!IsIncremental); its sole production caller is the Claude Code post-todo hook.Behavioral invariants preserved
FilesTouched.endSessionNow's eager condense, so that condense materializes it.SubagentStopfor a missing session state skips outright;PhaseEndedstate → complete + eager condense.Deliberate behavior changes
doctor --forcecondenses record-bearing dead-owner sessions instead of discarding them.sessionHasNewContent.checkpoint list --pending --jsontask rows carry emptyid/metadata_dir(no shadow commit exists); external consumers keying onidshould usetool_use_id. Rows render with accurateRunning/Completedverbs.SaveTaskSteperrors on non-incremental use.Repairs shipped alongside
finalizeExitedSessions(theentire status/doctorsweep) completes live records before ending the session, so their transcript-so-far reaches a permanent checkpoint.CondenseSessionByIDmaterializes instead of clearing when records exist; orphan cleanup can'tClearrecord-bearing states.FullyCondensedis terminal-healthy even with leftover live records — pre-fix states self-resolve, no migration.On-disk compatibility
Old state files'
transcript_only_task_steps,last_captured_transcript_bytes, andlast_snapshot_attemptkeys are silently dropped on the next save; no migration. Nothing durable shipped under the oldin_flight_tasksschema.OPF / redaction
git-branch (the default primary): task transcripts get the full 9 layers, automatically. The pre-push OPF rewrite walks the checkpoint tree subtree-recursively and treats every blob except
content_hash.txtas redactable, sotasks/<id>/agent-<id>.jsonlandtask.jsonare covered with no new wiring. This PR writes nocontent_hash.txtinto the task directory, so the apply pass's sibling-full.jsonlhash recompute is undisturbed.git-refs: OPF now runs there too — fixed in this PR. It previously ran on nothing on that backend:
prePushreturns for refs before ever reaching the OPF block, andRewriteUnpushedV1WithOPF's only call site was the git-branch path, so session transcripts and task transcripts alike shipped at 8 layers while a user who enabled OPF believed 9 ran. That gap was pre-existing and backend-wide — not a consequence of the subtree design and not specific to tasks — but this PR enlarges it by volume (subagent transcripts are the highest-volume, least-reviewed content in the system), so it is fixed here rather than deferred.RewriteQueuedCheckpointRefsWithOPF(strategy/manual_commit_opf_refs.go) mirrors the v1 rewrite and reuses its machinery rather than duplicating it — the same blob walk and open blob policy, the same raw-byte and leaf-byte caps, the same single batched OPF shell-out per push, the same error taxonomy, and the same two up-front fail-closed gates in the same order. Only discovery and update differ, and both are simpler on refs: the push queue already names exactly which refs are unpushed, so there is no merge-base or divergence analysis, and each ref is updated independently instead of CAS-ing one branch tip.Every unpushed commit on a queued ref is rewritten, not just its tip — pushing a ref sends its whole unpushed ancestry, so a tip-only rewrite would have shipped un-redacted ancestor blobs alongside the redacted tip. The walk stops at the first commit already carrying
Entire-OPF-Applied, which makes the trailer its own watermark: in steady state it stops at the tip's parent and costs nothing. A repo that enables OPF late is bounded by the existingresolveBootstrapLimit/BootstrapTooLargeErrorpair rather than rewriting unbounded history.Failure semantics differ by backend, deliberately. Both fail closed. git-branch aborts the user's
git push. On refs, unflushed refs simply stay queued, so an OPF failure or an explicit abort withholds the flush and returns cleanly: nothing un-redacted ships and the user's push still succeeds. It is not silent — alogging.Warnplus a stderr line say that checkpoint refs stayed queued and why.OPFSkip(the user opting out for one push) is unchanged: flush as-is, 8 layers, no trailer.One user-visible copy change on the previously-untouched v1 path:
BootstrapTooLargeErrornow says "N checkpoint commits" instead of namingentire/checkpoints/v1, because the error is shared across both backends and a git-refs user has no v1 branch. The remediation is identical for both.Known gaps / follow-ups
tasks/, on both backends.finalizeExitedSessionsnow completes live records before ending the session, so the transcript-so-far is condensed.🤖 Generated with Claude Code