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/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/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/cli_commands.go b/cmd/entire/cli/agent/opencode/cli_commands.go index a2693ac0f2..6c1c2036b4 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,13 @@ 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. 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 // 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/lifecycle.go b/cmd/entire/cli/agent/opencode/lifecycle.go index ad25ee1a06..12bf4ee865 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 @@ -317,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. // @@ -348,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 c1f9d3ed65..1da3310c84 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,81 @@ 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) + } + // Best-effort: an unresolved repository is no evidence, never the CWD. + repoRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + 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 name, ok := mockExportName(childID); ok { + data, err = entiredir.ReadFile(root, name) + } 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(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 +// 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..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 @@ -241,3 +242,48 @@ 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. + 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 { + 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") +} + +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/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/foreign_commit_linking_test.go b/cmd/entire/cli/integration_test/foreign_commit_linking_test.go index 99966e3f6a..1bb279c257 100644 --- a/cmd/entire/cli/integration_test/foreign_commit_linking_test.go +++ b/cmd/entire/cli/integration_test/foreign_commit_linking_test.go @@ -284,3 +284,13 @@ func TestForeignCommit_CarryForwardCheckpointedEditDoesNotLink(t *testing.T) { require.Empty(t, headCheckpointID(t, env), "an edit already in an earlier checkpoint is no evidence for a later commit") } + +func TestForeignCommit_RunningOpenCodeSubagentLinksActiveForeignParent(t *testing.T) { + t.Parallel() + env := NewFeatureBranchEnv(t) + // The commit comes from outside the parent's OpenCode while the parent is + // still in its turn and its background child has written the file. + openCodeActiveParentCoauthorsCommit(t, env, func(parentID string) { + makeOwnerForeign(t, env, parentID) + }) +} 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..bb059d2827 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,103 @@ 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") +} + +// 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() + openCodeActiveParentCoauthorsCommit(t, NewFeatureBranchEnv(t), nil) +} + +// openCodeActiveParentCoauthorsCommit runs the ACTIVE-parent scenario; +// beforeCommit, when set, adjusts the parent just before the commit. +func openCodeActiveParentCoauthorsCommit(t *testing.T, env *TestEnv, beforeCommit func(parentID string)) { + t.Helper() + 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") + + if beforeCommit != nil { + beforeCommit(parent.ID) + } + 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 af2fe693cb..101fc7e06a 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -16,6 +16,7 @@ import ( "path/filepath" "slices" "strings" + "sync" "time" "github.com/entireio/cli/cmd/entire/cli/agent" @@ -1066,14 +1067,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), ) @@ -1093,7 +1097,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), @@ -1120,12 +1125,14 @@ 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, and so must a session whose agent provably did not make the // commit, though for that one a running subagent's committed edit counts. -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 } @@ -1134,7 +1141,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 !h.foreign && ((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), @@ -1146,7 +1153,7 @@ func (h *postCommitActionHandler) shouldCondenseWithOverlapCheck(isActive bool, // A running subagent's edit is overlap too: its files reach // filesTouchedBefore only when it completes. This keeps a foreign parent // whose subagent wrote a committed file. - if h.liveTaskCoauthored(hasLiveTask) { + if h.liveTaskCoauthored(liveTaskEvidence) { return true } if len(h.filesTouchedBefore) == 0 { @@ -1173,12 +1180,48 @@ func (h *postCommitActionHandler) shouldCondenseWithOverlapCheck(isActive bool, }) } +// 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{} + +// 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)) +} + +// 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 { + 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 +// 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 ( @@ -1252,7 +1295,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() { @@ -1423,7 +1476,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(withCoauthorshipFetchDeadline(ctx, time.Now()), "process_sessions") for _, sess := range sessions { if sess.FullyCondensed && sess.Phase == session.PhaseEnded { continue @@ -1554,39 +1607,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 @@ -1753,9 +1836,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, foreign: foreign, } @@ -2069,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. @@ -2104,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 idleWithLiveTaskRecord(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 0148964734..86316525ca 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,169 @@ 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(withCoauthorshipFetchDeadline(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(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_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 +// 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() + 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 077d72da25..32a40e02e8 100644 --- a/docs/architecture/sessions-and-checkpoints.md +++ b/docs/architecture/sessions-and-checkpoints.md @@ -557,7 +557,18 @@ 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 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 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 as before.