From 6c5727f417cc8a373cf946f68ec22fcf8d309e5a Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Fri, 16 Oct 2026 15:20:31 -0700 Subject: [PATCH 1/5] strategy: credit a running OpenCode subagent in the co-authorship check liveTaskFilesInCommit read only transcripts on disk, and a running OpenCode child has none until its stop hook exports it, so an IDLE parent whose background child wrote a file another session also claims was skipped as read-only. Agents can now report what a running subagent call has modified (agent.LiveSubagentFileReporter). OpenCode exports the child to a private staging file, cuts it to the running call and removes it, writing nothing a capture reads. Post-commit gives all subagent exports one 30s budget and the co-authorship checks a 10s share of it, so they cannot starve condensation's re-exports. The gate is memoized per session. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M53CTW9M0EBHK204XGKMCH71 --- cmd/entire/cli/agent/agent.go | 15 +++ cmd/entire/cli/agent/capabilities.go | 7 ++ cmd/entire/cli/agent/opencode/AGENT.md | 10 +- cmd/entire/cli/agent/opencode/lifecycle.go | 1 + .../cli/agent/opencode/subagent_export.go | 63 ++++++++++++ .../agent/opencode/subagent_export_test.go | 23 +++++ cmd/entire/cli/agent/subagent_fetch.go | 45 +++++++-- cmd/entire/cli/integration_test/hooks.go | 14 ++- .../opencode_subagent_test.go | 50 ++++++++++ .../cli/strategy/manual_commit_hooks.go | 96 +++++++++++++++---- .../strategy/manual_commit_task_fetch_test.go | 70 ++++++++++++++ docs/architecture/sessions-and-checkpoints.md | 9 +- 12 files changed, 372 insertions(+), 31 deletions(-) diff --git a/cmd/entire/cli/agent/agent.go b/cmd/entire/cli/agent/agent.go index fecfbffadf..5fc0d2a2ea 100644 --- a/cmd/entire/cli/agent/agent.go +++ b/cmd/entire/cli/agent/agent.go @@ -346,6 +346,21 @@ type SubagentTranscriptFetcher interface { FetchSubagentTranscript(ctx context.Context, agentID, toolUseID string, startedAt, completedAt time.Time) (string, error) } +// LiveSubagentFileReporter is implemented by agents that can report what a +// still-running subagent call has modified so far (OpenCode: by exporting the +// child session). The co-authorship check in post-commit uses it for a live +// task record, which has no transcript on disk until its stop hook runs. It +// must not write anything a task record declares or a later capture reads: a +// report that lands after the call's stop must not replace its transcript. +type LiveSubagentFileReporter interface { + Agent + + // LiveSubagentModifiedFiles returns the files the task call toolUseID, + // run by subagent agentID since startedAt (zero when unknown), has + // modified so far. + LiveSubagentModifiedFiles(ctx context.Context, agentID, toolUseID string, startedAt time.Time) ([]string, error) +} + // SidecarImageProvider is implemented by agents that keep images OUTSIDE the // transcript Entire condenses — e.g. Cursor stores pasted images in a per-session // SQLite blob store, not the JSONL transcript. The strategy layer calls this diff --git a/cmd/entire/cli/agent/capabilities.go b/cmd/entire/cli/agent/capabilities.go index bd6093aa95..c5101e6d11 100644 --- a/cmd/entire/cli/agent/capabilities.go +++ b/cmd/entire/cli/agent/capabilities.go @@ -34,6 +34,7 @@ type CapabilityDeclarer interface { // - HookTimeoutRepairer // - InventoryAwareExtractor // - LateTranscriptWriter (refuses CapabilityDeclarer agents) +// - LiveSubagentFileReporter // - ModelExtractor // - ModelLister // - OutOfBandTokenSource (refuses CapabilityDeclarer agents) @@ -232,6 +233,12 @@ func AsSubagentTranscriptFetcher(ag Agent) (SubagentTranscriptFetcher, bool) { return builtinCapability[SubagentTranscriptFetcher](ag) } +// AsLiveSubagentFileReporter returns the agent as LiveSubagentFileReporter if +// it implements the interface. Built-in only, like SubagentTranscriptFetcher. +func AsLiveSubagentFileReporter(ag Agent) (LiveSubagentFileReporter, bool) { + return builtinCapability[LiveSubagentFileReporter](ag) +} + // AsTokenCalculator returns the agent as TokenCalculator if it both // implements the interface and (for CapabilityDeclarer agents) has declared the capability. func AsTokenCalculator(ag Agent) (TokenCalculator, bool) { diff --git a/cmd/entire/cli/agent/opencode/AGENT.md b/cmd/entire/cli/agent/opencode/AGENT.md index 44a39b6543..2ac2cd5d50 100644 --- a/cmd/entire/cli/agent/opencode/AGENT.md +++ b/cmd/entire/cli/agent/opencode/AGENT.md @@ -88,6 +88,15 @@ SessionEnd sweep share one 30s budget (`agent.SubagentFetchBudget`). The background result that OpenCode injects into the parent is synthetic text, so prompt extraction and the summary skip it. +A running child's edits reach the parent's files only at its stop. When an +IDLE parent with no tracked files of its own commits alongside another +session, post-commit's co-authorship check asks the agent what the running +call has modified so far (`LiveSubagentModifiedFiles`): it exports the child +to a private staging file, cut to the running call, and removes it, writing +nothing a capture reads. These checks share 10s of the commit's 30s fetch +budget. The commit stores the live record without a transcript; it is +stored in full once the call completes. + ## Gaps & Limitations - macOS unresolved-symlink paths (`/var/…` vs `/private/var/…`) drop from a task's `files`; the parent's turn end still covers them. @@ -97,4 +106,3 @@ text, so prompt extraction and the summary skip it. - A reused-`callID` call has no live marker while it runs (its start finds the earlier call's record under the same key, and the Go start path does not re-key), so commit overlap checks and the SessionEnd sweep miss it; if the parent ends before its stop, the call is lost. - A reused-`callID` stop with no `started_at` (the plugin restarted between the call's before and after hooks) for a child already recorded under that `callID` is indistinguishable from a duplicate, so it is skipped and that call's work is not recorded; deliberate, to avoid double-counting a real duplicate. - entire.io joins task records to task calls on exact `tool_use_id`, so mapped or re-keyed records show as unmatched until its subagent view joins as above. -- A running background child's edits reach the parent's files only at its stop. Until then, an IDLE parent with no tracked files of its own is left out of a commit that another session also claims; otherwise it condenses and stores the child's export so far. diff --git a/cmd/entire/cli/agent/opencode/lifecycle.go b/cmd/entire/cli/agent/opencode/lifecycle.go index ad25ee1a06..d64ec57716 100644 --- a/cmd/entire/cli/agent/opencode/lifecycle.go +++ b/cmd/entire/cli/agent/opencode/lifecycle.go @@ -28,6 +28,7 @@ var runOpenCodeExportToFileFn = runOpenCodeExportToFile var ( _ agent.ContextInjector = (*OpenCodeAgent)(nil) _ agent.SubagentTranscriptFetcher = (*OpenCodeAgent)(nil) + _ agent.LiveSubagentFileReporter = (*OpenCodeAgent)(nil) ) // InjectionEvent reports that OpenCode injects model context at TurnStart. The diff --git a/cmd/entire/cli/agent/opencode/subagent_export.go b/cmd/entire/cli/agent/opencode/subagent_export.go index c1f9d3ed65..c09f44128f 100644 --- a/cmd/entire/cli/agent/opencode/subagent_export.go +++ b/cmd/entire/cli/agent/opencode/subagent_export.go @@ -7,12 +7,14 @@ import ( "fmt" "log/slog" "math" + "os" "path/filepath" "time" "github.com/entireio/cli/cmd/entire/cli/entiredir" "github.com/entireio/cli/cmd/entire/cli/jsonutil" "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/osroot" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/validation" ) @@ -159,3 +161,64 @@ func isCallPrompt(info MessageInfo, parts []Part) bool { } return true } + +// LiveSubagentModifiedFiles exports a running child to a private staging file +// and returns the files its task call has modified so far. Unlike +// exportSubagent it writes neither the shared export cache nor the call's +// declared file: the call may stop while this export runs, and its stop writes +// the complete transcript to those names. +func (a *OpenCodeAgent) LiveSubagentModifiedFiles(ctx context.Context, childID, _ string, since time.Time) ([]string, error) { + if err := validation.ValidateSessionID(childID); err != nil { + return nil, fmt.Errorf("invalid session ID for live subagent export: %w", err) + } + repoRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + repoRoot = "." + } + root, err := entiredir.OpenAt(repoRoot) + if err != nil { + return nil, fmt.Errorf("open %s for live subagent export: %w", paths.EntireDir, err) + } + var data []byte + if os.Getenv("ENTIRE_TEST_OPENCODE_MOCK_EXPORT") != "" { + // Integration tests pre-write the child's export (see fetchAndCacheExport). + data, err = entiredir.ReadFile(root, entireTmpName+"/"+childID+".json") + } else { + data, err = exportToPrivateFile(ctx, root, childID) + } + if err != nil { + return nil, err + } + scoped, _, err := scopeExportToCall(data, since, time.Time{}) + if err != nil { + return nil, err + } + session, err := ParseExportSession(scoped) + if err != nil || session == nil { + return nil, err + } + return modifiedFilesFromMessages(session.Messages, 0), nil +} + +// exportToPrivateFile runs `opencode export` for sessionID into a staging file +// of its own and returns the bytes, removing the file. +func exportToPrivateFile(ctx context.Context, root *os.Root, sessionID string) ([]byte, error) { + if err := osroot.MkdirAllNoSymlink(root, entireTmpName, 0o750); err != nil { + return nil, fmt.Errorf("failed to create temp dir: %w", err) + } + staged, err := stageExportPath(root, entireTmpName, sessionID) + if err != nil { + return nil, err + } + defer func() { + _ = root.Remove(staged) //nolint:errcheck // best-effort cleanup of a private staging file + }() + if err := runOpenCodeExportToFileFn(ctx, root, sessionID, staged); err != nil { + return nil, err + } + data, err := entiredir.ReadFile(root, staged) + if err != nil { + return nil, fmt.Errorf("failed to read export file: %w", err) + } + return data, nil +} diff --git a/cmd/entire/cli/agent/opencode/subagent_export_test.go b/cmd/entire/cli/agent/opencode/subagent_export_test.go index ef92398b21..5ab6b22170 100644 --- a/cmd/entire/cli/agent/opencode/subagent_export_test.go +++ b/cmd/entire/cli/agent/opencode/subagent_export_test.go @@ -241,3 +241,26 @@ func TestFetchSubagentTranscript_NeverPromptedCallDeclaresNothing(t *testing.T) require.NoError(t, err) assert.Empty(t, files, "the other calls' files must not be attributed to the dropped call") } + +// TestLiveSubagentModifiedFiles_WritesNothingACaptureReads pins that the +// co-authorship probe of a running child reports the running call's files +// without writing the shared export cache or the call's declared file: the +// call can stop while the probe runs, and a late probe must not replace the +// stop's complete transcript. +func TestLiveSubagentModifiedFiles_WritesNothingACaptureReads(t *testing.T) { + // Not parallel: t.Chdir and stubExport. + t.Chdir(t.TempDir()) + paths.ClearWorktreeRootCache() + t.Cleanup(paths.ClearWorktreeRootCache) + stubExport(t, func(_ context.Context, root *os.Root, _, outputName string) error { + return root.WriteFile(outputName, []byte(resumedChildExportFixture), 0o600) + }) + + files, err := (&OpenCodeAgent{}).LiveSubagentModifiedFiles(context.Background(), "ses_child", "call_blue", time.Time{}) + require.NoError(t, err) + assert.Equal(t, []string{"/repo/docs/blue.md"}, files, "only the running (last) call's files") + + entries, err := os.ReadDir(paths.EntireTmpDir) + require.NoError(t, err) + assert.Empty(t, entries, "the probe must leave nothing under .entire/tmp") +} diff --git a/cmd/entire/cli/agent/subagent_fetch.go b/cmd/entire/cli/agent/subagent_fetch.go index 4aea15ebd3..a2954beffa 100644 --- a/cmd/entire/cli/agent/subagent_fetch.go +++ b/cmd/entire/cli/agent/subagent_fetch.go @@ -26,19 +26,50 @@ func WithSubagentFetchBudget(ctx context.Context, d time.Duration) context.Conte return context.WithValue(ctx, subagentFetchDeadlineKey{}, time.Now().Add(d)) } -// FetchSubagentTranscript runs fetcher's FetchSubagentTranscript within ctx's -// fetch budget, if any: refused once the budget is spent, otherwise cut off at -// its deadline. Each export is a separate agent process, so without a budget a -// commit holding several unreadable tasks waits for each in turn. -func FetchSubagentTranscript(ctx context.Context, fetcher SubagentTranscriptFetcher, agentID, toolUseID string, startedAt, completedAt time.Time) (string, error) { +// WithSubagentFetchDeadline caps ctx's fetch budget at deadline, keeping an +// earlier one. It gives one kind of fetch a smaller share of a budget, so it +// cannot spend what the rest of the pass needs. +func WithSubagentFetchDeadline(ctx context.Context, deadline time.Time) context.Context { + if outer, ok := ctx.Value(subagentFetchDeadlineKey{}).(time.Time); ok && outer.Before(deadline) { + return ctx + } + return context.WithValue(ctx, subagentFetchDeadlineKey{}, deadline) +} + +// subagentFetchContext returns the context a fetch under ctx's budget runs +// with, or ErrSubagentFetchBudgetSpent once the budget is spent. +func subagentFetchContext(ctx context.Context) (context.Context, context.CancelFunc, error) { deadline, ok := ctx.Value(subagentFetchDeadlineKey{}).(time.Time) if !ok { - return fetcher.FetchSubagentTranscript(ctx, agentID, toolUseID, startedAt, completedAt) //nolint:wrapcheck // passthrough + return ctx, func() {}, nil } if !time.Now().Before(deadline) { - return "", ErrSubagentFetchBudgetSpent + return nil, nil, ErrSubagentFetchBudgetSpent } fetchCtx, cancel := context.WithDeadline(ctx, deadline) + return fetchCtx, cancel, nil +} + +// FetchSubagentTranscript runs fetcher's FetchSubagentTranscript within ctx's +// fetch budget, if any: refused once the budget is spent, otherwise cut off at +// its deadline. Each export is a separate agent process, so without a budget a +// commit holding several unreadable tasks waits for each in turn. +func FetchSubagentTranscript(ctx context.Context, fetcher SubagentTranscriptFetcher, agentID, toolUseID string, startedAt, completedAt time.Time) (string, error) { + fetchCtx, cancel, err := subagentFetchContext(ctx) + if err != nil { + return "", err + } defer cancel() return fetcher.FetchSubagentTranscript(fetchCtx, agentID, toolUseID, startedAt, completedAt) //nolint:wrapcheck // passthrough } + +// LiveSubagentModifiedFiles runs reporter's LiveSubagentModifiedFiles within +// ctx's fetch budget, like FetchSubagentTranscript. +func LiveSubagentModifiedFiles(ctx context.Context, reporter LiveSubagentFileReporter, agentID, toolUseID string, startedAt time.Time) ([]string, error) { + fetchCtx, cancel, err := subagentFetchContext(ctx) + if err != nil { + return nil, err + } + defer cancel() + return reporter.LiveSubagentModifiedFiles(fetchCtx, agentID, toolUseID, startedAt) //nolint:wrapcheck // passthrough +} diff --git a/cmd/entire/cli/integration_test/hooks.go b/cmd/entire/cli/integration_test/hooks.go index db098c1f69..243b068a6e 100644 --- a/cmd/entire/cli/integration_test/hooks.go +++ b/cmd/entire/cli/integration_test/hooks.go @@ -1188,6 +1188,10 @@ type OpenCodeSession struct { // messages accumulates all messages across turns, matching real `opencode export` // behavior where each export returns the full session history. messages []map[string]interface{} + // TimeBase is the message clock in Unix ms (default 1708300000); message + // n is created at TimeBase+n. Set it to now for a record whose freshness + // is judged against the wall clock. + TimeBase int64 } // NewOpenCodeSession creates a new simulated OpenCode session. @@ -1209,13 +1213,17 @@ func (env *TestEnv) NewOpenCodeSession() *OpenCodeSession { // Each call appends new messages to the accumulated session history, matching real // `opencode export` behavior where each export returns the full session history. func (s *OpenCodeSession) CreateOpenCodeTranscript(prompt string, changes []FileChange) string { + base := s.TimeBase + if base == 0 { + base = 1708300000 + } // User message s.msgCounter++ s.messages = append(s.messages, map[string]interface{}{ "info": map[string]interface{}{ "id": fmt.Sprintf("msg-%d", s.msgCounter), "role": roleUser, - "time": map[string]interface{}{"created": 1708300000 + s.msgCounter}, + "time": map[string]interface{}{"created": base + int64(s.msgCounter)}, }, "parts": []map[string]interface{}{ {"type": blockTypeText, "text": prompt}, @@ -1251,8 +1259,8 @@ func (s *OpenCodeSession) CreateOpenCodeTranscript(prompt string, changes []File "id": fmt.Sprintf("msg-%d", s.msgCounter), "role": roleAssistant, "time": map[string]interface{}{ - "created": 1708300000 + s.msgCounter, - "completed": 1708300000 + s.msgCounter + 5, + "created": base + int64(s.msgCounter), + "completed": base + int64(s.msgCounter) + 5, }, "tokens": map[string]interface{}{ "input": 150, diff --git a/cmd/entire/cli/integration_test/opencode_subagent_test.go b/cmd/entire/cli/integration_test/opencode_subagent_test.go index 406a6b458c..86f9a10303 100644 --- a/cmd/entire/cli/integration_test/opencode_subagent_test.go +++ b/cmd/entire/cli/integration_test/opencode_subagent_test.go @@ -3,9 +3,12 @@ package integration import ( + "encoding/json" "testing" + "time" "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/stretchr/testify/require" ) @@ -359,3 +362,50 @@ func TestOpenCodeSubagentReusedCallIDRecordsEachCall(t *testing.T) { }) } } + +// TestOpenCodeSubagentRunningChildCoauthorsCommit covers an IDLE OpenCode +// parent whose background child, still running, wrote a file another session +// also claims. The parent has no files of its own yet (the child's reach it at +// completion), so only the child's live export shows it co-authored the +// commit; without it the parent is skipped as read-only. +func TestOpenCodeSubagentRunningChildCoauthorsCommit(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t) + env.InitEntireWithAgent(agent.AgentNameOpenCode) + const shared = "docs/shared.md" + + coder := env.NewOpenCodeSession() + require.NoError(t, env.SimulateOpenCodeSessionStart(coder.ID, coder.TranscriptPath)) + require.NoError(t, env.SimulateOpenCodeTurnStart(coder.ID, coder.TranscriptPath, "create docs/shared.md")) + env.WriteFile(shared, "first draft\n") + coder.CreateOpenCodeTranscript("create docs/shared.md", []FileChange{{Path: shared, Content: "first draft\n"}}) + require.NoError(t, env.SimulateOpenCodeTurnEnd(coder.ID, coder.TranscriptPath)) + + parent := env.NewOpenCodeSession() + child := env.NewOpenCodeSession() + // A live record counts only while fresh, so the call starts now. + child.TimeBase = time.Now().UnixMilli() + require.NoError(t, env.SimulateOpenCodeSessionStart(parent.ID, parent.TranscriptPath)) + require.NoError(t, env.SimulateOpenCodeTurnStart(parent.ID, parent.TranscriptPath, "polish docs/shared.md in the background")) + require.NoError(t, env.SimulateOpenCodeSubagentStartAt(parent.ID, "call_polish", child.ID, "general", "Polish docs/shared.md", child.TimeBase)) + parent.CreateOpenCodeTranscript("polish docs/shared.md in the background", nil) + require.NoError(t, env.SimulateOpenCodeTurnEnd(parent.ID, parent.TranscriptPath)) + + // The child, still running, edits the shared file; its export names it. + env.WriteFile(shared, "polished\n") + env.CopyTranscriptToEntireTmp(child.ID, child.CreateOpenCodeTranscript("Polish docs/shared.md", []FileChange{{Path: shared, Content: "polished\n"}})) + + state, err := env.GetSessionState(parent.ID) + require.NoError(t, err) + require.Empty(t, state.FilesTouched, "the parent has no files of its own before the child completes") + + env.GitCommitWithHooks("Polish shared doc", shared) + cpID := env.GetCheckpointIDFromCommitMessage(env.GetHeadHash()) + require.NotEmpty(t, cpID) + summaryContent, found := env.ReadFileFromBranch(paths.MetadataBranchName, CheckpointSummaryPath(cpID)) + require.True(t, found) + var summary checkpoint.CheckpointSummary + require.NoError(t, json.Unmarshal([]byte(summaryContent), &summary)) + require.Len(t, summary.Sessions, 2, "the parent co-authored the commit through its running child") +} diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index 63681953b2..c4d527f56f 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -15,6 +15,7 @@ import ( "os/exec" "path/filepath" "strings" + "sync" "time" "github.com/entireio/cli/cmd/entire/cli/agent" @@ -1151,6 +1152,31 @@ func (h *postCommitActionHandler) shouldCondenseWithOverlapCheck(isActive bool, }) } +// liveTaskFetchBudget is the share of a commit's subagent fetch budget +// (agent.SubagentFetchBudget) the co-authorship checks of all its sessions may +// spend together. They are best-effort, and a running subagent's files are +// recorded when it completes anyway; condensation's re-exports of completed +// tasks, which cannot be redone, keep the rest. +const liveTaskFetchBudget = 10 * time.Second + +type liveTaskFetchDeadlineKey struct{} + +// withCommitFetchBudgets sets a commit's subagent fetch budgets: one for every +// fetch post-commit makes, and the co-authorship checks' share of it. +func withCommitFetchBudgets(ctx context.Context, now time.Time) context.Context { + ctx = agent.WithSubagentFetchBudget(ctx, agent.SubagentFetchBudget) + return context.WithValue(ctx, liveTaskFetchDeadlineKey{}, now.Add(liveTaskFetchBudget)) +} + +// withLiveTaskFetchDeadline caps ctx's fetch budget at the commit's +// co-authorship share, when one is set. +func withLiveTaskFetchDeadline(ctx context.Context) context.Context { + if deadline, ok := ctx.Value(liveTaskFetchDeadlineKey{}).(time.Time); ok { + return agent.WithSubagentFetchDeadline(ctx, deadline) + } + return ctx +} + // liveTaskCoauthored reports whether a session with a live task record // contributed to this commit through a running subagent. The subagent's edits // are not in FilesTouched until it completes, so without this an IDLE session @@ -1398,7 +1424,7 @@ func (s *ManualCommitStrategy) PostCommit(ctx context.Context) error { // pre-loop snapshot would warn about sessions this commit just condensed. staleEnded := 0 - loopCtx, processSessionsLoop := perf.StartLoop(ctx, "process_sessions") + loopCtx, processSessionsLoop := perf.StartLoop(withCommitFetchBudgets(ctx, time.Now()), "process_sessions") for _, sess := range sessions { if sess.FullyCondensed && sess.Phase == session.PhaseEnded { continue @@ -1529,39 +1555,69 @@ func logUnclaimedCheckpointTrailer(logCtx context.Context, checkpointID id.Check // evidence that a record-bearing IDLE session co-authored a commit another // session also claims. Each record's transcript is resolved the way // materializeTaskRecords resolves it: the declared path, then the agent-layout -// fallback. +// fallback. A record with neither (OpenCode's, until its stop hook runs) is +// asked of the agent itself (agent.LiveSubagentFileReporter), within the +// commit's co-authorship fetch budget; a report that fails or runs out of +// budget is no evidence. func (s *ManualCommitStrategy) liveTaskFilesInCommit(ctx context.Context, state *SessionState, committedFileSet map[string]struct{}) bool { ag, err := agent.GetByAgentType(state.AgentType) if err != nil { return false } - analyzer, ok := agent.AsTranscriptAnalyzer(ag) - if !ok { + return liveTaskFilesInCommitFor(ctx, ag, state, committedFileSet) +} + +func liveTaskFilesInCommitFor(ctx context.Context, ag agent.Agent, state *SessionState, committedFileSet map[string]struct{}) bool { + analyzer, hasAnalyzer := agent.AsTranscriptAnalyzer(ag) + reporter, hasReporter := agent.AsLiveSubagentFileReporter(ag) + if !hasAnalyzer && !hasReporter { return false } for _, record := range state.LiveTaskRecords() { - if record.AgentID == "" || validation.ValidateAgentID(record.AgentID) != nil { + if record.AgentID == "" || validation.ValidateAgentID(record.AgentID) != nil || + validation.ValidateToolUseID(record.ToolUseID) != nil { continue } - for _, transcriptPath := range []string{record.DeclaredTranscriptPath, resolveTaskTranscriptPath(state, record.AgentID)} { - if transcriptPath == "" || !fileExists(transcriptPath) { - continue - } - files, _, extractErr := analyzer.ExtractModifiedFilesFromOffset(ctx, transcriptPath, 0) - if extractErr != nil { + files, found := liveTaskTranscriptFiles(ctx, analyzer, state, record) + if !found && hasReporter { + var reportErr error + files, reportErr = agent.LiveSubagentModifiedFiles(ctx, reporter, record.AgentID, record.ToolUseID, record.StartedAt) + if reportErr != nil { + logging.Debug(logging.WithComponent(ctx, "checkpoint"), "post-commit: no live subagent report for the co-authorship check", + slog.String("session_id", state.SessionID), + slog.String("tool_use_id", record.ToolUseID), + slog.String("error", reportErr.Error())) continue } - for _, f := range normalizeTranscriptFilePaths(ctx, state, files) { - if _, committed := committedFileSet[f]; committed { - return true - } + } + for _, f := range normalizeTranscriptFilePaths(ctx, state, files) { + if _, committed := committedFileSet[f]; committed { + return true } - break } } return false } +// liveTaskTranscriptFiles returns the files a live record's transcript on +// disk names, and whether one was found. +func liveTaskTranscriptFiles(ctx context.Context, analyzer agent.TranscriptAnalyzer, state *SessionState, record session.TaskRecord) ([]string, bool) { + if analyzer == nil { + return nil, false + } + for _, transcriptPath := range []string{record.DeclaredTranscriptPath, resolveTaskTranscriptPath(state, record.AgentID)} { + if transcriptPath == "" || !fileExists(transcriptPath) { + continue + } + files, _, extractErr := analyzer.ExtractModifiedFilesFromOffset(ctx, transcriptPath, 0) + if extractErr != nil { + continue + } + return files, true + } + return nil, false +} + // collectCommittedFileClaims counts sessions whose tracked files overlap with // committed files. When no persisted FilesTouched claims the // commit, it falls back to hasMidTurnClaimant; the read-only gate only asks @@ -1724,9 +1780,11 @@ func (s *ManualCommitStrategy) postCommitProcessSessionLocked( headTree: headTree, parentTree: parentTree, sessionsWithCommittedFiles: sessionsWithCommittedFiles, - liveTaskClaimsCommit: func() bool { - return s.liveTaskFilesInCommit(ctx, state, committedFileSet) - }, + // Memoized: the gate can be consulted more than once per session, and + // each consultation may export a running subagent. + liveTaskClaimsCommit: sync.OnceValue(func() bool { + return s.liveTaskFilesInCommit(withLiveTaskFetchDeadline(ctx), state, committedFileSet) + }), condensedTelemetry: condensedTelemetry, } diff --git a/cmd/entire/cli/strategy/manual_commit_task_fetch_test.go b/cmd/entire/cli/strategy/manual_commit_task_fetch_test.go index 0148964734..405f354bd5 100644 --- a/cmd/entire/cli/strategy/manual_commit_task_fetch_test.go +++ b/cmd/entire/cli/strategy/manual_commit_task_fetch_test.go @@ -170,3 +170,73 @@ func TestReadTaskTranscript_FetchBudgetIsShared(t *testing.T) { require.Empty(t, path) require.Empty(t, ag.fetched) } + +// reportingAgent is an agent.Agent whose only behaviour is +// LiveSubagentFileReporter. +type reportingAgent struct { + agent.Agent + + files []string + err error + reported []string + deadlines []time.Time +} + +func (r *reportingAgent) LiveSubagentModifiedFiles(ctx context.Context, agentID, _ string, _ time.Time) ([]string, error) { + r.reported = append(r.reported, agentID) + deadline, _ := ctx.Deadline() + r.deadlines = append(r.deadlines, deadline) + return r.files, r.err +} + +// TestLiveTaskFilesInCommit_AsksTheAgentForARunningSubagent pins that a live +// record with no transcript on disk (OpenCode's, until its stop hook runs) is +// reported by the agent for the co-authorship check, and that a failed or +// out-of-budget report is no evidence rather than an error. +func TestLiveTaskFilesInCommit_AsksTheAgentForARunningSubagent(t *testing.T) { + t.Parallel() + committed := map[string]struct{}{"docs/red.md": {}} + spent := withLiveTaskFetchDeadline(withCommitFetchBudgets(context.Background(), time.Now().Add(-time.Hour))) + + tests := []struct { + name string + ctx context.Context + files []string + err error + want bool + wantReported bool + }{ + {name: "subagent wrote a committed file", ctx: context.Background(), files: []string{"docs/red.md"}, want: true, wantReported: true}, + {name: "subagent wrote other files", ctx: context.Background(), files: []string{"docs/blue.md"}, wantReported: true}, + {name: "report failed", ctx: context.Background(), err: errors.New("opencode export: boom"), wantReported: true}, + {name: "co-authorship budget spent", ctx: spent, files: []string{"docs/red.md"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + ag := &reportingAgent{files: tt.files, err: tt.err} + state := &SessionState{ + SessionID: "ses_parent", + WorktreePath: dir, + TaskRecords: []session.TaskRecord{{ToolUseID: "call_1", AgentID: "ses_child", StartedAt: time.Now()}}, + } + require.Equal(t, tt.want, liveTaskFilesInCommitFor(tt.ctx, ag, state, committed)) + require.Equal(t, tt.wantReported, len(ag.reported) == 1) + }) + } +} + +// TestCommitFetchBudgets_CoauthorshipGetsAShare pins that the co-authorship +// checks are cut off at their share of the commit's fetch budget, so a hung +// export there cannot spend what condensation's re-exports need. +func TestCommitFetchBudgets_CoauthorshipGetsAShare(t *testing.T) { + t.Parallel() + now := time.Now() + ag := &reportingAgent{} + state := &SessionState{TaskRecords: []session.TaskRecord{{ToolUseID: "call_1", AgentID: "ses_child", StartedAt: now}}} + liveTaskFilesInCommitFor(withLiveTaskFetchDeadline(withCommitFetchBudgets(context.Background(), now)), ag, state, nil) + require.Len(t, ag.deadlines, 1) + require.WithinDuration(t, now.Add(liveTaskFetchBudget), ag.deadlines[0], time.Second) + require.Less(t, liveTaskFetchBudget, agent.SubagentFetchBudget) +} diff --git a/docs/architecture/sessions-and-checkpoints.md b/docs/architecture/sessions-and-checkpoints.md index 3bb74b9944..a4a0029f4d 100644 --- a/docs/architecture/sessions-and-checkpoints.md +++ b/docs/architecture/sessions-and-checkpoints.md @@ -557,7 +557,14 @@ commits. The same predicate feeds `shouldCondenseWithOverlapCheck`'s overlap-check bypass, so the trigger and the condensation trust share one rule. The trailer's content guarantee is the materializer itself: the commit's condensation stores each record's transcript-so-far under the -checkpoint's `tasks/` subtree, so no separate commit-time capture is needed. +checkpoint's `tasks/` subtree (where one is on disk; an agent whose live +transcript needs a re-export, OpenCode, gets it once the task completes), so +no separate commit-time capture is needed. When another session also claims +the committed files and the IDLE session has none of its own, it condenses +only if a live record modified a committed file (`liveTaskFilesInCommit`): +per the transcript on disk, or, for an agent with none yet, its own report +(`agent.LiveSubagentFileReporter`) within a 10s share of the commit's +subagent fetch budget. Ordinary idle commits with no task records are unaffected — they stay exactly as before. From 28919070f6a122455b0a2feccd060b773d3a0a34 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Mon, 19 Oct 2026 13:11:47 -0700 Subject: [PATCH 2/5] opencode: bound a launcher's export and count only finished edits - Kill the export's whole process group on cancel. opencode's npm launcher runs the real binary with inherited stdio, so a hung export outlived the co-authorship check's 10s share (42s in a repro). - The live probe counts only completed tool parts: a pending, running or failed edit is no evidence the subagent changed the file. - Document that a running subagent credits its session again on a later commit of the same file, as Claude's on-disk transcripts already do. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M5AWNAYJYQGEFQMV87JK61FE --- cmd/entire/cli/agent/architecture_test.go | 6 ++++ cmd/entire/cli/agent/opencode/cli_commands.go | 5 ++++ .../cli/agent/opencode/cli_commands_test.go | 29 +++++++++++++++++++ .../cli/agent/opencode/subagent_export.go | 19 +++++++++++- .../agent/opencode/subagent_export_test.go | 20 +++++++++++++ docs/architecture/sessions-and-checkpoints.md | 4 ++- 6 files changed, 81 insertions(+), 2 deletions(-) diff --git a/cmd/entire/cli/agent/architecture_test.go b/cmd/entire/cli/agent/architecture_test.go index 367d354004..096d8d390f 100644 --- a/cmd/entire/cli/agent/architecture_test.go +++ b/cmd/entire/cli/agent/architecture_test.go @@ -283,6 +283,12 @@ func TestAgentPackages_NoForbiddenImports(t *testing.T) { // agent's transport has that shape. repoPrefix + "internal/flock", }, + "opencode": { + // Process-group kill on cancel. opencode's npm launcher runs the + // real binary as a child with inherited stdio, so killing the + // launcher alone leaves an export blocking past its deadline. + repoPrefix + "execx", + }, } agentDir := findAgentDir(t) diff --git a/cmd/entire/cli/agent/opencode/cli_commands.go b/cmd/entire/cli/agent/opencode/cli_commands.go index a2693ac0f2..53945a9b59 100644 --- a/cmd/entire/cli/agent/opencode/cli_commands.go +++ b/cmd/entire/cli/agent/opencode/cli_commands.go @@ -11,6 +11,7 @@ import ( "time" "unicode" + "github.com/entireio/cli/cmd/entire/cli/execx" "github.com/entireio/cli/cmd/entire/cli/gitrepo" "github.com/entireio/cli/redact" @@ -62,6 +63,10 @@ func runOpenCodeExportToFile(ctx context.Context, root *os.Root, sessionID, outp }() cmd := exec.CommandContext(ctx, "opencode", "export", sessionID) + // The npm launcher runs the real binary as a child that inherits stderr; + // killing only the launcher would leave Run waiting on that pipe past the + // deadline, and a commit's fetch budget depends on this cut-off. + execx.TerminateOnCancel(cmd) // Exports run inside git hooks (condensation in post-commit), // whose GIT_DIR / GIT_INDEX_FILE would otherwise reach OpenCode's own git // calls (project detection) and point them at the hook's state. diff --git a/cmd/entire/cli/agent/opencode/cli_commands_test.go b/cmd/entire/cli/agent/opencode/cli_commands_test.go index 981fd7f2b6..dbb29f436f 100644 --- a/cmd/entire/cli/agent/opencode/cli_commands_test.go +++ b/cmd/entire/cli/agent/opencode/cli_commands_test.go @@ -9,6 +9,7 @@ import ( "runtime" "strings" "testing" + "time" ) func TestClassifyOpenCodeExportError(t *testing.T) { @@ -139,6 +140,34 @@ func TestRunOpenCodeExportToFile_WritesStdoutToPath(t *testing.T) { } } +// TestRunOpenCodeExportToFile_CancelKillsTheLaunchersChild pins that the +// deadline also stops a child the launcher started (opencode's npm launcher +// runs the real binary with inherited stdio), not only the launcher. +func TestRunOpenCodeExportToFile_CancelKillsTheLaunchersChild(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("stub opencode is a shell script") + } + // No t.Parallel: t.Setenv. + root := mustOpenRoot(t, t.TempDir()) + stubDir := t.TempDir() + // The trailing ":" keeps sh from exec'ing sleep in its own place. + script := "#!/bin/sh\n/bin/sleep 30\n:\n" + if err := os.WriteFile(filepath.Join(stubDir, "opencode"), []byte(script), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", stubDir) + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + start := time.Now() + if err := runOpenCodeExportToFile(ctx, root, "ses_hang", ".export-ses_hang.json-1"); err == nil { + t.Fatal("runOpenCodeExportToFile succeeded; want the deadline's error") + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("export returned after %s; the launcher's child outlived the deadline", elapsed) + } +} + // TestRunOpenCodeExportToFile_DropsHookRepoOverrides pins that an export run // inside a git hook does not hand the hook's repository selectors to OpenCode. func TestRunOpenCodeExportToFile_DropsHookRepoOverrides(t *testing.T) { diff --git a/cmd/entire/cli/agent/opencode/subagent_export.go b/cmd/entire/cli/agent/opencode/subagent_export.go index c09f44128f..b0a7a5abb1 100644 --- a/cmd/entire/cli/agent/opencode/subagent_export.go +++ b/cmd/entire/cli/agent/opencode/subagent_export.go @@ -197,7 +197,24 @@ func (a *OpenCodeAgent) LiveSubagentModifiedFiles(ctx context.Context, childID, if err != nil || session == nil { return nil, err } - return modifiedFilesFromMessages(session.Messages, 0), nil + return modifiedFilesFromMessages(completedToolParts(session.Messages), 0), nil +} + +// completedToolParts returns msgs with only finished tool parts. A live +// export carries pending, running and failed edits too, and none of those is +// evidence the subagent changed a file. +func completedToolParts(msgs []ExportMessage) []ExportMessage { + kept := make([]ExportMessage, len(msgs)) + for i, msg := range msgs { + kept[i] = ExportMessage{Info: msg.Info} + for _, part := range msg.Parts { + if part.Type == "tool" && (part.State == nil || part.State.Status != "completed") { + continue + } + kept[i].Parts = append(kept[i].Parts, part) + } + } + return kept } // exportToPrivateFile runs `opencode export` for sessionID into a staging file diff --git a/cmd/entire/cli/agent/opencode/subagent_export_test.go b/cmd/entire/cli/agent/opencode/subagent_export_test.go index 5ab6b22170..db83e7285a 100644 --- a/cmd/entire/cli/agent/opencode/subagent_export_test.go +++ b/cmd/entire/cli/agent/opencode/subagent_export_test.go @@ -264,3 +264,23 @@ func TestLiveSubagentModifiedFiles_WritesNothingACaptureReads(t *testing.T) { require.NoError(t, err) assert.Empty(t, entries, "the probe must leave nothing under .entire/tmp") } + +func TestCompletedToolParts_DropsUnfinishedEdits(t *testing.T) { + t.Parallel() + write := func(status, file string) Part { + return Part{Type: "tool", Tool: "write", State: &ToolState{Status: status, Input: map[string]any{"filePath": file}}} + } + msgs := []ExportMessage{{ + Info: MessageInfo{ID: "m1", Role: roleAssistant}, + Parts: []Part{ + {Type: "text", Text: "editing"}, + write("pending", "/repo/pending.md"), + write("running", "/repo/running.md"), + write("error", "/repo/error.md"), + write("completed", "/repo/done.md"), + }, + }} + + assert.Equal(t, []string{"/repo/done.md"}, modifiedFilesFromMessages(completedToolParts(msgs), 0)) + assert.Len(t, msgs[0].Parts, 5, "the caller's messages are left as they were") +} diff --git a/docs/architecture/sessions-and-checkpoints.md b/docs/architecture/sessions-and-checkpoints.md index a4a0029f4d..f475fc7eb5 100644 --- a/docs/architecture/sessions-and-checkpoints.md +++ b/docs/architecture/sessions-and-checkpoints.md @@ -564,7 +564,9 @@ the committed files and the IDLE session has none of its own, it condenses only if a live record modified a committed file (`liveTaskFilesInCommit`): per the transcript on disk, or, for an agent with none yet, its own report (`agent.LiveSubagentFileReporter`) within a 10s share of the commit's -subagent fetch budget. +subagent fetch budget. The check reads the whole running call, so while it +runs, a later commit of the same file by another session credits the session +again even when the subagent added nothing since. Ordinary idle commits with no task records are unaffected — they stay exactly as before. From a1762c469c97f99127f3e826fd725766e0eaa7bc Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Mon, 19 Oct 2026 17:25:09 -0700 Subject: [PATCH 3/5] strategy: credit a running subagent's edit for an ACTIVE parent too The read-only gate asked the live task records only for an IDLE session. A parent that keeps working after launching a background subagent is ACTIVE, so with another session claiming the committed file it was dropped although its running child wrote that file (an OpenCode parent's live transcript does not include its children). The evidence now comes from hasFreshLiveTaskRecord, any phase but ENDED; idleWithLiveTaskRecord still alone decides when to skip the overlap check. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M5BB580JKSNB5YS2N45HC2E8 --- .../opencode_subagent_test.go | 45 +++++++++++++++++++ .../cli/strategy/manual_commit_hooks.go | 37 ++++++++++----- .../strategy/manual_commit_task_fetch_test.go | 31 +++++++++++++ docs/architecture/sessions-and-checkpoints.md | 5 ++- 4 files changed, 106 insertions(+), 12 deletions(-) diff --git a/cmd/entire/cli/integration_test/opencode_subagent_test.go b/cmd/entire/cli/integration_test/opencode_subagent_test.go index 86f9a10303..5875e36ae5 100644 --- a/cmd/entire/cli/integration_test/opencode_subagent_test.go +++ b/cmd/entire/cli/integration_test/opencode_subagent_test.go @@ -409,3 +409,48 @@ func TestOpenCodeSubagentRunningChildCoauthorsCommit(t *testing.T) { require.NoError(t, json.Unmarshal([]byte(summaryContent), &summary)) require.Len(t, summary.Sessions, 2, "the parent co-authored the commit through its running child") } + +// The parent is still in its turn (ACTIVE) when the commit lands, as it is +// while it keeps working after launching the background child. +func TestOpenCodeSubagentRunningChildCoauthorsCommit_ActiveParent(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t) + env.InitEntireWithAgent(agent.AgentNameOpenCode) + const shared = "docs/shared.md" + + coder := env.NewOpenCodeSession() + require.NoError(t, env.SimulateOpenCodeSessionStart(coder.ID, coder.TranscriptPath)) + require.NoError(t, env.SimulateOpenCodeTurnStart(coder.ID, coder.TranscriptPath, "create docs/shared.md")) + env.WriteFile(shared, "first draft\n") + coder.CreateOpenCodeTranscript("create docs/shared.md", []FileChange{{Path: shared, Content: "first draft\n"}}) + require.NoError(t, env.SimulateOpenCodeTurnEnd(coder.ID, coder.TranscriptPath)) + + parent := env.NewOpenCodeSession() + child := env.NewOpenCodeSession() + // A live record counts only while fresh, so the call starts now. + child.TimeBase = time.Now().UnixMilli() + require.NoError(t, env.SimulateOpenCodeSessionStart(parent.ID, parent.TranscriptPath)) + require.NoError(t, env.SimulateOpenCodeTurnStart(parent.ID, parent.TranscriptPath, "polish docs/shared.md in the background")) + require.NoError(t, env.SimulateOpenCodeSubagentStartAt(parent.ID, "call_polish", child.ID, "general", "Polish docs/shared.md", child.TimeBase)) + // Mid-turn: no TurnEnd, so the parent's live export is what OpenCode would + // return at commit time. + env.CopyTranscriptToEntireTmp(parent.ID, parent.CreateOpenCodeTranscript("polish docs/shared.md in the background", nil)) + + // The child, still running, edits the shared file; its export names it. + env.WriteFile(shared, "polished\n") + env.CopyTranscriptToEntireTmp(child.ID, child.CreateOpenCodeTranscript("Polish docs/shared.md", []FileChange{{Path: shared, Content: "polished\n"}})) + + state, err := env.GetSessionState(parent.ID) + require.NoError(t, err) + require.Empty(t, state.FilesTouched, "the parent has no files of its own before the child completes") + + env.GitCommitWithHooks("Polish shared doc", shared) + cpID := env.GetCheckpointIDFromCommitMessage(env.GetHeadHash()) + require.NotEmpty(t, cpID) + summaryContent, found := env.ReadFileFromBranch(paths.MetadataBranchName, CheckpointSummaryPath(cpID)) + require.True(t, found) + var summary checkpoint.CheckpointSummary + require.NoError(t, json.Unmarshal([]byte(summaryContent), &summary)) + require.Len(t, summary.Sessions, 2, "the parent co-authored the commit through its running child") +} diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index c4d527f56f..89216c0f60 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -1052,14 +1052,17 @@ func (h *postCommitActionHandler) searchProbeGate() func() bool { func (h *postCommitActionHandler) HandleCondense(state *session.State) error { logCtx := logging.WithComponent(h.ctx, "checkpoint") - hasLiveTask := idleWithLiveTaskRecord(state, time.Now()) - shouldCondense := h.shouldCondenseWithOverlapCheck(state.Phase.IsActive(), state.LastInteractionTime, hasLiveTask) + now := time.Now() + hasLiveTask := idleWithLiveTaskRecord(state, now) + liveTaskEvidence := hasFreshLiveTaskRecord(state, now) + shouldCondense := h.shouldCondenseWithOverlapCheck(state.Phase.IsActive(), state.LastInteractionTime, hasLiveTask, liveTaskEvidence) logging.Debug(logCtx, "post-commit: HandleCondense decision", slog.String("session_id", state.SessionID), slog.String("phase", string(state.Phase)), slog.Bool("has_new", h.hasNew), slog.Bool("idle_with_live_task", hasLiveTask), + slog.Bool("live_task_evidence", liveTaskEvidence), slog.Int("task_records", len(state.TaskRecords)), slog.Bool("should_condense", shouldCondense), ) @@ -1079,7 +1082,8 @@ func (h *postCommitActionHandler) HandleCondense(state *session.State) error { func (h *postCommitActionHandler) HandleCondenseIfFilesTouched(state *session.State) error { logCtx := logging.WithComponent(h.ctx, "checkpoint") - shouldCondense := len(state.FilesTouched) > 0 && h.shouldCondenseWithOverlapCheck(state.Phase.IsActive(), state.LastInteractionTime, idleWithLiveTaskRecord(state, time.Now())) + now := time.Now() + shouldCondense := len(state.FilesTouched) > 0 && h.shouldCondenseWithOverlapCheck(state.Phase.IsActive(), state.LastInteractionTime, idleWithLiveTaskRecord(state, now), hasFreshLiveTaskRecord(state, now)) logging.Debug(logCtx, "post-commit: HandleCondenseIfFilesTouched decision", slog.String("session_id", state.SessionID), @@ -1106,11 +1110,13 @@ func (h *postCommitActionHandler) HandleCondenseIfFilesTouched(state *session.St // into this commit. Two shapes skip the overlap check: an ACTIVE session with // recent interaction, and an IDLE session with a fresh in-flight task record. // Both are still subject to the read-only gate — a session with no tracked files while -// another session claims the committed files is somebody else's commit. Every +// another session claims the committed files is somebody else's commit, unless +// a running subagent of the session wrote one of them (liveTaskEvidence; an +// ACTIVE session counts too, since it keeps working while its subagent runs). Every // other session (a stale ACTIVE one, an IDLE one with no fresh in-flight // record, an ENDED one) must show file-overlap evidence between its tracked files and the // committed files. -func (h *postCommitActionHandler) shouldCondenseWithOverlapCheck(isActive bool, lastInteraction *time.Time, hasLiveTask bool) bool { +func (h *postCommitActionHandler) shouldCondenseWithOverlapCheck(isActive bool, lastInteraction *time.Time, hasLiveTask, liveTaskEvidence bool) bool { if !h.hasNew { return false } @@ -1119,7 +1125,7 @@ func (h *postCommitActionHandler) shouldCondenseWithOverlapCheck(isActive bool, // LastInteractionTime keeps a stale ACTIVE session (agent killed without a // Stop hook) from condensing into every subsequent commit. if (isActive && isRecentInteraction(lastInteraction)) || hasLiveTask { - if h.sessionsWithCommittedFiles > 0 && len(h.filesTouchedBefore) == 0 && !h.liveTaskCoauthored(hasLiveTask) { + if h.sessionsWithCommittedFiles > 0 && len(h.filesTouchedBefore) == 0 && !h.liveTaskCoauthored(liveTaskEvidence) { logging.Debug(logging.WithComponent(h.ctx, "checkpoint"), "post-commit: skipping read-only session (no tracked files, other sessions claim committed files)", slog.Bool("is_active", isActive), slog.Int("sessions_with_committed_files", h.sessionsWithCommittedFiles), @@ -1179,10 +1185,11 @@ func withLiveTaskFetchDeadline(ctx context.Context) context.Context { // liveTaskCoauthored reports whether a session with a live task record // contributed to this commit through a running subagent. The subagent's edits -// are not in FilesTouched until it completes, so without this an IDLE session +// are not in FilesTouched until it completes, so without this a session // co-authoring a commit that another session also claims looks read-only. -func (h *postCommitActionHandler) liveTaskCoauthored(hasLiveTask bool) bool { - return hasLiveTask && h.liveTaskClaimsCommit != nil && h.liveTaskClaimsCommit() +// liveTaskEvidence is hasFreshLiveTaskRecord's answer for the session. +func (h *postCommitActionHandler) liveTaskCoauthored(liveTaskEvidence bool) bool { + return liveTaskEvidence && h.liveTaskClaimsCommit != nil && h.liveTaskClaimsCommit() } const ( @@ -1256,7 +1263,17 @@ func isRecentInteraction(lastInteraction *time.Time) bool { // shouldCondenseWithOverlapCheck's overlap-check bypass so the trigger and // the condensation trust can never drift apart into two different rules. func idleWithLiveTaskRecord(state *SessionState, now time.Time) bool { - if state.Phase != session.PhaseIdle { + return state.Phase == session.PhaseIdle && hasFreshLiveTaskRecord(state, now) +} + +// hasFreshLiveTaskRecord reports whether a session that has not ENDED has a +// task record in flight that started within activeSessionInteractionThreshold. +// It decides only whether a running subagent's edits are evidence for a +// commit, never whether to skip the overlap check (idleWithLiveTaskRecord): +// an ACTIVE session keeps working while its background subagent runs, so the +// evidence applies in either phase. +func hasFreshLiveTaskRecord(state *SessionState, now time.Time) bool { + if state.Phase == session.PhaseEnded { return false } for _, task := range state.LiveTaskRecords() { diff --git a/cmd/entire/cli/strategy/manual_commit_task_fetch_test.go b/cmd/entire/cli/strategy/manual_commit_task_fetch_test.go index 405f354bd5..7ba322e978 100644 --- a/cmd/entire/cli/strategy/manual_commit_task_fetch_test.go +++ b/cmd/entire/cli/strategy/manual_commit_task_fetch_test.go @@ -240,3 +240,34 @@ func TestCommitFetchBudgets_CoauthorshipGetsAShare(t *testing.T) { require.WithinDuration(t, now.Add(liveTaskFetchBudget), ag.deadlines[0], time.Second) require.Less(t, liveTaskFetchBudget, agent.SubagentFetchBudget) } + +func TestHasFreshLiveTaskRecord_AnyPhaseButEnded(t *testing.T) { + t.Parallel() + now := time.Now() + live := []session.TaskRecord{{ToolUseID: "call_live", StartedAt: now.Add(-time.Minute)}} + stale := []session.TaskRecord{{ToolUseID: "call_stale", StartedAt: now.Add(-25 * time.Hour)}} + done := []session.TaskRecord{{ToolUseID: "call_done", StartedAt: now.Add(-time.Minute), CompletedAt: now}} + + tests := []struct { + name string + phase session.Phase + records []session.TaskRecord + want bool + }{ + {"active", session.PhaseActive, live, true}, + {"idle", session.PhaseIdle, live, true}, + {"ended", session.PhaseEnded, live, false}, + {"started over a day ago", session.PhaseActive, stale, false}, + {"completed", session.PhaseActive, done, false}, + {"no records", session.PhaseActive, nil, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + state := &SessionState{SessionID: "s", Phase: tt.phase, TaskRecords: tt.records} + require.Equal(t, tt.want, hasFreshLiveTaskRecord(state, now)) + require.Equal(t, tt.want && tt.phase == session.PhaseIdle, idleWithLiveTaskRecord(state, now), + "the overlap-check bypass stays IDLE-only") + }) + } +} diff --git a/docs/architecture/sessions-and-checkpoints.md b/docs/architecture/sessions-and-checkpoints.md index f475fc7eb5..f5e4dcc518 100644 --- a/docs/architecture/sessions-and-checkpoints.md +++ b/docs/architecture/sessions-and-checkpoints.md @@ -560,8 +560,9 @@ commit's condensation stores each record's transcript-so-far under the checkpoint's `tasks/` subtree (where one is on disk; an agent whose live transcript needs a re-export, OpenCode, gets it once the task completes), so no separate commit-time capture is needed. When another session also claims -the committed files and the IDLE session has none of its own, it condenses -only if a live record modified a committed file (`liveTaskFilesInCommit`): +the committed files and the session (IDLE, or ACTIVE while its background +subagent runs) has none of its own, it condenses only if a live record +modified a committed file (`liveTaskFilesInCommit`): per the transcript on disk, or, for an agent with none yet, its own report (`agent.LiveSubagentFileReporter`) within a 10s share of the commit's subagent fetch budget. The check reads the whole running call, so while it From 4e97f280d689726ffce04366326040e5a4116d1b Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Tue, 20 Oct 2026 13:59:27 -0700 Subject: [PATCH 4/5] strategy, opencode: keep condensation's per-session fetch budget; review fixes - Post-commit set one 30s subagent fetch budget for the whole commit, so a second session condensing completed OpenCode tasks could find it spent and lose their transcripts. Only the co-authorship checks' 10s deadline is now commit-wide; each condensation keeps its own 30s, as on main. - LiveSubagentModifiedFiles returns the worktree error instead of falling back to the CWD. - One mockExportName helper holds the ENTIRE_TEST_OPENCODE_MOCK_EXPORT hook and its file name for both export paths. - Test the read-only gate's running-subagent credit for an ACTIVE parent of any agent; note what a forced second Ctrl-C leaves behind. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M5DHSAQ4BW7BT4NRMKDWFDMH --- cmd/entire/cli/agent/opencode/cli_commands.go | 5 +- cmd/entire/cli/agent/opencode/lifecycle.go | 16 +++++- .../cli/agent/opencode/subagent_export.go | 8 +-- .../agent/opencode/subagent_export_test.go | 5 +- .../cli/strategy/manual_commit_hooks.go | 23 ++++----- .../strategy/manual_commit_task_fetch_test.go | 49 ++++++++++++++++++- docs/architecture/sessions-and-checkpoints.md | 5 +- 7 files changed, 88 insertions(+), 23 deletions(-) diff --git a/cmd/entire/cli/agent/opencode/cli_commands.go b/cmd/entire/cli/agent/opencode/cli_commands.go index 53945a9b59..6c1c2036b4 100644 --- a/cmd/entire/cli/agent/opencode/cli_commands.go +++ b/cmd/entire/cli/agent/opencode/cli_commands.go @@ -65,7 +65,10 @@ func runOpenCodeExportToFile(ctx context.Context, root *os.Root, sessionID, outp cmd := exec.CommandContext(ctx, "opencode", "export", sessionID) // The npm launcher runs the real binary as a child that inherits stderr; // killing only the launcher would leave Run waiting on that pipe past the - // deadline, and a commit's fetch budget depends on this cut-off. + // deadline, and a commit's fetch budget depends on this cut-off. The new + // process group leaves the terminal's foreground group: a first Ctrl-C + // cancels ctx and so the export, but a forced second one exits the CLI + // without reaching it, which leaves the export and its staging file. execx.TerminateOnCancel(cmd) // Exports run inside git hooks (condensation in post-commit), // whose GIT_DIR / GIT_INDEX_FILE would otherwise reach OpenCode's own git diff --git a/cmd/entire/cli/agent/opencode/lifecycle.go b/cmd/entire/cli/agent/opencode/lifecycle.go index d64ec57716..12bf4ee865 100644 --- a/cmd/entire/cli/agent/opencode/lifecycle.go +++ b/cmd/entire/cli/agent/opencode/lifecycle.go @@ -318,6 +318,18 @@ func sessionTranscriptPath(ctx context.Context, sessionID string) (string, error // entireTmpName is .entire/tmp relative to the .entire root. var entireTmpName = entiredir.MustName(paths.EntireTmpDir) +// mockExportName reports whether integration tests replace every +// `opencode export` with a pre-written file (ENTIRE_TEST_OPENCODE_MOCK_EXPORT), +// and names that file for sessionID: .entire/tmp/.json, relative to +// the .entire root. Every export path asks here, so the test hook and its file +// name live in one place. +func mockExportName(sessionID string) (string, bool) { + if os.Getenv("ENTIRE_TEST_OPENCODE_MOCK_EXPORT") == "" { + return "", false + } + return entireTmpName + "/" + sessionID + ".json", true +} + // fetchAndCacheExport calls `opencode export ` and writes the result // to a temporary file. Returns the path to the temp file. // @@ -349,8 +361,8 @@ func (a *OpenCodeAgent) fetchAndCacheExport(ctx context.Context, sessionID strin tmpFile := filepath.Join(entireDirAbs, filepath.FromSlash(tmpName)) // Integration test mode: use pre-written mock file without calling opencode export - if os.Getenv("ENTIRE_TEST_OPENCODE_MOCK_EXPORT") != "" { - if _, err := root.Stat(tmpName); err == nil { + if mockName, ok := mockExportName(sessionID); ok { + if _, err := root.Stat(mockName); err == nil { return tmpFile, nil } return "", fmt.Errorf("mock export file not found: %s (ENTIRE_TEST_OPENCODE_MOCK_EXPORT is set)", tmpFile) diff --git a/cmd/entire/cli/agent/opencode/subagent_export.go b/cmd/entire/cli/agent/opencode/subagent_export.go index b0a7a5abb1..1da3310c84 100644 --- a/cmd/entire/cli/agent/opencode/subagent_export.go +++ b/cmd/entire/cli/agent/opencode/subagent_export.go @@ -171,18 +171,18 @@ func (a *OpenCodeAgent) LiveSubagentModifiedFiles(ctx context.Context, childID, if err := validation.ValidateSessionID(childID); err != nil { return nil, fmt.Errorf("invalid session ID for live subagent export: %w", err) } + // Best-effort: an unresolved repository is no evidence, never the CWD. repoRoot, err := paths.WorktreeRoot(ctx) if err != nil { - repoRoot = "." + return nil, fmt.Errorf("resolve worktree for live subagent export: %w", err) } root, err := entiredir.OpenAt(repoRoot) if err != nil { return nil, fmt.Errorf("open %s for live subagent export: %w", paths.EntireDir, err) } var data []byte - if os.Getenv("ENTIRE_TEST_OPENCODE_MOCK_EXPORT") != "" { - // Integration tests pre-write the child's export (see fetchAndCacheExport). - data, err = entiredir.ReadFile(root, entireTmpName+"/"+childID+".json") + if name, ok := mockExportName(childID); ok { + data, err = entiredir.ReadFile(root, name) } else { data, err = exportToPrivateFile(ctx, root, childID) } diff --git a/cmd/entire/cli/agent/opencode/subagent_export_test.go b/cmd/entire/cli/agent/opencode/subagent_export_test.go index db83e7285a..9b51bc098a 100644 --- a/cmd/entire/cli/agent/opencode/subagent_export_test.go +++ b/cmd/entire/cli/agent/opencode/subagent_export_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/require" "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/testutil" ) // resumedChildExportFixture is a child resumed through `task_id`: its first @@ -249,7 +250,9 @@ func TestFetchSubagentTranscript_NeverPromptedCallDeclaresNothing(t *testing.T) // stop's complete transcript. func TestLiveSubagentModifiedFiles_WritesNothingACaptureReads(t *testing.T) { // Not parallel: t.Chdir and stubExport. - t.Chdir(t.TempDir()) + dir := t.TempDir() + testutil.InitRepo(t, dir) + t.Chdir(dir) paths.ClearWorktreeRootCache() t.Cleanup(paths.ClearWorktreeRootCache) stubExport(t, func(_ context.Context, root *os.Root, _, outputName string) error { diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index 89216c0f60..bc105eaf57 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -1158,24 +1158,25 @@ func (h *postCommitActionHandler) shouldCondenseWithOverlapCheck(isActive bool, }) } -// liveTaskFetchBudget is the share of a commit's subagent fetch budget -// (agent.SubagentFetchBudget) the co-authorship checks of all its sessions may -// spend together. They are best-effort, and a running subagent's files are -// recorded when it completes anyway; condensation's re-exports of completed -// tasks, which cannot be redone, keep the rest. +// liveTaskFetchBudget is how long the co-authorship checks of all a commit's +// sessions may spend together, counted from the start of the commit's session +// loop. They are best-effort, and a running subagent's files are recorded when +// it completes anyway. Condensation's re-exports of completed tasks, which +// cannot be redone, are not bounded by it: each session's condensation keeps +// its own agent.SubagentFetchBudget. const liveTaskFetchBudget = 10 * time.Second type liveTaskFetchDeadlineKey struct{} -// withCommitFetchBudgets sets a commit's subagent fetch budgets: one for every -// fetch post-commit makes, and the co-authorship checks' share of it. -func withCommitFetchBudgets(ctx context.Context, now time.Time) context.Context { - ctx = agent.WithSubagentFetchBudget(ctx, agent.SubagentFetchBudget) +// withCoauthorshipFetchDeadline sets the deadline the commit's co-authorship +// checks share. It sets no fetch budget on ctx itself, so condensation's +// re-exports still start their own per session. +func withCoauthorshipFetchDeadline(ctx context.Context, now time.Time) context.Context { return context.WithValue(ctx, liveTaskFetchDeadlineKey{}, now.Add(liveTaskFetchBudget)) } // withLiveTaskFetchDeadline caps ctx's fetch budget at the commit's -// co-authorship share, when one is set. +// co-authorship deadline, when one is set. func withLiveTaskFetchDeadline(ctx context.Context) context.Context { if deadline, ok := ctx.Value(liveTaskFetchDeadlineKey{}).(time.Time); ok { return agent.WithSubagentFetchDeadline(ctx, deadline) @@ -1441,7 +1442,7 @@ func (s *ManualCommitStrategy) PostCommit(ctx context.Context) error { // pre-loop snapshot would warn about sessions this commit just condensed. staleEnded := 0 - loopCtx, processSessionsLoop := perf.StartLoop(withCommitFetchBudgets(ctx, time.Now()), "process_sessions") + loopCtx, processSessionsLoop := perf.StartLoop(withCoauthorshipFetchDeadline(ctx, time.Now()), "process_sessions") for _, sess := range sessions { if sess.FullyCondensed && sess.Phase == session.PhaseEnded { continue diff --git a/cmd/entire/cli/strategy/manual_commit_task_fetch_test.go b/cmd/entire/cli/strategy/manual_commit_task_fetch_test.go index 7ba322e978..3f4187516e 100644 --- a/cmd/entire/cli/strategy/manual_commit_task_fetch_test.go +++ b/cmd/entire/cli/strategy/manual_commit_task_fetch_test.go @@ -196,7 +196,7 @@ func (r *reportingAgent) LiveSubagentModifiedFiles(ctx context.Context, agentID, func TestLiveTaskFilesInCommit_AsksTheAgentForARunningSubagent(t *testing.T) { t.Parallel() committed := map[string]struct{}{"docs/red.md": {}} - spent := withLiveTaskFetchDeadline(withCommitFetchBudgets(context.Background(), time.Now().Add(-time.Hour))) + spent := withLiveTaskFetchDeadline(withCoauthorshipFetchDeadline(context.Background(), time.Now().Add(-time.Hour))) tests := []struct { name string @@ -235,12 +235,57 @@ func TestCommitFetchBudgets_CoauthorshipGetsAShare(t *testing.T) { now := time.Now() ag := &reportingAgent{} state := &SessionState{TaskRecords: []session.TaskRecord{{ToolUseID: "call_1", AgentID: "ses_child", StartedAt: now}}} - liveTaskFilesInCommitFor(withLiveTaskFetchDeadline(withCommitFetchBudgets(context.Background(), now)), ag, state, nil) + liveTaskFilesInCommitFor(withLiveTaskFetchDeadline(withCoauthorshipFetchDeadline(context.Background(), now)), ag, state, nil) require.Len(t, ag.deadlines, 1) require.WithinDuration(t, now.Add(liveTaskFetchBudget), ag.deadlines[0], time.Second) require.Less(t, liveTaskFetchBudget, agent.SubagentFetchBudget) } +// TestCommitFetchBudgets_CondensationKeepsItsOwnBudget pins that the commit's +// co-authorship deadline does not bound condensation: once it has passed, a +// session's re-exports still get their own agent.SubagentFetchBudget, as +// materializeTaskRecords starts one per session. +func TestCommitFetchBudgets_CondensationKeepsItsOwnBudget(t *testing.T) { + t.Parallel() + loopCtx := withCoauthorshipFetchDeadline(context.Background(), time.Now().Add(-time.Hour)) + ag := &fetchingAgent{dir: t.TempDir(), content: "{}"} + + _, err := agent.FetchSubagentTranscript(agent.WithSubagentFetchBudget(loopCtx, agent.SubagentFetchBudget), ag, "ses_child", "call_1", time.Time{}, time.Time{}) + + require.NoError(t, err) + require.Equal(t, []string{"ses_child"}, ag.fetched) +} + +// TestShouldCondense_RunningSubagentCreditsAnActiveParent pins the read-only +// gate for any agent: an ACTIVE session with no tracked files of its own, +// while another session claims the committed files, is condensed only when its +// running subagent wrote a committed file. +func TestShouldCondense_RunningSubagentCreditsAnActiveParent(t *testing.T) { + t.Parallel() + recent := time.Now() + for _, tt := range []struct { + name string + evidence bool + subagentWrote bool + want bool + }{ + {"subagent wrote a committed file", true, true, true}, + {"subagent wrote other files", true, false, false}, + {"no live task", false, true, false}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + h := &postCommitActionHandler{ + ctx: context.Background(), + hasNew: true, + sessionsWithCommittedFiles: 1, + liveTaskClaimsCommit: func() bool { return tt.subagentWrote }, + } + require.Equal(t, tt.want, h.shouldCondenseWithOverlapCheck(true, &recent, false, tt.evidence)) + }) + } +} + func TestHasFreshLiveTaskRecord_AnyPhaseButEnded(t *testing.T) { t.Parallel() now := time.Now() diff --git a/docs/architecture/sessions-and-checkpoints.md b/docs/architecture/sessions-and-checkpoints.md index f5e4dcc518..859ae599c5 100644 --- a/docs/architecture/sessions-and-checkpoints.md +++ b/docs/architecture/sessions-and-checkpoints.md @@ -564,8 +564,9 @@ the committed files and the session (IDLE, or ACTIVE while its background subagent runs) has none of its own, it condenses only if a live record modified a committed file (`liveTaskFilesInCommit`): per the transcript on disk, or, for an agent with none yet, its own report -(`agent.LiveSubagentFileReporter`) within a 10s share of the commit's -subagent fetch budget. The check reads the whole running call, so while it +(`agent.LiveSubagentFileReporter`) within 10s for all of a commit's +sessions together (condensation's re-exports keep their own 30s per +session). The check reads the whole running call, so while it runs, a later commit of the same file by another session credits the session again even when the subagent added nothing since. Ordinary idle commits with no task records are unaffected — they stay exactly From 0506eab0b182aae848146c3771f510afd191ffd8 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Tue, 20 Oct 2026 18:28:29 -0700 Subject: [PATCH 5/5] strategy: bound prepare-commit-msg's running-subagent exports The prepare-commit-msg check for a foreign session's running subagent could run an agent export per live task record with no deadline, so a slow or hung export held up git commit; this branch also widened that check to ACTIVE sessions. It now shares the commit's co-authorship deadline, as post-commit does, and never extends one already set. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M5E15XYD0DXHVD121CAVH76N --- .../cli/strategy/manual_commit_hooks.go | 15 +++++++++++++- .../strategy/manual_commit_task_fetch_test.go | 20 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index bd959841de..101fc7e06a 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -1197,6 +1197,15 @@ func withCoauthorshipFetchDeadline(ctx context.Context, now time.Time) context.C return context.WithValue(ctx, liveTaskFetchDeadlineKey{}, now.Add(liveTaskFetchBudget)) } +// ensureCoauthorshipFetchDeadline is withCoauthorshipFetchDeadline unless ctx +// already carries one, which it never extends. +func ensureCoauthorshipFetchDeadline(ctx context.Context, now time.Time) context.Context { + if _, ok := ctx.Value(liveTaskFetchDeadlineKey{}).(time.Time); ok { + return ctx + } + return withCoauthorshipFetchDeadline(ctx, now) +} + // withLiveTaskFetchDeadline caps ctx's fetch budget at the commit's // co-authorship deadline, when one is set. func withLiveTaskFetchDeadline(ctx context.Context) context.Context { @@ -2145,6 +2154,10 @@ func truncateHash(h string) string { // agent did not make, though a running subagent that wrote a staged file is. func (s *ManualCommitStrategy) filterSessionsWithNewContent(ctx context.Context, repo *git.Repository, sessions []*SessionState, redone redo, provenance commitProvenance) []*SessionState { logCtx := logging.WithComponent(ctx, "manual-commit") + // A running subagent's evidence can mean an agent export per live task + // record; they share the commit's co-authorship deadline so a slow or hung + // export cannot hold up the user's git commit. + ctx = ensureCoauthorshipFetchDeadline(ctx, time.Now()) var result []*SessionState // Compute staged files once for all sessions. @@ -2180,7 +2193,7 @@ func (s *ManualCommitStrategy) filterSessionsWithNewContent(ctx context.Context, } // A running subagent's edit is staged-file overlap, as in // post-commit's shouldCondenseWithOverlapCheck. - if hasFreshLiveTaskRecord(state, time.Now()) && s.liveTaskFilesInCommit(ctx, state, fileSet(stagedFiles)) { + if hasFreshLiveTaskRecord(state, time.Now()) && s.liveTaskFilesInCommit(withLiveTaskFetchDeadline(ctx), state, fileSet(stagedFiles)) { result = append(result, state) continue } diff --git a/cmd/entire/cli/strategy/manual_commit_task_fetch_test.go b/cmd/entire/cli/strategy/manual_commit_task_fetch_test.go index 3f4187516e..86316525ca 100644 --- a/cmd/entire/cli/strategy/manual_commit_task_fetch_test.go +++ b/cmd/entire/cli/strategy/manual_commit_task_fetch_test.go @@ -241,6 +241,26 @@ func TestCommitFetchBudgets_CoauthorshipGetsAShare(t *testing.T) { require.Less(t, liveTaskFetchBudget, agent.SubagentFetchBudget) } +// TestCommitFetchBudgets_PrepareCommitMsgIsBounded pins that prepare-commit-msg's +// running-subagent check gets the co-authorship deadline when nothing set one, +// and never extends a deadline post-commit already set. +func TestCommitFetchBudgets_PrepareCommitMsgIsBounded(t *testing.T) { + t.Parallel() + now := time.Now() + state := &SessionState{TaskRecords: []session.TaskRecord{{ToolUseID: "call_1", AgentID: "ses_child", StartedAt: now}}} + + ag := &reportingAgent{} + liveTaskFilesInCommitFor(withLiveTaskFetchDeadline(ensureCoauthorshipFetchDeadline(context.Background(), now)), ag, state, nil) + require.Len(t, ag.deadlines, 1) + require.WithinDuration(t, now.Add(liveTaskFetchBudget), ag.deadlines[0], time.Second) + + earlier := now.Add(-time.Minute) + ctx := ensureCoauthorshipFetchDeadline(withCoauthorshipFetchDeadline(context.Background(), earlier), now) + deadline, ok := ctx.Value(liveTaskFetchDeadlineKey{}).(time.Time) + require.True(t, ok) + require.Equal(t, earlier.Add(liveTaskFetchBudget), deadline, "an existing deadline is kept, not extended") +} + // TestCommitFetchBudgets_CondensationKeepsItsOwnBudget pins that the commit's // co-authorship deadline does not bound condensation: once it has passed, a // session's re-exports still get their own agent.SubagentFetchBudget, as