Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions cmd/entire/cli/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions cmd/entire/cli/agent/architecture_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions cmd/entire/cli/agent/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type CapabilityDeclarer interface {
// - HookTimeoutRepairer
// - InventoryAwareExtractor
// - LateTranscriptWriter (refuses CapabilityDeclarer agents)
// - LiveSubagentFileReporter
// - ModelExtractor
// - ModelLister
// - OutOfBandTokenSource (refuses CapabilityDeclarer agents)
Expand Down Expand Up @@ -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) {
Expand Down
10 changes: 9 additions & 1 deletion cmd/entire/cli/agent/opencode/AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
8 changes: 8 additions & 0 deletions cmd/entire/cli/agent/opencode/cli_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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.
Expand Down
29 changes: 29 additions & 0 deletions cmd/entire/cli/agent/opencode/cli_commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"runtime"
"strings"
"testing"
"time"
)

func TestClassifyOpenCodeExportError(t *testing.T) {
Expand Down Expand Up @@ -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) {
Expand Down
17 changes: 15 additions & 2 deletions cmd/entire/cli/agent/opencode/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/<sessionID>.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 <sessionID>` and writes the result
// to a temporary file. Returns the path to the temp file.
//
Expand Down Expand Up @@ -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)
Expand Down
80 changes: 80 additions & 0 deletions cmd/entire/cli/agent/opencode/subagent_export.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
}
46 changes: 46 additions & 0 deletions cmd/entire/cli/agent/opencode/subagent_export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
}
45 changes: 38 additions & 7 deletions cmd/entire/cli/agent/subagent_fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading
Loading