feat: link mid-task background-subagent commits to their session - #2034
feat: link mid-task background-subagent commits to their session#2034peyton-alt wants to merge 27 commits into
Conversation
…round tasks tryAgentCommitFastPath's ACTIVE-only gate silently dropped linkage for background subagents committing mid-task while the parent session was IDLE between turns. Widen eligibility to ACTIVE || (IDLE && InFlightTasks present), keep the empty-session content guard as-is, and update the diagnostic message/fields for the new predicate. Content guarantee for the idle+marker shape (commit-snapshot capture) is a follow-up task.
…aptures Adds captureInFlightTaskCommitSnapshot (cmd/entire/cli/lifecycle.go), a non-incremental, transcript-storing capture that runs in the post-commit hook (cmd/entire/cli/hooks_git_cmd.go) for every current-worktree session with a live in-flight background task, before strategy.PostCommit condenses the commit. This is the content half of subagent commit linkage: Task 1 (already merged) widened tryAgentCommitFastPath's eligibility to idle sessions with a live marker so the trailer gets added; this capture is what makes the checkpoint that trailer points at actually contain the subagent's work instead of nothing. Verified end to end (OPEN ITEM 2.5): idle + EventGitCommit already routes unconditionally to ActionCondense, but shouldCondenseWithOverlapCheck still required FilesTouched to overlap with the committed files for a non-active session — a requirement the realistic case (a subagent committing its own work) can never satisfy, since filterToUncommittedFiles strips exactly those files once HEAD includes them. Extended the existing ACTIVE-session overlap-check bypass to also trust an idle session with a live in-flight marker, with a regression test proving the fix (TestPostCommit_IdleSessionWithLiveMarker_CondensesCommitSnapshot). Also folds in review ride-alongs from Task 1: documents the no-TTY auto-link window now spanning a background task's whole lifetime (including raw-mode TUI git clients), fixes addTrailerForAgentCommit's stale doc comment, and adds an in-flight-task count to the fast path's decline diagnostics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
shouldCondenseWithOverlapCheck's marker bypass previously fired for ANY non-active session with a live InFlightTasks marker, not just idle ones. finalizeExitedSessions (session_finalize.go) ends a crashed/exited session via endSessionNow without running a final in-flight-task capture, so a PhaseEnded session can carry a stale marker indefinitely alongside FilesTouched from its earlier real work. The old bypass trusted that stale marker enough to skip the overlap check, so a later, completely unrelated human commit got condensed into the dead session. Renamed the parameter to idleWithLiveMarker and compute it at both call sites as state.Phase == session.PhaseIdle && len(state.InFlightTasks) > 0, so only a live IDLE session's marker bypasses the overlap check. Added TestPostCommit_EndedSessionWithLingeringMarker_UnrelatedCommitNotCondensed reproducing the reviewer's exact scenario (verified failing against the prior scope before this fix, passing after). Also corrected addTrailerForAgentCommit's doc comment: the idle+marker shape does not fire "regardless of TTY" — tryAgentCommitFastPath's skipContentDetection gate (no TTY, or commit_linking=always) is evaluated before the eligibility loop, so a TTY session without that setting never reaches this function at all. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… by age Two Important fixes from quality review of the subagent-commit-linkage Task 2 commits: 1. Trailer gate on the commit-snapshot capture (hooks_git_cmd.go): the post-commit hook now reads HEAD's commit message via the same trailers.ParseCheckpoint helper strategy.PostCommit itself uses, and skips captureCommitSnapshotsForInFlightTasks entirely when the commit carries no Entire-Checkpoint trailer. Condensation only ever happens for a trailer-bearing commit (PostCommit bails immediately otherwise), so running the capture ahead of a no-trailer commit paid a full analyzer scan plus a full sanitize->externalize->redact transcript write for content nothing would ever condense. Ordinary human commits stay fresh via the existing turn-end backstop, so skipping here loses nothing. 2. Staleness bound on marker trust: the ACTIVE linkage shape is bounded by isRecentInteraction, but the idle+marker shape was trusted forever. Markers are cleared only by final captures (SubagentStop/SessionEnd), so a subagent that dies without either (crash, kill) leaves an orphaned marker that would keep linking every later no-TTY commit into the idle session indefinitely. Added idleWithLiveMarker(state, now) in strategy/manual_commit_hooks.go — idle phase plus at least one marker younger than activeSessionInteractionThreshold (24h, reused directly rather than duplicated) — and wired it into both shouldCondenseWithOverlapCheck call sites and tryAgentCommitFastPath's eligibility check, replacing the previous duplicated/unbounded inline checks so trigger and condensation trust share one rule. New regression test TestTryAgentCommitFastPath_DeclinesIdleSessionWithStaleInFlightTask (verified failing against the prior unbounded rule, passing after). Also: renamed shouldCondenseWithOverlapCheck's bool parameter to hasLiveMarker to stop it shadowing the new idleWithLiveMarker function; reworded "IDLE sessions past their marker's lifetime" to "whose markers were already claimed"; added a comment to the ENDED-session regression test explaining why its unrelated commit still carries a trailer (so it reaches the overlap check instead of short-circuiting on PostCommit's own no-trailer bail); added TestHeadHasCheckpointTrailer_* unit tests for the new gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extends the mid-turn-commit integration test file with the incident this PR fixes end to end: a background subagent's marker survives turn-end into an IDLE session, a real prepare-commit-msg/post-commit hook chain links the commit (Entire-Checkpoint trailer) and backs it with content (commit-snapshot capture), and the marker survives condensation until the real SubagentStop Final capture claims it. A companion test pins that an ordinary idle commit with no in-flight markers still gets no trailer. A read-only seed task is required before the marker under test: an IDLE session with only an open marker and no shadow branch or LastCheckpointID gets pruned as an orphaned pre-state-machine record by findSessionsForWorktree before prepare-commit-msg ever sees it (pre-existing, unrelated to this PR) — the seed task gives the session a real shadow branch the way a multi-turn session would, without touching FilesTouched. mise run test:integration (497 tests) and mise run test:e2e:canary (56 Vogon + 4 roger-roger) pass unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two assertion sites in the idle-marker commit-linkage test read state before checking it for nil: hasInFlightTask(state, ...) ran ahead of the state == nil check after turn-end, and state.FindInFlightTask(...) had no guard at all after the commit. Either would nil-panic instead of failing cleanly if the session state ever went missing. Extract requireSessionState to centralize the guard, and make the seed-task transcript content match what's actually written to disk.
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 1408af6. Configure here.
There was a problem hiding this comment.
Pull request overview
This PR fixes missing session linkage for background subagent git commits that occur between parent-session turns by expanding the prepare-commit-msg fast-path eligibility to include IDLE sessions with a “live” in-flight task marker, and by adding a post-commit commit-snapshot capture so any newly added Entire-Checkpoint trailer is backed by condensible content.
Changes:
- Extend
tryAgentCommitFastPath+ PostCommit condensation trust rules to treat IDLE sessions with live in-flight task markers as linkable. - Add a post-commit trailer gate (
headHasCheckpointTrailer) and commit-snapshot capture path to persist in-flight task snapshot content beforestrategy.PostCommitruns. - Add unit + integration tests and architecture docs covering the idle+marker linkage and its scoping/regression boundaries.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| docs/architecture/sessions-and-checkpoints.md | Documents the idle+marker commit linkage and the commit-snapshot backstop. |
| cmd/entire/cli/strategy/phase_postcommit_test.go | Adds an end-to-end-ish strategy-level test asserting condensation behavior for idle+marker commit-snapshots and a regression for ENDED+marker scope. |
| cmd/entire/cli/strategy/manual_commit_hooks.go | Implements idle+marker eligibility + overlap-check bypass and shares logic via idleWithLiveMarker. |
| cmd/entire/cli/strategy/condense_skip_test.go | Adds fast-path eligibility regression tests for idle-with-marker vs idle-without-marker vs ended vs stale-marker. |
| cmd/entire/cli/lifecycle.go | Adds commit-snapshot capture for in-flight tasks to back trailer-bearing commits. |
| cmd/entire/cli/lifecycle_test.go | Adds unit tests for commit-snapshot capture behavior (files present, read-only, dedup/no-op). |
| cmd/entire/cli/integration_test/subagent_commit_in_turn_test.go | Adds full hook-chain integration coverage for idle+marker linkage + condensation, plus the “idle no markers” guard. |
| cmd/entire/cli/integration_test/subagent_checkpoints_test.go | Adds requireSessionState helper for safer integration assertions. |
| cmd/entire/cli/hooks_git_cmd.go | Adds post-commit trailer gate + invokes commit-snapshot capture before strategy PostCommit. |
| cmd/entire/cli/hooks_git_cmd_test.go | Adds unit tests for the HEAD trailer gate helper. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…inkage # Conflicts: # cmd/entire/cli/lifecycle_test.go
…inkage # Conflicts: # cmd/entire/cli/strategy/manual_commit_hooks.go
The base branch's fix moved transcript-only task-step registration off StepCount (whose ==0/==1 values carry first-checkpoint-baseline and transcript-anchor semantics in SaveStep) onto a dedicated counter; this branch's read-only commit-snapshot test still asserted the old signal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 01M0BPNBGTV4MPWXN79PEBPB8C
…inkage Resolve toward the base branch's durable task-record model (#2058): - lifecycle.go: drop the commit-snapshot capture pair (captureInFlightTasksForCommit / captureInFlightTaskCommitSnapshot) along with the base-deleted turn-end incremental machinery — the condensation materializer now stores each record's transcript-so-far directly, so a commit-time pre-write is redundant. - hooks_git_cmd.go: drop the headHasCheckpointTrailer gate and its post-commit wiring; both existed only to feed the deleted capture. - manual_commit_hooks.go: rekey the idle-session trigger to task records and rename it idleWithLiveMarker -> idleWithTaskContent (fresh = any record with StartedAt within 24h; in-flight or completed-unmaterialized both count, matching HasTaskContent). - Tests: delete commit-snapshot and trailer-gate tests; rekey the fast-path and post-commit trigger tests to TaskRecords; reshape the incident integration test to assert the condensed checkpoint carries tasks/<id>/agent-<id>.jsonl with real transcript content (and drop the seed-task workaround — the base branch's orphan sweep now spares record-bearing sessions). - docs: update the commit-linkage paragraph in sessions-and-checkpoints.md to the materializer model. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The idle+record trigger's rationale is already stated in the merge commit message and PR #2034's body; the inline blocks restated it a third time. Trimmed to the load-bearing facts at each site. The four idle+record fast-path tests differed only in one SessionState field and one expected bool, so they collapse into one table keyed by the original test names, keeping each regression's identity as its case name. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 01M0G4FTC1FGVAY94MRR4NXY0C
tryAgentCommitFastPath mints a trailer for an idle session on a fresh task record alone — its empty-session guard exempts HasTaskContent — but PostCommit then dropped that same session through the read-only skip, which fires when a session has no tracked files. That is the defining shape of a read-only subagent's record, so whenever another session claimed the committed files the trailer was left dangling over a checkpoint nothing ever filled. The skip predates task records and still read "no files" as "nothing to save"; records are themselves the evidence, and the materializer fills the checkpoint. Reported by Cursor Bugbot against the pre-reshape spelling of the trigger. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 01M0GA69GKRQ6NHY4M61T8W8R5
…ad-only exemption Two reviewers independently reproduced a dangling trailer: an IDLE session whose only content is a >24h task record was stamped by the slow path and then refused by PostCommit, leaving Entire-Checkpoint pointing at a checkpoint nothing wrote, on an unrelated human commit. idleWithTaskContent bounds freshness; sessionHasNewContent's HasTaskContent branch did not, so the two triggers disagreed. The bound now applies to the trailer decision only — sessionHasNewContent deliberately stays unbounded so a stale record still condenses and its transcript-so-far is never stranded. The read-only exemption from 1d4341a is reverted. It only fires when another session's files are in the commit, and PostCommit passes one checkpoint ID to every condensing session, so that other session fills the trailer anyway; in the single-session case the skip never fires at all. The exemption prevented no dangling trailer and only let a record-bearing session condense into someone else's commit — the contamination the gate exists to prevent. Also: PostCommit now warns when a trailer is present and no session condensed into it, the decision log records the task-record inputs where they vary, and several comments that contradicted the code are corrected — records DO reach FilesTouched at completion, and the slow path (not tryAgentCommitFastPath) is what vets the trailer on a TTY. Tests: drops a vacuous integration case that never reached the record gate, trims the pipeline anchor to its load-bearing assertion, and merges the two PostCommit cases into one table. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 01M0GEM603FX9RFTF4MCK22KKS
TestPathIsVersioned_MemoizesWithinProcess asserts the memo survives between two probes, but ran t.Parallel() alongside sibling subtests that call ClearVersionedPathCache on the same process-global map. When they interleaved, its second probe re-ran against a cleared cache, saw the file it had just git-added, and failed "Should be false" — which is exactly how it failed in CI. Reproduced locally with -count=40 -parallel 32 (4 failures), and the same command passes after serializing it. CLAUDE.md already carves out tests that depend on process-global state from the t.Parallel() convention. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 01M0GFVWG7GCHX6F39C88SXQAD
|
Reviewed at
The doc says the condition is "never a routine outcome, so it warns." It's routine:
In all of those the trailer isn't dangling — its checkpoint was written by the original commit. Verify: It's worth having, though: guarded properly it catches the IDLE + empty- |
Entire-Checkpoint: 01M0JNANWKM6GT1HV7E7WFABFZ
Resolve the overlapping hook comment around the combined idle-task eligibility and pending-checkpoint reuse contract. Keep main's serialized OPF cache test rationale. Entire-Checkpoint: 01M0JPBQCS2E8RM48042S96AV1
Entire-Checkpoint: 01M0JQF32ZXT3GT51E4K6FBZF4

https://entire.io/gh/entireio/cli/trails/1077
The incident
Background Claude Code subagents run
git commitbetween the parent session's turns.tryAgentCommitFastPath(the prepare-commit-msg fast path) only trusted ACTIVE sessions; between turns the session is IDLE, so the fast path declined, and the slow content-detection path then found no evidence linking the commit to the session (the subagent's work wasn't in the parent's shadow baseline). The commit shipped with noEntire-Checkpointtrailer at all. On a real subagent-driven feature branch, six of seven commits went unlinked this way.The design (after merging #2032's durable-record model)
tryAgentCommitFastPathnow treats a session as eligible when it's ACTIVE or IDLE with a fresh task record (idleWithTaskContent,strategy/manual_commit_hooks.go). A record counts whether it's still in flight or completed-but-unmaterialized — the sameHasTaskContent()shapes condensation keys on — and each record is bounded by its ownStartedAtagainstactiveSessionInteractionThreshold(24h), so a subagent that dies without ever sendingSubagentStop/SessionEnddoesn't leave the session a trailer candidate forever. The same predicate feedsshouldCondenseWithOverlapCheck's overlap-check bypass, so the trigger and the condensation trust share one rule.session.TaskRecorda durable ledger: condensation materializes each record's transcript-so-far into the checkpoint'stasks/<tool-use-id>/{agent-<agent-id>.jsonl, task.json}subtree, live records survive for the next condensation, and every checkpoint is self-contained. That means the trailer this PR adds is backed by content by construction: the very condensation the trailer triggers stores the subagent's transcript-so-far. The earlier revision of this PR pre-wrote a commit-time transcript snapshot (captureInFlightTaskCommitSnapshot/captureInFlightTasksForCommit) gated onheadHasCheckpointTrailerto keep the trailer from dangling — all of that is now removed as redundant, along with its tests and post-commit wiring.Overlap-trust scoping
idleWithTaskContentis deliberately scoped toPhaseIdleonly, notPhaseEnded. An earlier version leaked the overlap-check bypass to ended sessions too; that shape was empirically reproduced and fixed (fe2aa5fdb), with a regression test pinning that an ended session with a lingering record still declines — its records belong to its own final condensation, not this fast path.The trade-off
Because eligibility is keyed off a no-TTY commit plus a fresh task record, the trust window spans the entire lifetime of a background task — including raw-mode TUI git clients (lazygit, gitui, tig) that spawn
git commitwithout a real terminal. This means an unrelated no-TTY commit made while a background task happens to still be running could get mislinked. We accept this: a mislink is recoverable (and rare — it requires a fresh record plus an unrelated no-TTY commit in the same narrow window), while a silent unlink is not. Records are genuine evidence of live agent work, not a guess. The window is also narrower than it used to be: the base branch's dead-owner sweep completes a crashed agent's records, so orphaned records no longer linger for the full 24h in the common case.Non-regression pins
TestSubagentCheckpoints_IdleCommitNoMarkers_NoTrailer).TestSubagentCheckpoints_CommitWhileIdleWithLiveMarker_LinksAndCondensesContent, drives the real prepare-commit-msg + post-commit hook chain and now asserts the strong guarantee: the mid-task commit's condensed checkpoint containstasks/<id>/agent-<id>.jsonlwith the subagent's real transcript content (stronger than the previous revision, which could only check the pre-written shadow-side snapshot).Known gaps / follow-ups
Pre-existing orphan-prune gap.Fixed by feat(claude-code): capture background subagent work durably (SubagentStop + task records) #2032.findSessionsForWorktree's orphan sweep now spares record-bearing sessions (!state.HasTaskContent()in the prune predicate), so an idle session whose only activity is one open task record survives toprepare-commit-msg— a first-turn background commit links. The integration test's read-only seed-task workaround has been removed accordingly.Sweep-finalize follow-up.Fixed by feat(claude-code): capture background subagent work durably (SubagentStop + task records) #2032.finalizeExitedSessionsnow completes a dead-owner session's live records (sweepCompleteLiveTaskRecords,session_finalize.go) before ending it, exactly as a clean SessionEnd does — preserving transcripts and closing the trust-window trade-off from the other end.Trailer eligibility is bounded; condensability is not
A session's task records make it condensable with no freshness bound — that is deliberate, and it is what keeps a stale record's transcript-so-far from being stranded unmaterialized. Claiming a commit is bounded separately: only a record within
idleWithTaskContent's 24h window may mint a trailer. Without that split, an IDLE session whose only content was a >24h record got a trailer from the slow path and was then refused by PostCommit, leavingEntire-Checkpointpointing at a checkpoint nothing ever wrote — on an unrelated human commit. Two reviewers reproduced it end to end;staleRecordIsOnlyContentcloses it, and PostCommit now warns whenever a trailer is minted and no session condenses into it, so any future divergence is visible rather than silent.An earlier revision of this PR also exempted record-bearing sessions from PostCommit's read-only skip. That is reverted: the skip only fires when another session's files are in the commit, and PostCommit hands one checkpoint ID to every condensing session, so that other session fills the trailer anyway. The exemption prevented no dangling trailer and only let a record-bearing session condense into someone else's commit.
Test plan
mise run fmt && mise run lint— cleanmise run test:ci— unit + integration green, Vogon canary + roger-roger E2E 4/4🤖 Generated with Claude Code