Skip to content

feat(claude-code): capture background subagent work durably (SubagentStop + task records) - #2032

Merged
peyton-alt merged 34 commits into
mainfrom
feat/subagent-stop-capture
Aug 20, 2026
Merged

feat(claude-code): capture background subagent work durably (SubagentStop + task records)#2032
peyton-alt merged 34 commits into
mainfrom
feat/subagent-stop-capture

Conversation

@peyton-alt

@peyton-alt peyton-alt commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

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

  • SubagentStop fires per-agent at true completion, including after the parent's own turn already ended. Payload carries session_id (parent), agent_id, agent_transcript_path (the subagent's own transcript), tool_use_id, transcript_path, cwd. Notification-only; default timeout 600s.
  • Subagents fire no session-lifecycle hooks of their own (no SessionStart/UserPromptSubmit/Stop/SessionEnd) — SubagentStop is the only completion signal, and a finishing subagent does not fire the parent's Stop.
  • Subagent transcripts live at <project>/<session-id>/subagents/agent-<agent_id>.jsonl; the launch-time PostToolUse payload's tool_response.agentId matches that filename (verified against 29 real transcripts).

Parsing stays defensive: an absent agent_transcript_path degrades 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, json task_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:

  • Background launch creates a live record (labels captured at launch because SubagentStop's payload carries none of them).
  • Foreground tasks create-on-completion.
  • SubagentStop / foreground post-task / SessionEnd sweep complete exactly-once via strategy.CompleteTaskRecord — one MutateSessionState closure: claim CompletedAt, attach files/tokens/declared path, merge into the session's FilesTouched. 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.
  • Factory Droid Workers upsert (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 writes tasks/<toolUseID>/{agent-<agentID>.jsonl, task.json} inside the parent session's checkpoint on entire/checkpoints/v1 — both backends, via the shared applySessionWrite. An unresolvable/unreadable/empty transcript still gets a task.json with 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 (removeCompletedTaskRecords off resetCheckpointWindow). A mid-task commit carries a partial transcript; a later checkpoint carries the full one.

Trigger currency: State.HasTaskContent() (len(TaskRecords) > 0) replaces the deleted TranscriptOnlyTaskSteps counter 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 on StepCount only).

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-write IsTask persistent route and its Entire-Metadata-Task trailer (#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:

  • The turn-end incremental snapshot backstop (rotation, growth-dedup, cap of 8), persistCapturedTranscriptSize, IncrementalTypeBackgroundProgress / FormatBackgroundProgressSubject / FormatIncrementalSubject.
  • The per-write IsTask persistent route + Entire-Metadata-Task trailer (dead code, see above).
  • claimTaskRecord-as-removal: completion now marks, never deletes — the materializer needs the record.
  • SaveTaskStep is now formally incremental-only (errors on !IsIncremental); its sole production caller is the Claude Code post-todo hook.

Behavioral invariants preserved

  • Background launches stay lazy: no capture at the launch stub, only a record.
  • Foreground completion still merges the subagent's files into the session's FilesTouched.
  • Exactly-once completion (the old claim-and-remove dedup, reshaped as mark-completed-once).
  • SessionEnd ordering: the live-record sweep runs strictly before endSessionNow's eager condense, so that condense materializes it.
  • Read-only subagents' transcripts are stored: a completed record with no files still materializes, and "has records" makes the session condensable.
  • The late-arrival zombie guard: a SubagentStop for a missing session state skips outright; PhaseEnded state → complete + eager condense.

Deliberate behavior changes

  • (a) Turn-end no longer writes incremental background snapshots — in-flight visibility now comes from condensation materializing the transcript-so-far into each checkpoint.
  • (b) doctor --force condenses record-bearing dead-owner sessions instead of discarding them.
  • (c) Shadow branches are deletable while records exist — records don't live there.
  • (d) Record-bearing sessions are PrepareCommitMsg trailer candidates via sessionHasNewContent.
  • (e) checkpoint list --pending --json task rows carry empty id/metadata_dir (no shadow commit exists); external consumers keying on id should use tool_use_id. Rows render with accurate Running/Completed verbs.
  • (f) SaveTaskStep errors on non-incremental use.

Repairs shipped alongside

  • Hard-killed agents' live records get a terminal state: finalizeExitedSessions (the entire status/doctor sweep) completes live records before ending the session, so their transcript-so-far reaches a permanent checkpoint.
  • CondenseSessionByID materializes instead of clearing when records exist; orphan cleanup can't Clear record-bearing states.
  • ENDED + FullyCondensed is 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, and last_snapshot_attempt keys are silently dropped on the next save; no migration. Nothing durable shipped under the old in_flight_tasks schema.

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.txt as redactable, so tasks/<id>/agent-<id>.jsonl and task.json are covered with no new wiring. This PR writes no content_hash.txt into the task directory, so the apply pass's sibling-full.jsonl hash recompute is undisturbed.

git-refs: OPF now runs there too — fixed in this PR. It previously ran on nothing on that backend: prePush returns for refs before ever reaching the OPF block, and RewriteUnpushedV1WithOPF'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 existing resolveBootstrapLimit / BootstrapTooLargeError pair 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 — a logging.Warn plus 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: BootstrapTooLargeError now says "N checkpoint commits" instead of naming entire/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

🤖 Generated with Claude Code

peyton-alt and others added 10 commits August 17, 2026 19:47
…-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>
@peyton-alt
peyton-alt requested a review from a team as a code owner August 18, 2026 05:19
Copilot AI lite review requested due to automatic review settings August 18, 2026 05:19

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ 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.

Comment thread cmd/entire/cli/lifecycle.go
Comment thread cmd/entire/cli/lifecycle.go Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR addresses a gap in Claude Code background Task capture by introducing a true-completion signal path (SubagentStopSubagentEnd 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; SubagentStop performs the authoritative final capture (with late-arrival safeguards).
  • Extend session state to persist InFlightTask markers and related helper methods (add/find/remove + transcript-size growth dedup support).
  • Wire up Claude Code’s SubagentStop hook 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.

Comment thread cmd/entire/cli/strategy/messages_test.go Outdated
peyton-alt and others added 14 commits August 18, 2026 16:28
…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>
@peyton-alt peyton-alt changed the title feat(claude-code): capture background subagent work via SubagentStop + turn-end backstop feat(claude-code): capture background subagent work durably (SubagentStop + task records) Aug 20, 2026
peyton-alt and others added 4 commits August 20, 2026 11:09
…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
peyton-alt and others added 5 commits August 20, 2026 11:51
…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
@peyton-alt
peyton-alt merged commit ed9c31c into main Aug 20, 2026
12 of 13 checks passed
@peyton-alt
peyton-alt deleted the feat/subagent-stop-capture branch August 20, 2026 20:40
peyton-alt added a commit that referenced this pull request Aug 20, 2026
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
timothybrush pushed a commit to timothybrush/cli-2 that referenced this pull request Aug 21, 2026
… 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants