From 03ebe34b30fb5131e9e2d2da8e76e9ee55767437 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Mon, 31 Aug 2026 23:04:49 -0700 Subject: [PATCH 01/59] feat(codex): add authoritative subagent tracking foundation --- cmd/entire/cli/agent/agent.go | 37 ++ cmd/entire/cli/agent/capabilities.go | 11 +- cmd/entire/cli/agent/capabilities_test.go | 25 + cmd/entire/cli/agent/codex/codex.go | 187 ++++++ cmd/entire/cli/agent/codex/lifecycle.go | 29 +- cmd/entire/cli/agent/codex/lifecycle_test.go | 128 ++++- cmd/entire/cli/agent/codex/subagent_test.go | 530 ++++++++++++++++++ cmd/entire/cli/agent/codex/transcript.go | 342 ++++++++++- cmd/entire/cli/agent/codex/transcript_test.go | 60 ++ cmd/entire/cli/agent/event.go | 10 + cmd/entire/cli/agent/types/token_usage.go | 43 +- .../cli/agent/types/token_usage_test.go | 40 +- .../codex_image_externalize_test.go | 2 +- .../codex_shadow_sanitize_test.go | 4 +- cmd/entire/cli/session/state.go | 178 +++++- cmd/entire/cli/session/state_test.go | 202 +++++++ .../cli/strategy/manual_commit_session.go | 41 ++ cmd/entire/cli/strategy/manual_commit_test.go | 90 +++ 18 files changed, 1930 insertions(+), 29 deletions(-) create mode 100644 cmd/entire/cli/agent/codex/subagent_test.go diff --git a/cmd/entire/cli/agent/agent.go b/cmd/entire/cli/agent/agent.go index 20f1566cf1..e955eaf789 100644 --- a/cmd/entire/cli/agent/agent.go +++ b/cmd/entire/cli/agent/agent.go @@ -304,6 +304,43 @@ type TokenCalculator interface { CalculateTokenUsage(transcriptData []byte, fromOffset int) (*TokenUsage, error) } +// SubagentReference is the authoritative record of one spawned agent supplied +// by the session ledger. Transcript paths are hints only: implementations must +// verify that a path's native metadata identifies this exact AgentID. +type SubagentReference struct { + AgentID string + DeclaredTranscriptPath string + ResolvedTranscriptPath string +} + +// SubagentAnalysis is the exact evidence available for one supplied subagent. +// TokenUsage is nil when its cumulative native usage cannot be read exactly. +type SubagentAnalysis struct { + AgentID string + ResolvedPath string + ModifiedFiles []string + TokenUsage *TokenUsage + TerminalTurnIDs []string +} + +// InventoryExtraction contains parent evidence plus analysis of the supplied +// authoritative child inventory. TokenUsage records parent usage and, when +// complete, its exact cumulative child aggregate in SubagentTokens. +type InventoryExtraction struct { + ModifiedFiles []string + TokenUsage *TokenUsage + Children []SubagentAnalysis +} + +// InventoryAwareExtractor analyzes only an already-authoritative inventory of +// children. It is intentionally built-in only: external agents have no +// equivalent protocol capability yet. +type InventoryAwareExtractor interface { + Agent + + ExtractWithSubagentInventory(parent []byte, fromOffset int, refs []SubagentReference) (InventoryExtraction, error) +} + // ModelExtractor extracts the LLM model identifier from a transcript for agents // that do not report the model through lifecycle hooks. Pi, for example, records // the model on every assistant message (message.model) but its hook events carry diff --git a/cmd/entire/cli/agent/capabilities.go b/cmd/entire/cli/agent/capabilities.go index bebb33c9e7..ef5f5a56a0 100644 --- a/cmd/entire/cli/agent/capabilities.go +++ b/cmd/entire/cli/agent/capabilities.go @@ -18,7 +18,8 @@ type CapabilityDeclarer interface { // // Not every optional interface appears here: built-in-only capabilities that // have no external-protocol equivalent (SessionBaseDirProvider, ModelExtractor, -// SkillEventExtractor, TranscriptSanitizer, TranscriptFetcher) are intentionally +// SkillEventExtractor, TranscriptSanitizer, TranscriptFetcher, +// InventoryAwareExtractor) are intentionally // excluded — their As* helpers resolve by type assertion alone (see // builtinCapability), with no DeclaredCaps gate. type DeclaredCaps struct { @@ -148,6 +149,14 @@ func AsTokenCalculator(ag Agent) (TokenCalculator, bool) { return declaredCapability[TokenCalculator](ag, func(c DeclaredCaps) bool { return c.TokenCalculator }) } +// AsInventoryAwareExtractor returns the agent as InventoryAwareExtractor when +// it implements the built-in-only inventory protocol. External agents cannot +// declare this capability because its authoritative child ledger is internal to +// Entire rather than the external-agent protocol. +func AsInventoryAwareExtractor(ag Agent) (InventoryAwareExtractor, bool) { + return builtinCapability[InventoryAwareExtractor](ag) +} + // AsTextGenerator returns the agent as TextGenerator if it both // implements the interface and (for CapabilityDeclarer agents) has declared the capability. func AsTextGenerator(ag Agent) (TextGenerator, bool) { diff --git a/cmd/entire/cli/agent/capabilities_test.go b/cmd/entire/cli/agent/capabilities_test.go index e56db9b87a..7b2be3444a 100644 --- a/cmd/entire/cli/agent/capabilities_test.go +++ b/cmd/entire/cli/agent/capabilities_test.go @@ -78,6 +78,11 @@ func (m *mockFullAgent) PrepareTranscript(context.Context, string) error { retur // TokenCalculator func (m *mockFullAgent) CalculateTokenUsage([]byte, int) (*TokenUsage, error) { return nil, nil } //nolint:nilnil // test mock +// InventoryAwareExtractor is built-in only and deliberately has no DeclaredCaps bit. +func (m *mockFullAgent) ExtractWithSubagentInventory([]byte, int, []SubagentReference) (InventoryExtraction, error) { + return InventoryExtraction{}, nil +} + // ModelExtractor func (m *mockFullAgent) ExtractModel([]byte) (string, error) { return "mock-model", nil } @@ -257,6 +262,26 @@ func TestAsTokenCalculator(t *testing.T) { }) } +func TestAsInventoryAwareExtractor(t *testing.T) { + t.Parallel() + + t.Run("not implemented", func(t *testing.T) { + t.Parallel() + _, ok := AsInventoryAwareExtractor(&mockBaseAgent{}) + if ok { + t.Error("expected false") + } + }) + + t.Run("implemented without declared capability", func(t *testing.T) { + t.Parallel() + extractor, ok := AsInventoryAwareExtractor(&mockFullAgent{}) + if !ok || extractor == nil { + t.Error("expected built-in-only type assertion to succeed") + } + }) +} + func TestAsModelExtractor(t *testing.T) { t.Parallel() diff --git a/cmd/entire/cli/agent/codex/codex.go b/cmd/entire/cli/agent/codex/codex.go index 46a2ae6f10..047ef41c5b 100644 --- a/cmd/entire/cli/agent/codex/codex.go +++ b/cmd/entire/cli/agent/codex/codex.go @@ -5,6 +5,8 @@ import ( "context" "errors" "fmt" + "io" + "io/fs" "os" "os/exec" "path/filepath" @@ -26,6 +28,191 @@ func init() { //nolint:revive // CodexAgent is clearer than Agent in this context type CodexAgent struct { CommandRunner agent.TextCommandRunner + // RolloutRoots overrides the active and archived rollout roots for callers + // that already know them (notably tests). Nil uses Codex's normal home. + RolloutRoots []string + // loadRollout and walkDir are package-private deterministic test seams. + // Production always uses regular-file, same-descriptor loading and + // filepath.WalkDir respectively. + loadRollout func(string) (loadedRollout, error) + walkDir func(string, fs.WalkDirFunc) error +} + +type loadedRollout struct { + Path string + Data []byte +} + +func rolloutRegularMode(mode fs.FileMode) bool { + return mode.Type() == 0 +} + +func readRegularRollout(path string) (loadedRollout, error) { + info, err := os.Lstat(path) + if err != nil { + return loadedRollout{}, fmt.Errorf("lstat rollout: %w", err) + } + if !rolloutRegularMode(info.Mode()) { + return loadedRollout{}, errors.New("rollout is not a regular file") + } + file, err := os.Open(path) //nolint:gosec // Lstat above rejects known special entries; Stat below verifies the opened descriptor. + if err != nil { + return loadedRollout{}, fmt.Errorf("open rollout: %w", err) + } + defer file.Close() + opened, err := file.Stat() + if err != nil { + return loadedRollout{}, fmt.Errorf("stat opened rollout: %w", err) + } + if !rolloutRegularMode(opened.Mode()) || !os.SameFile(info, opened) { + return loadedRollout{}, errors.New("rollout changed or is not a regular file") + } + data, err := io.ReadAll(file) + if err != nil { + return loadedRollout{}, fmt.Errorf("read rollout: %w", err) + } + return loadedRollout{Path: path, Data: data}, nil +} + +func (c *CodexAgent) loadCandidateRollout(path string) (loadedRollout, error) { + if c.loadRollout != nil { + return c.loadRollout(path) + } + return readRegularRollout(path) +} + +func (c *CodexAgent) loadVerifiedRollout(path, agentID string) (loadedRollout, bool) { + loaded, err := c.loadCandidateRollout(path) + if err != nil { + return loadedRollout{}, false + } + if loaded.Path == "" { + loaded.Path = path + } + if loaded.Path != path { + return loadedRollout{}, false + } + id, err := sessionMetaID(loaded.Data) + if err != nil || id != agentID { + return loadedRollout{}, false + } + return loaded, true +} + +func (c *CodexAgent) rolloutRoots() []string { + if c.RolloutRoots != nil { + return c.RolloutRoots + } + sessionDir, err := c.GetSessionDir("") + if err != nil { + return nil + } + codexHome, err := resolveCodexHome() + if err != nil { + return []string{sessionDir} + } + return []string{sessionDir, filepath.Join(codexHome, "archived_sessions")} +} + +func (c *CodexAgent) loadDirectRollout(ref agent.SubagentReference) (loadedRollout, bool) { + for _, path := range []string{ref.DeclaredTranscriptPath, ref.ResolvedTranscriptPath} { + if path == "" { + continue + } + if loaded, ok := c.loadVerifiedRollout(path, ref.AgentID); ok { + return loaded, true + } + } + return loadedRollout{}, false +} + +func (c *CodexAgent) walkRollouts(root string, visit fs.WalkDirFunc) error { + if c.walkDir != nil { + return c.walkDir(root, visit) + } + if err := filepath.WalkDir(root, visit); err != nil { + return fmt.Errorf("walk Codex rollouts: %w", err) + } + return nil +} + +// scanFallbackRollouts scans every configured root once. Any traversal or +// regular-candidate metadata failure discards all results: partial results +// cannot prove a child ID is unique. +func (c *CodexAgent) scanFallbackRollouts(agentIDs map[string]struct{}) map[string]loadedRollout { + matches := make(map[string][]loadedRollout) + seenPaths := make(map[string]struct{}) + for _, root := range c.rolloutRoots() { + if root == "" { + continue + } + walkErr := c.walkRollouts(root, func(path string, entry fs.DirEntry, entryErr error) error { + if entryErr != nil { + if path == root && errors.Is(entryErr, fs.ErrNotExist) { + return nil // Missing configured roots are normal. + } + return fmt.Errorf("walk rollout candidate: %w", entryErr) + } + if entry.IsDir() || filepath.Ext(path) != ".jsonl" { + return nil + } + info, err := entry.Info() + if err != nil { + return fmt.Errorf("stat rollout candidate: %w", err) + } + if !rolloutRegularMode(info.Mode()) { + return nil + } + loaded, err := c.loadCandidateRollout(path) + if err != nil { + return fmt.Errorf("load rollout candidate: %w", err) + } + if loaded.Path == "" { + loaded.Path = path + } + if loaded.Path != path { + return errors.New("rollout loader returned a different path") + } + id, err := sessionMetaID(loaded.Data) + if err != nil { + return fmt.Errorf("read rollout metadata: %w", err) + } + if _, wanted := agentIDs[id]; !wanted { + return nil + } + if _, duplicate := seenPaths[path]; !duplicate { + seenPaths[path] = struct{}{} + matches[id] = append(matches[id], loaded) + } + return nil + }) + if walkErr != nil { + return nil + } + } + resolved := make(map[string]loadedRollout) + for id, candidates := range matches { + if len(candidates) == 1 { + resolved[id] = candidates[0] + } + } + return resolved +} + +// resolveSubagentRollout is the path-only compatibility wrapper used by +// callers that need only discovery. Inventory extraction uses the verified +// bytes returned by the same load operation instead. +func (c *CodexAgent) resolveSubagentRollout(ref agent.SubagentReference) string { + if ref.AgentID == "" { + return "" + } + if loaded, ok := c.loadDirectRollout(ref); ok { + return loaded.Path + } + if loaded, ok := c.scanFallbackRollouts(map[string]struct{}{ref.AgentID: {}})[ref.AgentID]; ok { + return loaded.Path + } + return "" } // NewCodexAgent creates a new Codex agent instance. diff --git a/cmd/entire/cli/agent/codex/lifecycle.go b/cmd/entire/cli/agent/codex/lifecycle.go index c33bdacfd1..f04adf6de2 100644 --- a/cmd/entire/cli/agent/codex/lifecycle.go +++ b/cmd/entire/cli/agent/codex/lifecycle.go @@ -150,6 +150,8 @@ func (c *CodexAgent) parseSubagentStart(stdin io.Reader) (*agent.Event, error) { SessionID: raw.SessionID, SessionRef: derefString(raw.TranscriptPath), ToolUseID: raw.AgentID, + TurnID: raw.TurnID, + SubagentID: raw.AgentID, SubagentType: raw.AgentType, Model: raw.Model, Timestamp: time.Now(), @@ -165,15 +167,18 @@ func (c *CodexAgent) parseSubagentStop(stdin io.Reader) (*agent.Event, error) { return nil, err } return &agent.Event{ - Type: agent.SubagentEnd, - SessionID: raw.SessionID, - SessionRef: derefString(raw.TranscriptPath), - ToolUseID: raw.AgentID, - SubagentID: raw.AgentID, - SubagentType: raw.AgentType, - SubagentTranscriptPath: derefString(raw.AgentTranscriptPath), - Model: raw.Model, - Timestamp: time.Now(), + Type: agent.SubagentEnd, + SessionID: raw.SessionID, + SessionRef: derefString(raw.TranscriptPath), + ToolUseID: raw.AgentID, + TurnID: raw.TurnID, + SubagentID: raw.AgentID, + StopHookActive: raw.StopHookActive, + ProvisionalSubagentStop: true, + SubagentType: raw.AgentType, + SubagentTranscriptPath: derefString(raw.AgentTranscriptPath), + Model: raw.Model, + Timestamp: time.Now(), }, nil } @@ -201,6 +206,9 @@ func (c *CodexAgent) parseTurnStart(stdin io.Reader) (*agent.Event, error) { if err != nil { return nil, err } + if classifyRollout(derefString(raw.TranscriptPath)) != rolloutRoot { + return nil, nil //nolint:nilnil // only proven root rollouts mutate lifecycle state + } return &agent.Event{ Type: agent.TurnStart, SessionID: raw.SessionID, @@ -272,6 +280,9 @@ func (c *CodexAgent) parseTurnEnd(stdin io.Reader) (*agent.Event, error) { if err != nil { return nil, err } + if classifyRollout(derefString(raw.TranscriptPath)) != rolloutRoot { + return nil, nil //nolint:nilnil // only proven root rollouts mutate lifecycle state + } return &agent.Event{ Type: agent.TurnEnd, SessionID: raw.SessionID, diff --git a/cmd/entire/cli/agent/codex/lifecycle_test.go b/cmd/entire/cli/agent/codex/lifecycle_test.go index ed51ebfe51..74c4a11683 100644 --- a/cmd/entire/cli/agent/codex/lifecycle_test.go +++ b/cmd/entire/cli/agent/codex/lifecycle_test.go @@ -2,6 +2,8 @@ package codex import ( "context" + "os" + "path/filepath" "strings" "testing" "time" @@ -12,6 +14,13 @@ import ( const testRolloutPath = "/Users/test/.codex/rollouts/01/01/rollout-20260324-550e8400.jsonl" +func writeRootRollout(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "rollout.jsonl") + require.NoError(t, os.WriteFile(path, []byte(`{"type":"session_meta","payload":{"thread_source":"user"}}`+"\n"), 0o600)) + return path +} + // SessionStart and SessionEnd share one parser, so they are covered together. // SessionEnd (Codex 0.146+) is what finally lets a quit Codex session be // finalized; its payload is thinner than every other Codex hook — no model, no @@ -122,10 +131,11 @@ func TestCodexAgent_SessionEndBudgetFitsConfiguredTimeout(t *testing.T) { func TestParseHookEvent_UserPromptSubmit(t *testing.T) { t.Parallel() ag := &CodexAgent{} + rolloutPath := writeRootRollout(t) input := `{ "session_id": "test-uuid", "turn_id": "turn-123", - "transcript_path": "/tmp/rollout.jsonl", + "transcript_path": "` + rolloutPath + `", "cwd": "/tmp/testrepo", "hook_event_name": "UserPromptSubmit", "model": "gpt-4.1", @@ -138,7 +148,7 @@ func TestParseHookEvent_UserPromptSubmit(t *testing.T) { require.NotNil(t, event) require.Equal(t, agent.TurnStart, event.Type) require.Equal(t, "test-uuid", event.SessionID) - require.Equal(t, "/tmp/rollout.jsonl", event.SessionRef) + require.Equal(t, rolloutPath, event.SessionRef) require.Equal(t, "Create a hello.txt file", event.Prompt) require.Equal(t, "gpt-4.1", event.Model) } @@ -146,10 +156,11 @@ func TestParseHookEvent_UserPromptSubmit(t *testing.T) { func TestParseHookEvent_Stop(t *testing.T) { t.Parallel() ag := &CodexAgent{} + rolloutPath := writeRootRollout(t) input := `{ "session_id": "test-uuid", "turn_id": "turn-123", - "transcript_path": "/tmp/rollout.jsonl", + "transcript_path": "` + rolloutPath + `", "cwd": "/tmp/testrepo", "hook_event_name": "Stop", "model": "gpt-4.1", @@ -163,10 +174,80 @@ func TestParseHookEvent_Stop(t *testing.T) { require.NotNil(t, event) require.Equal(t, agent.TurnEnd, event.Type) require.Equal(t, "test-uuid", event.SessionID) - require.Equal(t, "/tmp/rollout.jsonl", event.SessionRef) + require.Equal(t, rolloutPath, event.SessionRef) require.Equal(t, "gpt-4.1", event.Model) } +func TestParseHookEvent_UserPromptSubmitAndStopRequireRootRollout(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + metadata string + wantEvent bool + }{ + { + name: "root thread source", + metadata: `{"type":"session_meta","payload":{"thread_source":"user"}}` + "\n", + wantEvent: true, + }, + { + name: "root legacy string source", + metadata: `{"type":"session_meta","payload":{"source":"exec"}}` + "\n", + wantEvent: true, + }, + { + name: "child thread source", + metadata: `{"type":"session_meta","payload":{"thread_source":"subagent"}}` + "\n", + }, + { + name: "child legacy structured source", + metadata: `{"type":"session_meta","payload":{"source":{"subagent":{"thread_spawn":{"parent_thread_id":"root-thread"}}}}}` + "\n", + }, + { + name: "missing session metadata", + metadata: `{"type":"response_item","payload":{}}` + "\n", + }, + { + name: "malformed JSON", + metadata: `{"type":"session_meta","payload":` + "\n", + }, + { + name: "missing path", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + transcriptPath := "" + if tt.metadata != "" { + transcriptPath = filepath.Join(t.TempDir(), "rollout.jsonl") + require.NoError(t, os.WriteFile(transcriptPath, []byte(tt.metadata), 0o600)) + } + + for _, hookName := range []string{HookNameUserPromptSubmit, HookNameStop} { + t.Run(hookName, func(t *testing.T) { + t.Parallel() + pathJSON := "null" + if transcriptPath != "" { + pathJSON = `"` + transcriptPath + `"` + } + input := `{"session_id":"root-session-1","turn_id":"turn-1","transcript_path":` + pathJSON + `,"model":"gpt-5","prompt":"do work","stop_hook_active":true}` + + event, err := (&CodexAgent{}).ParseHookEvent(context.Background(), hookName, strings.NewReader(input)) + require.NoError(t, err) + if tt.wantEvent { + require.NotNil(t, event) + } else { + require.Nil(t, event) + } + }) + } + }) + } +} + func TestParseHookEvent_PreToolUse_ReturnsNil(t *testing.T) { t.Parallel() ag := &CodexAgent{} @@ -323,6 +404,45 @@ func TestCodexAgent_ContextInjector(t *testing.T) { // testCodexAgentID is the subagent thread id used by the subagent hook tests. const testCodexAgentID = "child-thread-9" +func TestParseHookEvent_SubagentNormalizesHookIdentity(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + hookName string + input string + stopHookActive bool + provisionalStop bool + }{ + { + name: "start", + hookName: HookNameSubagentStart, + input: `{"session_id":"root-session-1","turn_id":"turn-child-1","agent_id":"agent-child-1","agent_type":"reviewer"}`, + }, + { + name: "stop", + hookName: HookNameSubagentStop, + input: `{"session_id":"root-session-1","turn_id":"turn-child-1","agent_id":"agent-child-1","agent_type":"reviewer","stop_hook_active":true}`, + stopHookActive: true, + provisionalStop: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ev, err := (&CodexAgent{}).ParseHookEvent(context.Background(), tt.hookName, strings.NewReader(tt.input)) + require.NoError(t, err) + require.NotNil(t, ev) + require.Equal(t, "turn-child-1", ev.TurnID) + require.Equal(t, "agent-child-1", ev.SubagentID) + require.Equal(t, tt.stopHookActive, ev.StopHookActive) + require.Equal(t, tt.provisionalStop, ev.ProvisionalSubagentStop) + require.False(t, ev.Final) + }) + } +} + // TestParseHookEvent_SubagentStart pins the identity mapping, which is the part a // future reader is most likely to get backwards: session_id is the identity shared // by the root thread and all descendants (the user's session), agent_id the child diff --git a/cmd/entire/cli/agent/codex/subagent_test.go b/cmd/entire/cli/agent/codex/subagent_test.go new file mode 100644 index 0000000000..9deebb2435 --- /dev/null +++ b/cmd/entire/cli/agent/codex/subagent_test.go @@ -0,0 +1,530 @@ +package codex + +import ( + "encoding/json" + "errors" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/stretchr/testify/require" +) + +func TestResolveRollout_UsesExactMetadataID(t *testing.T) { + t.Parallel() + + root := t.TempDir() + active := filepath.Join(root, "sessions") + archived := filepath.Join(root, "archived_sessions") + ag := &CodexAgent{RolloutRoots: []string{active, archived}} + + activePath := writeRollout(t, active, "2026/08/31/rollout-near-child-a.jsonl", "child-a", nil) + archivedPath := writeRollout(t, archived, "2026/08/30/rollout-child-b.jsonl", "child-b", nil) + writeRollout(t, active, "2026/08/31/rollout-child-a-suffix.jsonl", "not-child-a", nil) + + got := ag.resolveSubagentRollout(agent.SubagentReference{AgentID: "child-a"}) + require.Equal(t, activePath, got) + + got = ag.resolveSubagentRollout(agent.SubagentReference{AgentID: "child-b"}) + require.Equal(t, archivedPath, got) +} + +func TestResolveRollout_DefaultCodexHomeIncludesArchivedSessions(t *testing.T) { + // This test changes CODEX_HOME, so it must not run in parallel. + codexHome := t.TempDir() + t.Setenv("CODEX_HOME", codexHome) + t.Setenv("ENTIRE_TEST_CODEX_SESSION_DIR", "") + + active := filepath.Join(codexHome, "sessions") + archived := filepath.Join(codexHome, "archived_sessions") + activePath := writeRollout(t, active, "2026/08/31/rollout-active.jsonl", "active", nil) + archivedPath := writeRollout(t, archived, "2026/08/30/rollout-archived.jsonl", "archived", nil) + ag := &CodexAgent{} + + require.Equal(t, activePath, ag.resolveSubagentRollout(agent.SubagentReference{AgentID: "active"})) + require.Equal(t, archivedPath, ag.resolveSubagentRollout(agent.SubagentReference{AgentID: "archived"})) +} + +func TestResolveRollout_MismatchedKnownPathsFallBackOnlyToExactID(t *testing.T) { + t.Parallel() + + root := t.TempDir() + active := filepath.Join(root, "sessions") + ag := &CodexAgent{RolloutRoots: []string{active}} + mismatch := writeRollout(t, root, "declared.jsonl", "wrong", nil) + exact := writeRollout(t, active, "2026/08/31/rollout-child.jsonl", "child", nil) + + for _, ref := range []agent.SubagentReference{ + {AgentID: "child", DeclaredTranscriptPath: mismatch}, + {AgentID: "child", ResolvedTranscriptPath: mismatch}, + } { + require.Equal(t, exact, ag.resolveSubagentRollout(ref)) + } +} + +func TestResolveRollout_KnownExactPathsNeedNoFallbackRoot(t *testing.T) { + t.Parallel() + + root := t.TempDir() + declared := writeRollout(t, root, "declared.jsonl", "declared", nil) + resolved := writeRollout(t, root, "resolved.jsonl", "resolved", nil) + ag := &CodexAgent{RolloutRoots: []string{filepath.Join(root, "no-fallback-here")}} + + require.Equal(t, declared, ag.resolveSubagentRollout(agent.SubagentReference{ + AgentID: "declared", + DeclaredTranscriptPath: declared, + })) + require.Equal(t, resolved, ag.resolveSubagentRollout(agent.SubagentReference{ + AgentID: "resolved", + ResolvedTranscriptPath: resolved, + })) +} + +func TestResolveRollout_RejectsInferredAndAmbiguousCandidates(t *testing.T) { + t.Parallel() + + root := t.TempDir() + active := filepath.Join(root, "sessions") + ag := &CodexAgent{RolloutRoots: []string{active}} + writeRollout(t, active, "2026/08/31/rollout-child.jsonl", "childish", nil) + require.Empty(t, ag.resolveSubagentRollout(agent.SubagentReference{AgentID: "child"})) + + writeRollout(t, active, "2026/08/30/rollout-child-one.jsonl", "child", nil) + writeRollout(t, active, "2026/08/31/rollout-child-two.jsonl", "child", nil) + require.Empty(t, ag.resolveSubagentRollout(agent.SubagentReference{AgentID: "child"})) +} + +func TestResolveRollout_RejectsSymlinkHint(t *testing.T) { + t.Parallel() + + root := t.TempDir() + target := writeRollout(t, root, "target.jsonl", "child", nil) + link := filepath.Join(root, "child-link.jsonl") + require.NoError(t, os.Symlink(target, link)) + + ag := &CodexAgent{RolloutRoots: []string{}} + require.Empty(t, ag.resolveSubagentRollout(agent.SubagentReference{ + AgentID: "child", + DeclaredTranscriptPath: link, + })) +} + +func TestRolloutRegularMode_RejectsSpecialEntries(t *testing.T) { + t.Parallel() + + for _, mode := range []fs.FileMode{0, fs.ModeDir, fs.ModeSymlink, fs.ModeNamedPipe, fs.ModeDevice, fs.ModeSocket} { + require.Equal(t, mode == 0, rolloutRegularMode(mode), "mode %v", mode) + } +} + +func TestTerminalTurnIDs_OnlyAcceptsUnambiguousBoundaries(t *testing.T) { + t.Parallel() + + valid := rolloutData(t, "child", []json.RawMessage{ + taskEvent("task_started", stringPointer("one")), + taskEvent("task_complete", stringPointer("one")), + taskEvent("task_started", stringPointer("two")), + taskEvent("task_complete", nil), + }) + require.Equal(t, []string{"one", "two"}, terminalTurnIDs(valid)) + + for _, invalid := range [][]json.RawMessage{ + {taskEvent("task_complete", stringPointer("one"))}, + {taskEvent("task_started", stringPointer("one")), taskEvent("task_started", stringPointer("two")), taskEvent("task_complete", nil)}, + {taskEvent("task_started", stringPointer("one")), taskEvent("task_complete", stringPointer("two"))}, + {taskEvent("task_started", stringPointer("one")), taskEvent("task_complete", stringPointer("one")), taskEvent("task_complete", stringPointer("one"))}, + } { + require.Empty(t, terminalTurnIDs(rolloutData(t, "child", invalid))) + } +} + +func TestTerminalTurnIDs_RealWireShape(t *testing.T) { + t.Parallel() + + modern := rolloutData(t, "child", []json.RawMessage{ + taskEvent("task_started", stringPointer("modern")), + taskEvent("task_complete", stringPointer("modern")), + }) + require.Equal(t, []string{"modern"}, terminalTurnIDs(modern)) + + legacy := rolloutData(t, "child", []json.RawMessage{ + taskEvent("task_started", stringPointer("legacy")), + taskEvent("task_complete", nil), + }) + require.Equal(t, []string{"legacy"}, terminalTurnIDs(legacy)) +} + +func TestTerminalTurnIDs_RejectsInvalidRealWireBoundaries(t *testing.T) { + t.Parallel() + + validThenMalformed := []json.RawMessage{ + taskEvent("task_started", stringPointer("one")), + taskEvent("task_complete", stringPointer("one")), + json.RawMessage(`{"type":"event_msg","payload":{"type":"task_started","turn_id":`), + } + for _, invalid := range [][]json.RawMessage{ + {taskEvent("task_complete", stringPointer("one"))}, + {taskEvent("task_started", nil)}, + {taskEvent("task_started", stringPointer("one"))}, + {taskEvent("task_started", stringPointer("one")), taskEvent("task_started", stringPointer("two"))}, + {taskEvent("task_started", stringPointer("one")), taskEvent("task_complete", stringPointer("two"))}, + {taskEvent("task_started", stringPointer("one")), taskEvent("task_complete", stringPointer("one")), taskEvent("task_started", stringPointer("one")), taskEvent("task_complete", stringPointer("one"))}, + {taskEvent("task_started", stringPointer("one")), taskEvent("task_complete", nil), taskEvent("task_complete", nil)}, + {json.RawMessage(`{"type":"event_msg","payload":{"type":"task_started","turn_id":7}}`)}, + validThenMalformed, + } { + require.Empty(t, terminalTurnIDs(rolloutData(t, "child", invalid))) + } +} + +func TestExactTokenUsage_UsesOnlyLastRecognizableSnapshot(t *testing.T) { + t.Parallel() + + valid := tokenCountEvent(map[string]any{"total_token_usage": map[string]any{ + "input_tokens": 15, "cached_input_tokens": 12, "output_tokens": 3, + "reasoning_output_tokens": 2, "total_tokens": 18, + }}) + usage := exactCumulativeTokenUsage(rolloutData(t, "child", []json.RawMessage{valid})) + require.Equal(t, &agent.TokenUsage{InputTokens: 3, CacheReadTokens: 12, OutputTokens: 3}, usage) + + malformedLast := tokenCountEvent(map[string]any{"total_token_usage": map[string]any{ + "input_tokens": 10, "cached_input_tokens": 11, "output_tokens": 3, + }}) + require.Nil(t, exactCumulativeTokenUsage(rolloutData(t, "child", []json.RawMessage{valid, malformedLast}))) + + missingRequired := tokenCountEvent(map[string]any{"total_token_usage": map[string]any{ + "input_tokens": 10, "output_tokens": 3, + }}) + require.Nil(t, exactCumulativeTokenUsage(rolloutData(t, "child", []json.RawMessage{missingRequired}))) +} + +func TestExactTokenUsage_RejectsEveryUnavailableOrInconsistentSnapshot(t *testing.T) { + t.Parallel() + + valid := func(values map[string]any) []byte { + return rolloutData(t, "child", []json.RawMessage{tokenCountEvent(map[string]any{"total_token_usage": values})}) + } + require.Nil(t, exactCumulativeTokenUsage(rolloutData(t, "child", nil))) + + for _, values := range []map[string]any{ + {"cached_input_tokens": 0, "output_tokens": 1}, + {"input_tokens": 1, "output_tokens": 1}, + {"input_tokens": 1, "cached_input_tokens": 0}, + {"input_tokens": -1, "cached_input_tokens": 0, "output_tokens": 0}, + {"input_tokens": 1, "cached_input_tokens": -1, "output_tokens": 0}, + {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": -1}, + {"input_tokens": 1, "cached_input_tokens": 2, "output_tokens": 0}, + {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1, "total_tokens": -1}, + {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1, "total_tokens": 1}, + {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1, "reasoning_output_tokens": -1}, + {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1, "reasoning_output_tokens": 2}, + } { + require.Nil(t, exactCumulativeTokenUsage(valid(values))) + } + + zeros := exactCumulativeTokenUsage(valid(map[string]any{"input_tokens": 0, "cached_input_tokens": 0, "output_tokens": 0})) + require.Equal(t, &agent.TokenUsage{}, zeros) + + multiple := rolloutData(t, "child", []json.RawMessage{ + tokenCountEvent(map[string]any{"total_token_usage": map[string]any{"input_tokens": 9, "cached_input_tokens": 1, "output_tokens": 2}}), + tokenCountEvent(map[string]any{"total_token_usage": map[string]any{"input_tokens": 4, "cached_input_tokens": 1, "output_tokens": 2}}), + }) + usage := exactCumulativeTokenUsage(multiple) + require.Equal(t, &agent.TokenUsage{InputTokens: 3, CacheReadTokens: 1, OutputTokens: 2}, usage) + require.Zero(t, usage.APICallCount, "snapshot record count is not an API-call count") + + malformedFinal := rolloutData(t, "child", []json.RawMessage{ + tokenCountEvent(map[string]any{"total_token_usage": map[string]any{"input_tokens": 4, "cached_input_tokens": 1, "output_tokens": 2}}), + tokenCountEvent(map[string]any{"total_token_usage": "not-an-object"}), + }) + require.Nil(t, exactCumulativeTokenUsage(malformedFinal), "must not fall back to the earlier valid snapshot") +} + +func TestSubagentInventory_CollectsExactEvidenceAndDoesNotPartialAggregate(t *testing.T) { + t.Parallel() + + root := t.TempDir() + ag := &CodexAgent{RolloutRoots: []string{root}} + childOne := writeRollout(t, root, "rollout-one.jsonl", "one", []json.RawMessage{ + patchEvent("child.txt"), + taskEvent("task_started", stringPointer("child-turn")), + taskEvent("task_complete", stringPointer("child-turn")), + tokenCountEvent(map[string]any{"total_token_usage": map[string]any{"input_tokens": 5, "cached_input_tokens": 2, "output_tokens": 1}}), + }) + childTwo := writeRollout(t, root, "rollout-two.jsonl", "two", []json.RawMessage{ + patchEvent("two.txt"), + taskEvent("task_started", stringPointer("two-turn")), + taskEvent("task_complete", stringPointer("two-turn")), + }) + parent := rolloutData(t, "parent", []json.RawMessage{patchEvent("parent.txt")}) + + result, err := ag.ExtractWithSubagentInventory(parent, 0, []agent.SubagentReference{ + {AgentID: "one", DeclaredTranscriptPath: childOne}, + {AgentID: "two", ResolvedTranscriptPath: childTwo}, + }) + require.NoError(t, err) + require.Equal(t, []string{"parent.txt", "child.txt", "two.txt"}, result.ModifiedFiles) + require.Len(t, result.Children, 2) + require.Equal(t, childOne, result.Children[0].ResolvedPath) + require.Equal(t, []string{"child.txt"}, result.Children[0].ModifiedFiles) + require.Equal(t, []string{"child-turn"}, result.Children[0].TerminalTurnIDs) + require.NotNil(t, result.Children[0].TokenUsage) + require.Equal(t, []string{"two.txt"}, result.Children[1].ModifiedFiles) + require.Equal(t, []string{"two-turn"}, result.Children[1].TerminalTurnIDs) + require.Nil(t, result.Children[1].TokenUsage) + require.NotNil(t, result.TokenUsage) + require.Nil(t, result.TokenUsage.SubagentTokens) + require.NotNil(t, result.TokenUsage.SubagentTokensComplete) + require.False(t, *result.TokenUsage.SubagentTokensComplete) +} + +func TestSubagentInventory_AggregatesOnlyCompleteExactChildren(t *testing.T) { + t.Parallel() + + root := t.TempDir() + ag := &CodexAgent{RolloutRoots: []string{root}} + first := writeRollout(t, root, "first.jsonl", "first", []json.RawMessage{ + patchEvent("first.txt"), + taskEvent("task_started", stringPointer("first-turn")), + taskEvent("task_complete", stringPointer("first-turn")), + tokenCountEvent(map[string]any{"total_token_usage": map[string]any{"input_tokens": 5, "cached_input_tokens": 2, "output_tokens": 1}}), + }) + second := writeRollout(t, root, "second.jsonl", "second", []json.RawMessage{ + patchEvent("second.txt"), + taskEvent("task_started", stringPointer("second-turn")), + taskEvent("task_complete", nil), + tokenCountEvent(map[string]any{"total_token_usage": map[string]any{"input_tokens": 10, "cached_input_tokens": 3, "output_tokens": 5}}), + }) + + result, err := ag.ExtractWithSubagentInventory(nil, 0, []agent.SubagentReference{ + {AgentID: "first", DeclaredTranscriptPath: first}, + {AgentID: "second", ResolvedTranscriptPath: second}, + }) + require.NoError(t, err) + require.Equal(t, []string{"first.txt", "second.txt"}, result.ModifiedFiles) + require.Len(t, result.Children, 2, "one analysis is retained for each supplied reference") + require.Equal(t, "first", result.Children[0].AgentID) + require.Equal(t, first, result.Children[0].ResolvedPath) + require.Equal(t, []string{"first.txt"}, result.Children[0].ModifiedFiles) + require.Equal(t, []string{"first-turn"}, result.Children[0].TerminalTurnIDs) + require.Equal(t, &agent.TokenUsage{InputTokens: 3, CacheReadTokens: 2, OutputTokens: 1}, result.Children[0].TokenUsage) + require.Equal(t, "second", result.Children[1].AgentID) + require.Equal(t, second, result.Children[1].ResolvedPath) + require.Equal(t, []string{"second.txt"}, result.Children[1].ModifiedFiles) + require.Equal(t, []string{"second-turn"}, result.Children[1].TerminalTurnIDs) + require.Equal(t, &agent.TokenUsage{InputTokens: 7, CacheReadTokens: 3, OutputTokens: 5}, result.Children[1].TokenUsage) + require.NotNil(t, result.TokenUsage) + require.NotNil(t, result.TokenUsage.SubagentTokensComplete) + require.True(t, *result.TokenUsage.SubagentTokensComplete) + require.Equal(t, &agent.TokenUsage{InputTokens: 10, CacheReadTokens: 5, OutputTokens: 6}, result.TokenUsage.SubagentTokens) +} + +func TestSubagentInventory_EmptyInventoryIsExactWithoutChildTotal(t *testing.T) { + t.Parallel() + + result, err := (&CodexAgent{RolloutRoots: []string{t.TempDir()}}).ExtractWithSubagentInventory(nil, 0, nil) + require.NoError(t, err) + require.Empty(t, result.Children) + require.NotNil(t, result.TokenUsage) + require.NotNil(t, result.TokenUsage.SubagentTokensComplete) + require.True(t, *result.TokenUsage.SubagentTokensComplete) + require.Nil(t, result.TokenUsage.SubagentTokens) +} + +func TestSubagentInventory_UnresolvedChildPreventsPartialAggregate(t *testing.T) { + t.Parallel() + + root := t.TempDir() + ag := &CodexAgent{RolloutRoots: []string{root}} + available := writeRollout(t, root, "available.jsonl", "available", []json.RawMessage{ + patchEvent("available.txt"), + taskEvent("task_started", stringPointer("available-turn")), + taskEvent("task_complete", stringPointer("available-turn")), + tokenCountEvent(map[string]any{"total_token_usage": map[string]any{"input_tokens": 2, "cached_input_tokens": 1, "output_tokens": 1}}), + }) + + result, err := ag.ExtractWithSubagentInventory(nil, 0, []agent.SubagentReference{ + {AgentID: "available", DeclaredTranscriptPath: available}, + {AgentID: "missing"}, + }) + require.NoError(t, err) + require.Len(t, result.Children, 2) + require.Equal(t, []string{"available.txt"}, result.ModifiedFiles) + require.Equal(t, []string{"available-turn"}, result.Children[0].TerminalTurnIDs) + require.NotNil(t, result.Children[0].TokenUsage) + require.Empty(t, result.Children[1].ResolvedPath) + require.Nil(t, result.Children[1].TokenUsage) + require.False(t, *result.TokenUsage.SubagentTokensComplete) + require.Nil(t, result.TokenUsage.SubagentTokens) +} + +func TestSubagentInventory_LoaderFailureFailsClosedBeforeResolution(t *testing.T) { + t.Parallel() + + root := t.TempDir() + path := writeRollout(t, root, "child.jsonl", "child", []json.RawMessage{ + patchEvent("child.txt"), + taskEvent("task_started", stringPointer("child-turn")), + taskEvent("task_complete", stringPointer("child-turn")), + tokenCountEvent(map[string]any{"total_token_usage": map[string]any{"input_tokens": 2, "cached_input_tokens": 1, "output_tokens": 1}}), + }) + readFailure := errors.New("injected child load failure") + ag := &CodexAgent{ + RolloutRoots: []string{}, + loadRollout: func(gotPath string) (loadedRollout, error) { + if gotPath == "" { + return loadedRollout{}, readFailure + } + require.Equal(t, path, gotPath) + return loadedRollout{}, readFailure + }, + } + + result, err := ag.ExtractWithSubagentInventory(nil, 0, []agent.SubagentReference{{ + AgentID: "child", + DeclaredTranscriptPath: path, + }}) + require.NoError(t, err) + require.Len(t, result.Children, 1) + require.Equal(t, "child", result.Children[0].AgentID) + require.Empty(t, result.Children[0].ResolvedPath, "same-byte validation cannot retain a failed load") + require.Empty(t, result.Children[0].ModifiedFiles) + require.Empty(t, result.Children[0].TerminalTurnIDs) + require.Nil(t, result.Children[0].TokenUsage) + require.False(t, *result.TokenUsage.SubagentTokensComplete) + require.Nil(t, result.TokenUsage.SubagentTokens) +} + +func TestSubagentInventory_RevalidatesInjectedRolloutBytes(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "child.jsonl") + ag := &CodexAgent{ + RolloutRoots: []string{}, + loadRollout: func(gotPath string) (loadedRollout, error) { + if gotPath == "" { + return loadedRollout{}, errors.New("empty path") + } + require.Equal(t, path, gotPath) + return loadedRollout{Path: path, Data: rolloutData(t, "other-child", nil)}, nil + }, + } + + result, err := ag.ExtractWithSubagentInventory(nil, 0, []agent.SubagentReference{{ + AgentID: "child", + DeclaredTranscriptPath: path, + }}) + require.NoError(t, err) + require.Empty(t, result.Children[0].ResolvedPath) + require.False(t, *result.TokenUsage.SubagentTokensComplete) +} + +func TestSubagentInventory_BatchesFallbackTraversal(t *testing.T) { + t.Parallel() + + firstRoot := t.TempDir() + secondRoot := t.TempDir() + first := writeRollout(t, firstRoot, "first.jsonl", "first", []json.RawMessage{tokenCountEvent(map[string]any{"total_token_usage": map[string]any{"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1}})}) + second := writeRollout(t, secondRoot, "second.jsonl", "second", []json.RawMessage{tokenCountEvent(map[string]any{"total_token_usage": map[string]any{"input_tokens": 2, "cached_input_tokens": 0, "output_tokens": 1}})}) + _ = first + _ = second + walks := 0 + ag := &CodexAgent{ + RolloutRoots: []string{firstRoot, secondRoot}, + walkDir: func(root string, visit fs.WalkDirFunc) error { + walks++ + return filepath.WalkDir(root, visit) + }, + } + + result, err := ag.ExtractWithSubagentInventory(nil, 0, []agent.SubagentReference{{AgentID: "first"}, {AgentID: "second"}}) + require.NoError(t, err) + require.Equal(t, 2, walks, "one traversal per configured root, not per child") + require.Equal(t, []string{"first", "second"}, []string{result.Children[0].AgentID, result.Children[1].AgentID}) + require.True(t, *result.TokenUsage.SubagentTokensComplete) +} + +func TestSubagentInventory_FallbackTraversalFailureDiscardsMatches(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeRollout(t, root, "child.jsonl", "child", []json.RawMessage{tokenCountEvent(map[string]any{"total_token_usage": map[string]any{"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1}})}) + traversalFailure := errors.New("injected traversal failure after match") + ag := &CodexAgent{ + RolloutRoots: []string{root}, + walkDir: func(root string, visit fs.WalkDirFunc) error { + require.NoError(t, filepath.WalkDir(root, visit)) + return traversalFailure + }, + } + + result, err := ag.ExtractWithSubagentInventory(nil, 0, []agent.SubagentReference{{AgentID: "child"}}) + require.NoError(t, err) + require.Empty(t, result.Children[0].ResolvedPath) + require.False(t, *result.TokenUsage.SubagentTokensComplete) + require.Nil(t, result.TokenUsage.SubagentTokens) +} + +func writeRollout(t *testing.T, root, name, id string, events []json.RawMessage) string { + t.Helper() + path := filepath.Join(root, name) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, rolloutData(t, id, events), 0o600)) + return path +} + +func rolloutData(t *testing.T, id string, events []json.RawMessage) []byte { + t.Helper() + lines := make([][]byte, 0, len(events)+1) + meta, err := json.Marshal(map[string]any{"type": "session_meta", "payload": map[string]any{"id": id}}) + require.NoError(t, err) + lines = append(lines, meta) + for _, event := range events { + lines = append(lines, event) + } + return append([]byte(joinLines(lines)), '\n') +} + +func tokenCountEvent(info map[string]any) json.RawMessage { + data, err := json.Marshal(map[string]any{"type": "event_msg", "payload": map[string]any{"type": "token_count", "info": info}}) + if err != nil { + panic(err) + } + return data +} + +func taskEvent(eventType string, turnID *string) json.RawMessage { + payload := map[string]any{"type": eventType} + if turnID != nil { + payload["turn_id"] = *turnID + } + data, err := json.Marshal(map[string]any{"type": "event_msg", "payload": payload}) + if err != nil { + panic(err) + } + return data +} + +func stringPointer(value string) *string { return &value } + +func patchEvent(path string) json.RawMessage { + data, err := json.Marshal(map[string]any{"type": "response_item", "payload": map[string]any{"type": "custom_tool_call", "name": "apply_patch", "input": "*** Update File: " + path}}) + if err != nil { + panic(err) + } + return data +} + +func joinLines(lines [][]byte) string { + var result strings.Builder + for index, line := range lines { + if index > 0 { + result.WriteByte('\n') + } + result.Write(line) + } + return result.String() +} diff --git a/cmd/entire/cli/agent/codex/transcript.go b/cmd/entire/cli/agent/codex/transcript.go index 3c98d9f7ff..d0cd495824 100644 --- a/cmd/entire/cli/agent/codex/transcript.go +++ b/cmd/entire/cli/agent/codex/transcript.go @@ -20,11 +20,50 @@ import ( var ( _ agent.TranscriptAnalyzer = (*CodexAgent)(nil) _ agent.TokenCalculator = (*CodexAgent)(nil) + _ agent.InventoryAwareExtractor = (*CodexAgent)(nil) _ agent.PromptExtractor = (*CodexAgent)(nil) _ agent.RestoredSessionPathResolver = (*CodexAgent)(nil) _ agent.TranscriptSanitizer = (*CodexAgent)(nil) ) +// readSessionMetaID reads the first record of a Codex rollout and returns its +// non-empty native thread ID. Callers use it to prove a path belongs to a +// supplied child rather than inferring that fact from its filename or age. +// +//nolint:unused // Kept as the path helper for direct internal callers; evidence uses same-byte loading instead. +func readSessionMetaID(path string) (string, error) { + if path == "" { + return "", errors.New("empty rollout path") + } + loaded, err := readRegularRollout(path) + if err != nil { + return "", fmt.Errorf("load rollout: %w", err) + } + return sessionMetaID(loaded.Data) +} + +func sessionMetaID(data []byte) (string, error) { + lines := splitJSONL(data) + if len(lines) == 0 { + return "", errors.New("rollout is empty") + } + var line rolloutLine + if err := json.Unmarshal(lines[0], &line); err != nil { + return "", fmt.Errorf("parse first rollout record: %w", err) + } + if line.Type != rolloutLineTypeSessionMeta { + return "", fmt.Errorf("first transcript line is %q, want session_meta", line.Type) + } + var meta sessionMetaPayload + if err := json.Unmarshal(line.Payload, &meta); err != nil { + return "", fmt.Errorf("parse session_meta payload: %w", err) + } + if meta.ID == "" { + return "", errors.New("session_meta id is empty") + } + return meta.ID, nil +} + // rolloutLine is the top-level JSONL line structure in Codex rollout files. type rolloutLine struct { Timestamp string `json:"timestamp"` @@ -32,12 +71,92 @@ type rolloutLine struct { Payload json.RawMessage `json:"payload"` } -const rolloutLineTypeResponseItem = "response_item" +const ( + rolloutLineTypeResponseItem = "response_item" + rolloutLineTypeSessionMeta = "session_meta" + rolloutLineTypeEventMsg = "event_msg" + eventMsgTypeTokenCount = "token_count" +) + +// rolloutClassification identifies whether a rollout belongs to a root thread +// or a child thread. Lifecycle hooks mutate the root session, so uncertainty is +// intentionally distinct from root and must not be treated as a root rollout. +type rolloutClassification uint8 + +const ( + rolloutUnknown rolloutClassification = iota + rolloutRoot + rolloutChild +) // sessionMetaPayload is the payload for type="session_meta" lines. type sessionMetaPayload struct { - ID string `json:"id"` - Timestamp string `json:"timestamp"` + ID string `json:"id"` + Timestamp string `json:"timestamp"` + ThreadSource string `json:"thread_source"` + Source json.RawMessage `json:"source"` +} + +// classifyRollout reads only the rollout's session_meta record. Newer Codex +// rollouts identify root and child threads with thread_source; older rollouts +// encode their source as either a recognized root string or source.subagent. +func classifyRollout(path string) rolloutClassification { + if path == "" { + return rolloutUnknown + } + + file, err := os.Open(path) //nolint:gosec // Path comes from agent hook input + if err != nil { + return rolloutUnknown + } + defer file.Close() + + lineData, err := bufio.NewReader(file).ReadBytes('\n') + if err != nil && !errors.Is(err, io.EOF) { + return rolloutUnknown + } + + var line rolloutLine + if json.Unmarshal(lineData, &line) != nil || line.Type != rolloutLineTypeSessionMeta { + return rolloutUnknown + } + + var meta sessionMetaPayload + if json.Unmarshal(line.Payload, &meta) != nil { + return rolloutUnknown + } + + switch meta.ThreadSource { + case "user": + return rolloutRoot + case "subagent": + return rolloutChild + case "": + // Fall through to the legacy source encoding. + default: + return rolloutUnknown + } + + var source string + if json.Unmarshal(meta.Source, &source) == nil { + switch source { + case "startup", "resume", "clear", "compact", "cli", codexExecCommand, "vscode", "mcp": + return rolloutRoot + default: + return rolloutUnknown + } + } + + var structuredSource struct { + Subagent json.RawMessage `json:"subagent"` + } + if json.Unmarshal(meta.Source, &structuredSource) == nil && + len(structuredSource.Subagent) > 0 && + !bytes.Equal(structuredSource.Subagent, []byte("null")) { + return rolloutChild + } + + return rolloutUnknown } // responseItemPayload is the payload for type="response_item" lines. @@ -57,8 +176,9 @@ type contentItem struct { // eventMsgPayload is the payload for type="event_msg" lines. type eventMsgPayload struct { - Type string `json:"type"` // "token_count", "task_started", "user_message", "agent_message", "task_complete" - Info json.RawMessage `json:"info,omitempty"` + Type string `json:"type"` // "token_count", "task_started", "user_message", "agent_message", "task_complete" + TurnID *string `json:"turn_id,omitempty"` + Info json.RawMessage `json:"info,omitempty"` } // tokenCountInfo contains token usage data from event_msg.token_count. @@ -75,6 +195,17 @@ type tokenUsageData struct { TotalTokens int `json:"total_tokens"` } +// exactTokenUsageData uses pointers so a native zero is distinguishable from a +// field Codex did not report. It is used for child cumulative snapshots, where +// approximation would turn an incomplete inventory into a misleading total. +type exactTokenUsageData struct { + InputTokens *int `json:"input_tokens"` + CachedInputTokens *int `json:"cached_input_tokens"` + OutputTokens *int `json:"output_tokens"` + ReasoningOutputTokens *int `json:"reasoning_output_tokens"` + TotalTokens *int `json:"total_tokens"` +} + // Apply-patch envelope verbs Codex uses in tool_input.command — see // codex-rs/core/src/tools/handlers/apply_patch.rs. Capture group 1 is the // verb, group 2 is the path. @@ -280,14 +411,14 @@ func (c *CodexAgent) CalculateTokenUsage(transcriptData []byte, fromOffset int) if json.Unmarshal(lineData, &line) != nil { continue } - if line.Type != "event_msg" { + if line.Type != rolloutLineTypeEventMsg { continue } var evt eventMsgPayload if json.Unmarshal(line.Payload, &evt) != nil { continue } - if evt.Type != "token_count" || len(evt.Info) == 0 { + if evt.Type != eventMsgTypeTokenCount || len(evt.Info) == 0 { continue } var info tokenCountInfo @@ -330,6 +461,201 @@ func (c *CodexAgent) CalculateTokenUsage(transcriptData []byte, fromOffset int) }, nil } +// exactCumulativeTokenUsage returns the last recognizable Codex token_count +// snapshot exactly as reported. A malformed final snapshot makes the entire +// result unavailable instead of silently falling back to an earlier record. +func exactCumulativeTokenUsage(data []byte) *agent.TokenUsage { + var lastInfo json.RawMessage + found := false + for _, lineData := range splitJSONL(data) { + var line rolloutLine + if json.Unmarshal(lineData, &line) != nil || line.Type != rolloutLineTypeEventMsg { + continue + } + var event eventMsgPayload + if json.Unmarshal(line.Payload, &event) != nil || event.Type != eventMsgTypeTokenCount { + continue + } + found = true + lastInfo = event.Info + } + if !found || len(lastInfo) == 0 { + return nil + } + var info struct { + TotalTokenUsage *exactTokenUsageData `json:"total_token_usage"` + } + if json.Unmarshal(lastInfo, &info) != nil || info.TotalTokenUsage == nil { + return nil + } + usage := info.TotalTokenUsage + if usage.InputTokens == nil || usage.CachedInputTokens == nil || usage.OutputTokens == nil { + return nil + } + input, cached, output := *usage.InputTokens, *usage.CachedInputTokens, *usage.OutputTokens + if input < 0 || cached < 0 || output < 0 || cached > input { + return nil + } + if usage.ReasoningOutputTokens != nil && (*usage.ReasoningOutputTokens < 0 || *usage.ReasoningOutputTokens > output) { + return nil + } + if usage.TotalTokens != nil && (*usage.TotalTokens < 0 || *usage.TotalTokens != input+output) { + return nil + } + return &agent.TokenUsage{InputTokens: input - cached, CacheReadTokens: cached, OutputTokens: output} +} + +// terminalTurnIDs accepts only ordered, one-at-a-time task boundaries. Modern +// records name the same turn at both ends; the legacy ID-less completion is +// accepted only while exactly one started turn is open. +func terminalTurnIDs(data []byte) []string { + var terminal []string + open := "" + seen := make(map[string]struct{}) + for _, lineData := range splitJSONL(data) { + var line rolloutLine + if json.Unmarshal(lineData, &line) != nil { + return nil + } + if line.Type != rolloutLineTypeEventMsg { + continue + } + var rawEvent struct { + Type string `json:"type"` + } + if json.Unmarshal(line.Payload, &rawEvent) != nil { + return nil + } + if rawEvent.Type != "task_started" && rawEvent.Type != "task_complete" { + continue + } + var event eventMsgPayload + if json.Unmarshal(line.Payload, &event) != nil { + return nil + } + switch event.Type { + case "task_started": + if open != "" || event.TurnID == nil || *event.TurnID == "" { + return nil + } + if _, duplicate := seen[*event.TurnID]; duplicate { + return nil + } + open = *event.TurnID + case "task_complete": + if open == "" || (event.TurnID != nil && (*event.TurnID == "" || *event.TurnID != open)) { + return nil + } + terminal = append(terminal, open) + seen[open] = struct{}{} + open = "" + } + } + if open != "" { + return nil + } + return terminal +} + +// ExtractWithSubagentInventory gathers evidence only for refs supplied by the +// caller's authoritative ledger. It never discovers children from transcript +// text, filenames, timestamps, or token-count events. +func (c *CodexAgent) ExtractWithSubagentInventory(parent []byte, fromOffset int, refs []agent.SubagentReference) (agent.InventoryExtraction, error) { + result := agent.InventoryExtraction{ModifiedFiles: extractFilesFromData(parent, fromOffset)} + parentUsage, err := c.CalculateTokenUsage(parent, fromOffset) + if err != nil { + return result, err + } + complete := true + var childTotal *agent.TokenUsage + resolved := make([]loadedRollout, len(refs)) + unresolvedIDs := make(map[string]struct{}) + for index, ref := range refs { + if loaded, ok := c.loadDirectRollout(ref); ok { + resolved[index] = loaded + } else if ref.AgentID != "" { + unresolvedIDs[ref.AgentID] = struct{}{} + } + } + fallback := c.scanFallbackRollouts(unresolvedIDs) + for index, ref := range refs { + if resolved[index].Path == "" { + resolved[index] = fallback[ref.AgentID] + } + } + for index, ref := range refs { + analysis := agent.SubagentAnalysis{AgentID: ref.AgentID} + loaded := resolved[index] + analysis.ResolvedPath = loaded.Path + if loaded.Path == "" { + complete = false + result.Children = append(result.Children, analysis) + continue + } + analysis.ModifiedFiles = extractFilesFromData(loaded.Data, 0) + analysis.TerminalTurnIDs = terminalTurnIDs(loaded.Data) + analysis.TokenUsage = exactCumulativeTokenUsage(loaded.Data) + if analysis.TokenUsage == nil { + complete = false + } else { + childTotal = addExactUsage(childTotal, analysis.TokenUsage) + } + result.ModifiedFiles = appendUniqueFiles(result.ModifiedFiles, analysis.ModifiedFiles) + result.Children = append(result.Children, analysis) + } + result.TokenUsage = withChildCoverage(parentUsage, complete) + if complete && len(refs) > 0 { + result.TokenUsage.SubagentTokens = childTotal + } + return result, nil +} + +func withChildCoverage(usage *agent.TokenUsage, complete bool) *agent.TokenUsage { + if usage == nil { + return &agent.TokenUsage{SubagentTokensComplete: &complete} + } + result := *usage + result.SubagentTokens = nil + result.SubagentTokensComplete = &complete + return &result +} + +func extractFilesFromData(data []byte, fromOffset int) []string { + var files []string + for index, lineData := range splitJSONL(data) { + if index+1 <= fromOffset { + continue + } + files = appendUniqueFiles(files, extractFilesFromLine(lineData)) + } + return files +} + +func appendUniqueFiles(files, additions []string) []string { + seen := make(map[string]struct{}, len(files)+len(additions)) + for _, file := range files { + seen[file] = struct{}{} + } + for _, file := range additions { + if _, exists := seen[file]; !exists { + seen[file] = struct{}{} + files = append(files, file) + } + } + return files +} + +func addExactUsage(total, addition *agent.TokenUsage) *agent.TokenUsage { + if total == nil { + cloned := *addition + return &cloned + } + total.InputTokens += addition.InputTokens + total.CacheReadTokens += addition.CacheReadTokens + total.OutputTokens += addition.OutputTokens + return total +} + // ExtractPrompts returns user prompts from the transcript starting at the given offset. func (c *CodexAgent) ExtractPrompts(sessionRef string, fromOffset int) ([]string, error) { data, err := os.ReadFile(sessionRef) //nolint:gosec // Path comes from agent hook input @@ -585,7 +911,7 @@ func parseSessionStartTime(data []byte) (time.Time, error) { if err := json.Unmarshal(lines[0], &line); err != nil { return time.Time{}, fmt.Errorf("parse first transcript line: %w", err) } - if line.Type != "session_meta" { + if line.Type != rolloutLineTypeSessionMeta { return time.Time{}, fmt.Errorf("first transcript line is %q, want session_meta", line.Type) } diff --git a/cmd/entire/cli/agent/codex/transcript_test.go b/cmd/entire/cli/agent/codex/transcript_test.go index 0a329f8db4..68c8fe2cc0 100644 --- a/cmd/entire/cli/agent/codex/transcript_test.go +++ b/cmd/entire/cli/agent/codex/transcript_test.go @@ -32,6 +32,66 @@ func writeSampleRollout(t *testing.T) string { return path } +func TestClassifyRollout(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data string + want rolloutClassification + }{ + { + name: "root thread source", + data: `{"type":"session_meta","payload":{"thread_source":"user"}}` + "\n", + want: rolloutRoot, + }, + { + name: "root legacy string source", + data: `{"type":"session_meta","payload":{"source":"exec"}}` + "\n", + want: rolloutRoot, + }, + { + name: "child thread source", + data: `{"type":"session_meta","payload":{"thread_source":"subagent"}}` + "\n", + want: rolloutChild, + }, + { + name: "child legacy structured source", + data: `{"type":"session_meta","payload":{"source":{"subagent":{"thread_spawn":{"parent_thread_id":"root-thread"}}}}}` + "\n", + want: rolloutChild, + }, + { + name: "missing session metadata", + data: `{"type":"response_item","payload":{}}` + "\n", + want: rolloutUnknown, + }, + { + name: "malformed JSON", + data: `{"type":"session_meta","payload":` + "\n", + want: rolloutUnknown, + }, + { + name: "unrecognized source", + data: `{"type":"session_meta","payload":{"source":"other"}}` + "\n", + want: rolloutUnknown, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "rollout.jsonl") + require.NoError(t, os.WriteFile(path, []byte(tt.data), 0o600)) + require.Equal(t, tt.want, classifyRollout(path)) + }) + } + + t.Run("missing path", func(t *testing.T) { + t.Parallel() + require.Equal(t, rolloutUnknown, classifyRollout(filepath.Join(t.TempDir(), "missing.jsonl"))) + }) +} + func TestGetTranscriptPosition(t *testing.T) { t.Parallel() ag := &CodexAgent{} diff --git a/cmd/entire/cli/agent/event.go b/cmd/entire/cli/agent/event.go index 95b7d172b9..a5bf085f8c 100644 --- a/cmd/entire/cli/agent/event.go +++ b/cmd/entire/cli/agent/event.go @@ -108,9 +108,19 @@ type Event struct { // ToolUseID identifies the tool invocation (for SubagentStart/SubagentEnd events). ToolUseID string + // TurnID identifies the agent turn that produced the event. + TurnID string + // SubagentID identifies the subagent instance (for SubagentEnd events). SubagentID string + // StopHookActive reports whether the agent's Stop hook remains active. + StopHookActive bool + + // ProvisionalSubagentStop is true when a subagent-stop event may arrive + // before the root rollout has reached its final state. + ProvisionalSubagentStop bool + // Final is true only for events that represent true completion of a // subagent (Claude Code's SubagentStop), never for the launch-time // PostToolUse SubagentEnd, which fires at the background launch stub diff --git a/cmd/entire/cli/agent/types/token_usage.go b/cmd/entire/cli/agent/types/token_usage.go index 9c6082079d..c651610eaa 100644 --- a/cmd/entire/cli/agent/types/token_usage.go +++ b/cmd/entire/cli/agent/types/token_usage.go @@ -15,6 +15,21 @@ type TokenUsage struct { APICallCount int `json:"api_call_count"` // SubagentTokens contains token usage from spawned subagents (if any) SubagentTokens *TokenUsage `json:"subagent_tokens,omitempty"` + // SubagentTokensComplete says whether the outer result has exact child coverage. + SubagentTokensComplete *bool `json:"subagent_tokens_complete,omitempty"` +} + +// WithClearedSubagentTokens returns an independent usage result with child +// totals removed and an explicit coverage marker. A nil usage becomes a +// marker-only result so unavailable coverage persists. +func WithClearedSubagentTokens(usage *TokenUsage, complete bool) *TokenUsage { + if usage == nil { + usage = &TokenUsage{} + } + cleared := *usage + cleared.SubagentTokens = nil + cleared.SubagentTokensComplete = &complete + return &cleared } // MaxSubagentDepth caps how deep a SubagentTokens chain is walked. Real chains @@ -61,18 +76,40 @@ func addTokenUsageAtDepth(a, b *TokenUsage, depth int) *TokenUsage { bSub = b.SubagentTokens } if depth >= MaxSubagentDepth { + if depth == 0 { + sum.SubagentTokensComplete = tokenCompleteness(a, b) + } return sum } sum.SubagentTokens = addTokenUsageAtDepth(aSub, bSub, depth+1) + if depth == 0 { + sum.SubagentTokensComplete = tokenCompleteness(a, b) + } return sum } +func tokenCompleteness(a, b *TokenUsage) *bool { + if a != nil && a.SubagentTokensComplete != nil { + complete := *a.SubagentTokensComplete + return &complete + } + if b != nil && b.SubagentTokensComplete != nil { + complete := *b.SubagentTokensComplete + return &complete + } + return nil +} + // SubtractTokenUsage returns a-b, recursing into subagent usage and clamping // every field at zero (a nil operand is treated as zero). Neither input is // mutated. Used to rescope a cumulative-since-session-start snapshot (e.g. // subagent token usage, which is always re-read from the start of each // subagent transcript) down to a delta since a previously captured baseline. func SubtractTokenUsage(a, b *TokenUsage) *TokenUsage { + return subtractTokenUsageAtDepth(a, b, 0) +} + +func subtractTokenUsageAtDepth(a, b *TokenUsage, depth int) *TokenUsage { if a == nil { return nil } @@ -86,7 +123,11 @@ func SubtractTokenUsage(a, b *TokenUsage) *TokenUsage { OutputTokens: clampSubtract(a.OutputTokens, b.OutputTokens), APICallCount: clampSubtract(a.APICallCount, b.APICallCount), } - diff.SubagentTokens = SubtractTokenUsage(a.SubagentTokens, b.SubagentTokens) + diff.SubagentTokens = subtractTokenUsageAtDepth(a.SubagentTokens, b.SubagentTokens, depth+1) + if depth == 0 && a.SubagentTokensComplete != nil { + complete := *a.SubagentTokensComplete + diff.SubagentTokensComplete = &complete + } return diff } diff --git a/cmd/entire/cli/agent/types/token_usage_test.go b/cmd/entire/cli/agent/types/token_usage_test.go index f0d82d250b..19a766f3b1 100644 --- a/cmd/entire/cli/agent/types/token_usage_test.go +++ b/cmd/entire/cli/agent/types/token_usage_test.go @@ -1,6 +1,44 @@ package types -import "testing" +import ( + "encoding/json" + "testing" +) + +func TestTokenUsage_SubagentTokensCompleteRoundTripAndClear(t *testing.T) { + t.Parallel() + + complete := true + usage := &TokenUsage{ + InputTokens: 3, + SubagentTokens: &TokenUsage{OutputTokens: 2}, + SubagentTokensComplete: &complete, + } + data, err := json.Marshal(usage) + if err != nil { + t.Fatal(err) + } + var roundTripped TokenUsage + if err := json.Unmarshal(data, &roundTripped); err != nil { + t.Fatal(err) + } + if roundTripped.SubagentTokensComplete == nil || !*roundTripped.SubagentTokensComplete { + t.Fatalf("round trip completeness = %v, want true", roundTripped.SubagentTokensComplete) + } + + cleared := WithClearedSubagentTokens(&roundTripped, false) + if cleared == &roundTripped || cleared.SubagentTokens != nil || cleared.SubagentTokensComplete == nil || *cleared.SubagentTokensComplete { + t.Fatalf("cleared usage = %+v, want independent explicitly incomplete copy", cleared) + } + if roundTripped.SubagentTokens == nil || roundTripped.SubagentTokensComplete == nil || !*roundTripped.SubagentTokensComplete { + t.Fatalf("clear mutated input: %+v", roundTripped) + } + + usageCopy := AddTokenUsage(nil, usage) + if usageCopy.SubagentTokensComplete == nil || !*usageCopy.SubagentTokensComplete { + t.Fatalf("AddTokenUsage copy dropped completeness: %+v", usageCopy) + } +} func TestAddTokenUsage(t *testing.T) { t.Parallel() diff --git a/cmd/entire/cli/integration_test/codex_image_externalize_test.go b/cmd/entire/cli/integration_test/codex_image_externalize_test.go index 97cc518307..c292f6ba9e 100644 --- a/cmd/entire/cli/integration_test/codex_image_externalize_test.go +++ b/cmd/entire/cli/integration_test/codex_image_externalize_test.go @@ -42,7 +42,7 @@ func TestCodexImageExternalization_FullHookFlow(t *testing.T) { // A Codex rollout: session meta, then a user message with an inline image // data-URI (the confirmed real format), then an assistant reply. rollout := strings.Join([]string{ - `{"timestamp":"2026-01-01T00:00:00Z","type":"session_meta","payload":{"id":"` + sessionID + `","cwd":"` + env.RepoDir + `"}}`, + `{"timestamp":"2026-01-01T00:00:00Z","type":"session_meta","payload":{"id":"` + sessionID + `","thread_source":"user","cwd":"` + env.RepoDir + `"}}`, `{"timestamp":"2026-01-01T00:00:01Z","type":"response_item","payload":{"type":"message","role":"user","content":[` + `{"type":"input_text","text":"add feature.txt and look at this screenshot"},` + `{"type":"input_image","image_url":"data:image/png;base64,` + b64 + `"}` + diff --git a/cmd/entire/cli/integration_test/codex_shadow_sanitize_test.go b/cmd/entire/cli/integration_test/codex_shadow_sanitize_test.go index b010d457c4..ece4683874 100644 --- a/cmd/entire/cli/integration_test/codex_shadow_sanitize_test.go +++ b/cmd/entire/cli/integration_test/codex_shadow_sanitize_test.go @@ -29,7 +29,7 @@ var codexCiphertext = strings.Repeat("QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVph", 40) // the `encrypted_content` key; that is the only key the sanitizer strips. func codexRolloutWithEncryptedReasoning(sessionID, repoDir, ciphertext string) string { return strings.Join([]string{ - `{"timestamp":"2026-01-01T00:00:00Z","type":"session_meta","payload":{"id":"` + sessionID + `","cwd":"` + repoDir + `"}}`, + `{"timestamp":"2026-01-01T00:00:00Z","type":"session_meta","payload":{"id":"` + sessionID + `","thread_source":"user","cwd":"` + repoDir + `"}}`, `{"timestamp":"2026-01-01T00:00:01Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"add feature.txt"}]}}`, `{"timestamp":"2026-01-01T00:00:02Z","type":"response_item","payload":{"type":"reasoning","summary":[],"encrypted_content":"` + ciphertext + `"}}`, `{"timestamp":"2026-01-01T00:00:03Z","type":"response_item","payload":{"type":"compaction","encrypted_content":"` + ciphertext + `"}}`, @@ -318,7 +318,7 @@ func TestCodexCondense_NoAssetsFromSanitizedAwayContent(t *testing.T) { sessionID := "codex-sanitize-before-extract" transcriptPath := filepath.Join(env.RepoDir, ".entire", "tmp", "codex-rollout.jsonl") rollout := strings.Join([]string{ - `{"timestamp":"2026-01-01T00:00:00Z","type":"session_meta","payload":{"id":"` + sessionID + `","cwd":"` + env.RepoDir + `"}}`, + `{"timestamp":"2026-01-01T00:00:00Z","type":"session_meta","payload":{"id":"` + sessionID + `","thread_source":"user","cwd":"` + env.RepoDir + `"}}`, `{"timestamp":"2026-01-01T00:00:01Z","type":"response_item","payload":{"type":"message","role":"user","content":[` + `{"type":"input_text","text":"add feature.txt and look at this screenshot"},` + `{"type":"input_image","image_url":"data:image/png;base64,` + keptB64 + `"}` + diff --git a/cmd/entire/cli/session/state.go b/cmd/entire/cli/session/state.go index 6703e4fbf9..86f4eedb41 100644 --- a/cmd/entire/cli/session/state.go +++ b/cmd/entire/cli/session/state.go @@ -332,6 +332,9 @@ type State struct { // cumulative total on every checkpoint. SubagentTokensBaseline *agent.TokenUsage `json:"subagent_tokens_baseline,omitempty"` + // SubagentTokensBaselineComplete records whether the baseline is exact. + SubagentTokensBaselineComplete *bool `json:"subagent_tokens_baseline_complete,omitempty"` + // SkillEvents records explicit native skill signals observed during this session. // Stored as sidecar metadata so consumers can collapse skill-related transcript events // without mutating the raw agent transcript. @@ -424,6 +427,28 @@ type State struct { // TaskRecords tracks subagents dispatched by this session — the durable // pointer ledger for subagent work. See TaskRecord. TaskRecords []TaskRecord `json:"task_records,omitempty"` + + // SubagentInventory retains Codex child identities independently of task + // records so follow-up turns remain discoverable after materialization. + SubagentInventory []SubagentInventoryEntry `json:"subagent_inventory,omitempty"` + // SubagentLedgerVersion advances on a new child identity or non-empty turn. + SubagentLedgerVersion uint64 `json:"subagent_ledger_version,omitempty"` + // SubagentInventoryComplete distinguishes exact empty from legacy unknown. + SubagentInventoryComplete *bool `json:"subagent_inventory_complete,omitempty"` +} + +type SubagentStopCandidate struct { + ObservedAt time.Time `json:"observed_at"` + StopHookActive bool `json:"stop_hook_active,omitempty"` +} + +type SubagentInventoryEntry struct { + AgentID string `json:"agent_id"` + DeclaredTranscriptPath string `json:"declared_transcript_path,omitempty"` + ResolvedTranscriptPath string `json:"resolved_transcript_path,omitempty"` + ObservedTurnIDs []string `json:"observed_turn_ids,omitempty"` + PendingStops map[string]SubagentStopCandidate `json:"pending_stops,omitempty"` + FinalizedTurnIDs []string `json:"finalized_turn_ids,omitempty"` } // TaskRecord is the durable pointer ledger entry for a subagent dispatched by @@ -502,6 +527,125 @@ func (s *State) AddTaskRecord(task TaskRecord) { s.TaskRecords = append(s.TaskRecords, task) } +// EnsureTaskRecord adds a follow-up record only after an earlier completed +// record was materialized and removed. Existing unmaterialized content wins. +func (s *State) EnsureTaskRecord(task TaskRecord) bool { + if task.ToolUseID == "" || s.FindTaskRecord(task.ToolUseID) != nil { + return false + } + s.AddTaskRecord(task) + return true +} + +// FindSubagentInventory returns an entry that aliases state. Callers must use +// it only inside their current MutateSessionState closure. +func (s *State) FindSubagentInventory(agentID string) *SubagentInventoryEntry { + for i := range s.SubagentInventory { + if s.SubagentInventory[i].AgentID == agentID { + return &s.SubagentInventory[i] + } + } + return nil +} + +// RegisterSubagent observes a stable child identity and optionally one child +// turn. Only a first child or first non-empty turn invalidates cached totals. +func (s *State) RegisterSubagent(agentID, turnID string) bool { + if agentID == "" { + return false + } + entry := s.FindSubagentInventory(agentID) + newObservation := false + if entry == nil { + s.SubagentInventory = append(s.SubagentInventory, SubagentInventoryEntry{AgentID: agentID}) + entry = &s.SubagentInventory[len(s.SubagentInventory)-1] + newObservation = true + } + if turnID != "" && !containsString(entry.ObservedTurnIDs, turnID) { + entry.ObservedTurnIDs = append(entry.ObservedTurnIDs, turnID) + newObservation = true + } + if newObservation { + s.invalidateSubagentTokenUsage() + } + return newObservation +} + +// RecordSubagentStop records a provisional stop. Stops can arrive before +// starts, so observing the child and turn happens in this same mutation. +func (s *State) RecordSubagentStop(agentID, turnID string, candidate SubagentStopCandidate) bool { + observed := s.RegisterSubagent(agentID, turnID) + if agentID == "" || turnID == "" { + return observed + } + entry := s.FindSubagentInventory(agentID) + if entry == nil || containsString(entry.FinalizedTurnIDs, turnID) { + return observed + } + if entry.PendingStops == nil { + entry.PendingStops = make(map[string]SubagentStopCandidate) + } + if existing, exists := entry.PendingStops[turnID]; exists { + if existing == candidate { + return observed + } + entry.PendingStops[turnID] = candidate + return true + } + entry.PendingStops[turnID] = candidate + return true +} + +// UpdateSubagentTranscriptPaths enriches an already-observed child's path +// metadata. Resolution is not an inventory observation, so it deliberately +// does not advance SubagentLedgerVersion or invalidate token coverage. +func (s *State) UpdateSubagentTranscriptPaths(agentID, declaredPath, resolvedPath string) bool { + entry := s.FindSubagentInventory(agentID) + if entry == nil { + return false + } + changed := false + if declaredPath != "" && entry.DeclaredTranscriptPath != declaredPath { + entry.DeclaredTranscriptPath = declaredPath + changed = true + } + if resolvedPath != "" && entry.ResolvedTranscriptPath != resolvedPath { + entry.ResolvedTranscriptPath = resolvedPath + changed = true + } + return changed +} + +// FinalizeSubagentTurn moves an observed turn out of PendingStops and into the +// finalized set atomically. A finalized turn is never finalized twice. +func (s *State) FinalizeSubagentTurn(agentID, turnID string) bool { + if agentID == "" || turnID == "" { + return false + } + entry := s.FindSubagentInventory(agentID) + if entry == nil || !containsString(entry.ObservedTurnIDs, turnID) || containsString(entry.FinalizedTurnIDs, turnID) { + return false + } + delete(entry.PendingStops, turnID) + entry.FinalizedTurnIDs = append(entry.FinalizedTurnIDs, turnID) + return true +} + +func (s *State) invalidateSubagentTokenUsage() { + s.SubagentLedgerVersion++ + s.TokenUsage = types.WithClearedSubagentTokens(s.TokenUsage, false) + s.CheckpointTokenUsage = types.WithClearedSubagentTokens(s.CheckpointTokenUsage, false) +} + +func containsString(values []string, value string) bool { + for _, existing := range values { + if existing == value { + return true + } + } + return false +} + // RemoveTaskRecord clears the record for toolUseID, if present. No-op when no // record matches. Retained for tests and any caller that genuinely wants to // discard a record outright — ordinary completion should use @@ -657,6 +801,27 @@ func (s *State) NormalizeAfterLoad(ctx context.Context) { if s.DivergenceNoticeShown && s.AttributionBaseCommit == s.BaseCommit { s.DivergenceNoticeShown = false } + + // Codex states saved before the authoritative child ledger cannot claim an + // exact child aggregate. Keep any exact task-record IDs as discovery hints, + // but make their coverage conservative and invalidate old totals. + if s.AgentType == agent.AgentTypeCodex { + if s.SubagentInventoryComplete == nil { + incomplete := false + s.SubagentInventoryComplete = &incomplete + for _, record := range s.TaskRecords { + if record.AgentID != "" && s.FindSubagentInventory(record.AgentID) == nil { + s.SubagentInventory = append(s.SubagentInventory, SubagentInventoryEntry{AgentID: record.AgentID}) + } + } + s.TokenUsage = types.WithClearedSubagentTokens(s.TokenUsage, false) + s.CheckpointTokenUsage = types.WithClearedSubagentTokens(s.CheckpointTokenUsage, false) + } + if s.SubagentTokensBaselineComplete == nil { + incomplete := false + s.SubagentTokensBaselineComplete = &incomplete + } + } } // ClearLegacyTranscriptOffsets clears deprecated transcript offset fields so @@ -710,9 +875,18 @@ func (s *State) ClearCondensationAttempt() { // helper (resetCheckpointWindow) and cross-repo session adoption, which likewise // opens a fresh target-local window. Sharing this here keeps the two in step. func (s *State) RebaselineSubagentTokens() { - if s.TokenUsage != nil { - s.SubagentTokensBaseline = s.TokenUsage.SubagentTokens + if s.TokenUsage == nil || (s.TokenUsage.SubagentTokensComplete != nil && !*s.TokenUsage.SubagentTokensComplete) { + incomplete := false + s.SubagentTokensBaseline = nil + s.SubagentTokensBaselineComplete = &incomplete + return } + // A nil marker retains the historic behaviour: the implicit initial + // baseline is exact zero. An explicit complete marker can intentionally + // snapshot a nil aggregate for an authoritative empty inventory. + complete := true + s.SubagentTokensBaseline = s.TokenUsage.SubagentTokens + s.SubagentTokensBaselineComplete = &complete } // RealignAttributionBase sets AttributionBaseCommit to newBase and clears any diff --git a/cmd/entire/cli/session/state_test.go b/cmd/entire/cli/session/state_test.go index f3eb8e09d6..1330a8dc5c 100644 --- a/cmd/entire/cli/session/state_test.go +++ b/cmd/entire/cli/session/state_test.go @@ -1026,3 +1026,205 @@ func TestState_LiveTaskRecords(t *testing.T) { assert.Empty(t, (&State{}).LiveTaskRecords()) } + +func TestState_SubagentInventoryLedger(t *testing.T) { + t.Parallel() + + complete := true + state := &State{ + TokenUsage: &agent.TokenUsage{InputTokens: 5, SubagentTokens: &agent.TokenUsage{InputTokens: 3}, SubagentTokensComplete: &complete}, + CheckpointTokenUsage: &agent.TokenUsage{OutputTokens: 2, SubagentTokens: &agent.TokenUsage{OutputTokens: 1}, SubagentTokensComplete: &complete}, + } + if !state.RegisterSubagent("child-1", "turn-1") { + t.Fatal("first child observation must be recorded") + } + assert.Equal(t, uint64(1), state.SubagentLedgerVersion) + assert.Nil(t, state.TokenUsage.SubagentTokens) + assert.False(t, *state.TokenUsage.SubagentTokensComplete) + assert.Nil(t, state.CheckpointTokenUsage.SubagentTokens) + assert.False(t, *state.CheckpointTokenUsage.SubagentTokensComplete) + + // A later exact extraction may have refreshed both aggregates. Duplicate + // observations and path-only enrichment must preserve that fresh coverage. + refreshedComplete := true + state.TokenUsage.SubagentTokens = &agent.TokenUsage{InputTokens: 21} + state.TokenUsage.SubagentTokensComplete = &refreshedComplete + state.CheckpointTokenUsage.SubagentTokens = &agent.TokenUsage{OutputTokens: 13} + state.CheckpointTokenUsage.SubagentTokensComplete = &refreshedComplete + versionBeforeDuplicate := state.SubagentLedgerVersion + totalBeforeDuplicate := state.TokenUsage.SubagentTokens + checkpointTotalBeforeDuplicate := state.CheckpointTokenUsage.SubagentTokens + coverageBeforeDuplicate := *state.TokenUsage.SubagentTokensComplete + checkpointCoverageBeforeDuplicate := *state.CheckpointTokenUsage.SubagentTokensComplete + if state.RegisterSubagent("child-1", "turn-1") { + t.Fatal("duplicate agent/turn observation must be a true no-op") + } + assert.Equal(t, versionBeforeDuplicate, state.SubagentLedgerVersion) + assert.Same(t, totalBeforeDuplicate, state.TokenUsage.SubagentTokens) + assert.Same(t, checkpointTotalBeforeDuplicate, state.CheckpointTokenUsage.SubagentTokens) + assert.Equal(t, coverageBeforeDuplicate, *state.TokenUsage.SubagentTokensComplete) + assert.Equal(t, checkpointCoverageBeforeDuplicate, *state.CheckpointTokenUsage.SubagentTokensComplete) + versionBeforePathEnrichment := state.SubagentLedgerVersion + totalBeforePathEnrichment := state.TokenUsage.SubagentTokens + checkpointTotalBeforePathEnrichment := state.CheckpointTokenUsage.SubagentTokens + coverageBeforePathEnrichment := *state.TokenUsage.SubagentTokensComplete + checkpointCoverageBeforePathEnrichment := *state.CheckpointTokenUsage.SubagentTokensComplete + assert.True(t, state.UpdateSubagentTranscriptPaths("child-1", "/tmp/declared.jsonl", "/tmp/resolved.jsonl")) + assert.Equal(t, versionBeforePathEnrichment, state.SubagentLedgerVersion, "path enrichment must not churn the ledger generation") + assert.Same(t, totalBeforePathEnrichment, state.TokenUsage.SubagentTokens) + assert.Same(t, checkpointTotalBeforePathEnrichment, state.CheckpointTokenUsage.SubagentTokens) + assert.Equal(t, coverageBeforePathEnrichment, *state.TokenUsage.SubagentTokensComplete) + assert.Equal(t, checkpointCoverageBeforePathEnrichment, *state.CheckpointTokenUsage.SubagentTokensComplete) + + stopObservedAt := time.Now().UTC().Truncate(time.Second) + stopCandidate := SubagentStopCandidate{ObservedAt: stopObservedAt, StopHookActive: true} + if !state.RecordSubagentStop("child-1", "turn-2", stopCandidate) { + t.Fatal("stop-first new turn must be recorded") + } + assert.Equal(t, uint64(2), state.SubagentLedgerVersion) + assert.Nil(t, state.TokenUsage.SubagentTokens, "new child turn must invalidate refreshed session totals") + require.NotNil(t, state.TokenUsage.SubagentTokensComplete) + assert.False(t, *state.TokenUsage.SubagentTokensComplete) + assert.Nil(t, state.CheckpointTokenUsage.SubagentTokens, "new child turn must invalidate refreshed checkpoint totals") + require.NotNil(t, state.CheckpointTokenUsage.SubagentTokensComplete) + assert.False(t, *state.CheckpointTokenUsage.SubagentTokensComplete) + entry := state.FindSubagentInventory("child-1") + require.NotNil(t, entry) + require.Contains(t, entry.PendingStops, "turn-2") + + // A stop retry may carry richer metadata. Both a true duplicate and the + // metadata refresh must leave already-calculated token coverage intact. + stopRefreshComplete := true + state.TokenUsage.SubagentTokens = &agent.TokenUsage{InputTokens: 34} + state.TokenUsage.SubagentTokensComplete = &stopRefreshComplete + state.CheckpointTokenUsage.SubagentTokens = &agent.TokenUsage{OutputTokens: 21} + state.CheckpointTokenUsage.SubagentTokensComplete = &stopRefreshComplete + versionBeforeStopRefresh := state.SubagentLedgerVersion + totalBeforeStopRefresh := state.TokenUsage.SubagentTokens + checkpointTotalBeforeStopRefresh := state.CheckpointTokenUsage.SubagentTokens + coverageBeforeStopRefresh := *state.TokenUsage.SubagentTokensComplete + checkpointCoverageBeforeStopRefresh := *state.CheckpointTokenUsage.SubagentTokensComplete + assert.False(t, state.RecordSubagentStop("child-1", "turn-2", stopCandidate), "exact duplicate stop must be a no-op") + assert.Equal(t, versionBeforeStopRefresh, state.SubagentLedgerVersion) + assert.Same(t, totalBeforeStopRefresh, state.TokenUsage.SubagentTokens) + assert.Same(t, checkpointTotalBeforeStopRefresh, state.CheckpointTokenUsage.SubagentTokens) + assert.Equal(t, coverageBeforeStopRefresh, *state.TokenUsage.SubagentTokensComplete) + assert.Equal(t, checkpointCoverageBeforeStopRefresh, *state.CheckpointTokenUsage.SubagentTokensComplete) + + refreshedCandidate := SubagentStopCandidate{ObservedAt: stopObservedAt.Add(time.Second), StopHookActive: false} + assert.True(t, state.RecordSubagentStop("child-1", "turn-2", refreshedCandidate), "changed stop metadata must upsert") + assert.Equal(t, refreshedCandidate, entry.PendingStops["turn-2"]) + assert.Equal(t, versionBeforeStopRefresh, state.SubagentLedgerVersion) + assert.Same(t, totalBeforeStopRefresh, state.TokenUsage.SubagentTokens) + assert.Same(t, checkpointTotalBeforeStopRefresh, state.CheckpointTokenUsage.SubagentTokens) + assert.Equal(t, coverageBeforeStopRefresh, *state.TokenUsage.SubagentTokensComplete) + assert.Equal(t, checkpointCoverageBeforeStopRefresh, *state.CheckpointTokenUsage.SubagentTokensComplete) + + state.RecordSubagentStop("child-1", "turn-3", SubagentStopCandidate{ObservedAt: time.Now()}) + require.Len(t, entry.PendingStops, 2, "several pending stops must coexist") + if !state.FinalizeSubagentTurn("child-1", "turn-2") { + t.Fatal("pending turn must finalize") + } + assert.NotContains(t, entry.PendingStops, "turn-2") + assert.Contains(t, entry.FinalizedTurnIDs, "turn-2") + if state.FinalizeSubagentTurn("child-1", "turn-2") { + t.Fatal("finalized turn must be exactly once") + } +} + +func TestState_SubagentInventoryRoundTripAndTaskRecordRecovery(t *testing.T) { + t.Parallel() + now := time.Now().UTC().Truncate(time.Second) + complete := true + state := State{ + AgentType: agent.AgentTypeCodex, + SubagentInventoryComplete: &complete, + SubagentTokensBaselineComplete: &complete, + SubagentLedgerVersion: 7, + SubagentInventory: []SubagentInventoryEntry{{ + AgentID: "child-1", + DeclaredTranscriptPath: "/tmp/child.jsonl", + ResolvedTranscriptPath: "/tmp/resolved.jsonl", + ObservedTurnIDs: []string{"turn-1"}, + PendingStops: map[string]SubagentStopCandidate{"turn-1": {ObservedAt: now, StopHookActive: true}}, + FinalizedTurnIDs: []string{"turn-0"}, + }}, + } + data, err := json.Marshal(state) + require.NoError(t, err) + var got State + require.NoError(t, json.Unmarshal(data, &got)) + require.NotNil(t, got.SubagentInventoryComplete) + assert.True(t, *got.SubagentInventoryComplete) + require.NotNil(t, got.SubagentTokensBaselineComplete) + assert.True(t, *got.SubagentTokensBaselineComplete) + assert.Equal(t, uint64(7), got.SubagentLedgerVersion) + require.Len(t, got.SubagentInventory, 1) + entry := got.SubagentInventory[0] + assert.Equal(t, "child-1", entry.AgentID) + assert.Equal(t, "/tmp/child.jsonl", entry.DeclaredTranscriptPath) + assert.Equal(t, "/tmp/resolved.jsonl", entry.ResolvedTranscriptPath) + assert.Equal(t, []string{"turn-1"}, entry.ObservedTurnIDs) + require.Contains(t, entry.PendingStops, "turn-1") + pending := entry.PendingStops["turn-1"] + assert.True(t, now.Equal(pending.ObservedAt)) + assert.True(t, pending.StopHookActive) + assert.Equal(t, []string{"turn-0"}, entry.FinalizedTurnIDs) + + materialized := TaskRecord{ToolUseID: "child-1", AgentID: "child-1", StartedAt: now, CompletedAt: now} + got.AddTaskRecord(materialized) + assert.False(t, got.EnsureTaskRecord(TaskRecord{ToolUseID: "child-1", AgentID: "child-1", StartedAt: now.Add(time.Minute)}), "unmaterialized record must not be replaced") + assert.True(t, got.TaskRecords[0].CompletedAt.Equal(now)) + got.RemoveTaskRecord("child-1") + assert.True(t, got.EnsureTaskRecord(TaskRecord{ToolUseID: "child-1", AgentID: "child-1", StartedAt: now.Add(time.Minute)}), "follow-up must recreate a materialized record") +} + +func TestState_NormalizeAfterLoad_CodexInventoryMigration(t *testing.T) { + t.Parallel() + legacy := &State{ + AgentType: agent.AgentTypeCodex, + TokenUsage: &agent.TokenUsage{SubagentTokens: &agent.TokenUsage{InputTokens: 4}}, + CheckpointTokenUsage: &agent.TokenUsage{SubagentTokens: &agent.TokenUsage{InputTokens: 2}}, + TaskRecords: []TaskRecord{{AgentID: "child-1"}}, + } + legacy.NormalizeAfterLoad(context.Background()) + require.NotNil(t, legacy.SubagentInventoryComplete) + assert.False(t, *legacy.SubagentInventoryComplete) + require.NotNil(t, legacy.SubagentTokensBaselineComplete) + assert.False(t, *legacy.SubagentTokensBaselineComplete) + assert.Nil(t, legacy.TokenUsage.SubagentTokens) + assert.False(t, *legacy.TokenUsage.SubagentTokensComplete) + assert.Nil(t, legacy.CheckpointTokenUsage.SubagentTokens) + assert.False(t, *legacy.CheckpointTokenUsage.SubagentTokensComplete) + require.Len(t, legacy.SubagentInventory, 1) + assert.Equal(t, "child-1", legacy.SubagentInventory[0].AgentID) + + nonCodex := &State{AgentType: agent.AgentTypeClaudeCode} + nonCodex.NormalizeAfterLoad(context.Background()) + assert.Nil(t, nonCodex.SubagentInventoryComplete) + assert.Nil(t, nonCodex.SubagentTokensBaselineComplete) + + explicitComplete := true + explicit := &State{AgentType: agent.AgentTypeCodex, SubagentInventoryComplete: &explicitComplete, SubagentTokensBaselineComplete: &explicitComplete, TokenUsage: &agent.TokenUsage{SubagentTokens: &agent.TokenUsage{InputTokens: 9}}} + explicit.NormalizeAfterLoad(context.Background()) + assert.True(t, *explicit.SubagentInventoryComplete) + assert.NotNil(t, explicit.TokenUsage.SubagentTokens, "an explicit state must not be migrated again") +} + +func TestState_RebaselineSubagentTokensPreservesTriState(t *testing.T) { + t.Parallel() + complete := true + incomplete := false + + exactEmpty := &State{TokenUsage: &agent.TokenUsage{SubagentTokensComplete: &complete}} + exactEmpty.RebaselineSubagentTokens() + require.NotNil(t, exactEmpty.SubagentTokensBaselineComplete) + assert.True(t, *exactEmpty.SubagentTokensBaselineComplete) + assert.Nil(t, exactEmpty.SubagentTokensBaseline) + + unknown := &State{TokenUsage: &agent.TokenUsage{SubagentTokens: &agent.TokenUsage{InputTokens: 9}, SubagentTokensComplete: &incomplete}} + unknown.RebaselineSubagentTokens() + require.NotNil(t, unknown.SubagentTokensBaselineComplete) + assert.False(t, *unknown.SubagentTokensBaselineComplete) + assert.Nil(t, unknown.SubagentTokensBaseline) +} diff --git a/cmd/entire/cli/strategy/manual_commit_session.go b/cmd/entire/cli/strategy/manual_commit_session.go index c9d6584fa2..856442d326 100644 --- a/cmd/entire/cli/strategy/manual_commit_session.go +++ b/cmd/entire/cli/strategy/manual_commit_session.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/agent/types" "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" @@ -647,6 +648,11 @@ func (s *ManualCommitStrategy) initializeSession(ctx context.Context, repo *git. TranscriptPath: transcriptPath, LastPrompt: truncatePromptForStorage(userPrompt), } + if agentType == agent.AgentTypeCodex { + complete := true + state.SubagentInventoryComplete = &complete + state.SubagentTokensBaselineComplete = &complete + } // Take the gate, then re-check under lock. Without this re-check a // concurrent turn-start hook that wrote a richer state in the gap @@ -665,6 +671,41 @@ func (s *ManualCommitStrategy) initializeSession(ctx context.Context, repo *git. if existing != nil && existing.BaseCommit != "" { return nil } + if existing != nil && agentType == agent.AgentTypeCodex { + // Repair the partial state in place. A child hook can have recorded task + // content and accounting before the parent session initializes, so a + // fresh replacement would silently discard durable child state. + state = existing + state.CLIVersion = versioninfo.Version + state.BaseCommit = headHash + state.AttributionBaseCommit = headHash + state.WorktreePath = worktreePath + state.WorktreeID = worktreeID + if state.StartedAt.IsZero() { + state.StartedAt = now + } + state.LastInteractionTime = &now + state.TurnID = turnID.String() + state.AgentType = agentType + if model != "" { + state.ModelName = model + } + if transcriptPath != "" { + state.TranscriptPath = transcriptPath + } + if userPrompt != "" { + state.LastPrompt = truncatePromptForStorage(userPrompt) + } + if state.UntrackedFilesAtStart == nil { + state.UntrackedFilesAtStart = untrackedFiles + } + + // This is a repair, not an authoritative SessionStart inventory. Keep + // the ledger and token data but retain conservative coverage markers. + incomplete := false + state.SubagentInventoryComplete = &incomplete + state.SubagentTokensBaselineComplete = &incomplete + } return s.saveSessionState(ctx, state) } diff --git a/cmd/entire/cli/strategy/manual_commit_test.go b/cmd/entire/cli/strategy/manual_commit_test.go index 655790c5cd..782403060f 100644 --- a/cmd/entire/cli/strategy/manual_commit_test.go +++ b/cmd/entire/cli/strategy/manual_commit_test.go @@ -28,6 +28,96 @@ import ( const testTrailerCheckpointID id.CheckpointID = "a1b2c3d4e5f6" +func TestCodexInventoryInitialization(t *testing.T) { + dir := t.TempDir() + testutil.InitRepo(t, dir) + testutil.WriteFile(t, dir, "initial.txt", "initial\n") + testutil.GitAdd(t, dir, "initial.txt") + testutil.GitCommit(t, dir, "initial") + t.Chdir(dir) + + s := NewManualCommitStrategy() + repo, err := OpenRepository(context.Background()) + require.NoError(t, err) + defer repo.Close() + require.NoError(t, s.initializeSession(context.Background(), repo, "codex-inventory-new", agent.AgentTypeCodex, "", "", "")) + newState, err := s.loadSessionState(context.Background(), "codex-inventory-new") + require.NoError(t, err) + require.NotNil(t, newState.SubagentInventoryComplete) + assert.True(t, *newState.SubagentInventoryComplete) + require.NotNil(t, newState.SubagentTokensBaselineComplete) + assert.True(t, *newState.SubagentTokensBaselineComplete) + + incomplete := false + pendingAt := time.Now().UTC().Truncate(time.Second) + partialInventory := []session.SubagentInventoryEntry{{ + AgentID: "child-observed-before-parent", + ObservedTurnIDs: []string{"turn-pending", "turn-finalized"}, + PendingStops: map[string]session.SubagentStopCandidate{ + "turn-pending": {ObservedAt: pendingAt, StopHookActive: true}, + }, + FinalizedTurnIDs: []string{"turn-finalized"}, + }} + partialTokenUsage := &agent.TokenUsage{InputTokens: 100, SubagentTokens: &agent.TokenUsage{InputTokens: 60}, SubagentTokensComplete: &incomplete} + partialCheckpointUsage := &agent.TokenUsage{OutputTokens: 50, SubagentTokens: &agent.TokenUsage{OutputTokens: 30}, SubagentTokensComplete: &incomplete} + partialBaseline := &agent.TokenUsage{SubagentTokens: &agent.TokenUsage{InputTokens: 40}, SubagentTokensComplete: &incomplete} + partialRecords := []session.TaskRecord{ + {ToolUseID: "child-live", AgentID: "child-observed-before-parent", StartedAt: pendingAt}, + {ToolUseID: "child-completed", AgentID: "child-observed-before-parent", StartedAt: pendingAt, CompletedAt: pendingAt.Add(time.Second)}, + } + require.NoError(t, s.saveSessionState(context.Background(), &SessionState{ + SessionID: "codex-inventory-partial", + StartedAt: time.Now(), + AgentType: agent.AgentTypeCodex, + SubagentInventory: partialInventory, + SubagentLedgerVersion: 9, + SubagentInventoryComplete: &incomplete, + SubagentTokensBaselineComplete: &incomplete, + TokenUsage: partialTokenUsage, + CheckpointTokenUsage: partialCheckpointUsage, + SubagentTokensBaseline: partialBaseline, + TaskRecords: partialRecords, + })) + beforeRepair, err := s.loadSessionState(context.Background(), "codex-inventory-partial") + require.NoError(t, err) + assert.Empty(t, beforeRepair.BaseCommit) + require.NotNil(t, beforeRepair.SubagentInventoryComplete) + assert.False(t, *beforeRepair.SubagentInventoryComplete) + assert.Equal(t, uint64(9), beforeRepair.SubagentLedgerVersion) + require.Len(t, beforeRepair.SubagentInventory, 1) + require.NoError(t, s.initializeSession(context.Background(), repo, "codex-inventory-partial", agent.AgentTypeCodex, "", "", "")) + partial, err := s.loadSessionState(context.Background(), "codex-inventory-partial") + require.NoError(t, err) + require.NotNil(t, partial.SubagentInventoryComplete) + assert.False(t, *partial.SubagentInventoryComplete, "partial-state repair must not promote unknown inventory coverage") + require.NotNil(t, partial.SubagentTokensBaselineComplete) + assert.False(t, *partial.SubagentTokensBaselineComplete) + assert.Equal(t, uint64(9), partial.SubagentLedgerVersion) + require.Len(t, partial.SubagentInventory, 1) + entry := partial.SubagentInventory[0] + assert.Equal(t, "child-observed-before-parent", entry.AgentID) + assert.Equal(t, []string{"turn-pending", "turn-finalized"}, entry.ObservedTurnIDs) + require.Contains(t, entry.PendingStops, "turn-pending") + assert.True(t, pendingAt.Equal(entry.PendingStops["turn-pending"].ObservedAt)) + assert.True(t, entry.PendingStops["turn-pending"].StopHookActive) + assert.Equal(t, []string{"turn-finalized"}, entry.FinalizedTurnIDs) + assert.True(t, partial.HasTaskContent(), "repair must retain both live and completed-unmaterialized task content") + require.Len(t, partial.TaskRecords, 2) + assert.Equal(t, "child-live", partial.TaskRecords[0].ToolUseID) + assert.True(t, partial.TaskRecords[1].CompletedAt.Equal(pendingAt.Add(time.Second))) + require.NotNil(t, partial.TokenUsage) + assert.Equal(t, 100, partial.TokenUsage.InputTokens) + require.NotNil(t, partial.TokenUsage.SubagentTokens) + assert.Equal(t, 60, partial.TokenUsage.SubagentTokens.InputTokens) + require.NotNil(t, partial.CheckpointTokenUsage) + assert.Equal(t, 50, partial.CheckpointTokenUsage.OutputTokens) + require.NotNil(t, partial.CheckpointTokenUsage.SubagentTokens) + assert.Equal(t, 30, partial.CheckpointTokenUsage.SubagentTokens.OutputTokens) + require.NotNil(t, partial.SubagentTokensBaseline) + require.NotNil(t, partial.SubagentTokensBaseline.SubagentTokens) + assert.Equal(t, 40, partial.SubagentTokensBaseline.SubagentTokens.InputTokens) +} + // testTranscriptPromptResponse is a minimal transcript used across strategy tests. const testTranscriptPromptResponse = "{\"type\":\"human\",\"message\":{\"content\":\"test prompt\"}}\n{\"type\":\"assistant\",\"message\":{\"content\":\"test response\"}}\n" From e5c44dffb109ed50c67447d3cac5975228529afc Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Mon, 31 Aug 2026 23:05:01 -0700 Subject: [PATCH 02/59] feat(codex): reconcile subagents with exact accounting --- cmd/entire/cli/agent/token_usage.go | 16 ++ cmd/entire/cli/lifecycle.go | 169 +++++++++++++++++- .../strategy/manual_commit_condensation.go | 9 +- cmd/entire/cli/strategy/manual_commit_git.go | 43 ++++- cmd/entire/cli/strategy/strategy.go | 4 + .../cli/strategy/subagent_tokens_test.go | 22 +++ 6 files changed, 256 insertions(+), 7 deletions(-) diff --git a/cmd/entire/cli/agent/token_usage.go b/cmd/entire/cli/agent/token_usage.go index cd9ec1920f..651a23cfd1 100644 --- a/cmd/entire/cli/agent/token_usage.go +++ b/cmd/entire/cli/agent/token_usage.go @@ -7,6 +7,22 @@ import ( "github.com/entireio/cli/cmd/entire/cli/logging" ) +// ExtractWithSubagentInventory gives built-in agents an authoritative child +// ledger. It deliberately has no external-agent protocol equivalent: callers +// supply the inventory rather than asking an agent to infer children from text. +func ExtractWithSubagentInventory(ctx context.Context, ag Agent, transcriptData []byte, transcriptLinesAtStart int, refs []SubagentReference) (InventoryExtraction, bool) { + extractor, ok := AsInventoryAwareExtractor(ag) + if !ok { + return InventoryExtraction{}, false + } + extraction, err := extractor.ExtractWithSubagentInventory(transcriptData, transcriptLinesAtStart, refs) + if err != nil { + logging.Debug(ctx, "failed inventory-aware token extraction", slog.String("error", err.Error())) + return InventoryExtraction{}, false + } + return extraction, true +} + // CalculateTokenUsage calculates token usage from transcript data. // Returns nil if the agent doesn't support token calculation or on error. // Errors are debug-logged because callers treat nil token usage as "no data available". diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index 14b84ac467..db557ee21f 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -934,6 +934,19 @@ func handleLifecycleTurnEnd(ctx context.Context, ag agent.Agent, event *agent.Ev relModifiedFiles = filterToUncommittedFiles(ctx, relModifiedFiles, repoRoot) normalizeSpan.End() + // Codex owns an authoritative child ledger. Refresh it before the + // no-files gate: a read-only child can finish without producing a shadow + // checkpoint, but its exact availability still must replace stale coverage. + var codexInventoryUsage *agent.TokenUsage + var codexLedgerVersion uint64 + if ag.Type() == agent.AgentTypeCodex { + inventoryOffset := 0 + if preState != nil { + inventoryOffset = preState.TranscriptOffset + } + codexInventoryUsage, codexLedgerVersion = refreshCodexInventory(ctx, ag, sessionID, transcriptData, inventoryOffset) + } + // Check if there are any changes totalChanges := len(relModifiedFiles) + len(relNewFiles) + len(relDeletedFiles) if totalChanges == 0 { @@ -993,7 +1006,11 @@ func handleLifecycleTurnEnd(ctx context.Context, ag agent.Agent, event *agent.Ev // to include subagent tokens. tokenUsage := event.TokenUsage if tokenUsage == nil { - tokenUsage = agent.CalculateTokenUsage(ctx, ag, transcriptData, transcriptLinesAtStart, subagentsDir) + if codexInventoryUsage != nil { + tokenUsage = codexInventoryUsage + } else { + tokenUsage = agent.CalculateTokenUsage(ctx, ag, transcriptData, transcriptLinesAtStart, subagentsDir) + } } // Build fully-populated step context and delegate to strategy @@ -1012,6 +1029,7 @@ func handleLifecycleTurnEnd(ctx context.Context, ag agent.Agent, event *agent.Ev StepTranscriptIdentifier: transcriptIdentifierAtStart, StepTranscriptStart: transcriptLinesAtStart, TokenUsage: tokenUsage, + SubagentLedgerVersion: codexLedgerVersion, } // finishTurn is the shared turn-end tail, run whether the save succeeded @@ -1116,6 +1134,12 @@ func handleLifecycleSessionEnd(ctx context.Context, ag agent.Agent, event *agent // (sessionEndCondenseDeadline) that budget-capped agents get; Claude Code // sets no budget, and for agents that do, bounding the final captures // against the same deadline is a known follow-up. + if ag.Type() == agent.AgentTypeCodex { + if transcript, readErr := ag.ReadTranscript(event.SessionRef); readErr == nil { + _, _ = refreshCodexInventory(ctx, ag, event.SessionID, transcript, 0) + } + finalizeCodexObservedAtSessionEnd(ctx, event.SessionID) + } completeLiveTaskRecords(ctx, ag, event.SessionID, event.SessionRef) if _, err := endSessionNow(ctx, event, event.SessionID, nil, sessionEndCondenseDeadline(ag), endedNow); err != nil { @@ -1126,6 +1150,104 @@ func handleLifecycleSessionEnd(ctx context.Context, ag agent.Agent, event *agent return nil } +// finalizeCodexObservedAtSessionEnd closes every observed turn that did not +// have a matching terminal record in the same verified rollout analysis. This +// deliberately iterates the inventory rather than live task records: a +// follow-up can be hidden behind a completed-but-unmaterialized record. +func finalizeCodexObservedAtSessionEnd(ctx context.Context, sessionID string) { + if err := strategy.MutateSessionState(ctx, sessionID, func(state *strategy.SessionState) error { + for _, entry := range state.SubagentInventory { + for _, turnID := range entry.ObservedTurnIDs { + if !state.FinalizeSubagentTurn(entry.AgentID, turnID) { + continue + } + for i := range state.TaskRecords { + record := &state.TaskRecords[i] + if record.AgentID == entry.AgentID && record.CompletedAt.IsZero() { + record.CompletedAt = time.Now() + record.TokenUsage = nil + break + } + } + } + } + return nil + }); err != nil && !errors.Is(err, strategy.ErrStateNotFound) { + logging.Debug(ctx, "failed to finalize codex turns at session end", slog.String("error", err.Error())) + } +} + +// refreshCodexInventory snapshots the durable child ledger, performs the +// potentially slow filesystem analysis outside its lock, then applies only +// path enrichment and terminal evidence if no new child observation raced it. +// It never manufactures an exact-empty result for an unknown/legacy ledger. +func refreshCodexInventory(ctx context.Context, ag agent.Agent, sessionID string, parent []byte, fromOffset int) (*agent.TokenUsage, uint64) { + state, err := strategy.LoadSessionState(ctx, sessionID) + if err != nil || state == nil || state.SubagentInventoryComplete == nil { + return nil, 0 + } + refs := make([]agent.SubagentReference, 0, len(state.SubagentInventory)) + for _, entry := range state.SubagentInventory { + refs = append(refs, agent.SubagentReference{AgentID: entry.AgentID, DeclaredTranscriptPath: entry.DeclaredTranscriptPath, ResolvedTranscriptPath: entry.ResolvedTranscriptPath}) + } + version := state.SubagentLedgerVersion + extraction, ok := agent.ExtractWithSubagentInventory(ctx, ag, parent, fromOffset, refs) + if !ok { + return nil, version + } + + usage := extraction.TokenUsage + if !*state.SubagentInventoryComplete { + // A legacy/partial inventory may still provide main transcript evidence, + // but cannot truthfully claim full child coverage. + usage = types.WithClearedSubagentTokens(usage, false) + } + if err := strategy.MutateSessionState(ctx, sessionID, func(current *strategy.SessionState) error { + if current.SubagentLedgerVersion != version { + return strategy.ErrMutationSkip + } + for _, child := range extraction.Children { + current.UpdateSubagentTranscriptPaths(child.AgentID, "", child.ResolvedPath) + for _, turnID := range child.TerminalTurnIDs { + if !current.FinalizeSubagentTurn(child.AgentID, turnID) { + continue + } + for i := range current.TaskRecords { + record := ¤t.TaskRecords[i] + if record.AgentID != child.AgentID || !record.CompletedAt.IsZero() { + continue + } + record.CompletedAt = time.Now() + record.Files = child.ModifiedFiles + record.DeclaredTranscriptPath = child.ResolvedPath + // nil is evidence too: a newer terminal snapshot without exact + // usage must clear, never preserve, an earlier total. + record.TokenUsage = child.TokenUsage + current.FilesTouched = mergeUnique(current.FilesTouched, child.ModifiedFiles) + break + } + } + } + // This is also the no-file refresh path: retain the latest exact child + // snapshot (including authoritative empty or unavailable) without + // creating a checkpoint step or changing main-agent counters. + if usage != nil { + if current.TokenUsage == nil { + current.TokenUsage = &agent.TokenUsage{} + } + current.TokenUsage.SubagentTokens = usage.SubagentTokens + if usage.SubagentTokensComplete != nil { + complete := *usage.SubagentTokensComplete + current.TokenUsage.SubagentTokensComplete = &complete + } + } + return nil + }); err != nil && !errors.Is(err, strategy.ErrStateNotFound) { + logging.Debug(ctx, "failed to persist codex inventory evidence", slog.String("error", err.Error())) + } + return usage, version +} + // processStart approximates when this hook process began. Package // initialization runs before main, so it is within milliseconds of exec — // precise enough to bound work against a deadline the agent measures from the @@ -1217,6 +1339,31 @@ func handleLifecycleSubagentStart(ctx context.Context, ag agent.Agent, event *ag slog.String("transcript", event.SessionRef), ) + if ag.Type() == agent.AgentTypeCodex { + if event.SubagentID == "" || event.TurnID == "" || event.ToolUseID == "" { + return errors.New("invalid codex subagent start: agent, turn, and tool IDs are required") + } + // The ledger is authoritative. Persist it before the generic capture, + // whose worktree read is intentionally best effort for Codex children. + if err := GetStrategy(ctx).EnsureSessionExists(ctx, event.SessionID, ag.Type()); err != nil { + return fmt.Errorf("ensure codex subagent session: %w", err) + } + if err := strategy.MutateSessionState(ctx, event.SessionID, func(state *strategy.SessionState) error { + state.RegisterSubagent(event.SubagentID, event.TurnID) + state.EnsureTaskRecord(session.TaskRecord{ + ToolUseID: event.ToolUseID, AgentID: event.SubagentID, StartedAt: time.Now(), + SubagentType: event.SubagentType, TaskDescription: event.TaskDescription, + }) + return nil + }); err != nil { + return fmt.Errorf("register codex subagent: %w", err) + } + if err := CapturePreTaskState(ctx, event.ToolUseID); err != nil { + logging.Warn(logCtx, "best-effort codex pre-task capture failed", slog.String("error", err.Error())) + } + return nil + } + // Capture pre-task state if err := CapturePreTaskState(ctx, event.ToolUseID); err != nil { return fmt.Errorf("failed to capture pre-task state: %w", err) @@ -1265,6 +1412,26 @@ func handleLifecycleSubagentEnd(ctx context.Context, ag agent.Agent, event *agen // Extract subagent type and description from tool input event.SubagentType, event.TaskDescription = ParseSubagentTypeAndDescription(event.ToolInput) } + if ag.Type() == agent.AgentTypeCodex && event.ProvisionalSubagentStop { + if event.SubagentID == "" || event.TurnID == "" { + return errors.New("invalid codex provisional subagent stop: agent and turn IDs are required") + } + // Codex's stop hook is deliberately not completion: its rollout can + // still be changing. Record only the observation for later transcript + // reconciliation; do not capture the parent worktree or mark a task done. + err := strategy.MutateSessionState(logCtx, event.SessionID, func(state *strategy.SessionState) error { + state.RecordSubagentStop(event.SubagentID, event.TurnID, session.SubagentStopCandidate{ObservedAt: time.Now(), StopHookActive: event.StopHookActive}) + state.UpdateSubagentTranscriptPaths(event.SubagentID, event.SubagentTranscriptPath, "") + return nil + }) + if errors.Is(err, strategy.ErrStateNotFound) { + return nil + } + if err != nil { + return fmt.Errorf("record codex provisional subagent stop: %w", err) + } + return nil + } if event.Final { return handleSubagentStopFinal(logCtx, ag, event) diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 2137790f3b..b1fc217e44 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -1146,11 +1146,18 @@ func hasTokenUsageData(usage *agent.TokenUsage) bool { // path could resolve a subagents dir from session state (as review/manifest.go // does) and rescope against SubagentTokensBaseline; deferred, not blocked. func withSubagentTokensFrom(usage, src *agent.TokenUsage) *agent.TokenUsage { - if usage == nil || usage.SubagentTokens != nil || src == nil || src.SubagentTokens == nil { + if usage == nil || usage.SubagentTokens != nil || usage.SubagentTokensComplete != nil || src == nil { return usage } filled := *usage filled.SubagentTokens = src.SubagentTokens + if src.SubagentTokensComplete != nil { + complete := *src.SubagentTokensComplete + filled.SubagentTokensComplete = &complete + if !complete { + filled.SubagentTokens = nil + } + } return &filled } diff --git a/cmd/entire/cli/strategy/manual_commit_git.go b/cmd/entire/cli/strategy/manual_commit_git.go index 75db9466e9..bcd76babfa 100644 --- a/cmd/entire/cli/strategy/manual_commit_git.go +++ b/cmd/entire/cli/strategy/manual_commit_git.go @@ -46,6 +46,11 @@ func (s *ManualCommitStrategy) SaveStep(ctx context.Context, step StepContext) e } mutErr := MutateSessionState(ctx, sessionID, func(state *SessionState) error { + if step.SubagentLedgerVersion != 0 && state.SubagentLedgerVersion != step.SubagentLedgerVersion && step.TokenUsage != nil { + // Keep valid main-agent deltas but never persist a child aggregate + // computed against an older authoritative inventory. + step.TokenUsage = types.WithClearedSubagentTokens(step.TokenUsage, false) + } _, migrateSpan := perf.Start(ctx, "migrate_shadow_branch") if _, _, err := s.migrateShadowBranchIfNeeded(ctx, repo, state); err != nil { migrateSpan.RecordError(err) @@ -148,9 +153,23 @@ func (s *ManualCommitStrategy) SaveStep(ctx context.Context, step StepContext) e // that would double-subtract and (via clampSubtract) shrink or zero a // real subagent total. Recomputing from the session-wide cumulative // is idempotent regardless of whether this step carried a snapshot. - if state.CheckpointTokenUsage != nil { - state.CheckpointTokenUsage.SubagentTokens = types.SubtractTokenUsage( - state.TokenUsage.SubagentTokens, state.SubagentTokensBaseline) + if state.CheckpointTokenUsage != nil && state.TokenUsage != nil { + complete := state.TokenUsage.SubagentTokensComplete + switch { + case complete != nil && !*complete: + state.CheckpointTokenUsage = types.WithClearedSubagentTokens(state.CheckpointTokenUsage, false) + case state.SubagentTokensBaselineComplete != nil && !*state.SubagentTokensBaselineComplete: + // A known-incomplete baseline cannot yield an exact delta, even + // when the current inventory has become complete again. + state.CheckpointTokenUsage = types.WithClearedSubagentTokens(state.CheckpointTokenUsage, false) + default: + state.CheckpointTokenUsage.SubagentTokens = types.SubtractTokenUsage( + state.TokenUsage.SubagentTokens, state.SubagentTokensBaseline) + if complete != nil { + value := *complete + state.CheckpointTokenUsage.SubagentTokensComplete = &value + } + } } } @@ -446,7 +465,7 @@ func accumulateTokenUsage(existing, incoming *agent.TokenUsage) *agent.TokenUsag } if existing == nil { // Return a copy to avoid sharing the pointer - return &agent.TokenUsage{ + result := &agent.TokenUsage{ InputTokens: incoming.InputTokens, CacheCreationTokens: incoming.CacheCreationTokens, CacheReadTokens: incoming.CacheReadTokens, @@ -454,6 +473,14 @@ func accumulateTokenUsage(existing, incoming *agent.TokenUsage) *agent.TokenUsag APICallCount: incoming.APICallCount, SubagentTokens: incoming.SubagentTokens, } + if incoming.SubagentTokensComplete != nil { + complete := *incoming.SubagentTokensComplete + result.SubagentTokensComplete = &complete + if !complete { + result.SubagentTokens = nil + } + } + return result } // Accumulate values @@ -466,7 +493,13 @@ func accumulateTokenUsage(existing, incoming *agent.TokenUsage) *agent.TokenUsag // Replace (not add) subagent tokens: incoming.SubagentTokens is already // the cumulative total as of this step, so the latest snapshot supersedes // whatever was recorded before rather than stacking on top of it. - if incoming.SubagentTokens != nil { + if incoming.SubagentTokensComplete != nil { + complete := *incoming.SubagentTokensComplete + existing.SubagentTokensComplete = &complete + // An explicit inventory result is authoritative, including exact empty + // (complete with nil) and unavailable (incomplete with nil). + existing.SubagentTokens = incoming.SubagentTokens + } else if incoming.SubagentTokens != nil { existing.SubagentTokens = incoming.SubagentTokens } diff --git a/cmd/entire/cli/strategy/strategy.go b/cmd/entire/cli/strategy/strategy.go index 1b53eab631..d9304158db 100644 --- a/cmd/entire/cli/strategy/strategy.go +++ b/cmd/entire/cli/strategy/strategy.go @@ -161,6 +161,10 @@ type StepContext struct { // TokenUsage contains the token usage for this checkpoint TokenUsage *agent.TokenUsage + + // SubagentLedgerVersion is the authoritative inventory version observed + // while token evidence was extracted. Zero means no inventory snapshot. + SubagentLedgerVersion uint64 } // TaskStepContext contains all information needed for saving a task step checkpoint. diff --git a/cmd/entire/cli/strategy/subagent_tokens_test.go b/cmd/entire/cli/strategy/subagent_tokens_test.go index 626e69aeaa..9c5e731831 100644 --- a/cmd/entire/cli/strategy/subagent_tokens_test.go +++ b/cmd/entire/cli/strategy/subagent_tokens_test.go @@ -51,6 +51,28 @@ func TestAccumulateTokenUsage_SubagentTokensReplacedNotSummed(t *testing.T) { require.Equal(t, 250, existing.SubagentTokens.OutputTokens, "SubagentTokens must be replaced, not summed") } +func TestAccumulateTokenUsage_ExplicitIncompleteClearsPriorChildTotal(t *testing.T) { + t.Parallel() + complete := true + incomplete := false + existing := &agent.TokenUsage{InputTokens: 3, SubagentTokens: &agent.TokenUsage{InputTokens: 9}, SubagentTokensComplete: &complete} + got := accumulateTokenUsage(existing, &agent.TokenUsage{OutputTokens: 4, SubagentTokensComplete: &incomplete}) + require.Nil(t, got.SubagentTokens) + require.NotNil(t, got.SubagentTokensComplete) + require.False(t, *got.SubagentTokensComplete) + require.Equal(t, 3, got.InputTokens) + require.Equal(t, 4, got.OutputTokens) +} + +func TestAccumulateTokenUsage_ExplicitEmptyReplacesPriorChildTotal(t *testing.T) { + t.Parallel() + complete := true + got := accumulateTokenUsage(&agent.TokenUsage{SubagentTokens: &agent.TokenUsage{InputTokens: 9}}, &agent.TokenUsage{SubagentTokensComplete: &complete}) + require.Nil(t, got.SubagentTokens) + require.NotNil(t, got.SubagentTokensComplete) + require.True(t, *got.SubagentTokensComplete) +} + // TestSaveStep_SubagentTokensNotDoubleCountedAcrossCheckpoints exercises the // real SaveStep path for both Claude Code and Factory AI Droid (the two // agents whose CalculateTotalTokenUsage implementations discover subagent IDs From 32cb75e4df21e2e45275f8fa63991759cffacc1b Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Mon, 31 Aug 2026 23:05:15 -0700 Subject: [PATCH 03/59] test(codex): verify subagent tracking integration --- cmd/entire/cli/agent/codex/AGENT.md | 6 ++- .../integration_test/codex_subagent_test.go | 40 ++++++++++++++----- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/cmd/entire/cli/agent/codex/AGENT.md b/cmd/entire/cli/agent/codex/AGENT.md index 7c2115f870..449bae9bd0 100644 --- a/cmd/entire/cli/agent/codex/AGENT.md +++ b/cmd/entire/cli/agent/codex/AGENT.md @@ -344,7 +344,9 @@ The `systemMessage` field can be used to display messages to the user via the ag - **Transcript may be null:** In `--ephemeral` mode, `transcript_path` is null. The integration handles this gracefully. - **No hooks fire under `-s read-only`:** verified against 0.147.0 — a `codex exec -s read-only` run produces no hook invocations at all, so no session is tracked. `-s workspace-write` fires the full set. - **Subagent identity fields are inverted from their names:** `SubagentStart` / `SubagentStop` (schemas at `codex-rs/hooks/schema/generated/subagent-{start,stop}.command.input.schema.json`) send `session_id` = the identity shared by the root thread *and every descendant*, i.e. the user's session, which maps straight to Entire's SessionID; `agent_id` = the subagent thread's own id. Codex sends no `tool_use_id`, so `agent_id` doubles as Entire's ToolUseID — it is the only value correlating a start with its stop, and Entire keys pre-task state and the task metadata directory on it. Getting this backwards attributes subagent work to a session Entire has never seen. -- **`SubagentStop` carries two transcripts:** `transcript_path` is the *parent* rollout, `agent_transcript_path` the subagent's own. Entire forwards the latter as `Event.SubagentTranscriptPath`, so it never guesses a layout for Codex. +- **`SubagentStop` is provisional, not authoritative completion.** It carries two transcripts: `transcript_path` is the *parent* rollout and `agent_transcript_path` the child rollout. Entire retains the child identity and declared path, then accepts a rollout only after its first `session_meta.id` exactly matches `agent_id`, it is a regular file, and the same verified bytes are analyzed. A hook-supplied filename is never trusted by itself. +- **Completion is reconciled from the child rollout.** Only explicit matching `task_complete.turn_id` records (or the narrowly correlated legacy boundary) finalize a pending child turn. The root hook reads the authoritative inventory, batches exact-ID rollout lookup across active and archived trees, and fails closed on an ambiguous, unreadable, malformed, or non-regular candidate. +- **Child accounting is exact-only.** Each child retains independently readable file and terminal evidence, but the aggregate is present only when every inventory child resolves and has a valid final cumulative token snapshot. A missing or malformed child makes aggregate coverage incomplete and leaves its aggregate nil; no timestamps, filenames, text length, or tool/API-call counts are estimated. - **Only thread-spawned subagents fire these hooks:** internal/synthetic ones expose no user-configured lifecycle hooks, so they are invisible to Entire. - **Hook response protocol differs from Claude Code:** Codex uses `systemMessage` (same field name) but also supports `hookSpecificOutput` with `additionalContext` for injecting context into the model. For Entire's purposes, `systemMessage` is sufficient. @@ -353,7 +355,7 @@ The `systemMessage` field can be used to display messages to the user via the ag - ~~Hooks require feature flag~~ — `CodexHooks` became `Stage::Stable, default_enabled: true` on 2026-04-23 (openai/codex#19012) and the config key was aliased from `codex_hooks` to `hooks` on 2026-05-01 (openai/codex#20522). No flag is needed. - ~~No SessionEnd hook~~ — added in 0.146; Entire consumes it. - ~~PreToolUse is shell-only~~ — now dispatched generically from the tool registry (`codex-rs/core/src/tools/registry.rs`), covering shell, `apply_patch`, MCP tools and unified_exec. -- ~~No subagent hooks~~ — `SubagentStart` / `SubagentStop` exist, carrying `agent_id`, `agent_type` and `agent_transcript_path`, and Entire now consumes both: they are Codex's PreTask/PostTask equivalents and drive task checkpoints. See the identity and transcript gotchas above. +- `SubagentStart` / `SubagentStop` retain child inventory. Start records the child before best-effort generic capture; stop only records a pending observation and never captures the whole parent worktree or marks completion. See the identity and transcript reconciliation rules above. ## Captured Payloads diff --git a/cmd/entire/cli/integration_test/codex_subagent_test.go b/cmd/entire/cli/integration_test/codex_subagent_test.go index 50ec2b2c16..f37716423b 100644 --- a/cmd/entire/cli/integration_test/codex_subagent_test.go +++ b/cmd/entire/cli/integration_test/codex_subagent_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/codex" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/session" "github.com/stretchr/testify/require" @@ -30,20 +31,29 @@ func TestCodexSubagent_StoresDeclaredSubagentTranscript(t *testing.T) { agentID = "child-thread-9" editedFile = "docs/red.md" ) + complete := true require.NoError(t, env.WriteSessionState(sessionID, &session.State{ - SessionID: sessionID, - AgentType: agent.AgentTypeCodex, - BaseCommit: env.GetHeadHash(), + SessionID: sessionID, + AgentType: agent.AgentTypeCodex, + BaseCommit: env.GetHeadHash(), + SubagentInventoryComplete: &complete, })) rolloutDir := filepath.Join(env.RepoDir, ".entire", "tmp", "codex-rollouts") require.NoError(t, os.MkdirAll(rolloutDir, 0o750)) parentRollout := filepath.Join(rolloutDir, "rollout-"+sessionID+".jsonl") - require.NoError(t, os.WriteFile(parentRollout, []byte(`{"type":"session_meta","payload":{"id":"`+sessionID+`"}}`+"\n"), 0o600)) + require.NoError(t, os.WriteFile(parentRollout, []byte(`{"type":"session_meta","payload":{"id":"`+sessionID+`","thread_source":"user"}}`+"\n"), 0o600)) subagentRollout := filepath.Join(rolloutDir, "rollout-"+agentID+".jsonl") - require.NoError(t, os.WriteFile(subagentRollout, - []byte(`{"type":"response_item","payload":{"content":"wrote `+editedFile+`"}}`+"\n"), 0o600)) + require.NoError(t, os.WriteFile(subagentRollout, []byte( + `{"type":"session_meta","payload":{"id":"`+agentID+`"}}`+"\n"+ + `{"type":"event_msg","payload":{"type":"task_started","turn_id":"turn-1"}}`+"\n"+ + `{"type":"response_item","payload":{"type":"custom_tool_call","status":"completed","name":"apply_patch","input":"*** Begin Patch\n*** Add File: `+editedFile+`\n+red\n*** End Patch"}}`+"\n"+ + `{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":10,"cached_input_tokens":2,"output_tokens":3}}}}`+"\n"+ + `{"type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1"}}`+"\n"), 0o600)) + probe, err := (&codex.CodexAgent{RolloutRoots: []string{}}).ExtractWithSubagentInventory(nil, 0, []agent.SubagentReference{{AgentID: agentID, DeclaredTranscriptPath: subagentRollout}}) + require.NoError(t, err) + require.Equal(t, []string{"turn-1"}, probe.Children[0].TerminalTurnIDs) hook := codexHooker(t, env.RepoDir, sessionID, parentRollout) hook("subagent-start", map[string]any{ @@ -70,11 +80,19 @@ func TestCodexSubagent_StoresDeclaredSubagentTranscript(t *testing.T) { require.NoError(t, err) rec := state.FindTaskRecord(agentID) require.NotNil(t, rec, "expected a task record keyed by agent_id") - require.False(t, rec.CompletedAt.IsZero(), "subagent-stop must complete the record") - require.Equal(t, subagentRollout, rec.DeclaredTranscriptPath, - "the declared agent_transcript_path was not honoured") - require.True(t, containsFile(rec.Files, editedFile), - "the record must carry the subagent's edit, got %v", rec.Files) + require.True(t, rec.CompletedAt.IsZero(), "provisional subagent-stop must not complete the record") + + // Root Stop observes terminal evidence in the same verified child rollout. + hook("stop", map[string]any{"hook_event_name": "Stop", "last_assistant_message": "done"}) + state, err = env.GetSessionState(sessionID) + require.NoError(t, err) + require.Len(t, state.SubagentInventory, 1) + require.Equal(t, subagentRollout, state.SubagentInventory[0].DeclaredTranscriptPath) + require.Equal(t, []string{"turn-1"}, state.SubagentInventory[0].FinalizedTurnIDs) + rec = state.FindTaskRecord(agentID) + require.NotNil(t, rec) + require.False(t, rec.CompletedAt.IsZero(), "terminal child rollout must reconcile the record") + require.True(t, containsFile(rec.Files, editedFile), "the record must carry the child edit, got %v", rec.Files) // Committing condenses the session, and the materializer must store the rollout // itself — the storage guarantee this test is named for. From 271b7f5d186ce54bf1e3ffc2c934538d15bbbb10 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Tue, 1 Sep 2026 10:06:15 -0700 Subject: [PATCH 04/59] fix(codex): address subagent tracking review findings --- cmd/entire/cli/agent/codex/lifecycle.go | 29 ++++++++++++++----- cmd/entire/cli/agent/codex/lifecycle_test.go | 25 ++++++++++++++++ cmd/entire/cli/agent/types/token_usage.go | 17 +++++++---- .../cli/agent/types/token_usage_test.go | 16 ++++++++++ 4 files changed, 75 insertions(+), 12 deletions(-) diff --git a/cmd/entire/cli/agent/codex/lifecycle.go b/cmd/entire/cli/agent/codex/lifecycle.go index f04adf6de2..f29ee61ab6 100644 --- a/cmd/entire/cli/agent/codex/lifecycle.go +++ b/cmd/entire/cli/agent/codex/lifecycle.go @@ -5,10 +5,12 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "os" "time" "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/logging" ) // Compile-time interface assertions. @@ -109,16 +111,16 @@ func (c *CodexAgent) SessionEndBudget() time.Duration { return sessionEndBudget // ParseHookEvent translates a Codex hook into a normalized lifecycle Event. // Returns nil if the hook has no lifecycle significance. -func (c *CodexAgent) ParseHookEvent(_ context.Context, hookName string, stdin io.Reader) (*agent.Event, error) { +func (c *CodexAgent) ParseHookEvent(ctx context.Context, hookName string, stdin io.Reader) (*agent.Event, error) { switch hookName { case HookNameSessionStart: return c.parseSessionInfoEvent(stdin, agent.SessionStart) case HookNameSessionEnd: return c.parseSessionInfoEvent(stdin, agent.SessionEnd) case HookNameUserPromptSubmit: - return c.parseTurnStart(stdin) + return c.parseTurnStart(ctx, stdin) case HookNameStop: - return c.parseTurnEnd(stdin) + return c.parseTurnEnd(ctx, stdin) case HookNamePreToolUse: // PreToolUse has no lifecycle significance — pass through return nil, nil //nolint:nilnil // nil event = no lifecycle action @@ -201,12 +203,12 @@ func (c *CodexAgent) parseSessionInfoEvent(stdin io.Reader, eventType agent.Even }, nil } -func (c *CodexAgent) parseTurnStart(stdin io.Reader) (*agent.Event, error) { +func (c *CodexAgent) parseTurnStart(ctx context.Context, stdin io.Reader) (*agent.Event, error) { raw, err := agent.ReadAndParseHookInput[userPromptSubmitRaw](stdin) if err != nil { return nil, err } - if classifyRollout(derefString(raw.TranscriptPath)) != rolloutRoot { + if !isRootTurnRollout(ctx, derefString(raw.TranscriptPath)) { return nil, nil //nolint:nilnil // only proven root rollouts mutate lifecycle state } return &agent.Event{ @@ -275,12 +277,12 @@ func isApplyPatchTool(name string) bool { } } -func (c *CodexAgent) parseTurnEnd(stdin io.Reader) (*agent.Event, error) { +func (c *CodexAgent) parseTurnEnd(ctx context.Context, stdin io.Reader) (*agent.Event, error) { raw, err := agent.ReadAndParseHookInput[stopRaw](stdin) if err != nil { return nil, err } - if classifyRollout(derefString(raw.TranscriptPath)) != rolloutRoot { + if !isRootTurnRollout(ctx, derefString(raw.TranscriptPath)) { return nil, nil //nolint:nilnil // only proven root rollouts mutate lifecycle state } return &agent.Event{ @@ -291,3 +293,16 @@ func (c *CodexAgent) parseTurnEnd(stdin io.Reader) (*agent.Event, error) { Timestamp: time.Now(), }, nil } + +func isRootTurnRollout(ctx context.Context, path string) bool { + switch classifyRollout(path) { + case rolloutRoot: + return true + case rolloutChild: + return false + case rolloutUnknown: + logging.Warn(ctx, "codex: skipped turn lifecycle event for unclassified rollout", slog.String("path", path)) + return false + } + return false +} diff --git a/cmd/entire/cli/agent/codex/lifecycle_test.go b/cmd/entire/cli/agent/codex/lifecycle_test.go index 74c4a11683..142bddd152 100644 --- a/cmd/entire/cli/agent/codex/lifecycle_test.go +++ b/cmd/entire/cli/agent/codex/lifecycle_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/stretchr/testify/require" ) @@ -248,6 +249,30 @@ func TestParseHookEvent_UserPromptSubmitAndStopRequireRootRollout(t *testing.T) } } +func TestParseHookEvent_UnknownTurnRolloutWritesDiagnostic(t *testing.T) { + t.Parallel() + + rolloutPath := filepath.Join(t.TempDir(), "rollout.jsonl") + require.NoError(t, os.WriteFile(rolloutPath, []byte(`{"type":"session_meta","payload":{"source":"future-source"}}`+"\n"), 0o600)) + + logDir := t.TempDir() + logger, err := logging.New(logging.Config{Dir: logDir}) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, logger.Close()) }) + ctx := logging.WithLogger(context.Background(), logger) + input := `{"session_id":"root-session-1","turn_id":"turn-1","transcript_path":"` + rolloutPath + `","model":"gpt-5","prompt":"do work"}` + + event, err := (&CodexAgent{}).ParseHookEvent(ctx, HookNameUserPromptSubmit, strings.NewReader(input)) + require.NoError(t, err) + require.Nil(t, event) + require.NoError(t, logger.Close()) + + logData, err := os.ReadFile(filepath.Join(logDir, "entire.log")) + require.NoError(t, err) + require.Contains(t, string(logData), "codex: skipped turn lifecycle event for unclassified rollout") + require.Contains(t, string(logData), rolloutPath) +} + func TestParseHookEvent_PreToolUse_ReturnsNil(t *testing.T) { t.Parallel() ag := &CodexAgent{} diff --git a/cmd/entire/cli/agent/types/token_usage.go b/cmd/entire/cli/agent/types/token_usage.go index c651610eaa..a04bc01fc3 100644 --- a/cmd/entire/cli/agent/types/token_usage.go +++ b/cmd/entire/cli/agent/types/token_usage.go @@ -89,12 +89,19 @@ func addTokenUsageAtDepth(a, b *TokenUsage, depth int) *TokenUsage { } func tokenCompleteness(a, b *TokenUsage) *bool { - if a != nil && a.SubagentTokensComplete != nil { - complete := *a.SubagentTokensComplete - return &complete + seen := false + for _, usage := range []*TokenUsage{a, b} { + if usage == nil || usage.SubagentTokensComplete == nil { + continue + } + seen = true + if !*usage.SubagentTokensComplete { + incomplete := false + return &incomplete + } } - if b != nil && b.SubagentTokensComplete != nil { - complete := *b.SubagentTokensComplete + if seen { + complete := true return &complete } return nil diff --git a/cmd/entire/cli/agent/types/token_usage_test.go b/cmd/entire/cli/agent/types/token_usage_test.go index 19a766f3b1..d62e72e4e2 100644 --- a/cmd/entire/cli/agent/types/token_usage_test.go +++ b/cmd/entire/cli/agent/types/token_usage_test.go @@ -69,6 +69,22 @@ func TestAddTokenUsage(t *testing.T) { } } +func TestAddTokenUsage_ExplicitIncompleteDominatesComplete(t *testing.T) { + t.Parallel() + + complete := true + incomplete := false + for _, operands := range [][2]*TokenUsage{ + {{SubagentTokensComplete: &complete}, {SubagentTokensComplete: &incomplete}}, + {{SubagentTokensComplete: &incomplete}, {SubagentTokensComplete: &complete}}, + } { + got := AddTokenUsage(operands[0], operands[1]) + if got.SubagentTokensComplete == nil || *got.SubagentTokensComplete { + t.Fatalf("AddTokenUsage(%v, %v) completeness = %v, want false", *operands[0].SubagentTokensComplete, *operands[1].SubagentTokensComplete, got.SubagentTokensComplete) + } + } +} + // TestAddTokenUsage_TruncatesDeepSubagentChains pins MaxSubagentDepth. Token usage // is read back from per-session metadata.json blobs on the shared checkpoint // branch, so the chain depth is not trustworthy; an unbounded chain reaching the From bc470d886e225f4685ccec4fd77c29e4af74cc34 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Tue, 1 Sep 2026 10:30:02 -0700 Subject: [PATCH 05/59] fix(codex): retain multi-turn child evidence --- cmd/entire/cli/lifecycle.go | 6 ++- cmd/entire/cli/lifecycle_test.go | 71 ++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index db557ee21f..6d946bca6c 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -1214,10 +1214,12 @@ func refreshCodexInventory(ctx context.Context, ag agent.Agent, sessionID string } for i := range current.TaskRecords { record := ¤t.TaskRecords[i] - if record.AgentID != child.AgentID || !record.CompletedAt.IsZero() { + if record.AgentID != child.AgentID { continue } - record.CompletedAt = time.Now() + if record.CompletedAt.IsZero() { + record.CompletedAt = time.Now() + } record.Files = child.ModifiedFiles record.DeclaredTranscriptPath = child.ResolvedPath // nil is evidence too: a newer terminal snapshot without exact diff --git a/cmd/entire/cli/lifecycle_test.go b/cmd/entire/cli/lifecycle_test.go index 5025cb40df..6a6572d6d7 100644 --- a/cmd/entire/cli/lifecycle_test.go +++ b/cmd/entire/cli/lifecycle_test.go @@ -134,6 +134,77 @@ func (m *mockAnalyzerAgent) ExtractModifiedFilesFromOffset(_ string, _ int) ([]s return m.analyzerFiles, 0, nil } +type mockInventoryAgent struct { + *mockLifecycleAgent + + extraction agent.InventoryExtraction +} + +var _ agent.InventoryAwareExtractor = (*mockInventoryAgent)(nil) + +func (m *mockInventoryAgent) ExtractWithSubagentInventory(_ []byte, _ int, _ []agent.SubagentReference) (agent.InventoryExtraction, error) { + return m.extraction, nil +} + +func TestRefreshCodexInventory_MultiTurnChildRefreshesCompletedTaskRecord(t *testing.T) { + // NOT parallel: setupStopTestRepo changes the process working directory. + setupStopTestRepo(t) + ctx := context.Background() + const ( + sessionID = "codex-multi-turn-child" + agentID = "child-1" + ) + completedAt := time.Now().UTC().Truncate(time.Microsecond) + complete := true + require.NoError(t, strategy.SaveSessionState(ctx, &strategy.SessionState{ + SessionID: sessionID, + StartedAt: time.Now(), + Phase: session.PhaseActive, + SubagentInventoryComplete: &complete, + SubagentLedgerVersion: 2, + SubagentInventory: []session.SubagentInventoryEntry{{ + AgentID: agentID, + ObservedTurnIDs: []string{"turn-1", "turn-2"}, + FinalizedTurnIDs: []string{"turn-1"}, + }}, + TaskRecords: []session.TaskRecord{{ + ToolUseID: agentID, + AgentID: agentID, + StartedAt: completedAt.Add(-time.Minute), + CompletedAt: completedAt, + Files: []string{"first.go"}, + TokenUsage: &agent.TokenUsage{InputTokens: 10}, + }}, + FilesTouched: []string{"first.go"}, + })) + + ag := &mockInventoryAgent{ + mockLifecycleAgent: newMockAgent(), + extraction: agent.InventoryExtraction{Children: []agent.SubagentAnalysis{{ + AgentID: agentID, + ResolvedPath: "/tmp/child-1.jsonl", + ModifiedFiles: []string{"first.go", "second.go"}, + TokenUsage: &agent.TokenUsage{InputTokens: 25}, + TerminalTurnIDs: []string{"turn-2"}, + }}}, + } + + _, version := refreshCodexInventory(ctx, ag, sessionID, nil, 0) + require.Equal(t, uint64(2), version) + + state, err := strategy.LoadSessionState(ctx, sessionID) + require.NoError(t, err) + record := state.FindTaskRecord(agentID) + require.NotNil(t, record) + assert.Equal(t, completedAt, record.CompletedAt, "a later terminal turn updates evidence without completing the task twice") + assert.Equal(t, []string{"first.go", "second.go"}, record.Files) + require.NotNil(t, record.TokenUsage) + assert.Equal(t, 25, record.TokenUsage.InputTokens) + assert.Equal(t, "/tmp/child-1.jsonl", record.DeclaredTranscriptPath) + assert.ElementsMatch(t, []string{"first.go", "second.go"}, state.FilesTouched) + assert.Contains(t, state.FindSubagentInventory(agentID).FinalizedTurnIDs, "turn-2") +} + // --- DispatchLifecycleEvent tests --- func TestDispatchLifecycleEvent_NilAgent(t *testing.T) { From 366fe69df4c06e488d413b8f77845e320b7f9bf5 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Tue, 1 Sep 2026 10:49:15 -0700 Subject: [PATCH 06/59] fix(codex): clear stale session-end evidence --- cmd/entire/cli/lifecycle.go | 10 +++++--- cmd/entire/cli/lifecycle_test.go | 40 ++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index 6d946bca6c..cf5ed8247b 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -1163,11 +1163,15 @@ func finalizeCodexObservedAtSessionEnd(ctx context.Context, sessionID string) { } for i := range state.TaskRecords { record := &state.TaskRecords[i] - if record.AgentID == entry.AgentID && record.CompletedAt.IsZero() { + if record.AgentID != entry.AgentID { + continue + } + if record.CompletedAt.IsZero() { record.CompletedAt = time.Now() - record.TokenUsage = nil - break } + record.Files = nil + record.TokenUsage = nil + break } } } diff --git a/cmd/entire/cli/lifecycle_test.go b/cmd/entire/cli/lifecycle_test.go index 6a6572d6d7..d7f6bd43d6 100644 --- a/cmd/entire/cli/lifecycle_test.go +++ b/cmd/entire/cli/lifecycle_test.go @@ -205,6 +205,46 @@ func TestRefreshCodexInventory_MultiTurnChildRefreshesCompletedTaskRecord(t *tes assert.Contains(t, state.FindSubagentInventory(agentID).FinalizedTurnIDs, "turn-2") } +func TestFinalizeCodexObservedAtSessionEnd_MultiTurnChildClearsStaleEvidence(t *testing.T) { + // NOT parallel: setupStopTestRepo changes the process working directory. + setupStopTestRepo(t) + ctx := context.Background() + const ( + sessionID = "codex-session-end-multi-turn-child" + agentID = "child-1" + ) + completedAt := time.Now().UTC().Truncate(time.Microsecond) + require.NoError(t, strategy.SaveSessionState(ctx, &strategy.SessionState{ + SessionID: sessionID, + StartedAt: time.Now(), + Phase: session.PhaseActive, + SubagentInventory: []session.SubagentInventoryEntry{{ + AgentID: agentID, + ObservedTurnIDs: []string{"turn-1", "turn-2"}, + FinalizedTurnIDs: []string{"turn-1"}, + }}, + TaskRecords: []session.TaskRecord{{ + ToolUseID: agentID, + AgentID: agentID, + StartedAt: completedAt.Add(-time.Minute), + CompletedAt: completedAt, + Files: []string{"first.go"}, + TokenUsage: &agent.TokenUsage{InputTokens: 10}, + }}, + })) + + finalizeCodexObservedAtSessionEnd(ctx, sessionID) + + state, err := strategy.LoadSessionState(ctx, sessionID) + require.NoError(t, err) + record := state.FindTaskRecord(agentID) + require.NotNil(t, record) + assert.Equal(t, completedAt, record.CompletedAt, "force-closing a later turn must not complete the task twice") + assert.Empty(t, record.Files, "files from an earlier turn are not exact evidence for an unresolved later turn") + assert.Nil(t, record.TokenUsage, "tokens from an earlier turn are not exact evidence for an unresolved later turn") + assert.Contains(t, state.FindSubagentInventory(agentID).FinalizedTurnIDs, "turn-2") +} + // --- DispatchLifecycleEvent tests --- func TestDispatchLifecycleEvent_NilAgent(t *testing.T) { From a5d4e248183f81c0205f349e13c8002228b553c0 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Tue, 1 Sep 2026 11:04:19 -0700 Subject: [PATCH 07/59] fix(codex): guard exact usage completeness --- cmd/entire/cli/lifecycle.go | 11 +++---- cmd/entire/cli/lifecycle_test.go | 51 +++++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index cf5ed8247b..259220cb8b 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -1201,15 +1201,16 @@ func refreshCodexInventory(ctx context.Context, ag agent.Agent, sessionID string } usage := extraction.TokenUsage - if !*state.SubagentInventoryComplete { - // A legacy/partial inventory may still provide main transcript evidence, - // but cannot truthfully claim full child coverage. - usage = types.WithClearedSubagentTokens(usage, false) - } if err := strategy.MutateSessionState(ctx, sessionID, func(current *strategy.SessionState) error { if current.SubagentLedgerVersion != version { return strategy.ErrMutationSkip } + usage = extraction.TokenUsage + if current.SubagentInventoryComplete == nil || !*current.SubagentInventoryComplete { + // A legacy/partial inventory may still provide main transcript evidence, + // but cannot truthfully claim full child coverage. + usage = types.WithClearedSubagentTokens(usage, false) + } for _, child := range extraction.Children { current.UpdateSubagentTranscriptPaths(child.AgentID, "", child.ResolvedPath) for _, turnID := range child.TerminalTurnIDs { diff --git a/cmd/entire/cli/lifecycle_test.go b/cmd/entire/cli/lifecycle_test.go index d7f6bd43d6..1b70669bce 100644 --- a/cmd/entire/cli/lifecycle_test.go +++ b/cmd/entire/cli/lifecycle_test.go @@ -137,12 +137,16 @@ func (m *mockAnalyzerAgent) ExtractModifiedFilesFromOffset(_ string, _ int) ([]s type mockInventoryAgent struct { *mockLifecycleAgent - extraction agent.InventoryExtraction + extraction agent.InventoryExtraction + beforeReturn func() } var _ agent.InventoryAwareExtractor = (*mockInventoryAgent)(nil) func (m *mockInventoryAgent) ExtractWithSubagentInventory(_ []byte, _ int, _ []agent.SubagentReference) (agent.InventoryExtraction, error) { + if m.beforeReturn != nil { + m.beforeReturn() + } return m.extraction, nil } @@ -245,6 +249,51 @@ func TestFinalizeCodexObservedAtSessionEnd_MultiTurnChildClearsStaleEvidence(t * assert.Contains(t, state.FindSubagentInventory(agentID).FinalizedTurnIDs, "turn-2") } +func TestRefreshCodexInventory_UsesCurrentCompletenessWhenPersistingUsage(t *testing.T) { + // NOT parallel: setupStopTestRepo changes the process working directory. + setupStopTestRepo(t) + ctx := context.Background() + const sessionID = "codex-completeness-race" + complete := true + require.NoError(t, strategy.SaveSessionState(ctx, &strategy.SessionState{ + SessionID: sessionID, + StartedAt: time.Now(), + Phase: session.PhaseActive, + SubagentInventoryComplete: &complete, + SubagentLedgerVersion: 2, + })) + + extractedComplete := true + ag := &mockInventoryAgent{ + mockLifecycleAgent: newMockAgent(), + extraction: agent.InventoryExtraction{TokenUsage: &agent.TokenUsage{ + SubagentTokens: &agent.TokenUsage{InputTokens: 25}, + SubagentTokensComplete: &extractedComplete, + }}, + beforeReturn: func() { + require.NoError(t, strategy.MutateSessionState(ctx, sessionID, func(state *strategy.SessionState) error { + incomplete := false + state.SubagentInventoryComplete = &incomplete + return nil + })) + }, + } + + usage, version := refreshCodexInventory(ctx, ag, sessionID, nil, 0) + assert.Equal(t, uint64(2), version) + require.NotNil(t, usage) + require.NotNil(t, usage.SubagentTokensComplete) + assert.False(t, *usage.SubagentTokensComplete) + assert.Nil(t, usage.SubagentTokens) + + state, err := strategy.LoadSessionState(ctx, sessionID) + require.NoError(t, err) + require.NotNil(t, state.TokenUsage) + require.NotNil(t, state.TokenUsage.SubagentTokensComplete) + assert.False(t, *state.TokenUsage.SubagentTokensComplete) + assert.Nil(t, state.TokenUsage.SubagentTokens) +} + // --- DispatchLifecycleEvent tests --- func TestDispatchLifecycleEvent_NilAgent(t *testing.T) { From 863faec50347dbef7c090dd491e75f6279fdf27e Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Tue, 1 Sep 2026 11:52:24 -0700 Subject: [PATCH 08/59] fix(codex): skip empty fallback scans --- cmd/entire/cli/agent/codex/codex.go | 3 +++ cmd/entire/cli/agent/codex/subagent_test.go | 12 +++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/cmd/entire/cli/agent/codex/codex.go b/cmd/entire/cli/agent/codex/codex.go index 047ef41c5b..6e9e64e178 100644 --- a/cmd/entire/cli/agent/codex/codex.go +++ b/cmd/entire/cli/agent/codex/codex.go @@ -140,6 +140,9 @@ func (c *CodexAgent) walkRollouts(root string, visit fs.WalkDirFunc) error { // regular-candidate metadata failure discards all results: partial results // cannot prove a child ID is unique. func (c *CodexAgent) scanFallbackRollouts(agentIDs map[string]struct{}) map[string]loadedRollout { + if len(agentIDs) == 0 { + return map[string]loadedRollout{} + } matches := make(map[string][]loadedRollout) seenPaths := make(map[string]struct{}) for _, root := range c.rolloutRoots() { diff --git a/cmd/entire/cli/agent/codex/subagent_test.go b/cmd/entire/cli/agent/codex/subagent_test.go index 9deebb2435..3b9b66f0b2 100644 --- a/cmd/entire/cli/agent/codex/subagent_test.go +++ b/cmd/entire/cli/agent/codex/subagent_test.go @@ -325,8 +325,18 @@ func TestSubagentInventory_AggregatesOnlyCompleteExactChildren(t *testing.T) { func TestSubagentInventory_EmptyInventoryIsExactWithoutChildTotal(t *testing.T) { t.Parallel() - result, err := (&CodexAgent{RolloutRoots: []string{t.TempDir()}}).ExtractWithSubagentInventory(nil, 0, nil) + root := t.TempDir() + walks := 0 + ag := &CodexAgent{ + RolloutRoots: []string{root}, + walkDir: func(root string, visit fs.WalkDirFunc) error { + walks++ + return filepath.WalkDir(root, visit) + }, + } + result, err := ag.ExtractWithSubagentInventory(nil, 0, nil) require.NoError(t, err) + require.Zero(t, walks, "an exact empty inventory has no unresolved child and must not scan rollout archives") require.Empty(t, result.Children) require.NotNil(t, result.TokenUsage) require.NotNil(t, result.TokenUsage.SubagentTokensComplete) From 62ba0a6f571230196d1a8bbcf8f95cac99b77e02 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Thu, 3 Sep 2026 00:47:38 -0700 Subject: [PATCH 09/59] fix(codex): harden subagent checkpoint tracking Entire-Checkpoint: 01M1K3SP30MWG6YHC2QZ9R34A1 --- cmd/entire/cli/agent/agent.go | 2 +- cmd/entire/cli/agent/capabilities_test.go | 2 +- cmd/entire/cli/agent/codex/AGENT.md | 13 +- cmd/entire/cli/agent/codex/codex.go | 433 ++++++++++++++++-- cmd/entire/cli/agent/codex/lifecycle.go | 9 +- cmd/entire/cli/agent/codex/lifecycle_test.go | 86 +++- cmd/entire/cli/agent/codex/subagent_test.go | 174 ++++++- cmd/entire/cli/agent/codex/transcript.go | 75 ++- cmd/entire/cli/agent/codex/transcript_test.go | 37 ++ cmd/entire/cli/agent/token_usage.go | 2 +- .../integration_test/codex_subagent_test.go | 2 +- cmd/entire/cli/lifecycle.go | 7 + cmd/entire/cli/lifecycle_test.go | 11 +- .../cli/strategy/agent_resolution_test.go | 147 ++++++ .../cli/strategy/manual_commit_hooks.go | 61 ++- 15 files changed, 960 insertions(+), 101 deletions(-) diff --git a/cmd/entire/cli/agent/agent.go b/cmd/entire/cli/agent/agent.go index e955eaf789..1f128bda53 100644 --- a/cmd/entire/cli/agent/agent.go +++ b/cmd/entire/cli/agent/agent.go @@ -338,7 +338,7 @@ type InventoryExtraction struct { type InventoryAwareExtractor interface { Agent - ExtractWithSubagentInventory(parent []byte, fromOffset int, refs []SubagentReference) (InventoryExtraction, error) + ExtractWithSubagentInventory(ctx context.Context, parent []byte, fromOffset int, refs []SubagentReference) (InventoryExtraction, error) } // ModelExtractor extracts the LLM model identifier from a transcript for agents diff --git a/cmd/entire/cli/agent/capabilities_test.go b/cmd/entire/cli/agent/capabilities_test.go index 7b2be3444a..fe26b26171 100644 --- a/cmd/entire/cli/agent/capabilities_test.go +++ b/cmd/entire/cli/agent/capabilities_test.go @@ -79,7 +79,7 @@ func (m *mockFullAgent) PrepareTranscript(context.Context, string) error { retur func (m *mockFullAgent) CalculateTokenUsage([]byte, int) (*TokenUsage, error) { return nil, nil } //nolint:nilnil // test mock // InventoryAwareExtractor is built-in only and deliberately has no DeclaredCaps bit. -func (m *mockFullAgent) ExtractWithSubagentInventory([]byte, int, []SubagentReference) (InventoryExtraction, error) { +func (m *mockFullAgent) ExtractWithSubagentInventory(context.Context, []byte, int, []SubagentReference) (InventoryExtraction, error) { return InventoryExtraction{}, nil } diff --git a/cmd/entire/cli/agent/codex/AGENT.md b/cmd/entire/cli/agent/codex/AGENT.md index 449bae9bd0..b38b3ef178 100644 --- a/cmd/entire/cli/agent/codex/AGENT.md +++ b/cmd/entire/cli/agent/codex/AGENT.md @@ -311,7 +311,8 @@ The `systemMessage` field can be used to display messages to the user via the ag - The `transcript_path` field in hook payloads provides the exact path - Format: JSONL (line-delimited JSON) - Session ID extraction: `session_id` field from hook payload (UUID format) -- Transcript may be null in `--ephemeral` mode +- Transcript may be null in `--ephemeral` mode; root ownership cannot be + verified, so Entire skips turn lifecycle mutation and checkpoint capture. **Note:** Codex's primary storage is SQLite (`~/.codex/state`), but the JSONL rollout file is the file-based transcript we can read. The `transcript_path` in hook payloads points to this file. @@ -341,7 +342,15 @@ The `systemMessage` field can be used to display messages to the user via the ag - **SessionEnd must be trusted before it fires:** Codex silently skips hooks with no `trusted_hash` entry in the user's `config.toml`. Existing users have trusted the four older events but not `session_end`, so the hook does nothing until they approve it via `/hooks` inside Codex. `HookTrustGaps` and `InspectHookConfig(...).Missing` both cover `session_end`, so `entire doctor` and the SessionStart banner say so — without that it would fail silently. The e2e suite pre-trusts hooks by generating the same hashes itself (`e2e/agents/codex_trust.go`), so **an event added to `managedHooks` must also be added to `codexHookEventLabels`, the `codexHookEvents` struct, and the `codexEventGroups` switch there** — all three, or it is installed but inert for every e2e run; `TestCodexHookTrustState_CoversEveryInstalledEvent` fails when they drift. - **A pre-SessionEnd install still counts as installed:** `AreHooksInstalled` gates on the core events only, so adding an event does not retroactively drop Codex out of `entire status` and the agent pickers for everyone who enabled it earlier. The stale install is reported as drift through `InspectHookConfig(...).Missing` instead, with `entire enable` as the fix. - **`reason` carries no information:** always `"other"`, so a session ended by `/clear` is indistinguishable from one ended by quitting. -- **Transcript may be null:** In `--ephemeral` mode, `transcript_path` is null. The integration handles this gracefully. +- **Transcript may be null:** In `--ephemeral` mode, `transcript_path` is null. + Entire fails closed: it emits a categorized diagnostic and skips TurnStart / + TurnEnd state mutation and checkpoint capture because the root rollout cannot + be verified. Ephemeral Codex sessions therefore are not tracked. +- **Unreadable, malformed, or future rollout metadata also fails closed:** the + turn hook emits a diagnostic categorized as `unreadable_transcript`, + `malformed_session_metadata`, or `unclassified_source`, then performs no root + lifecycle mutation. This prevents a child or unknown future rollout shape + from being attributed to the root session. - **No hooks fire under `-s read-only`:** verified against 0.147.0 — a `codex exec -s read-only` run produces no hook invocations at all, so no session is tracked. `-s workspace-write` fires the full set. - **Subagent identity fields are inverted from their names:** `SubagentStart` / `SubagentStop` (schemas at `codex-rs/hooks/schema/generated/subagent-{start,stop}.command.input.schema.json`) send `session_id` = the identity shared by the root thread *and every descendant*, i.e. the user's session, which maps straight to Entire's SessionID; `agent_id` = the subagent thread's own id. Codex sends no `tool_use_id`, so `agent_id` doubles as Entire's ToolUseID — it is the only value correlating a start with its stop, and Entire keys pre-task state and the task metadata directory on it. Getting this backwards attributes subagent work to a session Entire has never seen. - **`SubagentStop` is provisional, not authoritative completion.** It carries two transcripts: `transcript_path` is the *parent* rollout and `agent_transcript_path` the child rollout. Entire retains the child identity and declared path, then accepts a rollout only after its first `session_meta.id` exactly matches `agent_id`, it is a regular file, and the same verified bytes are analyzed. A hook-supplied filename is never trusted by itself. diff --git a/cmd/entire/cli/agent/codex/codex.go b/cmd/entire/cli/agent/codex/codex.go index 6e9e64e178..7bb97f7243 100644 --- a/cmd/entire/cli/agent/codex/codex.go +++ b/cmd/entire/cli/agent/codex/codex.go @@ -32,10 +32,15 @@ type CodexAgent struct { // that already know them (notably tests). Nil uses Codex's normal home. RolloutRoots []string // loadRollout and walkDir are package-private deterministic test seams. - // Production always uses regular-file, same-descriptor loading and - // filepath.WalkDir respectively. + // Production uses verified same-descriptor reads plus the bounded, + // incremental directory walker. loadRollout func(string) (loadedRollout, error) walkDir func(string, fs.WalkDirFunc) error + // scanLimits and observeRolloutRead are deterministic test seams for the + // fallback rollout budget. Production uses defaultRolloutScanLimits and no + // observer. + scanLimits *rolloutScanLimits + observeRolloutRead func(string, int) } type loadedRollout struct { @@ -43,11 +48,100 @@ type loadedRollout struct { Data []byte } +const ( + rolloutScanTimeout = 500 * time.Millisecond + rolloutCandidateLimit = 20_000 + rolloutMetadataByteLimit = int64(64 << 10) + rolloutBodyByteLimit = int64(128 << 20) + rolloutAggregateLimit = int64(256 << 20) + rolloutReadDirBatch = 128 + rolloutBodyReadChunk = 32 << 10 + rolloutMetadataChunk = 1 << 10 +) + +type rolloutScanLimits struct { + timeout time.Duration + candidateLimit int + metadataByteLimit int64 + bodyByteLimit int64 + aggregateByteLimit int64 + readDirBatch int + now func() time.Time +} + +var defaultRolloutScanLimits = rolloutScanLimits{ //nolint:gochecknoglobals // immutable production defaults + timeout: rolloutScanTimeout, + candidateLimit: rolloutCandidateLimit, + metadataByteLimit: rolloutMetadataByteLimit, + bodyByteLimit: rolloutBodyByteLimit, + aggregateByteLimit: rolloutAggregateLimit, + readDirBatch: rolloutReadDirBatch, + now: time.Now, +} + +var errRolloutScanBudget = errors.New("codex rollout scan budget exceeded") + +type rolloutScanBudget struct { + ctx context.Context + limits rolloutScanLimits + deadline time.Time + candidates int + aggregateBytes int64 +} + +func newRolloutScanBudget(ctx context.Context, limits rolloutScanLimits) *rolloutScanBudget { + if limits.now == nil { + limits.now = time.Now + } + if limits.readDirBatch <= 0 { + limits.readDirBatch = rolloutReadDirBatch + } + return &rolloutScanBudget{ + ctx: ctx, + limits: limits, + deadline: limits.now().Add(limits.timeout), + } +} + +func (b *rolloutScanBudget) check() error { + if err := b.ctx.Err(); err != nil { + return fmt.Errorf("rollout scan canceled: %w: %w", err, errRolloutScanBudget) + } + if b.limits.timeout > 0 && !b.limits.now().Before(b.deadline) { + return fmt.Errorf("rollout scan deadline reached: %w", errRolloutScanBudget) + } + return nil +} + +func (b *rolloutScanBudget) observeCandidate() error { + if err := b.check(); err != nil { + return err + } + b.candidates++ + if b.limits.candidateLimit >= 0 && b.candidates > b.limits.candidateLimit { + return fmt.Errorf("rollout candidate limit %d exceeded: %w", b.limits.candidateLimit, errRolloutScanBudget) + } + return nil +} + +func (b *rolloutScanBudget) observeBytes(count int64) error { + if count < 0 || count > b.limits.aggregateByteLimit-b.aggregateBytes { + return fmt.Errorf("aggregate rollout byte limit %d exceeded: %w", b.limits.aggregateByteLimit, errRolloutScanBudget) + } + b.aggregateBytes += count + return nil +} + func rolloutRegularMode(mode fs.FileMode) bool { return mode.Type() == 0 } +//nolint:unused // Companion for readSessionMetaID's retained direct-path helper. func readRegularRollout(path string) (loadedRollout, error) { + return readRegularRolloutContext(context.Background(), path, rolloutBodyByteLimit, nil) +} + +func readRegularRolloutContext(ctx context.Context, path string, byteLimit int64, observe func(string, int)) (loadedRollout, error) { info, err := os.Lstat(path) if err != nil { return loadedRollout{}, fmt.Errorf("lstat rollout: %w", err) @@ -67,22 +161,53 @@ func readRegularRollout(path string) (loadedRollout, error) { if !rolloutRegularMode(opened.Mode()) || !os.SameFile(info, opened) { return loadedRollout{}, errors.New("rollout changed or is not a regular file") } - data, err := io.ReadAll(file) + if opened.Size() > byteLimit { + return loadedRollout{}, fmt.Errorf("rollout size %d exceeds limit %d", opened.Size(), byteLimit) + } + data, err := readRolloutBody(ctx, file, path, byteLimit, observe) if err != nil { return loadedRollout{}, fmt.Errorf("read rollout: %w", err) } return loadedRollout{Path: path, Data: data}, nil } -func (c *CodexAgent) loadCandidateRollout(path string) (loadedRollout, error) { +func readRolloutBody(ctx context.Context, file *os.File, path string, byteLimit int64, observe func(string, int)) ([]byte, error) { + data := make([]byte, 0) + buffer := make([]byte, rolloutBodyReadChunk) + var readBytes int64 + for { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("read rollout canceled: %w", err) + } + n, err := file.Read(buffer) + if n > 0 { + if observe != nil { + observe(path, n) + } + readBytes += int64(n) + if readBytes > byteLimit { + return nil, fmt.Errorf("rollout exceeds byte limit %d", byteLimit) + } + data = append(data, buffer[:n]...) + } + if errors.Is(err, io.EOF) { + return data, nil + } + if err != nil { + return nil, fmt.Errorf("read rollout body: %w", err) + } + } +} + +func (c *CodexAgent) loadCandidateRollout(ctx context.Context, path string) (loadedRollout, error) { if c.loadRollout != nil { return c.loadRollout(path) } - return readRegularRollout(path) + return readRegularRolloutContext(ctx, path, rolloutBodyByteLimit, c.observeRolloutRead) } -func (c *CodexAgent) loadVerifiedRollout(path, agentID string) (loadedRollout, bool) { - loaded, err := c.loadCandidateRollout(path) +func (c *CodexAgent) loadVerifiedRollout(ctx context.Context, path, agentID string) (loadedRollout, bool) { + loaded, err := c.loadCandidateRollout(ctx, path) if err != nil { return loadedRollout{}, false } @@ -114,73 +239,291 @@ func (c *CodexAgent) rolloutRoots() []string { return []string{sessionDir, filepath.Join(codexHome, "archived_sessions")} } -func (c *CodexAgent) loadDirectRollout(ref agent.SubagentReference) (loadedRollout, bool) { +func (c *CodexAgent) loadDirectRollout(ctx context.Context, ref agent.SubagentReference) (loadedRollout, bool) { for _, path := range []string{ref.DeclaredTranscriptPath, ref.ResolvedTranscriptPath} { if path == "" { continue } - if loaded, ok := c.loadVerifiedRollout(path, ref.AgentID); ok { + if loaded, ok := c.loadVerifiedRollout(ctx, path, ref.AgentID); ok { return loaded, true } } return loadedRollout{}, false } -func (c *CodexAgent) walkRollouts(root string, visit fs.WalkDirFunc) error { +func (c *CodexAgent) walkRollouts(ctx context.Context, root string, budget *rolloutScanBudget, visit func(string, fs.DirEntry) error) error { if c.walkDir != nil { - return c.walkDir(root, visit) + return c.walkDir(root, func(path string, entry fs.DirEntry, entryErr error) error { + if entryErr != nil { + if path == root && errors.Is(entryErr, fs.ErrNotExist) { + return nil + } + return entryErr + } + if err := budget.check(); err != nil { + return err + } + return visit(path, entry) + }) } - if err := filepath.WalkDir(root, visit); err != nil { + if err := walkRolloutsIncremental(ctx, root, budget, visit); err != nil { return fmt.Errorf("walk Codex rollouts: %w", err) } return nil } +func walkRolloutsIncremental(ctx context.Context, root string, budget *rolloutScanBudget, visit func(string, fs.DirEntry) error) error { + if err := budget.check(); err != nil { + return err + } + rootInfo, err := os.Lstat(root) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return fmt.Errorf("lstat rollout root: %w", err) + } + if !rootInfo.IsDir() { + return nil + } + return walkRolloutDirectory(ctx, root, budget, visit) +} + +func walkRolloutDirectory(ctx context.Context, dirPath string, budget *rolloutScanBudget, visit func(string, fs.DirEntry) error) error { + dir, err := os.Open(dirPath) //nolint:gosec // rollout root is user configuration or Codex's own directory + if err != nil { + return fmt.Errorf("open rollout directory: %w", err) + } + defer dir.Close() + + for { + if err := budget.check(); err != nil { + return err + } + entries, readErr := dir.ReadDir(budget.limits.readDirBatch) + for _, entry := range entries { + if err := budget.check(); err != nil { + return err + } + path := filepath.Join(dirPath, entry.Name()) + if entry.IsDir() { + if err := walkRolloutDirectory(ctx, path, budget, visit); err != nil { + return err + } + continue + } + if err := visit(path, entry); err != nil { + return err + } + } + if errors.Is(readErr, io.EOF) { + return nil + } + if readErr != nil { + return fmt.Errorf("read rollout directory: %w", readErr) + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("walk rollout directory canceled: %w", err) + } + } +} + +func (c *CodexAgent) inspectFallbackCandidate( + path string, + agentIDs map[string]struct{}, + budget *rolloutScanBudget, +) (string, loadedRollout, error) { + info, err := os.Lstat(path) + if err != nil { + return "", loadedRollout{}, fmt.Errorf("lstat rollout candidate: %w", err) + } + if !rolloutRegularMode(info.Mode()) { + return "", loadedRollout{}, nil + } + + if c.loadRollout != nil { + loaded, loadErr := c.loadRollout(path) + if loadErr != nil { + return "", loadedRollout{}, loadErr + } + if loaded.Path == "" { + loaded.Path = path + } + if loaded.Path != path { + return "", loadedRollout{}, errors.New("rollout loader returned a different path") + } + id, metaErr := sessionMetaID(loaded.Data) + if metaErr != nil { + return "", loadedRollout{}, metaErr + } + if _, wanted := agentIDs[id]; !wanted { + return id, loadedRollout{}, nil + } + if int64(len(loaded.Data)) > budget.limits.bodyByteLimit { + return "", loadedRollout{}, errRolloutScanBudget + } + if err := budget.observeBytes(int64(len(loaded.Data))); err != nil { + return "", loadedRollout{}, err + } + return id, loaded, nil + } + + file, err := os.Open(path) //nolint:gosec // Lstat and descriptor Stat below enforce a regular unchanged file + if err != nil { + return "", loadedRollout{}, fmt.Errorf("open rollout candidate: %w", err) + } + defer file.Close() + opened, err := file.Stat() + if err != nil { + return "", loadedRollout{}, fmt.Errorf("stat opened rollout candidate: %w", err) + } + if !rolloutRegularMode(opened.Mode()) || !os.SameFile(info, opened) { + return "", loadedRollout{}, errors.New("rollout candidate changed or is not a regular file") + } + + metadata, err := c.readFallbackMetadata(file, path, budget) + if err != nil { + return "", loadedRollout{}, err + } + id, err := sessionMetaID(metadata) + if err != nil { + return "", loadedRollout{}, err + } + if _, wanted := agentIDs[id]; !wanted { + return id, loadedRollout{}, nil + } + if opened.Size() > budget.limits.bodyByteLimit { + return "", loadedRollout{}, fmt.Errorf("rollout body size %d exceeds limit %d: %w", opened.Size(), budget.limits.bodyByteLimit, errRolloutScanBudget) + } + if opened.Size() > budget.limits.aggregateByteLimit-budget.aggregateBytes { + return "", loadedRollout{}, fmt.Errorf("aggregate rollout size exceeds limit %d: %w", budget.limits.aggregateByteLimit, errRolloutScanBudget) + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + return "", loadedRollout{}, fmt.Errorf("seek rollout candidate: %w", err) + } + data, err := c.readFallbackBody(file, path, budget) + if err != nil { + return "", loadedRollout{}, err + } + return id, loadedRollout{Path: path, Data: data}, nil +} + +func (c *CodexAgent) readFallbackMetadata(file *os.File, path string, budget *rolloutScanBudget) ([]byte, error) { + data := make([]byte, 0, min(rolloutMetadataChunk, int(budget.limits.metadataByteLimit))) + buffer := make([]byte, rolloutMetadataChunk) + for { + if err := budget.check(); err != nil { + return nil, err + } + remaining := budget.limits.metadataByteLimit + 1 - int64(len(data)) + if remaining <= 0 { + return nil, fmt.Errorf("rollout metadata exceeds limit %d: %w", budget.limits.metadataByteLimit, errRolloutScanBudget) + } + readSize := len(buffer) + if int64(readSize) > remaining { + readSize = int(remaining) + } + n, err := file.Read(buffer[:readSize]) + if n > 0 { + if budgetErr := budget.observeBytes(int64(n)); budgetErr != nil { + return nil, budgetErr + } + if c.observeRolloutRead != nil { + c.observeRolloutRead(path, n) + } + chunk := buffer[:n] + if newline := indexByte(chunk, '\n'); newline >= 0 { + data = append(data, chunk[:newline+1]...) + if int64(len(data)) > budget.limits.metadataByteLimit { + return nil, fmt.Errorf("rollout metadata exceeds limit %d: %w", budget.limits.metadataByteLimit, errRolloutScanBudget) + } + return data, nil + } + data = append(data, chunk...) + if int64(len(data)) > budget.limits.metadataByteLimit { + return nil, fmt.Errorf("rollout metadata exceeds limit %d: %w", budget.limits.metadataByteLimit, errRolloutScanBudget) + } + } + if errors.Is(err, io.EOF) { + if len(data) == 0 { + return nil, errors.New("rollout metadata is empty") + } + return data, nil + } + if err != nil { + return nil, fmt.Errorf("read rollout metadata: %w", err) + } + } +} + +func (c *CodexAgent) readFallbackBody(file *os.File, path string, budget *rolloutScanBudget) ([]byte, error) { + data := make([]byte, 0) + buffer := make([]byte, rolloutBodyReadChunk) + for { + if err := budget.check(); err != nil { + return nil, err + } + n, err := file.Read(buffer) + if n > 0 { + if budgetErr := budget.observeBytes(int64(n)); budgetErr != nil { + return nil, budgetErr + } + if c.observeRolloutRead != nil { + c.observeRolloutRead(path, n) + } + if int64(len(data)+n) > budget.limits.bodyByteLimit { + return nil, errRolloutScanBudget + } + data = append(data, buffer[:n]...) + } + if errors.Is(err, io.EOF) { + return data, nil + } + if err != nil { + return nil, fmt.Errorf("read fallback rollout body: %w", err) + } + } +} + +func indexByte(data []byte, target byte) int { + for index, value := range data { + if value == target { + return index + } + } + return -1 +} + // scanFallbackRollouts scans every configured root once. Any traversal or // regular-candidate metadata failure discards all results: partial results // cannot prove a child ID is unique. -func (c *CodexAgent) scanFallbackRollouts(agentIDs map[string]struct{}) map[string]loadedRollout { +func (c *CodexAgent) scanFallbackRollouts(ctx context.Context, agentIDs map[string]struct{}) (map[string]loadedRollout, error) { if len(agentIDs) == 0 { - return map[string]loadedRollout{} + return map[string]loadedRollout{}, nil } + limits := defaultRolloutScanLimits + if c.scanLimits != nil { + limits = *c.scanLimits + } + budget := newRolloutScanBudget(ctx, limits) matches := make(map[string][]loadedRollout) seenPaths := make(map[string]struct{}) for _, root := range c.rolloutRoots() { if root == "" { continue } - walkErr := c.walkRollouts(root, func(path string, entry fs.DirEntry, entryErr error) error { - if entryErr != nil { - if path == root && errors.Is(entryErr, fs.ErrNotExist) { - return nil // Missing configured roots are normal. - } - return fmt.Errorf("walk rollout candidate: %w", entryErr) - } + walkErr := c.walkRollouts(ctx, root, budget, func(path string, entry fs.DirEntry) error { if entry.IsDir() || filepath.Ext(path) != ".jsonl" { return nil } - info, err := entry.Info() - if err != nil { - return fmt.Errorf("stat rollout candidate: %w", err) + if err := budget.observeCandidate(); err != nil { + return err } - if !rolloutRegularMode(info.Mode()) { - return nil - } - loaded, err := c.loadCandidateRollout(path) + id, loaded, err := c.inspectFallbackCandidate(path, agentIDs, budget) if err != nil { - return fmt.Errorf("load rollout candidate: %w", err) + return fmt.Errorf("inspect rollout candidate: %w", err) } if loaded.Path == "" { - loaded.Path = path - } - if loaded.Path != path { - return errors.New("rollout loader returned a different path") - } - id, err := sessionMetaID(loaded.Data) - if err != nil { - return fmt.Errorf("read rollout metadata: %w", err) - } - if _, wanted := agentIDs[id]; !wanted { return nil } if _, duplicate := seenPaths[path]; !duplicate { @@ -190,7 +533,7 @@ func (c *CodexAgent) scanFallbackRollouts(agentIDs map[string]struct{}) map[stri return nil }) if walkErr != nil { - return nil + return nil, walkErr } } resolved := make(map[string]loadedRollout) @@ -199,7 +542,7 @@ func (c *CodexAgent) scanFallbackRollouts(agentIDs map[string]struct{}) map[stri resolved[id] = candidates[0] } } - return resolved + return resolved, nil } // resolveSubagentRollout is the path-only compatibility wrapper used by @@ -209,10 +552,14 @@ func (c *CodexAgent) resolveSubagentRollout(ref agent.SubagentReference) string if ref.AgentID == "" { return "" } - if loaded, ok := c.loadDirectRollout(ref); ok { + if loaded, ok := c.loadDirectRollout(context.Background(), ref); ok { return loaded.Path } - if loaded, ok := c.scanFallbackRollouts(map[string]struct{}{ref.AgentID: {}})[ref.AgentID]; ok { + fallback, err := c.scanFallbackRollouts(context.Background(), map[string]struct{}{ref.AgentID: {}}) + if err != nil { + return "" + } + if loaded, ok := fallback[ref.AgentID]; ok { return loaded.Path } return "" diff --git a/cmd/entire/cli/agent/codex/lifecycle.go b/cmd/entire/cli/agent/codex/lifecycle.go index f29ee61ab6..d008295c10 100644 --- a/cmd/entire/cli/agent/codex/lifecycle.go +++ b/cmd/entire/cli/agent/codex/lifecycle.go @@ -295,13 +295,18 @@ func (c *CodexAgent) parseTurnEnd(ctx context.Context, stdin io.Reader) (*agent. } func isRootTurnRollout(ctx context.Context, path string) bool { - switch classifyRollout(path) { + classification := classifyRolloutDetailed(path) + switch classification.Classification { case rolloutRoot: return true case rolloutChild: + logging.Debug(ctx, "codex: skipped root lifecycle mutation for child rollout", slog.String("path", path)) return false case rolloutUnknown: - logging.Warn(ctx, "codex: skipped turn lifecycle event for unclassified rollout", slog.String("path", path)) + logging.Warn(ctx, "codex: skipped turn lifecycle event because rollout ownership is unverified", + slog.String("category", string(classification.Issue)), + slog.String("detail", classification.Detail), + slog.String("path", path)) return false } return false diff --git a/cmd/entire/cli/agent/codex/lifecycle_test.go b/cmd/entire/cli/agent/codex/lifecycle_test.go index 142bddd152..7154d1afa6 100644 --- a/cmd/entire/cli/agent/codex/lifecycle_test.go +++ b/cmd/entire/cli/agent/codex/lifecycle_test.go @@ -249,28 +249,80 @@ func TestParseHookEvent_UserPromptSubmitAndStopRequireRootRollout(t *testing.T) } } -func TestParseHookEvent_UnknownTurnRolloutWritesDiagnostic(t *testing.T) { +func TestParseHookEvent_UnknownTurnRolloutWritesCategorizedDiagnostic(t *testing.T) { t.Parallel() - rolloutPath := filepath.Join(t.TempDir(), "rollout.jsonl") - require.NoError(t, os.WriteFile(rolloutPath, []byte(`{"type":"session_meta","payload":{"source":"future-source"}}`+"\n"), 0o600)) + tests := []struct { + name string + path func(*testing.T) string + category rolloutClassificationIssue + detail string + }{ + { + name: "null transcript path", + path: func(*testing.T) string { return "" }, + category: rolloutIssueNullPath, + }, + { + name: "unreadable transcript", + path: func(t *testing.T) string { + return filepath.Join(t.TempDir(), "missing.jsonl") + }, + category: rolloutIssueUnreadable, + detail: "open", + }, + { + name: "malformed metadata", + path: func(t *testing.T) string { + path := filepath.Join(t.TempDir(), "rollout.jsonl") + require.NoError(t, os.WriteFile(path, []byte(`{"type":"session_meta","payload":`), 0o600)) + return path + }, + category: rolloutIssueMalformedMetadata, + detail: "first_record_json", + }, + { + name: "future source", + path: func(t *testing.T) string { + path := filepath.Join(t.TempDir(), "rollout.jsonl") + require.NoError(t, os.WriteFile(path, []byte(`{"type":"session_meta","payload":{"source":"future-source"}}`+"\n"), 0o600)) + return path + }, + category: rolloutIssueUnclassifiedSource, + detail: "future-source", + }, + } - logDir := t.TempDir() - logger, err := logging.New(logging.Config{Dir: logDir}) - require.NoError(t, err) - t.Cleanup(func() { require.NoError(t, logger.Close()) }) - ctx := logging.WithLogger(context.Background(), logger) - input := `{"session_id":"root-session-1","turn_id":"turn-1","transcript_path":"` + rolloutPath + `","model":"gpt-5","prompt":"do work"}` + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + rolloutPath := tt.path(t) + pathJSON := "null" + if rolloutPath != "" { + pathJSON = `"` + rolloutPath + `"` + } + logDir := t.TempDir() + logger, err := logging.New(logging.Config{Dir: logDir}) + require.NoError(t, err) + ctx := logging.WithLogger(context.Background(), logger) + input := `{"session_id":"root-session-1","turn_id":"turn-1","transcript_path":` + pathJSON + `,"model":"gpt-5","prompt":"do work"}` - event, err := (&CodexAgent{}).ParseHookEvent(ctx, HookNameUserPromptSubmit, strings.NewReader(input)) - require.NoError(t, err) - require.Nil(t, event) - require.NoError(t, logger.Close()) + event, err := (&CodexAgent{}).ParseHookEvent(ctx, HookNameUserPromptSubmit, strings.NewReader(input)) + require.NoError(t, err) + require.Nil(t, event) + require.NoError(t, logger.Close()) - logData, err := os.ReadFile(filepath.Join(logDir, "entire.log")) - require.NoError(t, err) - require.Contains(t, string(logData), "codex: skipped turn lifecycle event for unclassified rollout") - require.Contains(t, string(logData), rolloutPath) + logData, err := os.ReadFile(filepath.Join(logDir, "entire.log")) + require.NoError(t, err) + logText := string(logData) + require.Contains(t, logText, "codex: skipped turn lifecycle event because rollout ownership is unverified") + require.Contains(t, logText, string(tt.category)) + require.Contains(t, logText, tt.detail) + if rolloutPath != "" { + require.Contains(t, logText, rolloutPath) + } + }) + } } func TestParseHookEvent_PreToolUse_ReturnsNil(t *testing.T) { diff --git a/cmd/entire/cli/agent/codex/subagent_test.go b/cmd/entire/cli/agent/codex/subagent_test.go index 3b9b66f0b2..f9a3f5db9d 100644 --- a/cmd/entire/cli/agent/codex/subagent_test.go +++ b/cmd/entire/cli/agent/codex/subagent_test.go @@ -1,6 +1,7 @@ package codex import ( + "context" "encoding/json" "errors" "io/fs" @@ -8,6 +9,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/stretchr/testify/require" @@ -261,7 +263,7 @@ func TestSubagentInventory_CollectsExactEvidenceAndDoesNotPartialAggregate(t *te }) parent := rolloutData(t, "parent", []json.RawMessage{patchEvent("parent.txt")}) - result, err := ag.ExtractWithSubagentInventory(parent, 0, []agent.SubagentReference{ + result, err := ag.ExtractWithSubagentInventory(t.Context(), parent, 0, []agent.SubagentReference{ {AgentID: "one", DeclaredTranscriptPath: childOne}, {AgentID: "two", ResolvedTranscriptPath: childTwo}, }) @@ -299,7 +301,7 @@ func TestSubagentInventory_AggregatesOnlyCompleteExactChildren(t *testing.T) { tokenCountEvent(map[string]any{"total_token_usage": map[string]any{"input_tokens": 10, "cached_input_tokens": 3, "output_tokens": 5}}), }) - result, err := ag.ExtractWithSubagentInventory(nil, 0, []agent.SubagentReference{ + result, err := ag.ExtractWithSubagentInventory(t.Context(), nil, 0, []agent.SubagentReference{ {AgentID: "first", DeclaredTranscriptPath: first}, {AgentID: "second", ResolvedTranscriptPath: second}, }) @@ -334,7 +336,7 @@ func TestSubagentInventory_EmptyInventoryIsExactWithoutChildTotal(t *testing.T) return filepath.WalkDir(root, visit) }, } - result, err := ag.ExtractWithSubagentInventory(nil, 0, nil) + result, err := ag.ExtractWithSubagentInventory(t.Context(), nil, 0, nil) require.NoError(t, err) require.Zero(t, walks, "an exact empty inventory has no unresolved child and must not scan rollout archives") require.Empty(t, result.Children) @@ -356,7 +358,7 @@ func TestSubagentInventory_UnresolvedChildPreventsPartialAggregate(t *testing.T) tokenCountEvent(map[string]any{"total_token_usage": map[string]any{"input_tokens": 2, "cached_input_tokens": 1, "output_tokens": 1}}), }) - result, err := ag.ExtractWithSubagentInventory(nil, 0, []agent.SubagentReference{ + result, err := ag.ExtractWithSubagentInventory(t.Context(), nil, 0, []agent.SubagentReference{ {AgentID: "available", DeclaredTranscriptPath: available}, {AgentID: "missing"}, }) @@ -393,7 +395,7 @@ func TestSubagentInventory_LoaderFailureFailsClosedBeforeResolution(t *testing.T }, } - result, err := ag.ExtractWithSubagentInventory(nil, 0, []agent.SubagentReference{{ + result, err := ag.ExtractWithSubagentInventory(t.Context(), nil, 0, []agent.SubagentReference{{ AgentID: "child", DeclaredTranscriptPath: path, }}) @@ -423,7 +425,7 @@ func TestSubagentInventory_RevalidatesInjectedRolloutBytes(t *testing.T) { }, } - result, err := ag.ExtractWithSubagentInventory(nil, 0, []agent.SubagentReference{{ + result, err := ag.ExtractWithSubagentInventory(t.Context(), nil, 0, []agent.SubagentReference{{ AgentID: "child", DeclaredTranscriptPath: path, }}) @@ -450,7 +452,7 @@ func TestSubagentInventory_BatchesFallbackTraversal(t *testing.T) { }, } - result, err := ag.ExtractWithSubagentInventory(nil, 0, []agent.SubagentReference{{AgentID: "first"}, {AgentID: "second"}}) + result, err := ag.ExtractWithSubagentInventory(t.Context(), nil, 0, []agent.SubagentReference{{AgentID: "first"}, {AgentID: "second"}}) require.NoError(t, err) require.Equal(t, 2, walks, "one traversal per configured root, not per child") require.Equal(t, []string{"first", "second"}, []string{result.Children[0].AgentID, result.Children[1].AgentID}) @@ -471,13 +473,169 @@ func TestSubagentInventory_FallbackTraversalFailureDiscardsMatches(t *testing.T) }, } - result, err := ag.ExtractWithSubagentInventory(nil, 0, []agent.SubagentReference{{AgentID: "child"}}) + result, err := ag.ExtractWithSubagentInventory(t.Context(), nil, 0, []agent.SubagentReference{{AgentID: "child"}}) require.NoError(t, err) require.Empty(t, result.Children[0].ResolvedPath) require.False(t, *result.TokenUsage.SubagentTokensComplete) require.Nil(t, result.TokenUsage.SubagentTokens) } +func TestRolloutScanLimits_Defaults(t *testing.T) { + t.Parallel() + + require.Equal(t, 500*time.Millisecond, defaultRolloutScanLimits.timeout) + require.Equal(t, 20_000, defaultRolloutScanLimits.candidateLimit) + require.Equal(t, int64(64<<10), defaultRolloutScanLimits.metadataByteLimit) + require.Equal(t, int64(128<<20), defaultRolloutScanLimits.bodyByteLimit) + require.Equal(t, int64(256<<20), defaultRolloutScanLimits.aggregateByteLimit) + require.Equal(t, 128, defaultRolloutScanLimits.readDirBatch) +} + +func TestRolloutScanBudget_DefaultBoundaries(t *testing.T) { + t.Parallel() + + start := time.Unix(1, 0) + now := start + limits := defaultRolloutScanLimits + limits.now = func() time.Time { return now } + + candidates := newRolloutScanBudget(t.Context(), limits) + for range limits.candidateLimit { + require.NoError(t, candidates.observeCandidate()) + } + require.ErrorIs(t, candidates.observeCandidate(), errRolloutScanBudget) + + bytes := newRolloutScanBudget(t.Context(), limits) + require.NoError(t, bytes.observeBytes(limits.aggregateByteLimit)) + require.ErrorIs(t, bytes.observeBytes(1), errRolloutScanBudget) + + deadline := newRolloutScanBudget(t.Context(), limits) + now = start.Add(limits.timeout - time.Nanosecond) + require.NoError(t, deadline.check()) + now = start.Add(limits.timeout) + require.ErrorIs(t, deadline.check(), errRolloutScanBudget) +} + +func TestSubagentInventory_FallbackReadsOnlyMetadataForUnrelatedRollouts(t *testing.T) { + t.Parallel() + + root := t.TempDir() + unrelated := writeRollout(t, root, "unrelated.jsonl", "unrelated", nil) + require.NoError(t, os.WriteFile(unrelated, append(mustReadFile(t, unrelated), []byte(strings.Repeat("x", 1<<20))...), 0o600)) + writeRollout(t, root, "wanted.jsonl", "wanted", []json.RawMessage{ + tokenCountEvent(map[string]any{"total_token_usage": map[string]any{"input_tokens": 2, "cached_input_tokens": 0, "output_tokens": 1}}), + }) + + readByPath := make(map[string]int64) + ag := &CodexAgent{ + RolloutRoots: []string{root}, + observeRolloutRead: func(path string, n int) { + readByPath[path] += int64(n) + }, + } + result, err := ag.ExtractWithSubagentInventory(t.Context(), nil, 0, []agent.SubagentReference{{AgentID: "wanted"}}) + require.NoError(t, err) + require.True(t, *result.TokenUsage.SubagentTokensComplete) + require.Less(t, readByPath[unrelated], int64(4<<10), "unrelated rollout must not be read beyond its metadata prefix") +} + +func TestRolloutScanLimits_FailClosed(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + limits rolloutScanLimits + setup func(*testing.T, string) + refs []agent.SubagentReference + }{ + { + name: "candidate count", + limits: testRolloutScanLimits(1, 64<<10, 128<<20, 256<<20), + setup: func(t *testing.T, root string) { + writeRollout(t, root, "one.jsonl", "one", nil) + writeRollout(t, root, "two.jsonl", "two", nil) + }, + refs: []agent.SubagentReference{{AgentID: "one"}, {AgentID: "two"}}, + }, + { + name: "metadata bytes", + limits: testRolloutScanLimits(10, 8, 128<<20, 256<<20), + setup: func(t *testing.T, root string) { + writeRollout(t, root, "child.jsonl", "child", nil) + }, + refs: []agent.SubagentReference{{AgentID: "child"}}, + }, + { + name: "body bytes", + limits: testRolloutScanLimits(10, 64<<10, 80, 256<<20), + setup: func(t *testing.T, root string) { + writeRollout(t, root, "child.jsonl", "child", []json.RawMessage{patchEvent("child.txt")}) + }, + refs: []agent.SubagentReference{{AgentID: "child"}}, + }, + { + name: "aggregate bytes", + limits: testRolloutScanLimits(10, 64<<10, 1<<20, 120), + setup: func(t *testing.T, root string) { + writeRollout(t, root, "one.jsonl", "one", []json.RawMessage{patchEvent("one.txt")}) + writeRollout(t, root, "two.jsonl", "two", []json.RawMessage{patchEvent("two.txt")}) + }, + refs: []agent.SubagentReference{{AgentID: "one"}, {AgentID: "two"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + root := t.TempDir() + tt.setup(t, root) + ag := &CodexAgent{RolloutRoots: []string{root}, scanLimits: &tt.limits} + result, err := ag.ExtractWithSubagentInventory(t.Context(), nil, 0, tt.refs) + require.NoError(t, err) + require.NotNil(t, result.TokenUsage) + require.Nil(t, result.TokenUsage.SubagentTokens) + require.NotNil(t, result.TokenUsage.SubagentTokensComplete) + require.False(t, *result.TokenUsage.SubagentTokensComplete) + for _, child := range result.Children { + require.Empty(t, child.ResolvedPath, "a scan breach must discard all partial fallback matches") + } + }) + } +} + +func TestSubagentInventory_IncrementalCancellation(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeRollout(t, root, "child.jsonl", "child", nil) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + ag := &CodexAgent{RolloutRoots: []string{root}} + result, err := ag.ExtractWithSubagentInventory(ctx, nil, 0, []agent.SubagentReference{{AgentID: "child"}}) + require.NoError(t, err) + require.False(t, *result.TokenUsage.SubagentTokensComplete) + require.Empty(t, result.Children[0].ResolvedPath) +} + +func testRolloutScanLimits(candidateLimit int, metadata, body, aggregate int64) rolloutScanLimits { + return rolloutScanLimits{ + timeout: time.Hour, + candidateLimit: candidateLimit, + metadataByteLimit: metadata, + bodyByteLimit: body, + aggregateByteLimit: aggregate, + readDirBatch: 2, + now: time.Now, + } +} + +func mustReadFile(t *testing.T, path string) []byte { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err) + return data +} + func writeRollout(t *testing.T, root, name, id string, events []json.RawMessage) string { t.Helper() path := filepath.Join(root, name) diff --git a/cmd/entire/cli/agent/codex/transcript.go b/cmd/entire/cli/agent/codex/transcript.go index d0cd495824..ac78995f8f 100644 --- a/cmd/entire/cli/agent/codex/transcript.go +++ b/cmd/entire/cli/agent/codex/transcript.go @@ -3,6 +3,7 @@ package codex import ( "bufio" "bytes" + "context" "encoding/json" "errors" "fmt" @@ -89,6 +90,21 @@ const ( rolloutChild ) +type rolloutClassificationIssue string + +const ( + rolloutIssueNullPath rolloutClassificationIssue = "null_transcript_path" + rolloutIssueUnreadable rolloutClassificationIssue = "unreadable_transcript" + rolloutIssueMalformedMetadata rolloutClassificationIssue = "malformed_session_metadata" + rolloutIssueUnclassifiedSource rolloutClassificationIssue = "unclassified_source" +) + +type rolloutClassificationResult struct { + Classification rolloutClassification + Issue rolloutClassificationIssue + Detail string +} + // sessionMetaPayload is the payload for type="session_meta" lines. type sessionMetaPayload struct { ID string `json:"id"` @@ -101,49 +117,64 @@ type sessionMetaPayload struct { // rollouts identify root and child threads with thread_source; older rollouts // encode their source as either a recognized root string or source.subagent. func classifyRollout(path string) rolloutClassification { + return classifyRolloutDetailed(path).Classification +} + +func classifyRolloutDetailed(path string) rolloutClassificationResult { if path == "" { - return rolloutUnknown + return rolloutClassificationResult{Classification: rolloutUnknown, Issue: rolloutIssueNullPath} } file, err := os.Open(path) //nolint:gosec // Path comes from agent hook input if err != nil { - return rolloutUnknown + return rolloutClassificationResult{Classification: rolloutUnknown, Issue: rolloutIssueUnreadable, Detail: "open"} } defer file.Close() lineData, err := bufio.NewReader(file).ReadBytes('\n') if err != nil && !errors.Is(err, io.EOF) { - return rolloutUnknown + return rolloutClassificationResult{Classification: rolloutUnknown, Issue: rolloutIssueUnreadable, Detail: "read"} } var line rolloutLine - if json.Unmarshal(lineData, &line) != nil || line.Type != rolloutLineTypeSessionMeta { - return rolloutUnknown + if json.Unmarshal(lineData, &line) != nil { + return rolloutClassificationResult{Classification: rolloutUnknown, Issue: rolloutIssueMalformedMetadata, Detail: "first_record_json"} + } + if line.Type != rolloutLineTypeSessionMeta { + return rolloutClassificationResult{Classification: rolloutUnknown, Issue: rolloutIssueMalformedMetadata, Detail: "first_record_type"} } var meta sessionMetaPayload if json.Unmarshal(line.Payload, &meta) != nil { - return rolloutUnknown + return rolloutClassificationResult{Classification: rolloutUnknown, Issue: rolloutIssueMalformedMetadata, Detail: "session_meta_payload"} } switch meta.ThreadSource { case "user": - return rolloutRoot + return rolloutClassificationResult{Classification: rolloutRoot} case "subagent": - return rolloutChild + return rolloutClassificationResult{Classification: rolloutChild} case "": // Fall through to the legacy source encoding. default: - return rolloutUnknown + return rolloutClassificationResult{ + Classification: rolloutUnknown, + Issue: rolloutIssueUnclassifiedSource, + Detail: safeRolloutSource(meta.ThreadSource), + } } var source string if json.Unmarshal(meta.Source, &source) == nil { switch source { case "startup", "resume", "clear", "compact", "cli", codexExecCommand, "vscode", "mcp": - return rolloutRoot + return rolloutClassificationResult{Classification: rolloutRoot} default: - return rolloutUnknown + return rolloutClassificationResult{ + Classification: rolloutUnknown, + Issue: rolloutIssueUnclassifiedSource, + Detail: safeRolloutSource(source), + } } } @@ -153,10 +184,19 @@ func classifyRollout(path string) rolloutClassification { if json.Unmarshal(meta.Source, &structuredSource) == nil && len(structuredSource.Subagent) > 0 && !bytes.Equal(structuredSource.Subagent, []byte("null")) { - return rolloutChild + return rolloutClassificationResult{Classification: rolloutChild} } - return rolloutUnknown + return rolloutClassificationResult{Classification: rolloutUnknown, Issue: rolloutIssueUnclassifiedSource, Detail: "missing_or_structured_legacy_source"} +} + +func safeRolloutSource(source string) string { + const maxSourceRunes = 128 + runes := []rune(strings.ToValidUTF8(source, "�")) + if len(runes) > maxSourceRunes { + runes = runes[:maxSourceRunes] + } + return string(runes) } // responseItemPayload is the payload for type="response_item" lines. @@ -560,7 +600,7 @@ func terminalTurnIDs(data []byte) []string { // ExtractWithSubagentInventory gathers evidence only for refs supplied by the // caller's authoritative ledger. It never discovers children from transcript // text, filenames, timestamps, or token-count events. -func (c *CodexAgent) ExtractWithSubagentInventory(parent []byte, fromOffset int, refs []agent.SubagentReference) (agent.InventoryExtraction, error) { +func (c *CodexAgent) ExtractWithSubagentInventory(ctx context.Context, parent []byte, fromOffset int, refs []agent.SubagentReference) (agent.InventoryExtraction, error) { result := agent.InventoryExtraction{ModifiedFiles: extractFilesFromData(parent, fromOffset)} parentUsage, err := c.CalculateTokenUsage(parent, fromOffset) if err != nil { @@ -571,13 +611,16 @@ func (c *CodexAgent) ExtractWithSubagentInventory(parent []byte, fromOffset int, resolved := make([]loadedRollout, len(refs)) unresolvedIDs := make(map[string]struct{}) for index, ref := range refs { - if loaded, ok := c.loadDirectRollout(ref); ok { + if loaded, ok := c.loadDirectRollout(ctx, ref); ok { resolved[index] = loaded } else if ref.AgentID != "" { unresolvedIDs[ref.AgentID] = struct{}{} } } - fallback := c.scanFallbackRollouts(unresolvedIDs) + fallback, fallbackErr := c.scanFallbackRollouts(ctx, unresolvedIDs) + if fallbackErr != nil { + fallback = nil + } for index, ref := range refs { if resolved[index].Path == "" { resolved[index] = fallback[ref.AgentID] diff --git a/cmd/entire/cli/agent/codex/transcript_test.go b/cmd/entire/cli/agent/codex/transcript_test.go index 68c8fe2cc0..92cba4c889 100644 --- a/cmd/entire/cli/agent/codex/transcript_test.go +++ b/cmd/entire/cli/agent/codex/transcript_test.go @@ -92,6 +92,43 @@ func TestClassifyRollout(t *testing.T) { }) } +func TestClassifyRolloutDetailed_ExplainsFailClosedResult(t *testing.T) { + t.Parallel() + + t.Run("null transcript path", func(t *testing.T) { + t.Parallel() + got := classifyRolloutDetailed("") + require.Equal(t, rolloutUnknown, got.Classification) + require.Equal(t, rolloutIssueNullPath, got.Issue) + }) + + t.Run("unreadable transcript", func(t *testing.T) { + t.Parallel() + got := classifyRolloutDetailed(filepath.Join(t.TempDir(), "missing.jsonl")) + require.Equal(t, rolloutUnknown, got.Classification) + require.Equal(t, rolloutIssueUnreadable, got.Issue) + }) + + t.Run("malformed metadata", func(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "rollout.jsonl") + require.NoError(t, os.WriteFile(path, []byte(`{"type":"session_meta","payload":`), 0o600)) + got := classifyRolloutDetailed(path) + require.Equal(t, rolloutUnknown, got.Classification) + require.Equal(t, rolloutIssueMalformedMetadata, got.Issue) + }) + + t.Run("future source", func(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "rollout.jsonl") + require.NoError(t, os.WriteFile(path, []byte(`{"type":"session_meta","payload":{"thread_source":"future-source"}}`), 0o600)) + got := classifyRolloutDetailed(path) + require.Equal(t, rolloutUnknown, got.Classification) + require.Equal(t, rolloutIssueUnclassifiedSource, got.Issue) + require.Equal(t, "future-source", got.Detail) + }) +} + func TestGetTranscriptPosition(t *testing.T) { t.Parallel() ag := &CodexAgent{} diff --git a/cmd/entire/cli/agent/token_usage.go b/cmd/entire/cli/agent/token_usage.go index 651a23cfd1..b8da829ce9 100644 --- a/cmd/entire/cli/agent/token_usage.go +++ b/cmd/entire/cli/agent/token_usage.go @@ -15,7 +15,7 @@ func ExtractWithSubagentInventory(ctx context.Context, ag Agent, transcriptData if !ok { return InventoryExtraction{}, false } - extraction, err := extractor.ExtractWithSubagentInventory(transcriptData, transcriptLinesAtStart, refs) + extraction, err := extractor.ExtractWithSubagentInventory(ctx, transcriptData, transcriptLinesAtStart, refs) if err != nil { logging.Debug(ctx, "failed inventory-aware token extraction", slog.String("error", err.Error())) return InventoryExtraction{}, false diff --git a/cmd/entire/cli/integration_test/codex_subagent_test.go b/cmd/entire/cli/integration_test/codex_subagent_test.go index f37716423b..e3f3a1a0a1 100644 --- a/cmd/entire/cli/integration_test/codex_subagent_test.go +++ b/cmd/entire/cli/integration_test/codex_subagent_test.go @@ -51,7 +51,7 @@ func TestCodexSubagent_StoresDeclaredSubagentTranscript(t *testing.T) { `{"type":"response_item","payload":{"type":"custom_tool_call","status":"completed","name":"apply_patch","input":"*** Begin Patch\n*** Add File: `+editedFile+`\n+red\n*** End Patch"}}`+"\n"+ `{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":10,"cached_input_tokens":2,"output_tokens":3}}}}`+"\n"+ `{"type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1"}}`+"\n"), 0o600)) - probe, err := (&codex.CodexAgent{RolloutRoots: []string{}}).ExtractWithSubagentInventory(nil, 0, []agent.SubagentReference{{AgentID: agentID, DeclaredTranscriptPath: subagentRollout}}) + probe, err := (&codex.CodexAgent{RolloutRoots: []string{}}).ExtractWithSubagentInventory(t.Context(), nil, 0, []agent.SubagentReference{{AgentID: agentID, DeclaredTranscriptPath: subagentRollout}}) require.NoError(t, err) require.Equal(t, []string{"turn-1"}, probe.Children[0].TerminalTurnIDs) diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index 259220cb8b..bc0987c303 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -1169,6 +1169,13 @@ func finalizeCodexObservedAtSessionEnd(ctx context.Context, sessionID string) { if record.CompletedAt.IsZero() { record.CompletedAt = time.Now() } + // refreshCodexInventory accepts this path only after loading the + // rollout and matching session_meta.id to AgentID. Carry that + // verified path into the durable task record even when this + // fallback has no exact terminal file/token snapshot. + if entry.ResolvedTranscriptPath != "" { + record.DeclaredTranscriptPath = entry.ResolvedTranscriptPath + } record.Files = nil record.TokenUsage = nil break diff --git a/cmd/entire/cli/lifecycle_test.go b/cmd/entire/cli/lifecycle_test.go index 1b70669bce..b0b4f03f62 100644 --- a/cmd/entire/cli/lifecycle_test.go +++ b/cmd/entire/cli/lifecycle_test.go @@ -143,7 +143,7 @@ type mockInventoryAgent struct { var _ agent.InventoryAwareExtractor = (*mockInventoryAgent)(nil) -func (m *mockInventoryAgent) ExtractWithSubagentInventory(_ []byte, _ int, _ []agent.SubagentReference) (agent.InventoryExtraction, error) { +func (m *mockInventoryAgent) ExtractWithSubagentInventory(_ context.Context, _ []byte, _ int, _ []agent.SubagentReference) (agent.InventoryExtraction, error) { if m.beforeReturn != nil { m.beforeReturn() } @@ -223,9 +223,10 @@ func TestFinalizeCodexObservedAtSessionEnd_MultiTurnChildClearsStaleEvidence(t * StartedAt: time.Now(), Phase: session.PhaseActive, SubagentInventory: []session.SubagentInventoryEntry{{ - AgentID: agentID, - ObservedTurnIDs: []string{"turn-1", "turn-2"}, - FinalizedTurnIDs: []string{"turn-1"}, + AgentID: agentID, + ResolvedTranscriptPath: "/tmp/verified-child-1.jsonl", + ObservedTurnIDs: []string{"turn-1", "turn-2"}, + FinalizedTurnIDs: []string{"turn-1"}, }}, TaskRecords: []session.TaskRecord{{ ToolUseID: agentID, @@ -246,6 +247,8 @@ func TestFinalizeCodexObservedAtSessionEnd_MultiTurnChildClearsStaleEvidence(t * assert.Equal(t, completedAt, record.CompletedAt, "force-closing a later turn must not complete the task twice") assert.Empty(t, record.Files, "files from an earlier turn are not exact evidence for an unresolved later turn") assert.Nil(t, record.TokenUsage, "tokens from an earlier turn are not exact evidence for an unresolved later turn") + assert.Equal(t, "/tmp/verified-child-1.jsonl", record.DeclaredTranscriptPath, + "force-closing must retain the inventory's exact-ID-verified rollout path for condensation") assert.Contains(t, state.FindSubagentInventory(agentID).FinalizedTurnIDs, "turn-2") } diff --git a/cmd/entire/cli/strategy/agent_resolution_test.go b/cmd/entire/cli/strategy/agent_resolution_test.go index 7086377b0f..7cd4b6d23f 100644 --- a/cmd/entire/cli/strategy/agent_resolution_test.go +++ b/cmd/entire/cli/strategy/agent_resolution_test.go @@ -3,13 +3,16 @@ package strategy import ( "context" "path/filepath" + "strings" "sync" "testing" "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/session" // Register agents so AgentForTranscriptPath can resolve them. _ "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" + _ "github.com/entireio/cli/cmd/entire/cli/agent/codex" _ "github.com/entireio/cli/cmd/entire/cli/agent/cursor" "github.com/stretchr/testify/assert" @@ -33,6 +36,150 @@ func withClaudeSessionDir(t *testing.T) string { return filepath.Join(sessionDir, "abc-123.jsonl") } +func withCodexSessionDir(t *testing.T) string { + t.Helper() + sessionDir := filepath.Join(t.TempDir(), "codex-sessions") + t.Setenv("ENTIRE_TEST_CODEX_SESSION_DIR", sessionDir) + return filepath.Join(sessionDir, "2026", "09", "02", "rollout.jsonl") +} + +func TestInitializeSession_CodexCorrection_CleanKnownOwner(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + codexTranscript := withCodexSessionDir(t) + + ctx := context.Background() + sessionID := "codex-clean-known" + s := &ManualCommitStrategy{} + require.NoError(t, s.InitializeSession(ctx, sessionID, agent.AgentTypeClaudeCode, "", "first", "")) + require.NoError(t, s.InitializeSession(ctx, sessionID, agent.AgentTypeCodex, codexTranscript, "second", "")) + + state, err := s.loadSessionState(ctx, sessionID) + require.NoError(t, err) + assertCleanCodexCorrection(t, state) +} + +func TestInitializeSession_CodexCorrection_CleanUnknownOwner(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + codexTranscript := withCodexSessionDir(t) + + ctx := context.Background() + sessionID := "codex-clean-unknown" + s := &ManualCommitStrategy{} + require.NoError(t, s.InitializeSession(ctx, sessionID, agent.AgentTypeClaudeCode, "", "first", "")) + state, err := s.loadSessionState(ctx, sessionID) + require.NoError(t, err) + state.AgentType = "" + require.NoError(t, s.saveSessionState(ctx, state)) + + require.NoError(t, s.InitializeSession(ctx, sessionID, agent.AgentTypeCodex, codexTranscript, "second", "")) + state, err = s.loadSessionState(ctx, sessionID) + require.NoError(t, err) + assertCleanCodexCorrection(t, state) +} + +func TestInitializeSession_CodexCorrection_DirtyEvidence(t *testing.T) { + incomplete := false + tests := []struct { + name string + dirty func(*SessionState) + }{ + {"inventory", func(s *SessionState) { s.SubagentInventory = []session.SubagentInventoryEntry{{AgentID: "child"}} }}, + {"task record", func(s *SessionState) { + s.TaskRecords = []session.TaskRecord{{ToolUseID: "task", AgentID: "child-from-task"}} + }}, + {"ledger", func(s *SessionState) { s.SubagentLedgerVersion = 4 }}, + {"session child total", func(s *SessionState) { s.TokenUsage = &agent.TokenUsage{SubagentTokens: &agent.TokenUsage{}} }}, + {"checkpoint child total", func(s *SessionState) { s.CheckpointTokenUsage = &agent.TokenUsage{SubagentTokens: &agent.TokenUsage{}} }}, + {"baseline", func(s *SessionState) { s.SubagentTokensBaseline = &agent.TokenUsage{} }}, + {"inventory incomplete", func(s *SessionState) { s.SubagentInventoryComplete = &incomplete }}, + {"baseline incomplete", func(s *SessionState) { s.SubagentTokensBaselineComplete = &incomplete }}, + {"session usage incomplete", func(s *SessionState) { s.TokenUsage = &agent.TokenUsage{SubagentTokensComplete: &incomplete} }}, + {"checkpoint usage incomplete", func(s *SessionState) { s.CheckpointTokenUsage = &agent.TokenUsage{SubagentTokensComplete: &incomplete} }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + codexTranscript := withCodexSessionDir(t) + ctx := context.Background() + sessionID := "codex-dirty-" + strings.ReplaceAll(tt.name, " ", "-") + s := &ManualCommitStrategy{} + require.NoError(t, s.InitializeSession(ctx, sessionID, agent.AgentTypeClaudeCode, "", "first", "")) + state, err := s.loadSessionState(ctx, sessionID) + require.NoError(t, err) + tt.dirty(state) + require.NoError(t, s.saveSessionState(ctx, state)) + + require.NoError(t, s.InitializeSession(ctx, sessionID, agent.AgentTypeCodex, codexTranscript, "second", "")) + state, err = s.loadSessionState(ctx, sessionID) + require.NoError(t, err) + require.Equal(t, agent.AgentTypeCodex, state.AgentType) + require.NotNil(t, state.SubagentInventoryComplete) + require.False(t, *state.SubagentInventoryComplete) + require.NotNil(t, state.SubagentTokensBaselineComplete) + require.False(t, *state.SubagentTokensBaselineComplete) + require.NotNil(t, state.TokenUsage) + require.Nil(t, state.TokenUsage.SubagentTokens) + require.NotNil(t, state.TokenUsage.SubagentTokensComplete) + require.False(t, *state.TokenUsage.SubagentTokensComplete) + require.NotNil(t, state.CheckpointTokenUsage) + require.Nil(t, state.CheckpointTokenUsage.SubagentTokens) + require.NotNil(t, state.CheckpointTokenUsage.SubagentTokensComplete) + require.False(t, *state.CheckpointTokenUsage.SubagentTokensComplete) + require.Nil(t, state.SubagentTokensBaseline) + if tt.name == "task record" { + require.NotNil(t, state.FindSubagentInventory("child-from-task")) + } + }) + } +} + +func TestInitializeSession_CodexCallerWithoutTranscriptDoesNotMigrateAccounting(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + + ctx := context.Background() + sessionID := "codex-unproven-owner" + s := &ManualCommitStrategy{} + require.NoError(t, s.InitializeSession(ctx, sessionID, agent.AgentTypeClaudeCode, "", "first", "")) + state, err := s.loadSessionState(ctx, sessionID) + require.NoError(t, err) + state.AgentType = "" + state.SubagentTokensBaseline = &agent.TokenUsage{InputTokens: 7} + require.NoError(t, s.saveSessionState(ctx, state)) + + require.NoError(t, s.InitializeSession(ctx, sessionID, agent.AgentTypeCodex, "", "second", "")) + state, err = s.loadSessionState(ctx, sessionID) + require.NoError(t, err) + require.Equal(t, agent.AgentTypeCodex, state.AgentType) + require.NotNil(t, state.SubagentTokensBaseline) + require.Equal(t, 7, state.SubagentTokensBaseline.InputTokens, + "the firing hook alone must not rewrite legacy accounting evidence") +} + +func assertCleanCodexCorrection(t *testing.T, state *SessionState) { + t.Helper() + require.Equal(t, agent.AgentTypeCodex, state.AgentType) + require.Empty(t, state.SubagentInventory) + require.Zero(t, state.SubagentLedgerVersion) + require.Nil(t, state.SubagentTokensBaseline) + require.NotNil(t, state.SubagentInventoryComplete) + require.True(t, *state.SubagentInventoryComplete) + require.NotNil(t, state.SubagentTokensBaselineComplete) + require.True(t, *state.SubagentTokensBaselineComplete) + require.NotNil(t, state.TokenUsage) + require.Nil(t, state.TokenUsage.SubagentTokens) + require.NotNil(t, state.TokenUsage.SubagentTokensComplete) + require.True(t, *state.TokenUsage.SubagentTokensComplete) + require.NotNil(t, state.CheckpointTokenUsage) + require.Nil(t, state.CheckpointTokenUsage.SubagentTokens) + require.NotNil(t, state.CheckpointTokenUsage.SubagentTokensComplete) + require.True(t, *state.CheckpointTokenUsage.SubagentTokensComplete) +} + func TestResolveSessionAgentType_TranscriptPathBeatsHook(t *testing.T) { dir := setupGitRepo(t) t.Chdir(dir) diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index 066a4581ca..ab390b0ed5 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -2465,6 +2465,52 @@ func correctSessionAgentType(ctx context.Context, currentType types.AgentType, t return owner.Type(), true } +// transitionSessionToCodex initializes Codex child-accounting state when a +// transcript path proves that an existing session is Codex-owned. The +// transition deliberately happens in the same session-state mutation as the +// AgentType correction so readers can never observe a Codex session with +// legacy, ambiguous child-coverage markers. +func transitionSessionToCodex(state *SessionState) { + dirty := hasPriorSubagentEvidence(state) + complete := !dirty + + // Task records predate the durable Codex inventory. Preserve their child + // identities without calling RegisterSubagent: this is a migration of known + // evidence, not a new observation, so it must not advance the ledger again. + for _, record := range state.TaskRecords { + if record.AgentID == "" || state.FindSubagentInventory(record.AgentID) != nil { + continue + } + state.SubagentInventory = append(state.SubagentInventory, session.SubagentInventoryEntry{ + AgentID: record.AgentID, + DeclaredTranscriptPath: record.DeclaredTranscriptPath, + }) + } + + state.TokenUsage = types.WithClearedSubagentTokens(state.TokenUsage, complete) + state.CheckpointTokenUsage = types.WithClearedSubagentTokens(state.CheckpointTokenUsage, complete) + state.SubagentTokensBaseline = nil + state.SubagentInventoryComplete = &complete + state.SubagentTokensBaselineComplete = &complete +} + +func hasPriorSubagentEvidence(state *SessionState) bool { + if len(state.SubagentInventory) > 0 || len(state.TaskRecords) > 0 || state.SubagentLedgerVersion != 0 || state.SubagentTokensBaseline != nil { + return true + } + if state.TokenUsage != nil && (state.TokenUsage.SubagentTokens != nil || explicitlyIncomplete(state.TokenUsage.SubagentTokensComplete)) { + return true + } + if state.CheckpointTokenUsage != nil && (state.CheckpointTokenUsage.SubagentTokens != nil || explicitlyIncomplete(state.CheckpointTokenUsage.SubagentTokensComplete)) { + return true + } + return explicitlyIncomplete(state.SubagentInventoryComplete) || explicitlyIncomplete(state.SubagentTokensBaselineComplete) +} + +func explicitlyIncomplete(complete *bool) bool { + return complete != nil && !*complete +} + // InitializeSession creates session state for a new session or updates an existing one. // This implements the optional SessionInitializer interface. // Called during UserPromptSubmit to allow git hooks to detect active sessions. @@ -2522,17 +2568,22 @@ func (s *ManualCommitStrategy) InitializeSession(ctx context.Context, sessionID } state.TurnID = turnID.String() - // Update AgentType when it isn't set yet, or when the transcript path - // proves we're a different agent than the one stored. - if state.AgentType == "" && resolvedAgentType != "" { - state.AgentType = resolvedAgentType - } else if corrected, changed := correctSessionAgentType(ctx, state.AgentType, transcriptPath); changed { + // A transcript path is stronger evidence than both a stored owner and + // the current hook. Apply transcript-proven corrections first, including + // the empty-owner case, so a Codex correction can initialize all of its + // child-accounting markers atomically. + if corrected, changed := correctSessionAgentType(ctx, state.AgentType, transcriptPath); changed { logging.Info(logging.WithComponent(ctx, "hooks"), "corrected session agent type from transcript path", slog.String("session_id", sessionID), slog.String("from", string(state.AgentType)), slog.String("to", string(corrected)), slog.String("transcript_path", transcriptPath)) + if corrected == agent.AgentTypeCodex && state.AgentType != agent.AgentTypeCodex { + transitionSessionToCodex(state) + } state.AgentType = corrected + } else if state.AgentType == "" && resolvedAgentType != "" { + state.AgentType = resolvedAgentType } if model != "" { state.ModelName = model From 068d37470336552fb0b07122088ea4620ce62ebb Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Thu, 3 Sep 2026 13:01:56 -0700 Subject: [PATCH 10/59] refactor(codex): simplify subagent tracking Entire-Checkpoint: 01M1MDT7FYTX9025MWBCJE3ZPQ --- cmd/entire/cli/agent/codex/codex.go | 141 +++++------ cmd/entire/cli/agent/codex/lifecycle.go | 1 - cmd/entire/cli/agent/codex/lifecycle_test.go | 118 ++-------- cmd/entire/cli/agent/codex/subagent_test.go | 190 +++++---------- cmd/entire/cli/agent/codex/transcript.go | 219 ++++++++---------- cmd/entire/cli/agent/codex/transcript_test.go | 67 ++---- cmd/entire/cli/agent/codex/types.go | 1 - cmd/entire/cli/agent/event.go | 3 - .../integration_test/codex_subagent_test.go | 5 - cmd/entire/cli/lifecycle.go | 2 +- cmd/entire/cli/session/state.go | 43 +--- cmd/entire/cli/session/state_test.go | 103 +++----- .../cli/strategy/agent_resolution_test.go | 126 +++++----- cmd/entire/cli/strategy/manual_commit_test.go | 43 +--- 14 files changed, 351 insertions(+), 711 deletions(-) diff --git a/cmd/entire/cli/agent/codex/codex.go b/cmd/entire/cli/agent/codex/codex.go index 7bb97f7243..64cf0665c4 100644 --- a/cmd/entire/cli/agent/codex/codex.go +++ b/cmd/entire/cli/agent/codex/codex.go @@ -132,61 +132,77 @@ func (b *rolloutScanBudget) observeBytes(count int64) error { return nil } -func rolloutRegularMode(mode fs.FileMode) bool { - return mode.Type() == 0 -} - -//nolint:unused // Companion for readSessionMetaID's retained direct-path helper. -func readRegularRollout(path string) (loadedRollout, error) { - return readRegularRolloutContext(context.Background(), path, rolloutBodyByteLimit, nil) -} - func readRegularRolloutContext(ctx context.Context, path string, byteLimit int64, observe func(string, int)) (loadedRollout, error) { info, err := os.Lstat(path) if err != nil { return loadedRollout{}, fmt.Errorf("lstat rollout: %w", err) } - if !rolloutRegularMode(info.Mode()) { + if !info.Mode().IsRegular() { return loadedRollout{}, errors.New("rollout is not a regular file") } - file, err := os.Open(path) //nolint:gosec // Lstat above rejects known special entries; Stat below verifies the opened descriptor. + file, opened, err := openRolloutFile(path, info) if err != nil { - return loadedRollout{}, fmt.Errorf("open rollout: %w", err) + return loadedRollout{}, err } defer file.Close() - opened, err := file.Stat() - if err != nil { - return loadedRollout{}, fmt.Errorf("stat opened rollout: %w", err) - } - if !rolloutRegularMode(opened.Mode()) || !os.SameFile(info, opened) { - return loadedRollout{}, errors.New("rollout changed or is not a regular file") - } if opened.Size() > byteLimit { return loadedRollout{}, fmt.Errorf("rollout size %d exceeds limit %d", opened.Size(), byteLimit) } - data, err := readRolloutBody(ctx, file, path, byteLimit, observe) + data, err := readRolloutBody(file, rolloutReadOptions{ + path: path, byteLimit: byteLimit, check: ctx.Err, observe: observe, + limitErr: fmt.Errorf("rollout exceeds byte limit %d", byteLimit), + }) if err != nil { return loadedRollout{}, fmt.Errorf("read rollout: %w", err) } return loadedRollout{Path: path, Data: data}, nil } -func readRolloutBody(ctx context.Context, file *os.File, path string, byteLimit int64, observe func(string, int)) ([]byte, error) { +func openRolloutFile(path string, before fs.FileInfo) (*os.File, fs.FileInfo, error) { + file, err := os.Open(path) //nolint:gosec // Caller rejects special entries; descriptor Stat verifies the opened file. + if err != nil { + return nil, nil, fmt.Errorf("open rollout: %w", err) + } + opened, err := file.Stat() + if err != nil { + _ = file.Close() + return nil, nil, fmt.Errorf("stat opened rollout: %w", err) + } + if !opened.Mode().IsRegular() || !os.SameFile(before, opened) { + _ = file.Close() + return nil, nil, errors.New("rollout changed or is not a regular file") + } + return file, opened, nil +} + +type rolloutReadOptions struct { + path string + byteLimit int64 + check func() error + account func(int64) error + observe func(string, int) + limitErr error +} + +func readRolloutBody(file *os.File, opts rolloutReadOptions) ([]byte, error) { data := make([]byte, 0) buffer := make([]byte, rolloutBodyReadChunk) - var readBytes int64 for { - if err := ctx.Err(); err != nil { - return nil, fmt.Errorf("read rollout canceled: %w", err) + if err := opts.check(); err != nil { + return nil, err } n, err := file.Read(buffer) if n > 0 { - if observe != nil { - observe(path, n) + if opts.account != nil { + if accountErr := opts.account(int64(n)); accountErr != nil { + return nil, accountErr + } + } + if opts.observe != nil { + opts.observe(opts.path, n) } - readBytes += int64(n) - if readBytes > byteLimit { - return nil, fmt.Errorf("rollout exceeds byte limit %d", byteLimit) + if int64(len(data)+n) > opts.byteLimit { + return nil, opts.limitErr } data = append(data, buffer[:n]...) } @@ -337,7 +353,7 @@ func (c *CodexAgent) inspectFallbackCandidate( if err != nil { return "", loadedRollout{}, fmt.Errorf("lstat rollout candidate: %w", err) } - if !rolloutRegularMode(info.Mode()) { + if !info.Mode().IsRegular() { return "", loadedRollout{}, nil } @@ -368,18 +384,11 @@ func (c *CodexAgent) inspectFallbackCandidate( return id, loaded, nil } - file, err := os.Open(path) //nolint:gosec // Lstat and descriptor Stat below enforce a regular unchanged file + file, opened, err := openRolloutFile(path, info) if err != nil { - return "", loadedRollout{}, fmt.Errorf("open rollout candidate: %w", err) + return "", loadedRollout{}, err } defer file.Close() - opened, err := file.Stat() - if err != nil { - return "", loadedRollout{}, fmt.Errorf("stat opened rollout candidate: %w", err) - } - if !rolloutRegularMode(opened.Mode()) || !os.SameFile(info, opened) { - return "", loadedRollout{}, errors.New("rollout candidate changed or is not a regular file") - } metadata, err := c.readFallbackMetadata(file, path, budget) if err != nil { @@ -401,7 +410,10 @@ func (c *CodexAgent) inspectFallbackCandidate( if _, err := file.Seek(0, io.SeekStart); err != nil { return "", loadedRollout{}, fmt.Errorf("seek rollout candidate: %w", err) } - data, err := c.readFallbackBody(file, path, budget) + data, err := readRolloutBody(file, rolloutReadOptions{ + path: path, byteLimit: budget.limits.bodyByteLimit, check: budget.check, + account: budget.observeBytes, observe: c.observeRolloutRead, limitErr: errRolloutScanBudget, + }) if err != nil { return "", loadedRollout{}, err } @@ -456,35 +468,6 @@ func (c *CodexAgent) readFallbackMetadata(file *os.File, path string, budget *ro } } -func (c *CodexAgent) readFallbackBody(file *os.File, path string, budget *rolloutScanBudget) ([]byte, error) { - data := make([]byte, 0) - buffer := make([]byte, rolloutBodyReadChunk) - for { - if err := budget.check(); err != nil { - return nil, err - } - n, err := file.Read(buffer) - if n > 0 { - if budgetErr := budget.observeBytes(int64(n)); budgetErr != nil { - return nil, budgetErr - } - if c.observeRolloutRead != nil { - c.observeRolloutRead(path, n) - } - if int64(len(data)+n) > budget.limits.bodyByteLimit { - return nil, errRolloutScanBudget - } - data = append(data, buffer[:n]...) - } - if errors.Is(err, io.EOF) { - return data, nil - } - if err != nil { - return nil, fmt.Errorf("read fallback rollout body: %w", err) - } - } -} - func indexByte(data []byte, target byte) int { for index, value := range data { if value == target { @@ -545,26 +528,6 @@ func (c *CodexAgent) scanFallbackRollouts(ctx context.Context, agentIDs map[stri return resolved, nil } -// resolveSubagentRollout is the path-only compatibility wrapper used by -// callers that need only discovery. Inventory extraction uses the verified -// bytes returned by the same load operation instead. -func (c *CodexAgent) resolveSubagentRollout(ref agent.SubagentReference) string { - if ref.AgentID == "" { - return "" - } - if loaded, ok := c.loadDirectRollout(context.Background(), ref); ok { - return loaded.Path - } - fallback, err := c.scanFallbackRollouts(context.Background(), map[string]struct{}{ref.AgentID: {}}) - if err != nil { - return "" - } - if loaded, ok := fallback[ref.AgentID]; ok { - return loaded.Path - } - return "" -} - // NewCodexAgent creates a new Codex agent instance. func NewCodexAgent() agent.Agent { return &CodexAgent{} diff --git a/cmd/entire/cli/agent/codex/lifecycle.go b/cmd/entire/cli/agent/codex/lifecycle.go index d008295c10..f25dfc84c5 100644 --- a/cmd/entire/cli/agent/codex/lifecycle.go +++ b/cmd/entire/cli/agent/codex/lifecycle.go @@ -175,7 +175,6 @@ func (c *CodexAgent) parseSubagentStop(stdin io.Reader) (*agent.Event, error) { ToolUseID: raw.AgentID, TurnID: raw.TurnID, SubagentID: raw.AgentID, - StopHookActive: raw.StopHookActive, ProvisionalSubagentStop: true, SubagentType: raw.AgentType, SubagentTranscriptPath: derefString(raw.AgentTranscriptPath), diff --git a/cmd/entire/cli/agent/codex/lifecycle_test.go b/cmd/entire/cli/agent/codex/lifecycle_test.go index 7154d1afa6..13bfe0c68b 100644 --- a/cmd/entire/cli/agent/codex/lifecycle_test.go +++ b/cmd/entire/cli/agent/codex/lifecycle_test.go @@ -179,73 +179,16 @@ func TestParseHookEvent_Stop(t *testing.T) { require.Equal(t, "gpt-4.1", event.Model) } -func TestParseHookEvent_UserPromptSubmitAndStopRequireRootRollout(t *testing.T) { +func TestParseHookEvent_TurnHooksIgnoreChildRollout(t *testing.T) { t.Parallel() + path := filepath.Join(t.TempDir(), "rollout.jsonl") + require.NoError(t, os.WriteFile(path, []byte(`{"type":"session_meta","payload":{"thread_source":"subagent"}}`+"\n"), 0o600)) + input := `{"session_id":"root-session-1","turn_id":"turn-1","transcript_path":"` + path + `","model":"gpt-5","prompt":"do work","stop_hook_active":true}` - tests := []struct { - name string - metadata string - wantEvent bool - }{ - { - name: "root thread source", - metadata: `{"type":"session_meta","payload":{"thread_source":"user"}}` + "\n", - wantEvent: true, - }, - { - name: "root legacy string source", - metadata: `{"type":"session_meta","payload":{"source":"exec"}}` + "\n", - wantEvent: true, - }, - { - name: "child thread source", - metadata: `{"type":"session_meta","payload":{"thread_source":"subagent"}}` + "\n", - }, - { - name: "child legacy structured source", - metadata: `{"type":"session_meta","payload":{"source":{"subagent":{"thread_spawn":{"parent_thread_id":"root-thread"}}}}}` + "\n", - }, - { - name: "missing session metadata", - metadata: `{"type":"response_item","payload":{}}` + "\n", - }, - { - name: "malformed JSON", - metadata: `{"type":"session_meta","payload":` + "\n", - }, - { - name: "missing path", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - transcriptPath := "" - if tt.metadata != "" { - transcriptPath = filepath.Join(t.TempDir(), "rollout.jsonl") - require.NoError(t, os.WriteFile(transcriptPath, []byte(tt.metadata), 0o600)) - } - - for _, hookName := range []string{HookNameUserPromptSubmit, HookNameStop} { - t.Run(hookName, func(t *testing.T) { - t.Parallel() - pathJSON := "null" - if transcriptPath != "" { - pathJSON = `"` + transcriptPath + `"` - } - input := `{"session_id":"root-session-1","turn_id":"turn-1","transcript_path":` + pathJSON + `,"model":"gpt-5","prompt":"do work","stop_hook_active":true}` - - event, err := (&CodexAgent{}).ParseHookEvent(context.Background(), hookName, strings.NewReader(input)) - require.NoError(t, err) - if tt.wantEvent { - require.NotNil(t, event) - } else { - require.Nil(t, event) - } - }) - } - }) + for _, hookName := range []string{HookNameUserPromptSubmit, HookNameStop} { + event, err := (&CodexAgent{}).ParseHookEvent(context.Background(), hookName, strings.NewReader(input)) + require.NoError(t, err) + require.Nil(t, event) } } @@ -481,45 +424,6 @@ func TestCodexAgent_ContextInjector(t *testing.T) { // testCodexAgentID is the subagent thread id used by the subagent hook tests. const testCodexAgentID = "child-thread-9" -func TestParseHookEvent_SubagentNormalizesHookIdentity(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - hookName string - input string - stopHookActive bool - provisionalStop bool - }{ - { - name: "start", - hookName: HookNameSubagentStart, - input: `{"session_id":"root-session-1","turn_id":"turn-child-1","agent_id":"agent-child-1","agent_type":"reviewer"}`, - }, - { - name: "stop", - hookName: HookNameSubagentStop, - input: `{"session_id":"root-session-1","turn_id":"turn-child-1","agent_id":"agent-child-1","agent_type":"reviewer","stop_hook_active":true}`, - stopHookActive: true, - provisionalStop: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - ev, err := (&CodexAgent{}).ParseHookEvent(context.Background(), tt.hookName, strings.NewReader(tt.input)) - require.NoError(t, err) - require.NotNil(t, ev) - require.Equal(t, "turn-child-1", ev.TurnID) - require.Equal(t, "agent-child-1", ev.SubagentID) - require.Equal(t, tt.stopHookActive, ev.StopHookActive) - require.Equal(t, tt.provisionalStop, ev.ProvisionalSubagentStop) - require.False(t, ev.Final) - }) - } -} - // TestParseHookEvent_SubagentStart pins the identity mapping, which is the part a // future reader is most likely to get backwards: session_id is the identity shared // by the root thread and all descendants (the user's session), agent_id the child @@ -545,11 +449,14 @@ func TestParseHookEvent_SubagentStart(t *testing.T) { require.NotNil(t, ev) require.Equal(t, agent.SubagentStart, ev.Type) require.Equal(t, "root-session-1", ev.SessionID, "the shared root session id, not the child thread") + require.Equal(t, "turn-3", ev.TurnID) + require.Equal(t, testCodexAgentID, ev.SubagentID) // Codex sends no tool_use_id; agent_id is the only value correlating start with // stop, and Entire keys pre-task state on ToolUseID. require.Equal(t, testCodexAgentID, ev.ToolUseID) require.Equal(t, "reviewer", ev.SubagentType) require.Equal(t, "/rollouts/root-session-1.jsonl", ev.SessionRef, "the parent rollout") + require.False(t, ev.Final) } // TestParseHookEvent_SubagentStop covers the two transcripts SubagentStop carries: @@ -577,11 +484,14 @@ func TestParseHookEvent_SubagentStop(t *testing.T) { require.NotNil(t, ev) require.Equal(t, agent.SubagentEnd, ev.Type) require.Equal(t, "root-session-1", ev.SessionID, "the shared root session id") + require.Equal(t, "turn-3", ev.TurnID) require.Equal(t, testCodexAgentID, ev.SubagentID) require.Equal(t, testCodexAgentID, ev.ToolUseID, "agent_id doubles as the correlation key") require.Equal(t, "/rollouts/root-session-1.jsonl", ev.SessionRef, "the PARENT rollout") require.Equal(t, "/rollouts/"+testCodexAgentID+".jsonl", ev.SubagentTranscriptPath, "the subagent's own rollout") + require.True(t, ev.ProvisionalSubagentStop) + require.False(t, ev.Final) } // TestParseHookEvent_SubagentStop_NullTranscripts covers the nullable fields: Codex diff --git a/cmd/entire/cli/agent/codex/subagent_test.go b/cmd/entire/cli/agent/codex/subagent_test.go index f9a3f5db9d..430bab9c19 100644 --- a/cmd/entire/cli/agent/codex/subagent_test.go +++ b/cmd/entire/cli/agent/codex/subagent_test.go @@ -15,25 +15,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestResolveRollout_UsesExactMetadataID(t *testing.T) { - t.Parallel() - - root := t.TempDir() - active := filepath.Join(root, "sessions") - archived := filepath.Join(root, "archived_sessions") - ag := &CodexAgent{RolloutRoots: []string{active, archived}} - - activePath := writeRollout(t, active, "2026/08/31/rollout-near-child-a.jsonl", "child-a", nil) - archivedPath := writeRollout(t, archived, "2026/08/30/rollout-child-b.jsonl", "child-b", nil) - writeRollout(t, active, "2026/08/31/rollout-child-a-suffix.jsonl", "not-child-a", nil) - - got := ag.resolveSubagentRollout(agent.SubagentReference{AgentID: "child-a"}) - require.Equal(t, activePath, got) - - got = ag.resolveSubagentRollout(agent.SubagentReference{AgentID: "child-b"}) - require.Equal(t, archivedPath, got) -} - func TestResolveRollout_DefaultCodexHomeIncludesArchivedSessions(t *testing.T) { // This test changes CODEX_HOME, so it must not run in parallel. codexHome := t.TempDir() @@ -46,8 +27,10 @@ func TestResolveRollout_DefaultCodexHomeIncludesArchivedSessions(t *testing.T) { archivedPath := writeRollout(t, archived, "2026/08/30/rollout-archived.jsonl", "archived", nil) ag := &CodexAgent{} - require.Equal(t, activePath, ag.resolveSubagentRollout(agent.SubagentReference{AgentID: "active"})) - require.Equal(t, archivedPath, ag.resolveSubagentRollout(agent.SubagentReference{AgentID: "archived"})) + result, err := ag.ExtractWithSubagentInventory(t.Context(), nil, 0, []agent.SubagentReference{{AgentID: "active"}, {AgentID: "archived"}}) + require.NoError(t, err) + require.Equal(t, activePath, result.Children[0].ResolvedPath) + require.Equal(t, archivedPath, result.Children[1].ResolvedPath) } func TestResolveRollout_MismatchedKnownPathsFallBackOnlyToExactID(t *testing.T) { @@ -63,28 +46,11 @@ func TestResolveRollout_MismatchedKnownPathsFallBackOnlyToExactID(t *testing.T) {AgentID: "child", DeclaredTranscriptPath: mismatch}, {AgentID: "child", ResolvedTranscriptPath: mismatch}, } { - require.Equal(t, exact, ag.resolveSubagentRollout(ref)) + child, _ := extractChild(t, ag, ref) + require.Equal(t, exact, child.ResolvedPath) } } -func TestResolveRollout_KnownExactPathsNeedNoFallbackRoot(t *testing.T) { - t.Parallel() - - root := t.TempDir() - declared := writeRollout(t, root, "declared.jsonl", "declared", nil) - resolved := writeRollout(t, root, "resolved.jsonl", "resolved", nil) - ag := &CodexAgent{RolloutRoots: []string{filepath.Join(root, "no-fallback-here")}} - - require.Equal(t, declared, ag.resolveSubagentRollout(agent.SubagentReference{ - AgentID: "declared", - DeclaredTranscriptPath: declared, - })) - require.Equal(t, resolved, ag.resolveSubagentRollout(agent.SubagentReference{ - AgentID: "resolved", - ResolvedTranscriptPath: resolved, - })) -} - func TestResolveRollout_RejectsInferredAndAmbiguousCandidates(t *testing.T) { t.Parallel() @@ -92,11 +58,14 @@ func TestResolveRollout_RejectsInferredAndAmbiguousCandidates(t *testing.T) { active := filepath.Join(root, "sessions") ag := &CodexAgent{RolloutRoots: []string{active}} writeRollout(t, active, "2026/08/31/rollout-child.jsonl", "childish", nil) - require.Empty(t, ag.resolveSubagentRollout(agent.SubagentReference{AgentID: "child"})) + child, usage := extractChild(t, ag, agent.SubagentReference{AgentID: "child"}) + require.Empty(t, child.ResolvedPath) + require.False(t, *usage.SubagentTokensComplete) writeRollout(t, active, "2026/08/30/rollout-child-one.jsonl", "child", nil) writeRollout(t, active, "2026/08/31/rollout-child-two.jsonl", "child", nil) - require.Empty(t, ag.resolveSubagentRollout(agent.SubagentReference{AgentID: "child"})) + child, _ = extractChild(t, ag, agent.SubagentReference{AgentID: "child"}) + require.Empty(t, child.ResolvedPath) } func TestResolveRollout_RejectsSymlinkHint(t *testing.T) { @@ -108,77 +77,43 @@ func TestResolveRollout_RejectsSymlinkHint(t *testing.T) { require.NoError(t, os.Symlink(target, link)) ag := &CodexAgent{RolloutRoots: []string{}} - require.Empty(t, ag.resolveSubagentRollout(agent.SubagentReference{ + child, _ := extractChild(t, ag, agent.SubagentReference{ AgentID: "child", DeclaredTranscriptPath: link, - })) -} - -func TestRolloutRegularMode_RejectsSpecialEntries(t *testing.T) { - t.Parallel() - - for _, mode := range []fs.FileMode{0, fs.ModeDir, fs.ModeSymlink, fs.ModeNamedPipe, fs.ModeDevice, fs.ModeSocket} { - require.Equal(t, mode == 0, rolloutRegularMode(mode), "mode %v", mode) - } + }) + require.Empty(t, child.ResolvedPath) } func TestTerminalTurnIDs_OnlyAcceptsUnambiguousBoundaries(t *testing.T) { t.Parallel() - valid := rolloutData(t, "child", []json.RawMessage{ - taskEvent("task_started", stringPointer("one")), - taskEvent("task_complete", stringPointer("one")), - taskEvent("task_started", stringPointer("two")), - taskEvent("task_complete", nil), - }) - require.Equal(t, []string{"one", "two"}, terminalTurnIDs(valid)) - - for _, invalid := range [][]json.RawMessage{ - {taskEvent("task_complete", stringPointer("one"))}, - {taskEvent("task_started", stringPointer("one")), taskEvent("task_started", stringPointer("two")), taskEvent("task_complete", nil)}, - {taskEvent("task_started", stringPointer("one")), taskEvent("task_complete", stringPointer("two"))}, - {taskEvent("task_started", stringPointer("one")), taskEvent("task_complete", stringPointer("one")), taskEvent("task_complete", stringPointer("one"))}, - } { - require.Empty(t, terminalTurnIDs(rolloutData(t, "child", invalid))) + valid := []json.RawMessage{ + taskEvent("task_started", stringPointer("one")), taskEvent("task_complete", stringPointer("one")), + taskEvent("task_started", stringPointer("two")), taskEvent("task_complete", nil), } -} - -func TestTerminalTurnIDs_RealWireShape(t *testing.T) { - t.Parallel() + require.Equal(t, []string{"one", "two"}, analyzeRollout(rolloutData(t, "child", valid), 0).TerminalTurnIDs) + withUnknownEvent := append(append([]json.RawMessage(nil), valid...), json.RawMessage(`{"type":"event_msg","payload":{"type":"future_event","turn_id":7}}`)) + require.Equal(t, []string{"one", "two"}, analyzeRollout(rolloutData(t, "child", withUnknownEvent), 0).TerminalTurnIDs) - modern := rolloutData(t, "child", []json.RawMessage{ - taskEvent("task_started", stringPointer("modern")), - taskEvent("task_complete", stringPointer("modern")), - }) - require.Equal(t, []string{"modern"}, terminalTurnIDs(modern)) - - legacy := rolloutData(t, "child", []json.RawMessage{ - taskEvent("task_started", stringPointer("legacy")), - taskEvent("task_complete", nil), - }) - require.Equal(t, []string{"legacy"}, terminalTurnIDs(legacy)) -} - -func TestTerminalTurnIDs_RejectsInvalidRealWireBoundaries(t *testing.T) { - t.Parallel() - - validThenMalformed := []json.RawMessage{ - taskEvent("task_started", stringPointer("one")), - taskEvent("task_complete", stringPointer("one")), - json.RawMessage(`{"type":"event_msg","payload":{"type":"task_started","turn_id":`), + tests := []struct { + name string + events []json.RawMessage + }{ + {"completion without start", []json.RawMessage{taskEvent("task_complete", stringPointer("one"))}}, + {"start without id", []json.RawMessage{taskEvent("task_started", nil)}}, + {"unclosed start", []json.RawMessage{taskEvent("task_started", stringPointer("one"))}}, + {"overlapping starts", []json.RawMessage{taskEvent("task_started", stringPointer("one")), taskEvent("task_started", stringPointer("two"))}}, + {"mismatched completion", []json.RawMessage{taskEvent("task_started", stringPointer("one")), taskEvent("task_complete", stringPointer("two"))}}, + {"duplicate turn", []json.RawMessage{taskEvent("task_started", stringPointer("one")), taskEvent("task_complete", stringPointer("one")), taskEvent("task_started", stringPointer("one")), taskEvent("task_complete", stringPointer("one"))}}, + {"duplicate completion", []json.RawMessage{taskEvent("task_started", stringPointer("one")), taskEvent("task_complete", nil), taskEvent("task_complete", nil)}}, + {"invalid id type", []json.RawMessage{json.RawMessage(`{"type":"event_msg","payload":{"type":"task_started","turn_id":7}}`)}}, + {"malformed tail", append(valid, json.RawMessage(`{"type":"event_msg","payload":{"type":"task_started","turn_id":`))}, } - for _, invalid := range [][]json.RawMessage{ - {taskEvent("task_complete", stringPointer("one"))}, - {taskEvent("task_started", nil)}, - {taskEvent("task_started", stringPointer("one"))}, - {taskEvent("task_started", stringPointer("one")), taskEvent("task_started", stringPointer("two"))}, - {taskEvent("task_started", stringPointer("one")), taskEvent("task_complete", stringPointer("two"))}, - {taskEvent("task_started", stringPointer("one")), taskEvent("task_complete", stringPointer("one")), taskEvent("task_started", stringPointer("one")), taskEvent("task_complete", stringPointer("one"))}, - {taskEvent("task_started", stringPointer("one")), taskEvent("task_complete", nil), taskEvent("task_complete", nil)}, - {json.RawMessage(`{"type":"event_msg","payload":{"type":"task_started","turn_id":7}}`)}, - validThenMalformed, - } { - require.Empty(t, terminalTurnIDs(rolloutData(t, "child", invalid))) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Empty(t, analyzeRollout(rolloutData(t, "child", tt.events), 0).TerminalTurnIDs) + }) } } @@ -189,18 +124,18 @@ func TestExactTokenUsage_UsesOnlyLastRecognizableSnapshot(t *testing.T) { "input_tokens": 15, "cached_input_tokens": 12, "output_tokens": 3, "reasoning_output_tokens": 2, "total_tokens": 18, }}) - usage := exactCumulativeTokenUsage(rolloutData(t, "child", []json.RawMessage{valid})) + usage := analyzeRollout(rolloutData(t, "child", []json.RawMessage{valid}), 0).ExactTokenUsage require.Equal(t, &agent.TokenUsage{InputTokens: 3, CacheReadTokens: 12, OutputTokens: 3}, usage) malformedLast := tokenCountEvent(map[string]any{"total_token_usage": map[string]any{ "input_tokens": 10, "cached_input_tokens": 11, "output_tokens": 3, }}) - require.Nil(t, exactCumulativeTokenUsage(rolloutData(t, "child", []json.RawMessage{valid, malformedLast}))) + require.Nil(t, analyzeRollout(rolloutData(t, "child", []json.RawMessage{valid, malformedLast}), 0).ExactTokenUsage) missingRequired := tokenCountEvent(map[string]any{"total_token_usage": map[string]any{ "input_tokens": 10, "output_tokens": 3, }}) - require.Nil(t, exactCumulativeTokenUsage(rolloutData(t, "child", []json.RawMessage{missingRequired}))) + require.Nil(t, analyzeRollout(rolloutData(t, "child", []json.RawMessage{missingRequired}), 0).ExactTokenUsage) } func TestExactTokenUsage_RejectsEveryUnavailableOrInconsistentSnapshot(t *testing.T) { @@ -209,7 +144,7 @@ func TestExactTokenUsage_RejectsEveryUnavailableOrInconsistentSnapshot(t *testin valid := func(values map[string]any) []byte { return rolloutData(t, "child", []json.RawMessage{tokenCountEvent(map[string]any{"total_token_usage": values})}) } - require.Nil(t, exactCumulativeTokenUsage(rolloutData(t, "child", nil))) + require.Nil(t, analyzeRollout(rolloutData(t, "child", nil), 0).ExactTokenUsage) for _, values := range []map[string]any{ {"cached_input_tokens": 0, "output_tokens": 1}, @@ -224,17 +159,17 @@ func TestExactTokenUsage_RejectsEveryUnavailableOrInconsistentSnapshot(t *testin {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1, "reasoning_output_tokens": -1}, {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1, "reasoning_output_tokens": 2}, } { - require.Nil(t, exactCumulativeTokenUsage(valid(values))) + require.Nil(t, analyzeRollout(valid(values), 0).ExactTokenUsage) } - zeros := exactCumulativeTokenUsage(valid(map[string]any{"input_tokens": 0, "cached_input_tokens": 0, "output_tokens": 0})) + zeros := analyzeRollout(valid(map[string]any{"input_tokens": 0, "cached_input_tokens": 0, "output_tokens": 0}), 0).ExactTokenUsage require.Equal(t, &agent.TokenUsage{}, zeros) multiple := rolloutData(t, "child", []json.RawMessage{ tokenCountEvent(map[string]any{"total_token_usage": map[string]any{"input_tokens": 9, "cached_input_tokens": 1, "output_tokens": 2}}), tokenCountEvent(map[string]any{"total_token_usage": map[string]any{"input_tokens": 4, "cached_input_tokens": 1, "output_tokens": 2}}), }) - usage := exactCumulativeTokenUsage(multiple) + usage := analyzeRollout(multiple, 0).ExactTokenUsage require.Equal(t, &agent.TokenUsage{InputTokens: 3, CacheReadTokens: 1, OutputTokens: 2}, usage) require.Zero(t, usage.APICallCount, "snapshot record count is not an API-call count") @@ -242,7 +177,7 @@ func TestExactTokenUsage_RejectsEveryUnavailableOrInconsistentSnapshot(t *testin tokenCountEvent(map[string]any{"total_token_usage": map[string]any{"input_tokens": 4, "cached_input_tokens": 1, "output_tokens": 2}}), tokenCountEvent(map[string]any{"total_token_usage": "not-an-object"}), }) - require.Nil(t, exactCumulativeTokenUsage(malformedFinal), "must not fall back to the earlier valid snapshot") + require.Nil(t, analyzeRollout(malformedFinal, 0).ExactTokenUsage, "must not fall back to the earlier valid snapshot") } func TestSubagentInventory_CollectsExactEvidenceAndDoesNotPartialAggregate(t *testing.T) { @@ -434,31 +369,6 @@ func TestSubagentInventory_RevalidatesInjectedRolloutBytes(t *testing.T) { require.False(t, *result.TokenUsage.SubagentTokensComplete) } -func TestSubagentInventory_BatchesFallbackTraversal(t *testing.T) { - t.Parallel() - - firstRoot := t.TempDir() - secondRoot := t.TempDir() - first := writeRollout(t, firstRoot, "first.jsonl", "first", []json.RawMessage{tokenCountEvent(map[string]any{"total_token_usage": map[string]any{"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1}})}) - second := writeRollout(t, secondRoot, "second.jsonl", "second", []json.RawMessage{tokenCountEvent(map[string]any{"total_token_usage": map[string]any{"input_tokens": 2, "cached_input_tokens": 0, "output_tokens": 1}})}) - _ = first - _ = second - walks := 0 - ag := &CodexAgent{ - RolloutRoots: []string{firstRoot, secondRoot}, - walkDir: func(root string, visit fs.WalkDirFunc) error { - walks++ - return filepath.WalkDir(root, visit) - }, - } - - result, err := ag.ExtractWithSubagentInventory(t.Context(), nil, 0, []agent.SubagentReference{{AgentID: "first"}, {AgentID: "second"}}) - require.NoError(t, err) - require.Equal(t, 2, walks, "one traversal per configured root, not per child") - require.Equal(t, []string{"first", "second"}, []string{result.Children[0].AgentID, result.Children[1].AgentID}) - require.True(t, *result.TokenUsage.SubagentTokensComplete) -} - func TestSubagentInventory_FallbackTraversalFailureDiscardsMatches(t *testing.T) { t.Parallel() @@ -636,6 +546,14 @@ func mustReadFile(t *testing.T, path string) []byte { return data } +func extractChild(t *testing.T, ag *CodexAgent, ref agent.SubagentReference) (agent.SubagentAnalysis, *agent.TokenUsage) { + t.Helper() + result, err := ag.ExtractWithSubagentInventory(t.Context(), nil, 0, []agent.SubagentReference{ref}) + require.NoError(t, err) + require.Len(t, result.Children, 1) + return result.Children[0], result.TokenUsage +} + func writeRollout(t *testing.T, root, name, id string, events []json.RawMessage) string { t.Helper() path := filepath.Join(root, name) diff --git a/cmd/entire/cli/agent/codex/transcript.go b/cmd/entire/cli/agent/codex/transcript.go index ac78995f8f..b97d65a5b8 100644 --- a/cmd/entire/cli/agent/codex/transcript.go +++ b/cmd/entire/cli/agent/codex/transcript.go @@ -27,22 +27,6 @@ var ( _ agent.TranscriptSanitizer = (*CodexAgent)(nil) ) -// readSessionMetaID reads the first record of a Codex rollout and returns its -// non-empty native thread ID. Callers use it to prove a path belongs to a -// supplied child rather than inferring that fact from its filename or age. -// -//nolint:unused // Kept as the path helper for direct internal callers; evidence uses same-byte loading instead. -func readSessionMetaID(path string) (string, error) { - if path == "" { - return "", errors.New("empty rollout path") - } - loaded, err := readRegularRollout(path) - if err != nil { - return "", fmt.Errorf("load rollout: %w", err) - } - return sessionMetaID(loaded.Data) -} - func sessionMetaID(data []byte) (string, error) { lines := splitJSONL(data) if len(lines) == 0 { @@ -113,13 +97,8 @@ type sessionMetaPayload struct { Source json.RawMessage `json:"source"` } -// classifyRollout reads only the rollout's session_meta record. Newer Codex -// rollouts identify root and child threads with thread_source; older rollouts -// encode their source as either a recognized root string or source.subagent. -func classifyRollout(path string) rolloutClassification { - return classifyRolloutDetailed(path).Classification -} - +// classifyRolloutDetailed reads only the rollout's session_meta record. Newer +// Codex rollouts use thread_source; older rollouts use source. func classifyRolloutDetailed(path string) rolloutClassificationResult { if path == "" { return rolloutClassificationResult{Classification: rolloutUnknown, Issue: rolloutIssueNullPath} @@ -341,22 +320,7 @@ func extractFilesFromLine(lineData []byte) []string { if json.Unmarshal(lineData, &line) != nil { return nil } - - if line.Type != rolloutLineTypeResponseItem { - return nil - } - - var payload responseItemPayload - if json.Unmarshal(line.Payload, &payload) != nil { - return nil - } - - // apply_patch custom tool calls contain file paths in the input text - if payload.Type == "custom_tool_call" && payload.Name == "apply_patch" { - return extractFilesFromApplyPatch(payload.Input) - } - - return nil + return extractFilesFromParsedLine(line) } // extractFilesFromApplyPatch returns every file path in an apply_patch envelope, @@ -501,25 +465,106 @@ func (c *CodexAgent) CalculateTokenUsage(transcriptData []byte, fromOffset int) }, nil } -// exactCumulativeTokenUsage returns the last recognizable Codex token_count -// snapshot exactly as reported. A malformed final snapshot makes the entire -// result unavailable instead of silently falling back to an earlier record. -func exactCumulativeTokenUsage(data []byte) *agent.TokenUsage { - var lastInfo json.RawMessage - found := false - for _, lineData := range splitJSONL(data) { +type rolloutAnalysis struct { + ModifiedFiles []string + TerminalTurnIDs []string + ExactTokenUsage *agent.TokenUsage +} + +// analyzeRollout extracts every piece of child evidence in one JSONL pass. +// Each evidence channel keeps its own validity: malformed task boundaries +// invalidate terminal turns without discarding file paths already observed, +// while a malformed final token snapshot makes exact usage unavailable. +func analyzeRollout(data []byte, fromOffset int) rolloutAnalysis { + var result rolloutAnalysis + terminalValid := true + openTurn := "" + seenTurns := make(map[string]struct{}) + seenFiles := make(map[string]struct{}) + var lastTokenInfo json.RawMessage + foundToken := false + + for index, lineData := range splitJSONL(data) { var line rolloutLine - if json.Unmarshal(lineData, &line) != nil || line.Type != rolloutLineTypeEventMsg { + if json.Unmarshal(lineData, &line) != nil { + terminalValid = false + continue + } + if index+1 > fromOffset { + for _, file := range extractFilesFromParsedLine(line) { + if _, seen := seenFiles[file]; !seen { + seenFiles[file] = struct{}{} + result.ModifiedFiles = append(result.ModifiedFiles, file) + } + } + } + if line.Type != rolloutLineTypeEventMsg { + continue + } + + var header struct { + Type string `json:"type"` + } + if json.Unmarshal(line.Payload, &header) != nil { + terminalValid = false + continue + } + if header.Type != eventMsgTypeTokenCount && header.Type != "task_started" && header.Type != "task_complete" { continue } var event eventMsgPayload - if json.Unmarshal(line.Payload, &event) != nil || event.Type != eventMsgTypeTokenCount { + if json.Unmarshal(line.Payload, &event) != nil { + if header.Type != eventMsgTypeTokenCount { + terminalValid = false + } continue } - found = true - lastInfo = event.Info + switch header.Type { + case eventMsgTypeTokenCount: + foundToken = true + lastTokenInfo = event.Info + case "task_started": + if openTurn != "" || event.TurnID == nil || *event.TurnID == "" { + terminalValid = false + continue + } + if _, duplicate := seenTurns[*event.TurnID]; duplicate { + terminalValid = false + continue + } + openTurn = *event.TurnID + case "task_complete": + if openTurn == "" || (event.TurnID != nil && (*event.TurnID == "" || *event.TurnID != openTurn)) { + terminalValid = false + continue + } + result.TerminalTurnIDs = append(result.TerminalTurnIDs, openTurn) + seenTurns[openTurn] = struct{}{} + openTurn = "" + } + } + if !terminalValid || openTurn != "" { + result.TerminalTurnIDs = nil + } + if foundToken { + result.ExactTokenUsage = exactUsageFromInfo(lastTokenInfo) + } + return result +} + +func extractFilesFromParsedLine(line rolloutLine) []string { + if line.Type != rolloutLineTypeResponseItem { + return nil + } + var payload responseItemPayload + if json.Unmarshal(line.Payload, &payload) != nil || payload.Type != "custom_tool_call" || payload.Name != "apply_patch" { + return nil } - if !found || len(lastInfo) == 0 { + return extractFilesFromApplyPatch(payload.Input) +} + +func exactUsageFromInfo(lastInfo json.RawMessage) *agent.TokenUsage { + if len(lastInfo) == 0 { return nil } var info struct { @@ -545,63 +590,11 @@ func exactCumulativeTokenUsage(data []byte) *agent.TokenUsage { return &agent.TokenUsage{InputTokens: input - cached, CacheReadTokens: cached, OutputTokens: output} } -// terminalTurnIDs accepts only ordered, one-at-a-time task boundaries. Modern -// records name the same turn at both ends; the legacy ID-less completion is -// accepted only while exactly one started turn is open. -func terminalTurnIDs(data []byte) []string { - var terminal []string - open := "" - seen := make(map[string]struct{}) - for _, lineData := range splitJSONL(data) { - var line rolloutLine - if json.Unmarshal(lineData, &line) != nil { - return nil - } - if line.Type != rolloutLineTypeEventMsg { - continue - } - var rawEvent struct { - Type string `json:"type"` - } - if json.Unmarshal(line.Payload, &rawEvent) != nil { - return nil - } - if rawEvent.Type != "task_started" && rawEvent.Type != "task_complete" { - continue - } - var event eventMsgPayload - if json.Unmarshal(line.Payload, &event) != nil { - return nil - } - switch event.Type { - case "task_started": - if open != "" || event.TurnID == nil || *event.TurnID == "" { - return nil - } - if _, duplicate := seen[*event.TurnID]; duplicate { - return nil - } - open = *event.TurnID - case "task_complete": - if open == "" || (event.TurnID != nil && (*event.TurnID == "" || *event.TurnID != open)) { - return nil - } - terminal = append(terminal, open) - seen[open] = struct{}{} - open = "" - } - } - if open != "" { - return nil - } - return terminal -} - // ExtractWithSubagentInventory gathers evidence only for refs supplied by the // caller's authoritative ledger. It never discovers children from transcript // text, filenames, timestamps, or token-count events. func (c *CodexAgent) ExtractWithSubagentInventory(ctx context.Context, parent []byte, fromOffset int, refs []agent.SubagentReference) (agent.InventoryExtraction, error) { - result := agent.InventoryExtraction{ModifiedFiles: extractFilesFromData(parent, fromOffset)} + result := agent.InventoryExtraction{ModifiedFiles: analyzeRollout(parent, fromOffset).ModifiedFiles} parentUsage, err := c.CalculateTokenUsage(parent, fromOffset) if err != nil { return result, err @@ -635,9 +628,10 @@ func (c *CodexAgent) ExtractWithSubagentInventory(ctx context.Context, parent [] result.Children = append(result.Children, analysis) continue } - analysis.ModifiedFiles = extractFilesFromData(loaded.Data, 0) - analysis.TerminalTurnIDs = terminalTurnIDs(loaded.Data) - analysis.TokenUsage = exactCumulativeTokenUsage(loaded.Data) + rollout := analyzeRollout(loaded.Data, 0) + analysis.ModifiedFiles = rollout.ModifiedFiles + analysis.TerminalTurnIDs = rollout.TerminalTurnIDs + analysis.TokenUsage = rollout.ExactTokenUsage if analysis.TokenUsage == nil { complete = false } else { @@ -663,17 +657,6 @@ func withChildCoverage(usage *agent.TokenUsage, complete bool) *agent.TokenUsage return &result } -func extractFilesFromData(data []byte, fromOffset int) []string { - var files []string - for index, lineData := range splitJSONL(data) { - if index+1 <= fromOffset { - continue - } - files = appendUniqueFiles(files, extractFilesFromLine(lineData)) - } - return files -} - func appendUniqueFiles(files, additions []string) []string { seen := make(map[string]struct{}, len(files)+len(additions)) for _, file := range files { diff --git a/cmd/entire/cli/agent/codex/transcript_test.go b/cmd/entire/cli/agent/codex/transcript_test.go index 92cba4c889..9be16e24d5 100644 --- a/cmd/entire/cli/agent/codex/transcript_test.go +++ b/cmd/entire/cli/agent/codex/transcript_test.go @@ -36,9 +36,10 @@ func TestClassifyRollout(t *testing.T) { t.Parallel() tests := []struct { - name string - data string - want rolloutClassification + name string + data string + want rolloutClassification + wantIssue rolloutClassificationIssue }{ { name: "root thread source", @@ -61,19 +62,22 @@ func TestClassifyRollout(t *testing.T) { want: rolloutChild, }, { - name: "missing session metadata", - data: `{"type":"response_item","payload":{}}` + "\n", - want: rolloutUnknown, + name: "missing session metadata", + data: `{"type":"response_item","payload":{}}` + "\n", + want: rolloutUnknown, + wantIssue: rolloutIssueMalformedMetadata, }, { - name: "malformed JSON", - data: `{"type":"session_meta","payload":` + "\n", - want: rolloutUnknown, + name: "malformed JSON", + data: `{"type":"session_meta","payload":` + "\n", + want: rolloutUnknown, + wantIssue: rolloutIssueMalformedMetadata, }, { - name: "unrecognized source", - data: `{"type":"session_meta","payload":{"source":"other"}}` + "\n", - want: rolloutUnknown, + name: "unrecognized source", + data: `{"type":"session_meta","payload":{"source":"other"}}` + "\n", + want: rolloutUnknown, + wantIssue: rolloutIssueUnclassifiedSource, }, } @@ -82,51 +86,18 @@ func TestClassifyRollout(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "rollout.jsonl") require.NoError(t, os.WriteFile(path, []byte(tt.data), 0o600)) - require.Equal(t, tt.want, classifyRollout(path)) + got := classifyRolloutDetailed(path) + require.Equal(t, tt.want, got.Classification) + require.Equal(t, tt.wantIssue, got.Issue) }) } t.Run("missing path", func(t *testing.T) { - t.Parallel() - require.Equal(t, rolloutUnknown, classifyRollout(filepath.Join(t.TempDir(), "missing.jsonl"))) - }) -} - -func TestClassifyRolloutDetailed_ExplainsFailClosedResult(t *testing.T) { - t.Parallel() - - t.Run("null transcript path", func(t *testing.T) { - t.Parallel() - got := classifyRolloutDetailed("") - require.Equal(t, rolloutUnknown, got.Classification) - require.Equal(t, rolloutIssueNullPath, got.Issue) - }) - - t.Run("unreadable transcript", func(t *testing.T) { t.Parallel() got := classifyRolloutDetailed(filepath.Join(t.TempDir(), "missing.jsonl")) require.Equal(t, rolloutUnknown, got.Classification) require.Equal(t, rolloutIssueUnreadable, got.Issue) }) - - t.Run("malformed metadata", func(t *testing.T) { - t.Parallel() - path := filepath.Join(t.TempDir(), "rollout.jsonl") - require.NoError(t, os.WriteFile(path, []byte(`{"type":"session_meta","payload":`), 0o600)) - got := classifyRolloutDetailed(path) - require.Equal(t, rolloutUnknown, got.Classification) - require.Equal(t, rolloutIssueMalformedMetadata, got.Issue) - }) - - t.Run("future source", func(t *testing.T) { - t.Parallel() - path := filepath.Join(t.TempDir(), "rollout.jsonl") - require.NoError(t, os.WriteFile(path, []byte(`{"type":"session_meta","payload":{"thread_source":"future-source"}}`), 0o600)) - got := classifyRolloutDetailed(path) - require.Equal(t, rolloutUnknown, got.Classification) - require.Equal(t, rolloutIssueUnclassifiedSource, got.Issue) - require.Equal(t, "future-source", got.Detail) - }) } func TestGetTranscriptPosition(t *testing.T) { diff --git a/cmd/entire/cli/agent/codex/types.go b/cmd/entire/cli/agent/codex/types.go index 781f0b875b..1177b6ed64 100644 --- a/cmd/entire/cli/agent/codex/types.go +++ b/cmd/entire/cli/agent/codex/types.go @@ -135,6 +135,5 @@ type subagentStopRaw struct { HookEventName string `json:"hook_event_name"` Model string `json:"model"` PermissionMode string `json:"permission_mode"` - StopHookActive bool `json:"stop_hook_active"` TurnID string `json:"turn_id"` } diff --git a/cmd/entire/cli/agent/event.go b/cmd/entire/cli/agent/event.go index a5bf085f8c..2711d511c6 100644 --- a/cmd/entire/cli/agent/event.go +++ b/cmd/entire/cli/agent/event.go @@ -114,9 +114,6 @@ type Event struct { // SubagentID identifies the subagent instance (for SubagentEnd events). SubagentID string - // StopHookActive reports whether the agent's Stop hook remains active. - StopHookActive bool - // ProvisionalSubagentStop is true when a subagent-stop event may arrive // before the root rollout has reached its final state. ProvisionalSubagentStop bool diff --git a/cmd/entire/cli/integration_test/codex_subagent_test.go b/cmd/entire/cli/integration_test/codex_subagent_test.go index e3f3a1a0a1..618e8e3282 100644 --- a/cmd/entire/cli/integration_test/codex_subagent_test.go +++ b/cmd/entire/cli/integration_test/codex_subagent_test.go @@ -8,7 +8,6 @@ import ( "testing" "github.com/entireio/cli/cmd/entire/cli/agent" - "github.com/entireio/cli/cmd/entire/cli/agent/codex" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/session" "github.com/stretchr/testify/require" @@ -51,10 +50,6 @@ func TestCodexSubagent_StoresDeclaredSubagentTranscript(t *testing.T) { `{"type":"response_item","payload":{"type":"custom_tool_call","status":"completed","name":"apply_patch","input":"*** Begin Patch\n*** Add File: `+editedFile+`\n+red\n*** End Patch"}}`+"\n"+ `{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":10,"cached_input_tokens":2,"output_tokens":3}}}}`+"\n"+ `{"type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1"}}`+"\n"), 0o600)) - probe, err := (&codex.CodexAgent{RolloutRoots: []string{}}).ExtractWithSubagentInventory(t.Context(), nil, 0, []agent.SubagentReference{{AgentID: agentID, DeclaredTranscriptPath: subagentRollout}}) - require.NoError(t, err) - require.Equal(t, []string{"turn-1"}, probe.Children[0].TerminalTurnIDs) - hook := codexHooker(t, env.RepoDir, sessionID, parentRollout) hook("subagent-start", map[string]any{ "hook_event_name": "SubagentStart", diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index bc0987c303..0ffa51dfbb 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -1434,7 +1434,7 @@ func handleLifecycleSubagentEnd(ctx context.Context, ag agent.Agent, event *agen // still be changing. Record only the observation for later transcript // reconciliation; do not capture the parent worktree or mark a task done. err := strategy.MutateSessionState(logCtx, event.SessionID, func(state *strategy.SessionState) error { - state.RecordSubagentStop(event.SubagentID, event.TurnID, session.SubagentStopCandidate{ObservedAt: time.Now(), StopHookActive: event.StopHookActive}) + state.RecordSubagentStop(event.SubagentID, event.TurnID) state.UpdateSubagentTranscriptPaths(event.SubagentID, event.SubagentTranscriptPath, "") return nil }) diff --git a/cmd/entire/cli/session/state.go b/cmd/entire/cli/session/state.go index 86f4eedb41..a89404c88e 100644 --- a/cmd/entire/cli/session/state.go +++ b/cmd/entire/cli/session/state.go @@ -437,18 +437,12 @@ type State struct { SubagentInventoryComplete *bool `json:"subagent_inventory_complete,omitempty"` } -type SubagentStopCandidate struct { - ObservedAt time.Time `json:"observed_at"` - StopHookActive bool `json:"stop_hook_active,omitempty"` -} - type SubagentInventoryEntry struct { - AgentID string `json:"agent_id"` - DeclaredTranscriptPath string `json:"declared_transcript_path,omitempty"` - ResolvedTranscriptPath string `json:"resolved_transcript_path,omitempty"` - ObservedTurnIDs []string `json:"observed_turn_ids,omitempty"` - PendingStops map[string]SubagentStopCandidate `json:"pending_stops,omitempty"` - FinalizedTurnIDs []string `json:"finalized_turn_ids,omitempty"` + AgentID string `json:"agent_id"` + DeclaredTranscriptPath string `json:"declared_transcript_path,omitempty"` + ResolvedTranscriptPath string `json:"resolved_transcript_path,omitempty"` + ObservedTurnIDs []string `json:"observed_turn_ids,omitempty"` + FinalizedTurnIDs []string `json:"finalized_turn_ids,omitempty"` } // TaskRecord is the durable pointer ledger entry for a subagent dispatched by @@ -573,27 +567,8 @@ func (s *State) RegisterSubagent(agentID, turnID string) bool { // RecordSubagentStop records a provisional stop. Stops can arrive before // starts, so observing the child and turn happens in this same mutation. -func (s *State) RecordSubagentStop(agentID, turnID string, candidate SubagentStopCandidate) bool { - observed := s.RegisterSubagent(agentID, turnID) - if agentID == "" || turnID == "" { - return observed - } - entry := s.FindSubagentInventory(agentID) - if entry == nil || containsString(entry.FinalizedTurnIDs, turnID) { - return observed - } - if entry.PendingStops == nil { - entry.PendingStops = make(map[string]SubagentStopCandidate) - } - if existing, exists := entry.PendingStops[turnID]; exists { - if existing == candidate { - return observed - } - entry.PendingStops[turnID] = candidate - return true - } - entry.PendingStops[turnID] = candidate - return true +func (s *State) RecordSubagentStop(agentID, turnID string) bool { + return s.RegisterSubagent(agentID, turnID) } // UpdateSubagentTranscriptPaths enriches an already-observed child's path @@ -616,8 +591,7 @@ func (s *State) UpdateSubagentTranscriptPaths(agentID, declaredPath, resolvedPat return changed } -// FinalizeSubagentTurn moves an observed turn out of PendingStops and into the -// finalized set atomically. A finalized turn is never finalized twice. +// FinalizeSubagentTurn marks an observed turn finalized exactly once. func (s *State) FinalizeSubagentTurn(agentID, turnID string) bool { if agentID == "" || turnID == "" { return false @@ -626,7 +600,6 @@ func (s *State) FinalizeSubagentTurn(agentID, turnID string) bool { if entry == nil || !containsString(entry.ObservedTurnIDs, turnID) || containsString(entry.FinalizedTurnIDs, turnID) { return false } - delete(entry.PendingStops, turnID) entry.FinalizedTurnIDs = append(entry.FinalizedTurnIDs, turnID) return true } diff --git a/cmd/entire/cli/session/state_test.go b/cmd/entire/cli/session/state_test.go index 1330a8dc5c..06730f2c33 100644 --- a/cmd/entire/cli/session/state_test.go +++ b/cmd/entire/cli/session/state_test.go @@ -1039,10 +1039,7 @@ func TestState_SubagentInventoryLedger(t *testing.T) { t.Fatal("first child observation must be recorded") } assert.Equal(t, uint64(1), state.SubagentLedgerVersion) - assert.Nil(t, state.TokenUsage.SubagentTokens) - assert.False(t, *state.TokenUsage.SubagentTokensComplete) - assert.Nil(t, state.CheckpointTokenUsage.SubagentTokens) - assert.False(t, *state.CheckpointTokenUsage.SubagentTokensComplete) + assertIncompleteSubagentUsage(t, state) // A later exact extraction may have refreshed both aggregates. Duplicate // observations and path-only enrichment must preserve that fresh coverage. @@ -1052,86 +1049,66 @@ func TestState_SubagentInventoryLedger(t *testing.T) { state.CheckpointTokenUsage.SubagentTokens = &agent.TokenUsage{OutputTokens: 13} state.CheckpointTokenUsage.SubagentTokensComplete = &refreshedComplete versionBeforeDuplicate := state.SubagentLedgerVersion - totalBeforeDuplicate := state.TokenUsage.SubagentTokens - checkpointTotalBeforeDuplicate := state.CheckpointTokenUsage.SubagentTokens - coverageBeforeDuplicate := *state.TokenUsage.SubagentTokensComplete - checkpointCoverageBeforeDuplicate := *state.CheckpointTokenUsage.SubagentTokensComplete if state.RegisterSubagent("child-1", "turn-1") { t.Fatal("duplicate agent/turn observation must be a true no-op") } assert.Equal(t, versionBeforeDuplicate, state.SubagentLedgerVersion) - assert.Same(t, totalBeforeDuplicate, state.TokenUsage.SubagentTokens) - assert.Same(t, checkpointTotalBeforeDuplicate, state.CheckpointTokenUsage.SubagentTokens) - assert.Equal(t, coverageBeforeDuplicate, *state.TokenUsage.SubagentTokensComplete) - assert.Equal(t, checkpointCoverageBeforeDuplicate, *state.CheckpointTokenUsage.SubagentTokensComplete) + assertCompleteSubagentUsage(t, state, 21, 13) versionBeforePathEnrichment := state.SubagentLedgerVersion - totalBeforePathEnrichment := state.TokenUsage.SubagentTokens - checkpointTotalBeforePathEnrichment := state.CheckpointTokenUsage.SubagentTokens - coverageBeforePathEnrichment := *state.TokenUsage.SubagentTokensComplete - checkpointCoverageBeforePathEnrichment := *state.CheckpointTokenUsage.SubagentTokensComplete assert.True(t, state.UpdateSubagentTranscriptPaths("child-1", "/tmp/declared.jsonl", "/tmp/resolved.jsonl")) assert.Equal(t, versionBeforePathEnrichment, state.SubagentLedgerVersion, "path enrichment must not churn the ledger generation") - assert.Same(t, totalBeforePathEnrichment, state.TokenUsage.SubagentTokens) - assert.Same(t, checkpointTotalBeforePathEnrichment, state.CheckpointTokenUsage.SubagentTokens) - assert.Equal(t, coverageBeforePathEnrichment, *state.TokenUsage.SubagentTokensComplete) - assert.Equal(t, checkpointCoverageBeforePathEnrichment, *state.CheckpointTokenUsage.SubagentTokensComplete) - - stopObservedAt := time.Now().UTC().Truncate(time.Second) - stopCandidate := SubagentStopCandidate{ObservedAt: stopObservedAt, StopHookActive: true} - if !state.RecordSubagentStop("child-1", "turn-2", stopCandidate) { + assertCompleteSubagentUsage(t, state, 21, 13) + + if !state.RecordSubagentStop("child-1", "turn-2") { t.Fatal("stop-first new turn must be recorded") } assert.Equal(t, uint64(2), state.SubagentLedgerVersion) - assert.Nil(t, state.TokenUsage.SubagentTokens, "new child turn must invalidate refreshed session totals") - require.NotNil(t, state.TokenUsage.SubagentTokensComplete) - assert.False(t, *state.TokenUsage.SubagentTokensComplete) - assert.Nil(t, state.CheckpointTokenUsage.SubagentTokens, "new child turn must invalidate refreshed checkpoint totals") - require.NotNil(t, state.CheckpointTokenUsage.SubagentTokensComplete) - assert.False(t, *state.CheckpointTokenUsage.SubagentTokensComplete) + assertIncompleteSubagentUsage(t, state) entry := state.FindSubagentInventory("child-1") require.NotNil(t, entry) - require.Contains(t, entry.PendingStops, "turn-2") + require.Contains(t, entry.ObservedTurnIDs, "turn-2") + require.NotContains(t, entry.FinalizedTurnIDs, "turn-2") - // A stop retry may carry richer metadata. Both a true duplicate and the - // metadata refresh must leave already-calculated token coverage intact. + // A stop retry must leave already-calculated token coverage intact. stopRefreshComplete := true state.TokenUsage.SubagentTokens = &agent.TokenUsage{InputTokens: 34} state.TokenUsage.SubagentTokensComplete = &stopRefreshComplete state.CheckpointTokenUsage.SubagentTokens = &agent.TokenUsage{OutputTokens: 21} state.CheckpointTokenUsage.SubagentTokensComplete = &stopRefreshComplete versionBeforeStopRefresh := state.SubagentLedgerVersion - totalBeforeStopRefresh := state.TokenUsage.SubagentTokens - checkpointTotalBeforeStopRefresh := state.CheckpointTokenUsage.SubagentTokens - coverageBeforeStopRefresh := *state.TokenUsage.SubagentTokensComplete - checkpointCoverageBeforeStopRefresh := *state.CheckpointTokenUsage.SubagentTokensComplete - assert.False(t, state.RecordSubagentStop("child-1", "turn-2", stopCandidate), "exact duplicate stop must be a no-op") - assert.Equal(t, versionBeforeStopRefresh, state.SubagentLedgerVersion) - assert.Same(t, totalBeforeStopRefresh, state.TokenUsage.SubagentTokens) - assert.Same(t, checkpointTotalBeforeStopRefresh, state.CheckpointTokenUsage.SubagentTokens) - assert.Equal(t, coverageBeforeStopRefresh, *state.TokenUsage.SubagentTokensComplete) - assert.Equal(t, checkpointCoverageBeforeStopRefresh, *state.CheckpointTokenUsage.SubagentTokensComplete) - - refreshedCandidate := SubagentStopCandidate{ObservedAt: stopObservedAt.Add(time.Second), StopHookActive: false} - assert.True(t, state.RecordSubagentStop("child-1", "turn-2", refreshedCandidate), "changed stop metadata must upsert") - assert.Equal(t, refreshedCandidate, entry.PendingStops["turn-2"]) + assert.False(t, state.RecordSubagentStop("child-1", "turn-2"), "duplicate stop must be a no-op") assert.Equal(t, versionBeforeStopRefresh, state.SubagentLedgerVersion) - assert.Same(t, totalBeforeStopRefresh, state.TokenUsage.SubagentTokens) - assert.Same(t, checkpointTotalBeforeStopRefresh, state.CheckpointTokenUsage.SubagentTokens) - assert.Equal(t, coverageBeforeStopRefresh, *state.TokenUsage.SubagentTokensComplete) - assert.Equal(t, checkpointCoverageBeforeStopRefresh, *state.CheckpointTokenUsage.SubagentTokensComplete) + assertCompleteSubagentUsage(t, state, 34, 21) - state.RecordSubagentStop("child-1", "turn-3", SubagentStopCandidate{ObservedAt: time.Now()}) - require.Len(t, entry.PendingStops, 2, "several pending stops must coexist") + state.RecordSubagentStop("child-1", "turn-3") + require.Contains(t, entry.ObservedTurnIDs, "turn-3", "several pending turns must coexist") if !state.FinalizeSubagentTurn("child-1", "turn-2") { t.Fatal("pending turn must finalize") } - assert.NotContains(t, entry.PendingStops, "turn-2") assert.Contains(t, entry.FinalizedTurnIDs, "turn-2") if state.FinalizeSubagentTurn("child-1", "turn-2") { t.Fatal("finalized turn must be exactly once") } } +func assertIncompleteSubagentUsage(t *testing.T, state *State) { + t.Helper() + for _, usage := range []*agent.TokenUsage{state.TokenUsage, state.CheckpointTokenUsage} { + require.NotNil(t, usage) + assert.Nil(t, usage.SubagentTokens) + require.NotNil(t, usage.SubagentTokensComplete) + assert.False(t, *usage.SubagentTokensComplete) + } +} + +func assertCompleteSubagentUsage(t *testing.T, state *State, sessionInput, checkpointOutput int) { + t.Helper() + assert.Equal(t, &agent.TokenUsage{InputTokens: sessionInput}, state.TokenUsage.SubagentTokens) + assert.Equal(t, &agent.TokenUsage{OutputTokens: checkpointOutput}, state.CheckpointTokenUsage.SubagentTokens) + assert.True(t, *state.TokenUsage.SubagentTokensComplete) + assert.True(t, *state.CheckpointTokenUsage.SubagentTokensComplete) +} + func TestState_SubagentInventoryRoundTripAndTaskRecordRecovery(t *testing.T) { t.Parallel() now := time.Now().UTC().Truncate(time.Second) @@ -1146,7 +1123,6 @@ func TestState_SubagentInventoryRoundTripAndTaskRecordRecovery(t *testing.T) { DeclaredTranscriptPath: "/tmp/child.jsonl", ResolvedTranscriptPath: "/tmp/resolved.jsonl", ObservedTurnIDs: []string{"turn-1"}, - PendingStops: map[string]SubagentStopCandidate{"turn-1": {ObservedAt: now, StopHookActive: true}}, FinalizedTurnIDs: []string{"turn-0"}, }}, } @@ -1159,17 +1135,7 @@ func TestState_SubagentInventoryRoundTripAndTaskRecordRecovery(t *testing.T) { require.NotNil(t, got.SubagentTokensBaselineComplete) assert.True(t, *got.SubagentTokensBaselineComplete) assert.Equal(t, uint64(7), got.SubagentLedgerVersion) - require.Len(t, got.SubagentInventory, 1) - entry := got.SubagentInventory[0] - assert.Equal(t, "child-1", entry.AgentID) - assert.Equal(t, "/tmp/child.jsonl", entry.DeclaredTranscriptPath) - assert.Equal(t, "/tmp/resolved.jsonl", entry.ResolvedTranscriptPath) - assert.Equal(t, []string{"turn-1"}, entry.ObservedTurnIDs) - require.Contains(t, entry.PendingStops, "turn-1") - pending := entry.PendingStops["turn-1"] - assert.True(t, now.Equal(pending.ObservedAt)) - assert.True(t, pending.StopHookActive) - assert.Equal(t, []string{"turn-0"}, entry.FinalizedTurnIDs) + assert.Equal(t, state.SubagentInventory, got.SubagentInventory) materialized := TaskRecord{ToolUseID: "child-1", AgentID: "child-1", StartedAt: now, CompletedAt: now} got.AddTaskRecord(materialized) @@ -1192,10 +1158,7 @@ func TestState_NormalizeAfterLoad_CodexInventoryMigration(t *testing.T) { assert.False(t, *legacy.SubagentInventoryComplete) require.NotNil(t, legacy.SubagentTokensBaselineComplete) assert.False(t, *legacy.SubagentTokensBaselineComplete) - assert.Nil(t, legacy.TokenUsage.SubagentTokens) - assert.False(t, *legacy.TokenUsage.SubagentTokensComplete) - assert.Nil(t, legacy.CheckpointTokenUsage.SubagentTokens) - assert.False(t, *legacy.CheckpointTokenUsage.SubagentTokensComplete) + assertIncompleteSubagentUsage(t, legacy) require.Len(t, legacy.SubagentInventory, 1) assert.Equal(t, "child-1", legacy.SubagentInventory[0].AgentID) diff --git a/cmd/entire/cli/strategy/agent_resolution_test.go b/cmd/entire/cli/strategy/agent_resolution_test.go index 7cd4b6d23f..e129b7b815 100644 --- a/cmd/entire/cli/strategy/agent_resolution_test.go +++ b/cmd/entire/cli/strategy/agent_resolution_test.go @@ -3,11 +3,11 @@ package strategy import ( "context" "path/filepath" - "strings" "sync" "testing" "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/types" "github.com/entireio/cli/cmd/entire/cli/session" // Register agents so AgentForTranscriptPath can resolve them. @@ -43,47 +43,40 @@ func withCodexSessionDir(t *testing.T) string { return filepath.Join(sessionDir, "2026", "09", "02", "rollout.jsonl") } -func TestInitializeSession_CodexCorrection_CleanKnownOwner(t *testing.T) { +func TestInitializeSession_CodexCorrection_CleanOwners(t *testing.T) { dir := setupGitRepo(t) t.Chdir(dir) codexTranscript := withCodexSessionDir(t) - ctx := context.Background() - sessionID := "codex-clean-known" s := &ManualCommitStrategy{} - require.NoError(t, s.InitializeSession(ctx, sessionID, agent.AgentTypeClaudeCode, "", "first", "")) - require.NoError(t, s.InitializeSession(ctx, sessionID, agent.AgentTypeCodex, codexTranscript, "second", "")) - - state, err := s.loadSessionState(ctx, sessionID) - require.NoError(t, err) - assertCleanCodexCorrection(t, state) + for _, owner := range []struct { + name string + agentType types.AgentType + }{ + {name: "known", agentType: agent.AgentTypeClaudeCode}, + {name: "unknown"}, + } { + sessionID := "codex-clean-" + owner.name + require.NoError(t, s.InitializeSession(ctx, sessionID, agent.AgentTypeClaudeCode, "", "first", "")) + state, err := s.loadSessionState(ctx, sessionID) + require.NoError(t, err) + state.AgentType = owner.agentType + require.NoError(t, s.saveSessionState(ctx, state)) + + require.NoError(t, s.InitializeSession(ctx, sessionID, agent.AgentTypeCodex, codexTranscript, "second", "")) + state, err = s.loadSessionState(ctx, sessionID) + require.NoError(t, err) + assertCleanCodexCorrection(t, state) + } } -func TestInitializeSession_CodexCorrection_CleanUnknownOwner(t *testing.T) { - dir := setupGitRepo(t) - t.Chdir(dir) - codexTranscript := withCodexSessionDir(t) - - ctx := context.Background() - sessionID := "codex-clean-unknown" - s := &ManualCommitStrategy{} - require.NoError(t, s.InitializeSession(ctx, sessionID, agent.AgentTypeClaudeCode, "", "first", "")) - state, err := s.loadSessionState(ctx, sessionID) - require.NoError(t, err) - state.AgentType = "" - require.NoError(t, s.saveSessionState(ctx, state)) - - require.NoError(t, s.InitializeSession(ctx, sessionID, agent.AgentTypeCodex, codexTranscript, "second", "")) - state, err = s.loadSessionState(ctx, sessionID) - require.NoError(t, err) - assertCleanCodexCorrection(t, state) -} +func TestHasPriorSubagentEvidence(t *testing.T) { + t.Parallel() -func TestInitializeSession_CodexCorrection_DirtyEvidence(t *testing.T) { incomplete := false tests := []struct { - name string - dirty func(*SessionState) + name string + set func(*SessionState) }{ {"inventory", func(s *SessionState) { s.SubagentInventory = []session.SubagentInventoryEntry{{AgentID: "child"}} }}, {"task record", func(s *SessionState) { @@ -101,40 +94,45 @@ func TestInitializeSession_CodexCorrection_DirtyEvidence(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - dir := setupGitRepo(t) - t.Chdir(dir) - codexTranscript := withCodexSessionDir(t) - ctx := context.Background() - sessionID := "codex-dirty-" + strings.ReplaceAll(tt.name, " ", "-") - s := &ManualCommitStrategy{} - require.NoError(t, s.InitializeSession(ctx, sessionID, agent.AgentTypeClaudeCode, "", "first", "")) - state, err := s.loadSessionState(ctx, sessionID) - require.NoError(t, err) - tt.dirty(state) - require.NoError(t, s.saveSessionState(ctx, state)) - - require.NoError(t, s.InitializeSession(ctx, sessionID, agent.AgentTypeCodex, codexTranscript, "second", "")) - state, err = s.loadSessionState(ctx, sessionID) - require.NoError(t, err) - require.Equal(t, agent.AgentTypeCodex, state.AgentType) - require.NotNil(t, state.SubagentInventoryComplete) - require.False(t, *state.SubagentInventoryComplete) - require.NotNil(t, state.SubagentTokensBaselineComplete) - require.False(t, *state.SubagentTokensBaselineComplete) - require.NotNil(t, state.TokenUsage) - require.Nil(t, state.TokenUsage.SubagentTokens) - require.NotNil(t, state.TokenUsage.SubagentTokensComplete) - require.False(t, *state.TokenUsage.SubagentTokensComplete) - require.NotNil(t, state.CheckpointTokenUsage) - require.Nil(t, state.CheckpointTokenUsage.SubagentTokens) - require.NotNil(t, state.CheckpointTokenUsage.SubagentTokensComplete) - require.False(t, *state.CheckpointTokenUsage.SubagentTokensComplete) - require.Nil(t, state.SubagentTokensBaseline) - if tt.name == "task record" { - require.NotNil(t, state.FindSubagentInventory("child-from-task")) - } + t.Parallel() + state := &SessionState{} + tt.set(state) + require.True(t, hasPriorSubagentEvidence(state)) }) } + require.False(t, hasPriorSubagentEvidence(&SessionState{})) +} + +func TestTransitionSessionToCodex_PreservesDirtyEvidence(t *testing.T) { + t.Parallel() + + incomplete := false + state := &SessionState{ + TaskRecords: []session.TaskRecord{{ToolUseID: "task", AgentID: "child"}}, + SubagentLedgerVersion: 4, + SubagentTokensBaseline: &agent.TokenUsage{InputTokens: 3}, + TokenUsage: &agent.TokenUsage{SubagentTokens: &agent.TokenUsage{InputTokens: 1}}, + CheckpointTokenUsage: &agent.TokenUsage{SubagentTokens: &agent.TokenUsage{InputTokens: 2}}, + SubagentInventoryComplete: &incomplete, + SubagentTokensBaselineComplete: &incomplete, + } + + transitionSessionToCodex(state) + require.Equal(t, uint64(4), state.SubagentLedgerVersion) + require.NotNil(t, state.FindSubagentInventory("child")) + require.Nil(t, state.SubagentTokensBaseline) + assertIncompleteUsage(t, state.TokenUsage) + assertIncompleteUsage(t, state.CheckpointTokenUsage) + require.False(t, *state.SubagentInventoryComplete) + require.False(t, *state.SubagentTokensBaselineComplete) +} + +func assertIncompleteUsage(t *testing.T, usage *agent.TokenUsage) { + t.Helper() + require.NotNil(t, usage) + require.Nil(t, usage.SubagentTokens) + require.NotNil(t, usage.SubagentTokensComplete) + require.False(t, *usage.SubagentTokensComplete) } func TestInitializeSession_CodexCallerWithoutTranscriptDoesNotMigrateAccounting(t *testing.T) { diff --git a/cmd/entire/cli/strategy/manual_commit_test.go b/cmd/entire/cli/strategy/manual_commit_test.go index 782403060f..37b93a8465 100644 --- a/cmd/entire/cli/strategy/manual_commit_test.go +++ b/cmd/entire/cli/strategy/manual_commit_test.go @@ -51,11 +51,8 @@ func TestCodexInventoryInitialization(t *testing.T) { incomplete := false pendingAt := time.Now().UTC().Truncate(time.Second) partialInventory := []session.SubagentInventoryEntry{{ - AgentID: "child-observed-before-parent", - ObservedTurnIDs: []string{"turn-pending", "turn-finalized"}, - PendingStops: map[string]session.SubagentStopCandidate{ - "turn-pending": {ObservedAt: pendingAt, StopHookActive: true}, - }, + AgentID: "child-observed-before-parent", + ObservedTurnIDs: []string{"turn-pending", "turn-finalized"}, FinalizedTurnIDs: []string{"turn-finalized"}, }} partialTokenUsage := &agent.TokenUsage{InputTokens: 100, SubagentTokens: &agent.TokenUsage{InputTokens: 60}, SubagentTokensComplete: &incomplete} @@ -78,44 +75,18 @@ func TestCodexInventoryInitialization(t *testing.T) { SubagentTokensBaseline: partialBaseline, TaskRecords: partialRecords, })) - beforeRepair, err := s.loadSessionState(context.Background(), "codex-inventory-partial") - require.NoError(t, err) - assert.Empty(t, beforeRepair.BaseCommit) - require.NotNil(t, beforeRepair.SubagentInventoryComplete) - assert.False(t, *beforeRepair.SubagentInventoryComplete) - assert.Equal(t, uint64(9), beforeRepair.SubagentLedgerVersion) - require.Len(t, beforeRepair.SubagentInventory, 1) require.NoError(t, s.initializeSession(context.Background(), repo, "codex-inventory-partial", agent.AgentTypeCodex, "", "", "")) partial, err := s.loadSessionState(context.Background(), "codex-inventory-partial") require.NoError(t, err) - require.NotNil(t, partial.SubagentInventoryComplete) assert.False(t, *partial.SubagentInventoryComplete, "partial-state repair must not promote unknown inventory coverage") - require.NotNil(t, partial.SubagentTokensBaselineComplete) assert.False(t, *partial.SubagentTokensBaselineComplete) assert.Equal(t, uint64(9), partial.SubagentLedgerVersion) - require.Len(t, partial.SubagentInventory, 1) - entry := partial.SubagentInventory[0] - assert.Equal(t, "child-observed-before-parent", entry.AgentID) - assert.Equal(t, []string{"turn-pending", "turn-finalized"}, entry.ObservedTurnIDs) - require.Contains(t, entry.PendingStops, "turn-pending") - assert.True(t, pendingAt.Equal(entry.PendingStops["turn-pending"].ObservedAt)) - assert.True(t, entry.PendingStops["turn-pending"].StopHookActive) - assert.Equal(t, []string{"turn-finalized"}, entry.FinalizedTurnIDs) + assert.Equal(t, partialInventory, partial.SubagentInventory) assert.True(t, partial.HasTaskContent(), "repair must retain both live and completed-unmaterialized task content") - require.Len(t, partial.TaskRecords, 2) - assert.Equal(t, "child-live", partial.TaskRecords[0].ToolUseID) - assert.True(t, partial.TaskRecords[1].CompletedAt.Equal(pendingAt.Add(time.Second))) - require.NotNil(t, partial.TokenUsage) - assert.Equal(t, 100, partial.TokenUsage.InputTokens) - require.NotNil(t, partial.TokenUsage.SubagentTokens) - assert.Equal(t, 60, partial.TokenUsage.SubagentTokens.InputTokens) - require.NotNil(t, partial.CheckpointTokenUsage) - assert.Equal(t, 50, partial.CheckpointTokenUsage.OutputTokens) - require.NotNil(t, partial.CheckpointTokenUsage.SubagentTokens) - assert.Equal(t, 30, partial.CheckpointTokenUsage.SubagentTokens.OutputTokens) - require.NotNil(t, partial.SubagentTokensBaseline) - require.NotNil(t, partial.SubagentTokensBaseline.SubagentTokens) - assert.Equal(t, 40, partial.SubagentTokensBaseline.SubagentTokens.InputTokens) + assert.Equal(t, partialRecords, partial.TaskRecords) + assert.Equal(t, partialTokenUsage, partial.TokenUsage) + assert.Equal(t, partialCheckpointUsage, partial.CheckpointTokenUsage) + assert.Equal(t, partialBaseline, partial.SubagentTokensBaseline) } // testTranscriptPromptResponse is a minimal transcript used across strategy tests. From edc128933d8ff648176d10a1d3e4758dcd36a297 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Thu, 3 Sep 2026 13:27:20 -0700 Subject: [PATCH 11/59] fix(codex): preserve concurrent child evidence Entire-Checkpoint: 01M1MF8R024N9A0FHGZK7M45PR --- cmd/entire/cli/lifecycle.go | 10 +++--- cmd/entire/cli/lifecycle_test.go | 6 ++-- cmd/entire/cli/session/state.go | 32 ++++++++++++++++--- cmd/entire/cli/session/state_test.go | 20 ++++++++++++ cmd/entire/cli/strategy/manual_commit_git.go | 16 +++++++--- cmd/entire/cli/strategy/strategy.go | 5 +-- .../cli/strategy/subagent_tokens_test.go | 22 +++++++++++++ 7 files changed, 93 insertions(+), 18 deletions(-) diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index 0ffa51dfbb..31839811b5 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -938,7 +938,7 @@ func handleLifecycleTurnEnd(ctx context.Context, ag agent.Agent, event *agent.Ev // no-files gate: a read-only child can finish without producing a shadow // checkpoint, but its exact availability still must replace stale coverage. var codexInventoryUsage *agent.TokenUsage - var codexLedgerVersion uint64 + var codexLedgerVersion *uint64 if ag.Type() == agent.AgentTypeCodex { inventoryOffset := 0 if preState != nil { @@ -1192,10 +1192,10 @@ func finalizeCodexObservedAtSessionEnd(ctx context.Context, sessionID string) { // potentially slow filesystem analysis outside its lock, then applies only // path enrichment and terminal evidence if no new child observation raced it. // It never manufactures an exact-empty result for an unknown/legacy ledger. -func refreshCodexInventory(ctx context.Context, ag agent.Agent, sessionID string, parent []byte, fromOffset int) (*agent.TokenUsage, uint64) { +func refreshCodexInventory(ctx context.Context, ag agent.Agent, sessionID string, parent []byte, fromOffset int) (*agent.TokenUsage, *uint64) { state, err := strategy.LoadSessionState(ctx, sessionID) if err != nil || state == nil || state.SubagentInventoryComplete == nil { - return nil, 0 + return nil, nil } refs := make([]agent.SubagentReference, 0, len(state.SubagentInventory)) for _, entry := range state.SubagentInventory { @@ -1204,7 +1204,7 @@ func refreshCodexInventory(ctx context.Context, ag agent.Agent, sessionID string version := state.SubagentLedgerVersion extraction, ok := agent.ExtractWithSubagentInventory(ctx, ag, parent, fromOffset, refs) if !ok { - return nil, version + return nil, &version } usage := extraction.TokenUsage @@ -1259,7 +1259,7 @@ func refreshCodexInventory(ctx context.Context, ag agent.Agent, sessionID string }); err != nil && !errors.Is(err, strategy.ErrStateNotFound) { logging.Debug(ctx, "failed to persist codex inventory evidence", slog.String("error", err.Error())) } - return usage, version + return usage, &version } // processStart approximates when this hook process began. Package diff --git a/cmd/entire/cli/lifecycle_test.go b/cmd/entire/cli/lifecycle_test.go index b0b4f03f62..f8411670f1 100644 --- a/cmd/entire/cli/lifecycle_test.go +++ b/cmd/entire/cli/lifecycle_test.go @@ -194,7 +194,8 @@ func TestRefreshCodexInventory_MultiTurnChildRefreshesCompletedTaskRecord(t *tes } _, version := refreshCodexInventory(ctx, ag, sessionID, nil, 0) - require.Equal(t, uint64(2), version) + require.NotNil(t, version) + require.Equal(t, uint64(2), *version) state, err := strategy.LoadSessionState(ctx, sessionID) require.NoError(t, err) @@ -283,7 +284,8 @@ func TestRefreshCodexInventory_UsesCurrentCompletenessWhenPersistingUsage(t *tes } usage, version := refreshCodexInventory(ctx, ag, sessionID, nil, 0) - assert.Equal(t, uint64(2), version) + require.NotNil(t, version) + assert.Equal(t, uint64(2), *version) require.NotNil(t, usage) require.NotNil(t, usage.SubagentTokensComplete) assert.False(t, *usage.SubagentTokensComplete) diff --git a/cmd/entire/cli/session/state.go b/cmd/entire/cli/session/state.go index a89404c88e..e112ea96c2 100644 --- a/cmd/entire/cli/session/state.go +++ b/cmd/entire/cli/session/state.go @@ -522,9 +522,28 @@ func (s *State) AddTaskRecord(task TaskRecord) { } // EnsureTaskRecord adds a follow-up record only after an earlier completed -// record was materialized and removed. Existing unmaterialized content wins. +// record was materialized and removed. Existing unmaterialized content wins, +// but missing launch metadata is enriched for stop-before-start delivery. func (s *State) EnsureTaskRecord(task TaskRecord) bool { - if task.ToolUseID == "" || s.FindTaskRecord(task.ToolUseID) != nil { + if task.ToolUseID == "" { + return false + } + if existing := s.FindTaskRecord(task.ToolUseID); existing != nil { + if existing.AgentID == "" { + existing.AgentID = task.AgentID + } + if existing.StartedAt.IsZero() { + existing.StartedAt = task.StartedAt + } + if existing.SubagentType == "" { + existing.SubagentType = task.SubagentType + } + if existing.TaskDescription == "" { + existing.TaskDescription = task.TaskDescription + } + if existing.DeclaredTranscriptPath == "" { + existing.DeclaredTranscriptPath = task.DeclaredTranscriptPath + } return false } s.AddTaskRecord(task) @@ -566,9 +585,14 @@ func (s *State) RegisterSubagent(agentID, turnID string) bool { } // RecordSubagentStop records a provisional stop. Stops can arrive before -// starts, so observing the child and turn happens in this same mutation. +// starts, so the same mutation also preserves a pending task record. A late +// start enriches that placeholder through EnsureTaskRecord. func (s *State) RecordSubagentStop(agentID, turnID string) bool { - return s.RegisterSubagent(agentID, turnID) + newObservation := s.RegisterSubagent(agentID, turnID) + if newObservation { + s.EnsureTaskRecord(TaskRecord{ToolUseID: agentID, AgentID: agentID}) + } + return newObservation } // UpdateSubagentTranscriptPaths enriches an already-observed child's path diff --git a/cmd/entire/cli/session/state_test.go b/cmd/entire/cli/session/state_test.go index 06730f2c33..905cc5e035 100644 --- a/cmd/entire/cli/session/state_test.go +++ b/cmd/entire/cli/session/state_test.go @@ -1030,6 +1030,26 @@ func TestState_LiveTaskRecords(t *testing.T) { func TestState_SubagentInventoryLedger(t *testing.T) { t.Parallel() + stopFirst := &State{} + if !stopFirst.RecordSubagentStop("child-stop-first", "turn-stop-first") { + t.Fatal("stop-before-start observation must be recorded") + } + record := stopFirst.FindTaskRecord("child-stop-first") + require.NotNil(t, record, "a stop-before-start observation must preserve pending task content") + assert.Equal(t, "child-stop-first", record.AgentID) + assert.True(t, stopFirst.HasTaskContent()) + startedAt := time.Now().UTC() + assert.False(t, stopFirst.EnsureTaskRecord(TaskRecord{ + ToolUseID: "child-stop-first", + AgentID: "child-stop-first", + StartedAt: startedAt, + SubagentType: "default", + TaskDescription: "late start metadata", + }), "the late start must enrich, not replace, the pending record") + assert.Equal(t, startedAt, record.StartedAt) + assert.Equal(t, "default", record.SubagentType) + assert.Equal(t, "late start metadata", record.TaskDescription) + complete := true state := &State{ TokenUsage: &agent.TokenUsage{InputTokens: 5, SubagentTokens: &agent.TokenUsage{InputTokens: 3}, SubagentTokensComplete: &complete}, diff --git a/cmd/entire/cli/strategy/manual_commit_git.go b/cmd/entire/cli/strategy/manual_commit_git.go index bcd76babfa..ae8f19edc9 100644 --- a/cmd/entire/cli/strategy/manual_commit_git.go +++ b/cmd/entire/cli/strategy/manual_commit_git.go @@ -46,11 +46,7 @@ func (s *ManualCommitStrategy) SaveStep(ctx context.Context, step StepContext) e } mutErr := MutateSessionState(ctx, sessionID, func(state *SessionState) error { - if step.SubagentLedgerVersion != 0 && state.SubagentLedgerVersion != step.SubagentLedgerVersion && step.TokenUsage != nil { - // Keep valid main-agent deltas but never persist a child aggregate - // computed against an older authoritative inventory. - step.TokenUsage = types.WithClearedSubagentTokens(step.TokenUsage, false) - } + invalidateStaleSubagentSnapshot(&step, state) _, migrateSpan := perf.Start(ctx, "migrate_shadow_branch") if _, _, err := s.migrateShadowBranchIfNeeded(ctx, repo, state); err != nil { migrateSpan.RecordError(err) @@ -200,6 +196,16 @@ func (s *ManualCommitStrategy) SaveStep(ctx context.Context, step StepContext) e return mutErr } +func invalidateStaleSubagentSnapshot(step *StepContext, state *SessionState) { + if step.SubagentLedgerVersion == nil || step.TokenUsage == nil || + state.SubagentLedgerVersion == *step.SubagentLedgerVersion { + return + } + // Keep valid main-agent deltas but never persist a child aggregate + // computed against an older authoritative inventory. + step.TokenUsage = types.WithClearedSubagentTokens(step.TokenUsage, false) +} + // ensureSessionInitialized creates the session state file if it doesn't yet // exist (or has empty BaseCommit). Idempotent: the existence check and the // create both happen inside initializeSession's session gate so a concurrent diff --git a/cmd/entire/cli/strategy/strategy.go b/cmd/entire/cli/strategy/strategy.go index d9304158db..8e8131d409 100644 --- a/cmd/entire/cli/strategy/strategy.go +++ b/cmd/entire/cli/strategy/strategy.go @@ -163,8 +163,9 @@ type StepContext struct { TokenUsage *agent.TokenUsage // SubagentLedgerVersion is the authoritative inventory version observed - // while token evidence was extracted. Zero means no inventory snapshot. - SubagentLedgerVersion uint64 + // while token evidence was extracted. nil means no inventory snapshot; + // a pointer to zero is a valid snapshot before the first child is observed. + SubagentLedgerVersion *uint64 } // TaskStepContext contains all information needed for saving a task step checkpoint. diff --git a/cmd/entire/cli/strategy/subagent_tokens_test.go b/cmd/entire/cli/strategy/subagent_tokens_test.go index 9c5e731831..587d4629a4 100644 --- a/cmd/entire/cli/strategy/subagent_tokens_test.go +++ b/cmd/entire/cli/strategy/subagent_tokens_test.go @@ -73,6 +73,28 @@ func TestAccumulateTokenUsage_ExplicitEmptyReplacesPriorChildTotal(t *testing.T) require.True(t, *got.SubagentTokensComplete) } +func TestInvalidateStaleSubagentSnapshot_ZeroVersion(t *testing.T) { + t.Parallel() + complete := true + zero := uint64(0) + step := StepContext{ + SubagentLedgerVersion: &zero, + TokenUsage: &agent.TokenUsage{ + InputTokens: 11, + SubagentTokens: &agent.TokenUsage{InputTokens: 7}, + SubagentTokensComplete: &complete, + }, + } + + invalidateStaleSubagentSnapshot(&step, &SessionState{SubagentLedgerVersion: 1}) + + require.NotNil(t, step.TokenUsage) + require.Equal(t, 11, step.TokenUsage.InputTokens, "main-agent evidence must survive invalidation") + require.Nil(t, step.TokenUsage.SubagentTokens) + require.NotNil(t, step.TokenUsage.SubagentTokensComplete) + require.False(t, *step.TokenUsage.SubagentTokensComplete) +} + // TestSaveStep_SubagentTokensNotDoubleCountedAcrossCheckpoints exercises the // real SaveStep path for both Claude Code and Factory AI Droid (the two // agents whose CalculateTotalTokenUsage implementations discover subagent IDs From f586632fd4b750a9b5f808d3f36e9cfb00546b46 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Thu, 3 Sep 2026 16:07:40 -0700 Subject: [PATCH 12/59] fix(codex): track current subagent rollout evidence Entire-Checkpoint: 01M1MREADV8D9JS18B5CJA253E --- cmd/entire/cli/agent/codex/subagent_test.go | 34 +++++++++ cmd/entire/cli/agent/codex/transcript.go | 77 ++++++++++++++++++--- cmd/entire/cli/lifecycle.go | 5 +- cmd/entire/cli/lifecycle_test.go | 5 +- 4 files changed, 107 insertions(+), 14 deletions(-) diff --git a/cmd/entire/cli/agent/codex/subagent_test.go b/cmd/entire/cli/agent/codex/subagent_test.go index 430bab9c19..c4c7e4c0b0 100644 --- a/cmd/entire/cli/agent/codex/subagent_test.go +++ b/cmd/entire/cli/agent/codex/subagent_test.go @@ -117,6 +117,40 @@ func TestTerminalTurnIDs_OnlyAcceptsUnambiguousBoundaries(t *testing.T) { } } +func TestAnalyzeRollout_PaginatedSubagentIgnoresInheritedParentHistory(t *testing.T) { + t.Parallel() + + lines := []map[string]any{ + { + "ordinal": 0, + "type": "session_meta", + "payload": map[string]any{ + "id": "child", + "thread_source": "subagent", + "subagent_history_start_ordinal": 10, + }, + }, + {"ordinal": 2, "type": "event_msg", "payload": map[string]any{"type": "task_started", "turn_id": "parent-turn"}}, + {"ordinal": 3, "type": "event_msg", "payload": map[string]any{"type": "item_completed", "item": map[string]any{"type": "FileChange", "status": "completed", "changes": map[string]any{"/repo/parent.txt": map[string]any{"type": "update"}}}}}, + {"ordinal": 4, "type": "event_msg", "payload": map[string]any{"type": "token_count", "info": map[string]any{"total_token_usage": map[string]any{"input_tokens": 99, "cached_input_tokens": 50, "output_tokens": 9}}}}, + {"ordinal": 11, "type": "event_msg", "payload": map[string]any{"type": "task_started", "turn_id": "child-turn"}}, + {"ordinal": 12, "type": "event_msg", "payload": map[string]any{"type": "item_completed", "item": map[string]any{"type": "FileChange", "status": "completed", "changes": map[string]any{"/repo/child.txt": map[string]any{"type": "update"}}}}}, + {"ordinal": 13, "type": "event_msg", "payload": map[string]any{"type": "token_count", "info": map[string]any{"total_token_usage": map[string]any{"input_tokens": 5, "cached_input_tokens": 2, "output_tokens": 1}}}}, + {"ordinal": 14, "type": "event_msg", "payload": map[string]any{"type": "task_complete", "turn_id": "child-turn"}}, + } + encoded := make([][]byte, 0, len(lines)) + for _, line := range lines { + data, err := json.Marshal(line) + require.NoError(t, err) + encoded = append(encoded, data) + } + + result := analyzeRollout(append([]byte(joinLines(encoded)), '\n'), 0) + require.Equal(t, []string{"/repo/child.txt"}, result.ModifiedFiles) + require.Equal(t, []string{"child-turn"}, result.TerminalTurnIDs) + require.Equal(t, &agent.TokenUsage{InputTokens: 3, CacheReadTokens: 2, OutputTokens: 1}, result.ExactTokenUsage) +} + func TestExactTokenUsage_UsesOnlyLastRecognizableSnapshot(t *testing.T) { t.Parallel() diff --git a/cmd/entire/cli/agent/codex/transcript.go b/cmd/entire/cli/agent/codex/transcript.go index b97d65a5b8..3d8e36e23f 100644 --- a/cmd/entire/cli/agent/codex/transcript.go +++ b/cmd/entire/cli/agent/codex/transcript.go @@ -52,6 +52,7 @@ func sessionMetaID(data []byte) (string, error) { // rolloutLine is the top-level JSONL line structure in Codex rollout files. type rolloutLine struct { Timestamp string `json:"timestamp"` + Ordinal *int `json:"ordinal,omitempty"` Type string `json:"type"` // "session_meta", "response_item", "event_msg", "turn_context" Payload json.RawMessage `json:"payload"` } @@ -91,10 +92,11 @@ type rolloutClassificationResult struct { // sessionMetaPayload is the payload for type="session_meta" lines. type sessionMetaPayload struct { - ID string `json:"id"` - Timestamp string `json:"timestamp"` - ThreadSource string `json:"thread_source"` - Source json.RawMessage `json:"source"` + ID string `json:"id"` + Timestamp string `json:"timestamp"` + ThreadSource string `json:"thread_source"` + Source json.RawMessage `json:"source"` + SubagentHistoryStartOrdinal *int `json:"subagent_history_start_ordinal,omitempty"` } // classifyRolloutDetailed reads only the rollout's session_meta record. Newer @@ -198,6 +200,13 @@ type eventMsgPayload struct { Type string `json:"type"` // "token_count", "task_started", "user_message", "agent_message", "task_complete" TurnID *string `json:"turn_id,omitempty"` Info json.RawMessage `json:"info,omitempty"` + Item json.RawMessage `json:"item,omitempty"` +} + +type fileChangeItem struct { + Type string `json:"type"` + Status string `json:"status"` + Changes map[string]json.RawMessage `json:"changes"` } // tokenCountInfo contains token usage data from event_msg.token_count. @@ -478,18 +487,42 @@ type rolloutAnalysis struct { func analyzeRollout(data []byte, fromOffset int) rolloutAnalysis { var result rolloutAnalysis terminalValid := true + scopeValid := true openTurn := "" seenTurns := make(map[string]struct{}) seenFiles := make(map[string]struct{}) var lastTokenInfo json.RawMessage foundToken := false + lines := splitJSONL(data) + var localStartOrdinal *int + if len(lines) > 0 { + var first rolloutLine + if json.Unmarshal(lines[0], &first) == nil && first.Type == rolloutLineTypeSessionMeta { + var meta sessionMetaPayload + if json.Unmarshal(first.Payload, &meta) == nil && meta.SubagentHistoryStartOrdinal != nil && *meta.SubagentHistoryStartOrdinal >= 0 { + localStartOrdinal = meta.SubagentHistoryStartOrdinal + } + } + } - for index, lineData := range splitJSONL(data) { + for index, lineData := range lines { var line rolloutLine if json.Unmarshal(lineData, &line) != nil { terminalValid = false + if localStartOrdinal != nil { + scopeValid = false + } continue } + if localStartOrdinal != nil { + if line.Ordinal == nil { + scopeValid = false + continue + } + if *line.Ordinal < *localStartOrdinal { + continue + } + } if index+1 > fromOffset { for _, file := range extractFilesFromParsedLine(line) { if _, seen := seenFiles[file]; !seen { @@ -543,6 +576,9 @@ func analyzeRollout(data []byte, fromOffset int) rolloutAnalysis { openTurn = "" } } + if !scopeValid { + return rolloutAnalysis{} + } if !terminalValid || openTurn != "" { result.TerminalTurnIDs = nil } @@ -553,14 +589,33 @@ func analyzeRollout(data []byte, fromOffset int) rolloutAnalysis { } func extractFilesFromParsedLine(line rolloutLine) []string { - if line.Type != rolloutLineTypeResponseItem { - return nil - } - var payload responseItemPayload - if json.Unmarshal(line.Payload, &payload) != nil || payload.Type != "custom_tool_call" || payload.Name != "apply_patch" { + switch line.Type { + case rolloutLineTypeResponseItem: + var payload responseItemPayload + if json.Unmarshal(line.Payload, &payload) != nil || payload.Type != "custom_tool_call" || payload.Name != "apply_patch" { + return nil + } + return extractFilesFromApplyPatch(payload.Input) + case rolloutLineTypeEventMsg: + var event eventMsgPayload + if json.Unmarshal(line.Payload, &event) != nil || event.Type != "item_completed" { + return nil + } + var item fileChangeItem + if json.Unmarshal(event.Item, &item) != nil || item.Type != "FileChange" || item.Status != "completed" { + return nil + } + files := make([]string, 0, len(item.Changes)) + for path := range item.Changes { + if path != "" { + files = append(files, path) + } + } + sort.Strings(files) + return files + default: return nil } - return extractFilesFromApplyPatch(payload.Input) } func exactUsageFromInfo(lastInfo json.RawMessage) *agent.TokenUsage { diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index 31839811b5..0af68db726 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -1219,6 +1219,7 @@ func refreshCodexInventory(ctx context.Context, ag agent.Agent, sessionID string usage = types.WithClearedSubagentTokens(usage, false) } for _, child := range extraction.Children { + childFiles := FilterAndNormalizePaths(child.ModifiedFiles, current.WorktreePath) current.UpdateSubagentTranscriptPaths(child.AgentID, "", child.ResolvedPath) for _, turnID := range child.TerminalTurnIDs { if !current.FinalizeSubagentTurn(child.AgentID, turnID) { @@ -1232,12 +1233,12 @@ func refreshCodexInventory(ctx context.Context, ag agent.Agent, sessionID string if record.CompletedAt.IsZero() { record.CompletedAt = time.Now() } - record.Files = child.ModifiedFiles + record.Files = childFiles record.DeclaredTranscriptPath = child.ResolvedPath // nil is evidence too: a newer terminal snapshot without exact // usage must clear, never preserve, an earlier total. record.TokenUsage = child.TokenUsage - current.FilesTouched = mergeUnique(current.FilesTouched, child.ModifiedFiles) + current.FilesTouched = mergeUnique(current.FilesTouched, childFiles) break } } diff --git a/cmd/entire/cli/lifecycle_test.go b/cmd/entire/cli/lifecycle_test.go index f8411670f1..06c5b2b69f 100644 --- a/cmd/entire/cli/lifecycle_test.go +++ b/cmd/entire/cli/lifecycle_test.go @@ -158,10 +158,13 @@ func TestRefreshCodexInventory_MultiTurnChildRefreshesCompletedTaskRecord(t *tes sessionID = "codex-multi-turn-child" agentID = "child-1" ) + repoRoot, err := os.Getwd() + require.NoError(t, err) completedAt := time.Now().UTC().Truncate(time.Microsecond) complete := true require.NoError(t, strategy.SaveSessionState(ctx, &strategy.SessionState{ SessionID: sessionID, + WorktreePath: repoRoot, StartedAt: time.Now(), Phase: session.PhaseActive, SubagentInventoryComplete: &complete, @@ -187,7 +190,7 @@ func TestRefreshCodexInventory_MultiTurnChildRefreshesCompletedTaskRecord(t *tes extraction: agent.InventoryExtraction{Children: []agent.SubagentAnalysis{{ AgentID: agentID, ResolvedPath: "/tmp/child-1.jsonl", - ModifiedFiles: []string{"first.go", "second.go"}, + ModifiedFiles: []string{filepath.Join(repoRoot, "first.go"), filepath.Join(repoRoot, "second.go")}, TokenUsage: &agent.TokenUsage{InputTokens: 25}, TerminalTurnIDs: []string{"turn-2"}, }}}, From db899722d056e1a1584327ed0848a13c072970e9 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Thu, 3 Sep 2026 17:32:40 -0700 Subject: [PATCH 13/59] fix(codex): close subagent tracking edge cases Entire-Checkpoint: 01M1MX9YXDDG0H204M6XN4WB63 --- cmd/entire/cli/agent/codex/AGENT.md | 17 ++++++++--------- cmd/entire/cli/agent/codex/lifecycle.go | 8 ++++---- cmd/entire/cli/agent/codex/lifecycle_test.go | 19 ++++++++++++++----- cmd/entire/cli/agent/codex/transcript.go | 4 ++-- cmd/entire/cli/agent/types/token_usage.go | 16 ++++++++++++---- .../cli/agent/types/token_usage_test.go | 12 +++++++++++- 6 files changed, 51 insertions(+), 25 deletions(-) diff --git a/cmd/entire/cli/agent/codex/AGENT.md b/cmd/entire/cli/agent/codex/AGENT.md index b38b3ef178..5bd8f4cc23 100644 --- a/cmd/entire/cli/agent/codex/AGENT.md +++ b/cmd/entire/cli/agent/codex/AGENT.md @@ -312,7 +312,8 @@ The `systemMessage` field can be used to display messages to the user via the ag - Format: JSONL (line-delimited JSON) - Session ID extraction: `session_id` field from hook payload (UUID format) - Transcript may be null in `--ephemeral` mode; root ownership cannot be - verified, so Entire skips turn lifecycle mutation and checkpoint capture. + verified, so Entire preserves the turn lifecycle event while logging that + transcript-derived evidence may be unavailable. **Note:** Codex's primary storage is SQLite (`~/.codex/state`), but the JSONL rollout file is the file-based transcript we can read. The `transcript_path` in hook payloads points to this file. @@ -343,14 +344,12 @@ The `systemMessage` field can be used to display messages to the user via the ag - **A pre-SessionEnd install still counts as installed:** `AreHooksInstalled` gates on the core events only, so adding an event does not retroactively drop Codex out of `entire status` and the agent pickers for everyone who enabled it earlier. The stale install is reported as drift through `InspectHookConfig(...).Missing` instead, with `entire enable` as the fix. - **`reason` carries no information:** always `"other"`, so a session ended by `/clear` is indistinguishable from one ended by quitting. - **Transcript may be null:** In `--ephemeral` mode, `transcript_path` is null. - Entire fails closed: it emits a categorized diagnostic and skips TurnStart / - TurnEnd state mutation and checkpoint capture because the root rollout cannot - be verified. Ephemeral Codex sessions therefore are not tracked. -- **Unreadable, malformed, or future rollout metadata also fails closed:** the - turn hook emits a diagnostic categorized as `unreadable_transcript`, - `malformed_session_metadata`, or `unclassified_source`, then performs no root - lifecycle mutation. This prevents a child or unknown future rollout shape - from being attributed to the root session. + Entire emits a categorized diagnostic and preserves TurnStart / TurnEnd state + mutation; only a rollout positively identified as a child is skipped. +- **Unreadable, malformed, or future rollout metadata is treated as unknown:** + the turn hook emits a diagnostic categorized as `unreadable_transcript`, + `malformed_session_metadata`, or `unclassified_source`, then preserves the + lifecycle event so a root session is not silently left active. - **No hooks fire under `-s read-only`:** verified against 0.147.0 — a `codex exec -s read-only` run produces no hook invocations at all, so no session is tracked. `-s workspace-write` fires the full set. - **Subagent identity fields are inverted from their names:** `SubagentStart` / `SubagentStop` (schemas at `codex-rs/hooks/schema/generated/subagent-{start,stop}.command.input.schema.json`) send `session_id` = the identity shared by the root thread *and every descendant*, i.e. the user's session, which maps straight to Entire's SessionID; `agent_id` = the subagent thread's own id. Codex sends no `tool_use_id`, so `agent_id` doubles as Entire's ToolUseID — it is the only value correlating a start with its stop, and Entire keys pre-task state and the task metadata directory on it. Getting this backwards attributes subagent work to a session Entire has never seen. - **`SubagentStop` is provisional, not authoritative completion.** It carries two transcripts: `transcript_path` is the *parent* rollout and `agent_transcript_path` the child rollout. Entire retains the child identity and declared path, then accepts a rollout only after its first `session_meta.id` exactly matches `agent_id`, it is a regular file, and the same verified bytes are analyzed. A hook-supplied filename is never trusted by itself. diff --git a/cmd/entire/cli/agent/codex/lifecycle.go b/cmd/entire/cli/agent/codex/lifecycle.go index f25dfc84c5..b765d782ee 100644 --- a/cmd/entire/cli/agent/codex/lifecycle.go +++ b/cmd/entire/cli/agent/codex/lifecycle.go @@ -208,7 +208,7 @@ func (c *CodexAgent) parseTurnStart(ctx context.Context, stdin io.Reader) (*agen return nil, err } if !isRootTurnRollout(ctx, derefString(raw.TranscriptPath)) { - return nil, nil //nolint:nilnil // only proven root rollouts mutate lifecycle state + return nil, nil //nolint:nilnil // only confirmed child rollouts are skipped } return &agent.Event{ Type: agent.TurnStart, @@ -282,7 +282,7 @@ func (c *CodexAgent) parseTurnEnd(ctx context.Context, stdin io.Reader) (*agent. return nil, err } if !isRootTurnRollout(ctx, derefString(raw.TranscriptPath)) { - return nil, nil //nolint:nilnil // only proven root rollouts mutate lifecycle state + return nil, nil //nolint:nilnil // only confirmed child rollouts are skipped } return &agent.Event{ Type: agent.TurnEnd, @@ -302,11 +302,11 @@ func isRootTurnRollout(ctx context.Context, path string) bool { logging.Debug(ctx, "codex: skipped root lifecycle mutation for child rollout", slog.String("path", path)) return false case rolloutUnknown: - logging.Warn(ctx, "codex: skipped turn lifecycle event because rollout ownership is unverified", + logging.Warn(ctx, "codex: preserved root lifecycle event because rollout ownership is unverified", slog.String("category", string(classification.Issue)), slog.String("detail", classification.Detail), slog.String("path", path)) - return false + return true } return false } diff --git a/cmd/entire/cli/agent/codex/lifecycle_test.go b/cmd/entire/cli/agent/codex/lifecycle_test.go index 13bfe0c68b..a91b25d863 100644 --- a/cmd/entire/cli/agent/codex/lifecycle_test.go +++ b/cmd/entire/cli/agent/codex/lifecycle_test.go @@ -192,7 +192,7 @@ func TestParseHookEvent_TurnHooksIgnoreChildRollout(t *testing.T) { } } -func TestParseHookEvent_UnknownTurnRolloutWritesCategorizedDiagnostic(t *testing.T) { +func TestParseHookEvent_UnknownTurnRolloutPreservesRootLifecycle(t *testing.T) { t.Parallel() tests := []struct { @@ -250,15 +250,24 @@ func TestParseHookEvent_UnknownTurnRolloutWritesCategorizedDiagnostic(t *testing ctx := logging.WithLogger(context.Background(), logger) input := `{"session_id":"root-session-1","turn_id":"turn-1","transcript_path":` + pathJSON + `,"model":"gpt-5","prompt":"do work"}` - event, err := (&CodexAgent{}).ParseHookEvent(ctx, HookNameUserPromptSubmit, strings.NewReader(input)) - require.NoError(t, err) - require.Nil(t, event) + for _, hook := range []struct { + name string + want agent.EventType + }{ + {HookNameUserPromptSubmit, agent.TurnStart}, + {HookNameStop, agent.TurnEnd}, + } { + event, err := (&CodexAgent{}).ParseHookEvent(ctx, hook.name, strings.NewReader(input)) + require.NoError(t, err) + require.NotNil(t, event) + require.Equal(t, hook.want, event.Type) + } require.NoError(t, logger.Close()) logData, err := os.ReadFile(filepath.Join(logDir, "entire.log")) require.NoError(t, err) logText := string(logData) - require.Contains(t, logText, "codex: skipped turn lifecycle event because rollout ownership is unverified") + require.Contains(t, logText, "codex: preserved root lifecycle event because rollout ownership is unverified") require.Contains(t, logText, string(tt.category)) require.Contains(t, logText, tt.detail) if rolloutPath != "" { diff --git a/cmd/entire/cli/agent/codex/transcript.go b/cmd/entire/cli/agent/codex/transcript.go index 3d8e36e23f..2d146eeccd 100644 --- a/cmd/entire/cli/agent/codex/transcript.go +++ b/cmd/entire/cli/agent/codex/transcript.go @@ -65,8 +65,8 @@ const ( ) // rolloutClassification identifies whether a rollout belongs to a root thread -// or a child thread. Lifecycle hooks mutate the root session, so uncertainty is -// intentionally distinct from root and must not be treated as a root rollout. +// or a child thread. Uncertainty remains distinct so callers can diagnose it; +// root lifecycle hooks preserve their event unless the rollout is a confirmed child. type rolloutClassification uint8 const ( diff --git a/cmd/entire/cli/agent/types/token_usage.go b/cmd/entire/cli/agent/types/token_usage.go index a04bc01fc3..9c1f6d4368 100644 --- a/cmd/entire/cli/agent/types/token_usage.go +++ b/cmd/entire/cli/agent/types/token_usage.go @@ -89,18 +89,26 @@ func addTokenUsageAtDepth(a, b *TokenUsage, depth int) *TokenUsage { } func tokenCompleteness(a, b *TokenUsage) *bool { - seen := false + seenComplete := false + seenUnknown := false for _, usage := range []*TokenUsage{a, b} { - if usage == nil || usage.SubagentTokensComplete == nil { + if usage == nil { + continue + } + if usage.SubagentTokensComplete == nil { + seenUnknown = true continue } - seen = true if !*usage.SubagentTokensComplete { incomplete := false return &incomplete } + seenComplete = true + } + if seenUnknown { + return nil } - if seen { + if seenComplete { complete := true return &complete } diff --git a/cmd/entire/cli/agent/types/token_usage_test.go b/cmd/entire/cli/agent/types/token_usage_test.go index d62e72e4e2..a506152aec 100644 --- a/cmd/entire/cli/agent/types/token_usage_test.go +++ b/cmd/entire/cli/agent/types/token_usage_test.go @@ -69,7 +69,7 @@ func TestAddTokenUsage(t *testing.T) { } } -func TestAddTokenUsage_ExplicitIncompleteDominatesComplete(t *testing.T) { +func TestAddTokenUsage_CompletenessCombination(t *testing.T) { t.Parallel() complete := true @@ -83,6 +83,16 @@ func TestAddTokenUsage_ExplicitIncompleteDominatesComplete(t *testing.T) { t.Fatalf("AddTokenUsage(%v, %v) completeness = %v, want false", *operands[0].SubagentTokensComplete, *operands[1].SubagentTokensComplete, got.SubagentTokensComplete) } } + + unknown := &TokenUsage{} + for _, operands := range [][2]*TokenUsage{ + {{SubagentTokensComplete: &complete}, unknown}, + {unknown, {SubagentTokensComplete: &complete}}, + } { + if got := AddTokenUsage(operands[0], operands[1]); got.SubagentTokensComplete != nil { + t.Fatalf("AddTokenUsage with unknown coverage = %v, want nil", *got.SubagentTokensComplete) + } + } } // TestAddTokenUsage_TruncatesDeepSubagentChains pins MaxSubagentDepth. Token usage From 99268bb2584f1363ca14cfd52bb4a1c44e3c4de6 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Thu, 3 Sep 2026 18:09:22 -0700 Subject: [PATCH 14/59] fix(codex): make rollout byte guard overflow-safe Entire-Checkpoint: 01M1MZD5TNZTQGX5W9Z02YDW9Z --- cmd/entire/cli/agent/codex/codex.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/entire/cli/agent/codex/codex.go b/cmd/entire/cli/agent/codex/codex.go index 64cf0665c4..40aa189322 100644 --- a/cmd/entire/cli/agent/codex/codex.go +++ b/cmd/entire/cli/agent/codex/codex.go @@ -125,7 +125,7 @@ func (b *rolloutScanBudget) observeCandidate() error { } func (b *rolloutScanBudget) observeBytes(count int64) error { - if count < 0 || count > b.limits.aggregateByteLimit-b.aggregateBytes { + if count < 0 || count > b.limits.aggregateByteLimit || b.aggregateBytes > b.limits.aggregateByteLimit-count { return fmt.Errorf("aggregate rollout byte limit %d exceeded: %w", b.limits.aggregateByteLimit, errRolloutScanBudget) } b.aggregateBytes += count From 5c185dd48511f5cc48bcd9d05abd12a80b84d062 Mon Sep 17 00:00:00 2001 From: Thomas Dohmke Date: Tue, 8 Sep 2026 19:41:48 +0200 Subject: [PATCH 15/59] feat: install missing Graph plugin on demand Show installation progress and announce the forwarded command. Update the terminal renderer so completed confirmation prompts are cleared, with regression coverage for terminal cleanup and plugin install/dispatch behavior. Entire-Checkpoint: 01M211S73J1V1YPNB123826DE8 --- cmd/entire/cli/plugin.go | 18 +++ cmd/entire/cli/plugin_fetch.go | 12 ++ cmd/entire/cli/plugin_group.go | 5 + cmd/entire/cli/plugin_install_remote.go | 6 + cmd/entire/cli/plugin_on_demand.go | 57 ++++++++ cmd/entire/cli/plugin_on_demand_test.go | 133 ++++++++++++++++++ cmd/entire/cli/plugin_progress.go | 36 +++++ cmd/entire/cli/plugin_progress_test.go | 52 +++++++ cmd/entire/cli/uiform/prompt_terminal_test.go | 72 ++++++++++ docs/architecture/external-commands.md | 4 + go.mod | 2 +- go.sum | 4 +- 12 files changed, 398 insertions(+), 3 deletions(-) create mode 100644 cmd/entire/cli/plugin_on_demand.go create mode 100644 cmd/entire/cli/plugin_on_demand_test.go create mode 100644 cmd/entire/cli/plugin_progress.go create mode 100644 cmd/entire/cli/plugin_progress_test.go create mode 100644 cmd/entire/cli/uiform/prompt_terminal_test.go diff --git a/cmd/entire/cli/plugin.go b/cmd/entire/cli/plugin.go index 449091395f..8afcb3fb33 100644 --- a/cmd/entire/cli/plugin.go +++ b/cmd/entire/cli/plugin.go @@ -53,6 +53,19 @@ func MaybeRunPlugin(ctx context.Context, rootCmd *cobra.Command, args []string) return false, 0 } pluginName := args[0] + if binPath == "" { + var err error + binPath, err = installMissingPlugin(ctx, rootCmd, pluginName) + if err != nil { + fmt.Fprintln(rootCmd.ErrOrStderr(), RenderUserFacingError(err)) + return true, 1 + } + if binPath == "" { + // The command was not executed because installation was declined. + return true, 1 + } + fmt.Fprintf(rootCmd.ErrOrStderr(), "Running plugin with command: %s\n", strings.Join(pluginArgs, " ")) + } exitCode = runPlugin(ctx, pluginName, binPath, pluginArgs) if exitCode == 0 { maybeTrackPluginInvocation(ctx, pluginName) @@ -86,6 +99,8 @@ func maybeTrackPluginInvocation(ctx context.Context, pluginName string) { telemetry.TrackPluginDetached(pluginName, s.Enabled, versioninfo.Version) } +// resolvePlugin returns an empty binary path for a missing graph plugin so +// the dispatcher can offer installation. Other missing names fall through. func resolvePlugin(rootCmd *cobra.Command, args []string) (binPath string, pluginArgs []string, ok bool) { if len(args) == 0 { return "", nil, false @@ -115,6 +130,9 @@ func resolvePlugin(rootCmd *cobra.Command, args []string) (binPath string, plugi if p, found := findInaccessiblePlugin(binName); found { return p, args[1:], true } + if name == "graph" && errors.Is(err, exec.ErrNotFound) { + return "", args[1:], true + } return "", nil, false } if isAgentProtocolBinary(binPath) { diff --git a/cmd/entire/cli/plugin_fetch.go b/cmd/entire/cli/plugin_fetch.go index 2da3d10a29..2b3971d273 100644 --- a/cmd/entire/cli/plugin_fetch.go +++ b/cmd/entire/cli/plugin_fetch.go @@ -276,6 +276,8 @@ type fetchedAsset struct { // one is published. Returns errAssetNotFound (possibly wrapped) when the // tag has no asset for this platform. func downloadPluginAsset(ctx context.Context, meta *PluginMetadata, repoURL, name, tag, stagingDir string, allowUnverified bool) (*fetchedAsset, error) { + stopLocate := startPluginStep(ctx, "Locating plugin release files...") + defer stopLocate() // Resolve the prefix once. It does not depend on the asset name, so // deriving it per candidate meant re-parsing the repo URL ~36 times in the // probe loop and carrying an error return through three call sites for a @@ -302,6 +304,7 @@ func downloadPluginAsset(ctx context.Context, meta *PluginMetadata, repoURL, nam errUnverifiedAsset, pluginMetadataFileName, checksumsFileName) } u := expandDownloadTemplate(meta.DownloadURL, name, tag, "") + stopLocate() return fetchAndVerify(ctx, u, assetNameFromURL(u), "", stagingDir) } @@ -323,6 +326,7 @@ func downloadPluginAsset(ctx context.Context, meta *PluginMetadata, repoURL, nam // directly. continue } + stopLocate() return fetchAndVerify(ctx, assetURL(asset), asset, digest, stagingDir) } @@ -337,6 +341,7 @@ func downloadPluginAsset(ctx context.Context, meta *PluginMetadata, repoURL, nam // wrong would report a missing release for a plugin that simply doesn't // ship checksums. for _, asset := range assetCandidates(name, tag) { + stopLocate() fa, err := fetchAndVerify(ctx, assetURL(asset), asset, "", stagingDir) switch { case errors.Is(err, errAssetNotFound): @@ -409,6 +414,8 @@ func httpGetSmall(ctx context.Context, rawURL string) ([]byte, error) { // command errors to stderr and a download failure is an ordinary event // (network hiccup, 5xx, checksum mismatch), not an exceptional one. func fetchAndVerify(ctx context.Context, rawURL, asset, wantDigest, stagingDir string) (*fetchedAsset, error) { + stopDownload := startPluginStep(ctx, "Downloading plugin archive...") + defer stopDownload() stagingRoot, err := osroot.Shared(stagingDir) if err != nil { return nil, fmt.Errorf("open staging dir: %w", err) @@ -469,6 +476,11 @@ func fetchAndVerify(ctx context.Context, rawURL, asset, wantDigest, stagingDir s _ = osroot.RemoveNoSymlinks(stagingRoot, asset) //nolint:errcheck // best-effort cleanup of a staging file we are already abandoning return nil, fmt.Errorf("download %s: exceeds %d byte limit", redactURL(rawURL), int64(maxPluginAssetSize)) } + stopDownload() + if wantDigest != "" { + stopVerify := startPluginStep(ctx, "Verifying plugin checksum...") + defer stopVerify() + } got := hex.EncodeToString(h.Sum(nil)) if wantDigest != "" && !strings.EqualFold(got, wantDigest) { _ = osroot.RemoveNoSymlinks(stagingRoot, asset) //nolint:errcheck // best-effort cleanup of a staging file we are already abandoning diff --git a/cmd/entire/cli/plugin_group.go b/cmd/entire/cli/plugin_group.go index 9fdb29daa5..8338397466 100644 --- a/cmd/entire/cli/plugin_group.go +++ b/cmd/entire/cli/plugin_group.go @@ -202,6 +202,7 @@ type remoteInstallFlags struct { func runRemoteInstall(ctx context.Context, cmd *cobra.Command, src installSource, flags remoteInstallFlags) error { out, errOut := cmd.OutOrStdout(), cmd.ErrOrStderr() + ctx = withPluginProgress(ctx, errOut) repoURL := src.Ref var trusted bool @@ -210,7 +211,9 @@ func runRemoteInstall(ctx context.Context, cmd *cobra.Command, src installSource // Both paths need the catalog: one to resolve a name, the other for the // trust check. Sync once. An unreachable index is fatal only for the // name-resolution path; a URL install degrades to "not listed". + stopIndex := startPluginStep(ctx, "Checking plugin index...") idx, idxErr := SyncPluginIndex(ctx, resolvePluginIndexURL(flags.index), false) + stopIndex() if src.Kind == installFromIndex { if idxErr != nil { @@ -300,7 +303,9 @@ func runRemoteInstall(ctx context.Context, cmd *cobra.Command, src installSource // error — doctor reports the gap afterwards. func installPlannedDeps(ctx context.Context, cmd *cobra.Command, reqs []PluginRequirement, idx *PluginIndex, flags remoteInstallFlags) error { out, errOut := cmd.OutOrStdout(), cmd.ErrOrStderr() + stopPlan := startPluginStep(ctx, "Checking plugin dependencies...") plan, err := PlanDependencyInstalls(ctx, reqs, idx) + stopPlan() if err != nil { return fmt.Errorf("resolve dependencies: %w", err) } diff --git a/cmd/entire/cli/plugin_install_remote.go b/cmd/entire/cli/plugin_install_remote.go index 9016a6dcc9..16ae051d0e 100644 --- a/cmd/entire/cli/plugin_install_remote.go +++ b/cmd/entire/cli/plugin_install_remote.go @@ -81,7 +81,9 @@ func InstallPluginFromRepo(ctx context.Context, repoURL, expectedName string, op tags = []string{opts.Pin} } else { var err error + stopTags := startPluginStep(ctx, "Finding latest plugin release...") tags, err = listRemoteSemverTags(ctx, repoURL) + stopTags() if err != nil { return nil, err } @@ -109,7 +111,9 @@ func InstallPluginFromRepo(ctx context.Context, repoURL, expectedName string, op } func installRepoAtTag(ctx context.Context, repoURL, expectedName, tag string, opts RemoteInstallOptions) (*RemoteInstallResult, error) { + stopMetadata := startPluginStep(ctx, fmt.Sprintf("Fetching plugin metadata for %s...", tag)) meta, err := fetchPluginMetadataAtTag(ctx, repoURL, tag) + stopMetadata() if err != nil { return nil, err } @@ -190,6 +194,8 @@ func installRepoAtTag(ctx context.Context, repoURL, expectedName, tag string, op return nil, err } + stopInstall := startPluginStep(ctx, fmt.Sprintf("Installing entire-%s %s...", name, tag)) + defer stopInstall() binBase := pluginBinaryName(name) stagedName := "extracted-" + binBase if err := extractPluginBinary(asset.Path, name, stagingRoot, stagedName); err != nil { diff --git a/cmd/entire/cli/plugin_on_demand.go b/cmd/entire/cli/plugin_on_demand.go new file mode 100644 index 0000000000..ef604c791a --- /dev/null +++ b/cmd/entire/cli/plugin_on_demand.go @@ -0,0 +1,57 @@ +package cli + +import ( + "context" + "fmt" + + "charm.land/huh/v2" + "github.com/entireio/cli/cmd/entire/cli/interactive" + "github.com/spf13/cobra" +) + +// onDemandPluginInstall shares the normal install workflow, including index +// overrides, name/checksum validation and dependency confirmation. Tests replace +// it to exercise the prompt and dispatch without downloading real releases. +var onDemandPluginInstall = runRemoteInstall + +func installMissingPlugin(ctx context.Context, rootCmd *cobra.Command, name string) (string, error) { + if !interactive.CanPromptInteractively() { + return "", fmt.Errorf("the entire-%s plugin is not installed; run 'entire plugin install %s' and retry", name, name) + } + if err := ctx.Err(); err != nil { + return "", fmt.Errorf("install plugin: %w", err) + } + confirmed := true + form := NewAccessibleForm(huh.NewGroup( + huh.NewConfirm().Title(fmt.Sprintf("Install the entire-%s plugin?", name)).Value(&confirmed), + )).WithInput(rootCmd.InOrStdin()).WithOutput(rootCmd.ErrOrStderr()) + if err := form.RunWithContext(ctx); err != nil { + return "", handleFormCancellation(rootCmd.ErrOrStderr(), "Install", err) + } + if !confirmed { + fmt.Fprintln(rootCmd.ErrOrStderr(), "Install cancelled.") + return "", nil + } + if err := ctx.Err(); err != nil { + return "", fmt.Errorf("install plugin: %w", err) + } + + // Keep install progress off stdout: the original command may emit JSON or + // be piped to another tool. Do not parse any of the plugin's arguments. + cmd := newPluginInstallCmd() + cmd.SetOut(rootCmd.ErrOrStderr()) + cmd.SetErr(rootCmd.ErrOrStderr()) + if err := onDemandPluginInstall(ctx, cmd, installSource{Kind: installFromIndex, Ref: name}, remoteInstallFlags{}); err != nil { + return "", err + } + installed, err := FindInstalledPlugin(name) + if err != nil { + return "", err + } + if installed == nil { + return "", fmt.Errorf("the entire-%s plugin was not installed; run 'entire plugin install %s' and retry", name, name) + } + // Execute the managed entry directly, even if the managed directory could + // not be prepended to PATH at startup. + return installed.Path, nil +} diff --git a/cmd/entire/cli/plugin_on_demand_test.go b/cmd/entire/cli/plugin_on_demand_test.go new file mode 100644 index 0000000000..44ca1deaed --- /dev/null +++ b/cmd/entire/cli/plugin_on_demand_test.go @@ -0,0 +1,133 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestMaybeRunPlugin_MissingGraphNonInteractive(t *testing.T) { //nolint:paralleltest // isolates PATH and terminal detection + t.Setenv("PATH", t.TempDir()) + t.Setenv("ENTIRE_TEST_TTY", "0") + root := newTestRoot() + var stderr bytes.Buffer + root.SetErr(&stderr) + handled, code := MaybeRunPlugin(t.Context(), root, []string{"graph", "search", "hello"}) + if !handled || code != 1 { + t.Fatalf("handled=%v code=%d, want true, 1", handled, code) + } + if !strings.Contains(stderr.String(), "entire plugin install graph") { + t.Fatalf("missing installation hint: %q", stderr.String()) + } +} + +func TestMaybeRunPlugin_InstallGraphAndRun(t *testing.T) { //nolint:paralleltest // isolates environment and installer seam + for _, tc := range []struct { + name string + answer string + installErr error + pluginCode int + wantCode int + wantInstall bool + wantRun bool + }{ + {name: "enter accepts default yes", answer: "\n", wantInstall: true, wantRun: true}, + {name: "explicit yes preserves exit code", answer: "y\n", pluginCode: 42, wantCode: 42, wantInstall: true, wantRun: true}, + {name: "no cancels", answer: "n\n", wantCode: 1}, + {name: "failed install does not run", answer: "\n", installErr: errors.New("download failed"), wantCode: 1, wantInstall: true}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + t.Setenv("PATH", dir) + t.Setenv("ENTIRE_PLUGIN_DIR", filepath.Join(dir, "managed")) + t.Setenv("ENTIRE_TEST_TTY", "1") + t.Setenv("ACCESSIBLE", "1") + t.Setenv("ENTIRE_TELEMETRY_OPTOUT", "1") + interceptVersionCheck(t) + argFile := filepath.Join(dir, "args.txt") + sourceDir := t.TempDir() + source := writePluginBinary(t, sourceDir, "entire-graph", argFile, tc.pluginCode) + installCalls := 0 + original := onDemandPluginInstall + onDemandPluginInstall = func(_ context.Context, cmd *cobra.Command, src installSource, flags remoteInstallFlags) error { + installCalls++ + if src.Kind != installFromIndex || src.Ref != "graph" || flags != (remoteInstallFlags{}) { + t.Fatalf("unexpected install request: %+v %+v", src, flags) + } + if tc.installErr != nil { + return tc.installErr + } + _, err := InstallPluginFromPath(InstallPluginOptions{SourcePath: source}) + fmt.Fprintln(cmd.OutOrStdout(), "Installed graph") + return err + } + t.Cleanup(func() { onDemandPluginInstall = original }) + root := newTestRoot() + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetIn(strings.NewReader(tc.answer)) + args := []string{"graph", "search", "two words", "--json", "--", "$(untouched)", ""} + handled, code := MaybeRunPlugin(t.Context(), root, args) + if !handled || code != tc.wantCode { + t.Fatalf("handled=%v code=%d, want true, %d; stderr=%s", handled, code, tc.wantCode, &stderr) + } + if (installCalls == 1) != tc.wantInstall { + t.Errorf("install calls=%d, want install=%v", installCalls, tc.wantInstall) + } + if !strings.Contains(stderr.String(), "Install the entire-graph plugin?") || !strings.Contains(stderr.String(), "[Y/n]") { + t.Errorf("missing Yes-default prompt: %q", stderr.String()) + } + if stdout.Len() != 0 { + t.Errorf("installation polluted stdout: %q", stdout.String()) + } + got, err := os.ReadFile(argFile) + if tc.wantRun { + if err != nil || string(got) != strings.Join(args[1:], "\n")+"\n" { + t.Errorf("forwarded args=%q err=%v", got, err) + } + } else if !os.IsNotExist(err) { + t.Errorf("plugin unexpectedly ran: args=%q err=%v", got, err) + } + if tc.installErr != nil && !strings.Contains(stderr.String(), tc.installErr.Error()) { + t.Errorf("missing install failure: %q", stderr.String()) + } + }) + } +} + +func TestMaybeRunPlugin_GraphInstalledSkipsPrompt(t *testing.T) { //nolint:paralleltest // isolates PATH and version check + dir := t.TempDir() + argFile := filepath.Join(dir, "args.txt") + writePluginBinary(t, dir, "entire-graph", argFile, 0) + t.Setenv("PATH", dir) + interceptVersionCheck(t) + root := newTestRoot() + var stderr bytes.Buffer + root.SetErr(&stderr) + handled, code := MaybeRunPlugin(t.Context(), root, []string{"graph", "--help"}) + if !handled || code != 0 || stderr.Len() != 0 { + t.Fatalf("handled=%v code=%d stderr=%q", handled, code, stderr.String()) + } +} + +func TestResolvePlugin_OnDemandEligibility(t *testing.T) { //nolint:paralleltest // isolates PATH + t.Setenv("PATH", t.TempDir()) + for _, args := range [][]string{nil, {"--help"}, {"other-plugin"}, {"Graph"}, {"agent-graph"}, {"session", "graph"}} { + if _, _, ok := resolvePlugin(newTestRoot(), args); ok { + t.Errorf("unexpected plugin resolution for %q", args) + } + } + root := newTestRoot() + root.AddCommand(&cobra.Command{Use: "graph"}) + if _, _, ok := resolvePlugin(root, []string{"graph", "search"}); ok { + t.Fatal("built-in graph must take precedence over on-demand installation") + } +} diff --git a/cmd/entire/cli/plugin_progress.go b/cmd/entire/cli/plugin_progress.go new file mode 100644 index 0000000000..9b4bb9a7fe --- /dev/null +++ b/cmd/entire/cli/plugin_progress.go @@ -0,0 +1,36 @@ +package cli + +import ( + "context" + "fmt" + "io" + "sync" + + "github.com/entireio/cli/cmd/entire/cli/interactive" +) + +type pluginProgressKey struct{} + +// withPluginProgress opts the command into progress reporting. The context +// carries the writer through dependency installs without making library callers +// print to the process's terminal. +func withPluginProgress(ctx context.Context, out io.Writer) context.Context { + return context.WithValue(ctx, pluginProgressKey{}, out) +} + +// startPluginStep reports work before it starts. Stop the spinner before any +// prompt, warning or result is printed. The returned stop is idempotent so a +// deferred cleanup can also cover early returns. +func startPluginStep(ctx context.Context, message string) func() { + out, ok := ctx.Value(pluginProgressKey{}).(io.Writer) + if !ok { + return func() {} + } + if IsAccessibleMode() || !interactive.ShouldStyle(out) { + fmt.Fprintln(out, message) + return func() {} + } + stop := startSpinner(out, message) + var once sync.Once + return func() { once.Do(func() { stop(false) }) } +} diff --git a/cmd/entire/cli/plugin_progress_test.go b/cmd/entire/cli/plugin_progress_test.go new file mode 100644 index 0000000000..2c4a9fffd8 --- /dev/null +++ b/cmd/entire/cli/plugin_progress_test.go @@ -0,0 +1,52 @@ +package cli + +import ( + "bytes" + "fmt" + "strings" + "testing" +) + +func TestPluginStepPlainOutputIsImmediate(t *testing.T) { + t.Parallel() + var out bytes.Buffer + stop := startPluginStep(withPluginProgress(t.Context(), &out), "Downloading plugin archive...") + if got := out.String(); got != "Downloading plugin archive...\n" { + t.Fatalf("status must be visible before work completes, got %q", got) + } + stop() + stop() + if strings.Count(out.String(), "Downloading") != 1 || strings.Contains(out.String(), "\x1b") { + t.Fatalf("plain progress duplicated output or wrote terminal escapes: %q", out.String()) + } +} + +func TestPluginInstallReportsStagesOnStderr(t *testing.T) { //nolint:paralleltest // isolates managed plugins and index cache + withIsolatedPluginEnv(t) + withIndexCache(t) + repoURL, _ := newDemoPluginRepo(t, []string{remoteTestTagOld}, "0.1.0") + indexURL, _ := newIndexRepo(t, fmt.Sprintf(`{"version":1,"plugins":[{"name":"demo","repo_url":%q}]}`, repoURL)) + cmd := newPluginInstallCmd() + var out, errOut bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errOut) + err := runRemoteInstall(t.Context(), cmd, installSource{Kind: installFromIndex, Ref: "demo"}, remoteInstallFlags{index: indexURL}) + if err != nil { + t.Fatal(err) + } + stages := []string{ + "Checking plugin index...", + "Finding latest plugin release...", + "Fetching plugin metadata for v0.1.0...", + "Locating plugin release files...", + "Downloading plugin archive...", + "Verifying plugin checksum...", + "Installing entire-demo v0.1.0...", + } + if got, want := errOut.String(), strings.Join(stages, "\n")+"\n"; got != want { + t.Fatalf("install progress:\ngot %q\nwant %q", got, want) + } + if !strings.HasPrefix(out.String(), `Installed plugin "demo" v0.1.0 from `) || strings.Count(out.String(), "\n") != 1 { + t.Fatalf("stdout should contain only the install result: %q", out.String()) + } +} diff --git a/cmd/entire/cli/uiform/prompt_terminal_test.go b/cmd/entire/cli/uiform/prompt_terminal_test.go new file mode 100644 index 0000000000..8b4e70bc3e --- /dev/null +++ b/cmd/entire/cli/uiform/prompt_terminal_test.go @@ -0,0 +1,72 @@ +//go:build !windows + +package uiform + +import ( + "context" + "io" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/huh/v2" + "github.com/creack/pty" +) + +// An empty Form.View is not enough: the renderer must move back over the +// question before erasing it. Exercise the actual terminal output, because +// accessible-mode tests bypass the renderer that left answered prompts behind. +func TestConfirmationClearsPromptAfterAnswer(t *testing.T) { + t.Parallel() + terminal, input, err := pty.Open() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = terminal.Close() }) + t.Cleanup(func() { _ = input.Close() }) + if err := pty.Setsize(terminal, &pty.Winsize{Rows: 24, Cols: 100}); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + const question = "Install the entire-graph plugin?" + output := make(chan string, 1) + go func() { + var transcript strings.Builder + answered := false + buf := make([]byte, 4096) + for { + n, readErr := terminal.Read(buf) + transcript.Write(buf[:n]) + if !answered && strings.Contains(transcript.String(), question) { + answered = true + if _, writeErr := io.WriteString(terminal, "\r"); writeErr != nil { + cancel() + } + } + if readErr != nil { + output <- transcript.String() + return + } + } + }() + answer := true + form := New(huh.NewGroup(huh.NewConfirm().Title(question).Value(&answer))). + WithProgramOptions(tea.WithEnvironment([]string{"TERM=xterm-256color"})). + WithAccessible(false).WithInput(input).WithOutput(input) + err = form.RunWithContext(ctx) + _ = input.Close() // End the reader after the final render has been flushed. + if err != nil { + t.Fatal(err) + } + transcript := <-output + // This fixed-width, five-row form ends with the cursor on its help row. + // Clearing from that row alone leaves the question and choices visible. + if !strings.Contains(transcript, "\x1b[4A\x1b[J") { + t.Fatalf("completed prompt was not erased from its first row: %q", transcript) + } + if !answer { + t.Fatal("Enter did not retain the default Yes answer") + } +} diff --git a/docs/architecture/external-commands.md b/docs/architecture/external-commands.md index 1ca23b77d4..15f144100d 100644 --- a/docs/architecture/external-commands.md +++ b/docs/architecture/external-commands.md @@ -16,9 +16,12 @@ Rules, in order: 2. **Reserved names are skipped.** Names beginning with `agent-` are reserved for the [agent protocol](external-agent-protocol.md). The resolver refuses to invoke them as external commands. 3. **Path-traversal candidates are rejected.** Names containing `/` or `\` never resolve. 4. **Found-but-not-executable surfaces as a launch error.** If `entire-` exists on `$PATH` but lacks the executable bit, the resolver reports `Failed to run plugin entire-` with exit code 1, rather than falling through to Cobra's "unknown command" path. +5. **Missing Graph offers installation.** When `entire-graph` is absent, `entire graph ` asks `Install the entire-graph plugin?` with Yes selected by default. Accepting installs `graph` through the configured plugin index using the normal managed installer, then executes the installed binary with all remaining arguments unchanged. This also works for bare `entire graph` and `entire graph --help`. Installation output goes to stderr. Declining, cancelling, or failing installation exits nonzero without running the command. Non-interactive sessions receive an `entire plugin install graph` hint instead of a prompt. Other missing plugin names still fall through to Cobra. ### Managed install directory +Remote installs report index lookup, release metadata, download, checksum verification, and installation progress on stderr. Styled terminals show a spinner; non-terminal and accessibility output prints plain status lines as each step starts. Progress stops before confirmations and results, including when installation fails. The same reporting applies when `entire graph` offers to install its missing plugin and when installing dependencies. + Users can drop binaries anywhere on `$PATH`, but a per-user managed directory is also automatically discovered: - **Default:** `$XDG_DATA_HOME/entire/plugins/bin` (Linux/macOS) or `%LOCALAPPDATA%\entire\plugins\bin` (Windows). @@ -279,6 +282,7 @@ The resolver lives in `cmd/entire/cli/plugin.go`. The entry point is `MaybeRunPl Key files: - `cmd/entire/cli/plugin.go` — entry point, `resolvePlugin`, `runPlugin` +- `cmd/entire/cli/plugin_on_demand.go` — missing Graph installation prompt and managed-install handoff - `cmd/entire/cli/plugin_env.go` — `pluginEnv`, the allowlist, and `ENTIRE_PLUGIN_ENV` parsing - `cmd/entire/cli/plugin_official.go` — `officialPlugins` allowlist, `IsOfficialPlugin` - `cmd/entire/cli/plugin_store.go` — managed install directory, `PluginBinDir`, `PluginDataDir`, `InstallPluginFromPath`, `ListInstalledPlugins`, `RemoveInstalledPlugin`, `PrependPluginBinDirToPATH` diff --git a/go.mod b/go.mod index 31b5ed4856..0bbfc1f5dc 100644 --- a/go.mod +++ b/go.mod @@ -68,7 +68,7 @@ require ( github.com/catppuccin/go v0.3.0 // indirect github.com/charlievieth/fastwalk v1.0.14 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260906173415-0277a179edd9 // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect diff --git a/go.sum b/go.sum index 29f725e194..76ec28d8e7 100644 --- a/go.sum +++ b/go.sum @@ -54,8 +54,8 @@ github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICg github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= -github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA= -github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro= +github.com/charmbracelet/ultraviolet v0.0.0-20260906173415-0277a179edd9 h1:tYBWVoMfQHTwp88mWeWH7o0uJFZqRJeEGJmeHpIR8Ms= +github.com/charmbracelet/ultraviolet v0.0.0-20260906173415-0277a179edd9/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro= github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= From 7b26295d79632a38329b2bf91db72115f850a876 Mon Sep 17 00:00:00 2001 From: Thomas Dohmke Date: Wed, 9 Sep 2026 14:12:46 +0200 Subject: [PATCH 16/59] fix: preserve plugin prompt streams and cancellation Entire-Checkpoint: 01M231BF0DXEPSR8G9AWF7NCMC --- cmd/entire/cli/plugin.go | 5 +- cmd/entire/cli/plugin_confirm.go | 96 +++++++++++++++++++ .../cli/plugin_confirm_terminal_test.go | 78 +++++++++++++++ cmd/entire/cli/plugin_confirm_test.go | 74 ++++++++++++++ cmd/entire/cli/plugin_fetch.go | 12 +-- cmd/entire/cli/plugin_fetch_test.go | 9 +- cmd/entire/cli/plugin_group.go | 18 ++-- cmd/entire/cli/plugin_on_demand.go | 14 +-- cmd/entire/cli/plugin_on_demand_test.go | 40 ++++++-- cmd/entire/cli/plugin_progress_test.go | 1 - cmd/entire/main.go | 3 + docs/architecture/external-commands.md | 2 +- go.mod | 6 +- 13 files changed, 316 insertions(+), 42 deletions(-) create mode 100644 cmd/entire/cli/plugin_confirm.go create mode 100644 cmd/entire/cli/plugin_confirm_terminal_test.go create mode 100644 cmd/entire/cli/plugin_confirm_test.go diff --git a/cmd/entire/cli/plugin.go b/cmd/entire/cli/plugin.go index 8afcb3fb33..d84655b629 100644 --- a/cmd/entire/cli/plugin.go +++ b/cmd/entire/cli/plugin.go @@ -57,7 +57,10 @@ func MaybeRunPlugin(ctx context.Context, rootCmd *cobra.Command, args []string) var err error binPath, err = installMissingPlugin(ctx, rootCmd, pluginName) if err != nil { - fmt.Fprintln(rootCmd.ErrOrStderr(), RenderUserFacingError(err)) + var silent *SilentError + if !errors.As(silencePluginCancel(ctx, err), &silent) { + fmt.Fprintln(rootCmd.ErrOrStderr(), RenderUserFacingError(err)) + } return true, 1 } if binPath == "" { diff --git a/cmd/entire/cli/plugin_confirm.go b/cmd/entire/cli/plugin_confirm.go new file mode 100644 index 0000000000..15920963a8 --- /dev/null +++ b/cmd/entire/cli/plugin_confirm.go @@ -0,0 +1,96 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + + tea "charm.land/bubbletea/v2" + "charm.land/huh/v2" + "github.com/muesli/cancelreader" +) + +// A separate terminal keeps confirmation from consuming the plugin's stdin. +// Tests replace the opener rather than redirecting the command's data stream. +var openPluginPromptInput = func() (io.ReadCloser, error) { + in, out, err := tea.OpenTTY() + if err != nil { + return nil, fmt.Errorf("open confirmation terminal: %w", err) + } + if out != in { + _ = out.Close() + } + return in, nil +} + +func runPluginConfirm(ctx context.Context, out io.Writer, prompt string, defaultYes bool) (bool, error) { + if err := ctx.Err(); err != nil { + return false, fmt.Errorf("confirmation cancelled: %w", err) + } + input, err := openPluginPromptInput() + if err != nil { + return false, err + } + defer input.Close() + answer := defaultYes + form := NewAccessibleForm(huh.NewGroup(huh.NewConfirm().Title(prompt).Value(&answer))).WithOutput(out).WithInput(input) + if IsAccessibleMode() { + // Huh's accessible scanner ignores context and treats EOF as the default. + // Make the read cancellable and retain EOF so it cannot authorize an install. + reader, readErr := cancelreader.NewReader(input) + if readErr != nil { + return false, fmt.Errorf("confirmation input: %w", readErr) + } + defer reader.Close() + cancelled := make(chan struct{}) + stop := context.AfterFunc(ctx, func() { + if !reader.Cancel() { + // Some platforms cannot cancel reads on a separately opened + // terminal. This descriptor belongs to the prompt, so closing + // it is safe and also releases a blocked read. + _ = input.Close() + } + close(cancelled) + }) + defer func() { + if !stop() { + <-cancelled + } + }() + checked := &pluginConfirmReader{Reader: reader} + err = form.WithInput(checked).RunWithContext(ctx) + if ctx.Err() != nil { + return false, fmt.Errorf("confirmation cancelled: %w", ctx.Err()) + } + if checked.err != nil { + if errors.Is(checked.err, io.EOF) { + return false, nil + } + return false, fmt.Errorf("confirmation input: %w", checked.err) + } + } else { + err = form.RunWithContext(ctx) + } + if ctx.Err() != nil { + return false, fmt.Errorf("confirmation cancelled: %w", ctx.Err()) + } + if err != nil { + return false, fmt.Errorf("confirmation form: %w", err) + } + return answer, nil +} + +type pluginConfirmReader struct { + io.Reader + + err error +} + +func (r *pluginConfirmReader) Read(p []byte) (int, error) { + n, err := r.Reader.Read(p) + if n == 0 { + r.err = err + } + return n, err //nolint:wrapcheck // preserve io.Reader EOF semantics for the scanner +} diff --git a/cmd/entire/cli/plugin_confirm_terminal_test.go b/cmd/entire/cli/plugin_confirm_terminal_test.go new file mode 100644 index 0000000000..2dd779bc80 --- /dev/null +++ b/cmd/entire/cli/plugin_confirm_terminal_test.go @@ -0,0 +1,78 @@ +//go:build !windows + +package cli + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "strings" + "syscall" + "testing" + "time" + + "github.com/creack/pty" +) + +// The child has a controlling terminal but piped stdin. Both prompt modes must +// read the terminal answer and leave every byte of the plugin's input intact. +func TestPluginConfirmationRedirectedInput(t *testing.T) { + t.Parallel() + const marker = "ENTIRE_TEST_PLUGIN_CONFIRM_CHILD" + const question = "Install test plugin?" + const payload = "plugin input that must survive\n" + if os.Getenv(marker) == "1" { + answer, err := runPluginConfirm(t.Context(), os.Stderr, question, true) + if err != nil || !answer { + t.Fatalf("confirmation: answer=%v err=%v", answer, err) + } + data, err := io.ReadAll(os.Stdin) + if err != nil || string(data) != payload { + t.Fatalf("plugin stdin=%q err=%v", data, err) + } + fmt.Fprintln(os.Stderr, "INPUT_PRESERVED") + return + } + for _, accessible := range []string{"", "1"} { + t.Run("accessible="+accessible, func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestPluginConfirmationRedirectedInput$") + cmd.Env = append(os.Environ(), marker+"=1", "ACCESSIBLE="+accessible, "TERM=xterm-256color") + cmd.Stdin = strings.NewReader(payload) + terminal, err := pty.StartWithAttrs(cmd, &pty.Winsize{Rows: 24, Cols: 100}, &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 1}) + if err != nil { + t.Fatal(err) + } + defer terminal.Close() + output := make(chan string, 1) + go func() { + var transcript strings.Builder + answered := false + buf := make([]byte, 4096) + for { + n, readErr := terminal.Read(buf) + transcript.Write(buf[:n]) + if !answered && strings.Contains(transcript.String(), question) { + answered = true + if _, err := io.WriteString(terminal, "\r"); err != nil { + cancel() + } + } + if readErr != nil { + output <- transcript.String() + return + } + } + }() + err = cmd.Wait() + transcript := <-output + if err != nil || !strings.Contains(transcript, "INPUT_PRESERVED") { + t.Fatalf("child: %v\n%s", err, transcript) + } + }) + } +} diff --git a/cmd/entire/cli/plugin_confirm_test.go b/cmd/entire/cli/plugin_confirm_test.go new file mode 100644 index 0000000000..fd8badb721 --- /dev/null +++ b/cmd/entire/cli/plugin_confirm_test.go @@ -0,0 +1,74 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "strings" + "testing" + "time" +) + +func TestPluginDependencyConfirmationUsesWriter(t *testing.T) { //nolint:paralleltest // isolates terminal opener and accessibility + t.Setenv("ACCESSIBLE", "1") + t.Setenv("ENTIRE_TEST_TTY", "1") + original := openPluginPromptInput + openPluginPromptInput = func() (io.ReadCloser, error) { return io.NopCloser(strings.NewReader("y\n")), nil } + t.Cleanup(func() { openPluginPromptInput = original }) + var stderr bytes.Buffer + ok, err := confirmPluginAction(t.Context(), &stderr, "Install them now?", false) + if err != nil || !ok { + t.Fatalf("answer=%v err=%v", ok, err) + } + if !strings.Contains(stderr.String(), "Install them now? [y/N]") { + t.Fatalf("missing prompt on stderr: %q", stderr.String()) + } +} + +func TestPluginAccessibleConfirmationCancellation(t *testing.T) { //nolint:paralleltest // isolates terminal opener and accessibility + t.Setenv("ACCESSIBLE", "1") + input, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer writer.Close() + original := openPluginPromptInput + openPluginPromptInput = func() (io.ReadCloser, error) { return input, nil } + t.Cleanup(func() { openPluginPromptInput = original }) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + ready := make(chan struct{}, 1) + result := make(chan error, 1) + go func() { + _, promptErr := runPluginConfirm(ctx, pluginPromptNotifyWriter{ready}, "Install?", true) + result <- promptErr + }() + select { + case <-ready: + case <-time.After(5 * time.Second): + t.Fatal("prompt did not start") + } + cancel() + select { + case err := <-result: + if !errors.Is(err, context.Canceled) { + t.Fatalf("got %v, want cancellation", err) + } + case <-time.After(5 * time.Second): + _ = writer.Close() + <-result + t.Fatal("accessible prompt did not stop on cancellation") + } +} + +type pluginPromptNotifyWriter struct{ ready chan<- struct{} } + +func (w pluginPromptNotifyWriter) Write(p []byte) (int, error) { + select { + case w.ready <- struct{}{}: + default: + } + return len(p), nil +} diff --git a/cmd/entire/cli/plugin_fetch.go b/cmd/entire/cli/plugin_fetch.go index 2b3971d273..8c4ed9fe72 100644 --- a/cmd/entire/cli/plugin_fetch.go +++ b/cmd/entire/cli/plugin_fetch.go @@ -305,6 +305,7 @@ func downloadPluginAsset(ctx context.Context, meta *PluginMetadata, repoURL, nam } u := expandDownloadTemplate(meta.DownloadURL, name, tag, "") stopLocate() + defer startPluginStep(ctx, "Downloading plugin archive...")() return fetchAndVerify(ctx, u, assetNameFromURL(u), "", stagingDir) } @@ -327,6 +328,7 @@ func downloadPluginAsset(ctx context.Context, meta *PluginMetadata, repoURL, nam continue } stopLocate() + defer startPluginStep(ctx, "Downloading plugin archive...")() return fetchAndVerify(ctx, assetURL(asset), asset, digest, stagingDir) } @@ -340,8 +342,9 @@ func downloadPluginAsset(ctx context.Context, meta *PluginMetadata, repoURL, nam // (errUnverifiedAsset, which an older tag wouldn't fix). Getting that // wrong would report a missing release for a plugin that simply doesn't // ship checksums. + stopLocate() + defer startPluginStep(ctx, "Downloading plugin archive...")() for _, asset := range assetCandidates(name, tag) { - stopLocate() fa, err := fetchAndVerify(ctx, assetURL(asset), asset, "", stagingDir) switch { case errors.Is(err, errAssetNotFound): @@ -414,8 +417,6 @@ func httpGetSmall(ctx context.Context, rawURL string) ([]byte, error) { // command errors to stderr and a download failure is an ordinary event // (network hiccup, 5xx, checksum mismatch), not an exceptional one. func fetchAndVerify(ctx context.Context, rawURL, asset, wantDigest, stagingDir string) (*fetchedAsset, error) { - stopDownload := startPluginStep(ctx, "Downloading plugin archive...") - defer stopDownload() stagingRoot, err := osroot.Shared(stagingDir) if err != nil { return nil, fmt.Errorf("open staging dir: %w", err) @@ -476,11 +477,6 @@ func fetchAndVerify(ctx context.Context, rawURL, asset, wantDigest, stagingDir s _ = osroot.RemoveNoSymlinks(stagingRoot, asset) //nolint:errcheck // best-effort cleanup of a staging file we are already abandoning return nil, fmt.Errorf("download %s: exceeds %d byte limit", redactURL(rawURL), int64(maxPluginAssetSize)) } - stopDownload() - if wantDigest != "" { - stopVerify := startPluginStep(ctx, "Verifying plugin checksum...") - defer stopVerify() - } got := hex.EncodeToString(h.Sum(nil)) if wantDigest != "" && !strings.EqualFold(got, wantDigest) { _ = osroot.RemoveNoSymlinks(stagingRoot, asset) //nolint:errcheck // best-effort cleanup of a staging file we are already abandoning diff --git a/cmd/entire/cli/plugin_fetch_test.go b/cmd/entire/cli/plugin_fetch_test.go index 1221f9c22c..5cd9bcad33 100644 --- a/cmd/entire/cli/plugin_fetch_test.go +++ b/cmd/entire/cli/plugin_fetch_test.go @@ -472,17 +472,22 @@ func TestDownloadPluginAsset_ViaChecksumManifest(t *testing.T) { func TestDownloadPluginAsset_ProbeFallbackWithoutChecksums(t *testing.T) { t.Parallel() payload := makeTarGz(t, map[string][]byte{"entire-run": []byte("bin")}) - asset := fmt.Sprintf("entire-run_1.0.0_%s_%s.tar.gz", runtime.GOOS, runtime.GOARCH) + candidates := assetCandidates("run", "v1.0.0") + asset := candidates[len(candidates)-1] srv := assetServer(t, asset, payload, "") meta := &PluginMetadata{DownloadURL: srv.URL + "/dl/{asset}"} - fa, err := downloadPluginAsset(context.Background(), meta, "https://example.invalid/entire-run", "run", "v1.0.0", t.TempDir(), true) + var progress bytes.Buffer + fa, err := downloadPluginAsset(withPluginProgress(t.Context(), &progress), meta, "https://example.invalid/entire-run", "run", "v1.0.0", t.TempDir(), true) if err != nil { t.Fatalf("downloadPluginAsset: %v", err) } if fa.Asset != asset { t.Errorf("Asset = %q, want %q", fa.Asset, asset) } + if got := strings.Count(progress.String(), "Downloading plugin archive..."); got != 1 { + t.Fatalf("download phase reported %d times: %s", got, &progress) + } } // allowUnverified stays false here on purpose: with verification required, diff --git a/cmd/entire/cli/plugin_group.go b/cmd/entire/cli/plugin_group.go index 8338397466..508541b5fa 100644 --- a/cmd/entire/cli/plugin_group.go +++ b/cmd/entire/cli/plugin_group.go @@ -253,7 +253,7 @@ func runRemoteInstall(ctx context.Context, cmd *cobra.Command, src installSource // An untrusted source cannot proceed unconfirmed: automation never // reaches this prompt, because the non-interactive path fails above // with the --yes hint. - proceed, err := confirmInstallOrCancel(ctx, out, + proceed, err := confirmInstallOrCancel(ctx, errOut, fmt.Sprintf("Install from %s? The repository is not listed in the plugin index.", redactURL(repoURL)), flags.yes) if err != nil || !proceed { @@ -330,7 +330,7 @@ func installPlannedDeps(ctx context.Context, cmd *cobra.Command, reqs []PluginRe fmt.Fprintf(out, " %s (%s)\n", a.Name, redactURL(a.RepoURL)) } } - ok, err := confirmPluginAction(ctx, "Install them now?", flags.yes) + ok, err := confirmPluginAction(ctx, errOut, "Install them now?", flags.yes) switch { case errors.Is(err, errConfirmNeedsTerminal): // Non-interactive without --yes: the main install already @@ -380,19 +380,15 @@ var errConfirmNeedsTerminal = errors.New("confirmation required but no terminal // non-interactive runs without --yes return errConfirmNeedsTerminal rather // than guessing. Prompt errors (including huh.ErrUserAborted on Ctrl+C/Esc) // are returned raw for callers to map via handleFormCancellation. -func confirmPluginAction(ctx context.Context, prompt string, assumeYes bool) (bool, error) { +func confirmPluginAction(ctx context.Context, out io.Writer, prompt string, assumeYes bool) (bool, error) { if assumeYes { return true, nil } if !interactive.CanPromptInteractively() { return false, fmt.Errorf("%w (%s)", errConfirmNeedsTerminal, prompt) } - confirmed := false - form := NewAccessibleForm(huh.NewGroup( - huh.NewConfirm().Title(prompt).Value(&confirmed), - )) - if err := form.RunWithContext(ctx); err != nil { - // %w keeps huh.ErrUserAborted reachable for handleFormCancellation. + confirmed, err := runPluginConfirm(ctx, out, prompt, false) + if err != nil { return false, fmt.Errorf("confirm: %w", err) } return confirmed, nil @@ -405,7 +401,7 @@ func confirmPluginAction(ctx context.Context, prompt string, assumeYes bool) (bo // wrapped, and errConfirmNeedsTerminal propagates unchanged so the caller // decides whether an unattended run may proceed without an answer. func confirmInstallOrCancel(ctx context.Context, out io.Writer, prompt string, assumeYes bool) (bool, error) { - ok, err := confirmPluginAction(ctx, prompt, assumeYes) + ok, err := confirmPluginAction(ctx, out, prompt, assumeYes) switch { case errors.Is(err, errConfirmNeedsTerminal): return false, err @@ -755,7 +751,7 @@ in scripts and non-interactive runs.`, // binary and links it onto PATH in one keystroke. The picker also // only shows name and description, so the repository the binary // actually comes from is named here for the first time. - out := cmd.OutOrStdout() + out := cmd.ErrOrStderr() prompt := fmt.Sprintf("Install %q?", choice) if entry := idx.Find(choice); entry != nil { prompt = fmt.Sprintf("Install %q from %s?", choice, redactURL(entry.RepoURL)) diff --git a/cmd/entire/cli/plugin_on_demand.go b/cmd/entire/cli/plugin_on_demand.go index ef604c791a..3468373146 100644 --- a/cmd/entire/cli/plugin_on_demand.go +++ b/cmd/entire/cli/plugin_on_demand.go @@ -4,7 +4,6 @@ import ( "context" "fmt" - "charm.land/huh/v2" "github.com/entireio/cli/cmd/entire/cli/interactive" "github.com/spf13/cobra" ) @@ -21,11 +20,11 @@ func installMissingPlugin(ctx context.Context, rootCmd *cobra.Command, name stri if err := ctx.Err(); err != nil { return "", fmt.Errorf("install plugin: %w", err) } - confirmed := true - form := NewAccessibleForm(huh.NewGroup( - huh.NewConfirm().Title(fmt.Sprintf("Install the entire-%s plugin?", name)).Value(&confirmed), - )).WithInput(rootCmd.InOrStdin()).WithOutput(rootCmd.ErrOrStderr()) - if err := form.RunWithContext(ctx); err != nil { + confirmed, err := runPluginConfirm(ctx, rootCmd.ErrOrStderr(), fmt.Sprintf("Install the entire-%s plugin?", name), true) + if err != nil { + if ctx.Err() != nil { + return "", err + } return "", handleFormCancellation(rootCmd.ErrOrStderr(), "Install", err) } if !confirmed { @@ -44,6 +43,9 @@ func installMissingPlugin(ctx context.Context, rootCmd *cobra.Command, name stri if err := onDemandPluginInstall(ctx, cmd, installSource{Kind: installFromIndex, Ref: name}, remoteInstallFlags{}); err != nil { return "", err } + if err := ctx.Err(); err != nil { + return "", fmt.Errorf("install plugin: %w", err) + } installed, err := FindInstalledPlugin(name) if err != nil { return "", err diff --git a/cmd/entire/cli/plugin_on_demand_test.go b/cmd/entire/cli/plugin_on_demand_test.go index 44ca1deaed..a12bc9387b 100644 --- a/cmd/entire/cli/plugin_on_demand_test.go +++ b/cmd/entire/cli/plugin_on_demand_test.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "io" "os" "path/filepath" "strings" @@ -30,16 +31,20 @@ func TestMaybeRunPlugin_MissingGraphNonInteractive(t *testing.T) { //nolint:para func TestMaybeRunPlugin_InstallGraphAndRun(t *testing.T) { //nolint:paralleltest // isolates environment and installer seam for _, tc := range []struct { - name string - answer string - installErr error - pluginCode int - wantCode int - wantInstall bool - wantRun bool + name string + answer string + installErr error + cancelInstall bool + pluginCode int + wantCode int + wantInstall bool + wantRun bool }{ {name: "enter accepts default yes", answer: "\n", wantInstall: true, wantRun: true}, {name: "explicit yes preserves exit code", answer: "y\n", pluginCode: 42, wantCode: 42, wantInstall: true, wantRun: true}, + {name: "cancelled install stays quiet", answer: "y\n", cancelInstall: true, installErr: context.Canceled, wantCode: 1, wantInstall: true}, + {name: "cancelled dependency confirmation does not run", answer: "y\n", cancelInstall: true, wantCode: 1, wantInstall: true}, + {name: "EOF declines", answer: "", wantCode: 1}, {name: "no cancels", answer: "n\n", wantCode: 1}, {name: "failed install does not run", answer: "\n", installErr: errors.New("download failed"), wantCode: 1, wantInstall: true}, } { @@ -54,10 +59,15 @@ func TestMaybeRunPlugin_InstallGraphAndRun(t *testing.T) { //nolint:paralleltest argFile := filepath.Join(dir, "args.txt") sourceDir := t.TempDir() source := writePluginBinary(t, sourceDir, "entire-graph", argFile, tc.pluginCode) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() installCalls := 0 original := onDemandPluginInstall onDemandPluginInstall = func(_ context.Context, cmd *cobra.Command, src installSource, flags remoteInstallFlags) error { installCalls++ + if tc.cancelInstall { + cancel() + } if src.Kind != installFromIndex || src.Ref != "graph" || flags != (remoteInstallFlags{}) { t.Fatalf("unexpected install request: %+v %+v", src, flags) } @@ -73,9 +83,13 @@ func TestMaybeRunPlugin_InstallGraphAndRun(t *testing.T) { //nolint:paralleltest var stdout, stderr bytes.Buffer root.SetOut(&stdout) root.SetErr(&stderr) - root.SetIn(strings.NewReader(tc.answer)) + originalInput := openPluginPromptInput + openPluginPromptInput = func() (io.ReadCloser, error) { return io.NopCloser(strings.NewReader(tc.answer)), nil } + t.Cleanup(func() { openPluginPromptInput = originalInput }) + data := strings.NewReader("plugin data\n") + root.SetIn(data) args := []string{"graph", "search", "two words", "--json", "--", "$(untouched)", ""} - handled, code := MaybeRunPlugin(t.Context(), root, args) + handled, code := MaybeRunPlugin(ctx, root, args) if !handled || code != tc.wantCode { t.Fatalf("handled=%v code=%d, want true, %d; stderr=%s", handled, code, tc.wantCode, &stderr) } @@ -85,6 +99,9 @@ func TestMaybeRunPlugin_InstallGraphAndRun(t *testing.T) { //nolint:paralleltest if !strings.Contains(stderr.String(), "Install the entire-graph plugin?") || !strings.Contains(stderr.String(), "[Y/n]") { t.Errorf("missing Yes-default prompt: %q", stderr.String()) } + if data.Len() != len("plugin data\n") { + t.Error("confirmation consumed plugin stdin") + } if stdout.Len() != 0 { t.Errorf("installation polluted stdout: %q", stdout.String()) } @@ -96,7 +113,10 @@ func TestMaybeRunPlugin_InstallGraphAndRun(t *testing.T) { //nolint:paralleltest } else if !os.IsNotExist(err) { t.Errorf("plugin unexpectedly ran: args=%q err=%v", got, err) } - if tc.installErr != nil && !strings.Contains(stderr.String(), tc.installErr.Error()) { + if tc.cancelInstall && strings.Contains(stderr.String(), "context canceled") { + t.Errorf("raw cancellation: %s", &stderr) + } + if tc.installErr != nil && !tc.cancelInstall && !strings.Contains(stderr.String(), tc.installErr.Error()) { t.Errorf("missing install failure: %q", stderr.String()) } }) diff --git a/cmd/entire/cli/plugin_progress_test.go b/cmd/entire/cli/plugin_progress_test.go index 2c4a9fffd8..a8ab439484 100644 --- a/cmd/entire/cli/plugin_progress_test.go +++ b/cmd/entire/cli/plugin_progress_test.go @@ -40,7 +40,6 @@ func TestPluginInstallReportsStagesOnStderr(t *testing.T) { //nolint:paralleltes "Fetching plugin metadata for v0.1.0...", "Locating plugin release files...", "Downloading plugin archive...", - "Verifying plugin checksum...", "Installing entire-demo v0.1.0...", } if got, want := errOut.String(), strings.Join(stages, "\n")+"\n"; got != want { diff --git a/cmd/entire/main.go b/cmd/entire/main.go index 4c48b34887..cd53940eda 100644 --- a/cmd/entire/main.go +++ b/cmd/entire/main.go @@ -81,6 +81,9 @@ func main() { restorePATH := cli.PrependPluginBinDirToPATH(ctx) if handled, code := cli.MaybeRunPlugin(ctx, rootCmd, os.Args[1:]); handled { + if ctx.Err() != nil && procsignal.Load() != nil { + dieFromSignal(terminatingSignal()) + } cancel() os.Exit(code) } diff --git a/docs/architecture/external-commands.md b/docs/architecture/external-commands.md index 15f144100d..ae11ddcbba 100644 --- a/docs/architecture/external-commands.md +++ b/docs/architecture/external-commands.md @@ -20,7 +20,7 @@ Rules, in order: ### Managed install directory -Remote installs report index lookup, release metadata, download, checksum verification, and installation progress on stderr. Styled terminals show a spinner; non-terminal and accessibility output prints plain status lines as each step starts. Progress stops before confirmations and results, including when installation fails. The same reporting applies when `entire graph` offers to install its missing plugin and when installing dependencies. +Remote installs report index lookup, release metadata, download (including checksum verification), and installation progress on stderr. Styled terminals show a spinner; non-terminal and accessibility output prints plain status lines as each step starts. Progress stops before confirmations and results, including when installation fails. The same reporting applies when `entire graph` offers to install its missing plugin and when installing dependencies. Asset-name probes share one download status per release. Confirmations read from the controlling terminal and render to the supplied output writer (stderr for on-demand installs and dependency prompts), preserving piped plugin input and output. EOF declines installation; cancellation stops the prompt and preserves signal termination. Users can drop binaries anywhere on `$PATH`, but a per-user managed directory is also automatically discovered: diff --git a/go.mod b/go.mod index 0bbfc1f5dc..b1bae8ac82 100644 --- a/go.mod +++ b/go.mod @@ -50,7 +50,10 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) -require github.com/lastpersonlabs/goredact v0.1.0 +require ( + github.com/lastpersonlabs/goredact v0.1.0 + github.com/muesli/cancelreader v0.2.2 +) require ( dario.cat/mergo v1.0.2 // indirect @@ -116,7 +119,6 @@ require ( github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect github.com/nwaples/rardecode/v2 v2.2.2 // indirect github.com/pelletier/go-toml/v2 v2.3.1 // indirect github.com/pierrec/lz4/v4 v4.1.26 // indirect From 270d17b54eab45ddb0704a415b44a08c493de0b1 Mon Sep 17 00:00:00 2001 From: Thomas Dohmke Date: Wed, 9 Sep 2026 17:48:06 +0200 Subject: [PATCH 17/59] fix: close plugin confirmation input exactly once Entire-Checkpoint: 01M23DNRDKQ7Z7SYTXZ4PBT5SB --- cmd/entire/cli/plugin_confirm.go | 6 +- cmd/entire/cli/plugin_confirm_test.go | 92 ++++++++++++++++++--------- 2 files changed, 65 insertions(+), 33 deletions(-) diff --git a/cmd/entire/cli/plugin_confirm.go b/cmd/entire/cli/plugin_confirm.go index 15920963a8..6b274c442c 100644 --- a/cmd/entire/cli/plugin_confirm.go +++ b/cmd/entire/cli/plugin_confirm.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "sync" tea "charm.land/bubbletea/v2" "charm.land/huh/v2" @@ -32,7 +33,8 @@ func runPluginConfirm(ctx context.Context, out io.Writer, prompt string, default if err != nil { return false, err } - defer input.Close() + closeInput := sync.OnceFunc(func() { _ = input.Close() }) + defer closeInput() answer := defaultYes form := NewAccessibleForm(huh.NewGroup(huh.NewConfirm().Title(prompt).Value(&answer))).WithOutput(out).WithInput(input) if IsAccessibleMode() { @@ -49,7 +51,7 @@ func runPluginConfirm(ctx context.Context, out io.Writer, prompt string, default // Some platforms cannot cancel reads on a separately opened // terminal. This descriptor belongs to the prompt, so closing // it is safe and also releases a blocked read. - _ = input.Close() + closeInput() } close(cancelled) }) diff --git a/cmd/entire/cli/plugin_confirm_test.go b/cmd/entire/cli/plugin_confirm_test.go index fd8badb721..2946f29981 100644 --- a/cmd/entire/cli/plugin_confirm_test.go +++ b/cmd/entire/cli/plugin_confirm_test.go @@ -28,38 +28,55 @@ func TestPluginDependencyConfirmationUsesWriter(t *testing.T) { //nolint:paralle } func TestPluginAccessibleConfirmationCancellation(t *testing.T) { //nolint:paralleltest // isolates terminal opener and accessibility - t.Setenv("ACCESSIBLE", "1") - input, writer, err := os.Pipe() - if err != nil { - t.Fatal(err) - } - defer writer.Close() - original := openPluginPromptInput - openPluginPromptInput = func() (io.ReadCloser, error) { return input, nil } - t.Cleanup(func() { openPluginPromptInput = original }) - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - ready := make(chan struct{}, 1) - result := make(chan error, 1) - go func() { - _, promptErr := runPluginConfirm(ctx, pluginPromptNotifyWriter{ready}, "Install?", true) - result <- promptErr - }() - select { - case <-ready: - case <-time.After(5 * time.Second): - t.Fatal("prompt did not start") - } - cancel() - select { - case err := <-result: - if !errors.Is(err, context.Canceled) { - t.Fatalf("got %v, want cancellation", err) + for _, fallback := range []bool{false, true} { + name := "pollable input" + if fallback { + name = "fallback input" } - case <-time.After(5 * time.Second): - _ = writer.Close() - <-result - t.Fatal("accessible prompt did not stop on cancellation") + t.Run(name, func(t *testing.T) { + t.Setenv("ACCESSIBLE", "1") + input, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer writer.Close() + tracked := &pluginPromptCloseTracker{ReadCloser: input} + original := openPluginPromptInput + openPluginPromptInput = func() (io.ReadCloser, error) { + if fallback { + return tracked, nil + } + return input, nil + } + t.Cleanup(func() { openPluginPromptInput = original }) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + ready := make(chan struct{}, 1) + result := make(chan error, 1) + go func() { + _, promptErr := runPluginConfirm(ctx, pluginPromptNotifyWriter{ready}, "Install?", true) + result <- promptErr + }() + select { + case <-ready: + case <-time.After(5 * time.Second): + t.Fatal("prompt did not start") + } + cancel() + select { + case err := <-result: + if !errors.Is(err, context.Canceled) { + t.Fatalf("got %v, want cancellation", err) + } + case <-time.After(5 * time.Second): + _ = writer.Close() + <-result + t.Fatal("accessible prompt did not stop on cancellation") + } + if fallback && tracked.closes != 1 { + t.Fatalf("input closed %d times, want exactly once", tracked.closes) + } + }) } } @@ -72,3 +89,16 @@ func (w pluginPromptNotifyWriter) Write(p []byte) (int, error) { } return len(p), nil } + +// Hiding the file descriptor forces cancelreader's non-pollable fallback: +// Cancel returns false, so closing the input must unblock the prompt. +type pluginPromptCloseTracker struct { + io.ReadCloser + + closes int +} + +func (r *pluginPromptCloseTracker) Close() error { + r.closes++ + return r.ReadCloser.Close() +} From 6cd08c498c5f93f59debe4460c88f9f51228c3a5 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Wed, 9 Sep 2026 12:07:03 -0700 Subject: [PATCH 18/59] fix(status): report disabled checkpoint pushing --- cmd/entire/cli/status.go | 27 ++++- cmd/entire/cli/status_test.go | 203 ++++++++++++++++++++++++++++++++++ 2 files changed, 224 insertions(+), 6 deletions(-) diff --git a/cmd/entire/cli/status.go b/cmd/entire/cli/status.go index 5150d2bee0..76ec9a8dec 100644 --- a/cmd/entire/cli/status.go +++ b/cmd/entire/cli/status.go @@ -332,9 +332,12 @@ const checkpointSyncSourceDedicated = "dedicated" // drift. Everything here reads local state only (settings, .git/config, local // refs, the push queue) — status must stay network-free. type checkpointSyncInfo struct { + // PushDisabled reflects the explicit automatic-push setting, not every + // possible reason checkpoint sync might fail. + PushDisabled bool // Remote is the elected git remote name, or the org/repo slug in - // dedicated checkpoint_remote mode. Empty when nothing resolved (no - // remotes configured, or the fail-closed case). + // dedicated checkpoint_remote mode. Empty when pushing is disabled or + // nothing resolved (no remotes configured, or the fail-closed case). Remote string // Source is config|observed|default|sole|first (resolver values) or // "dedicated". @@ -354,6 +357,10 @@ type checkpointSyncInfo struct { } func computeCheckpointSyncInfo(ctx context.Context, s *EntireSettings) checkpointSyncInfo { + if s.IsPushSessionsDisabled() { + return checkpointSyncInfo{PushDisabled: true} + } + elected, err := strategy.ResolveCheckpointSyncRemote(ctx) if err != nil { // Fail-closed: checkpoint_push_remote names a remote that does not @@ -426,13 +433,15 @@ func countUnpushedCheckpointsForStatus(ctx context.Context, remoteName string) i return n } -// writeCheckpointSyncLines appends the checkpoint sync destination line (and -// the unpushed counter, when non-zero) to the enabled status block. Rendered -// whenever something resolved: an elected remote, a dedicated store, or the -// fail-closed misconfiguration. No remotes configured -> no lines. +// writeCheckpointSyncLines reports disabled automatic pushing or the checkpoint +// sync destination (and the unpushed counter, when non-zero) in the enabled status +// block. With pushing enabled, no remotes configured means no lines. func writeCheckpointSyncLines(ctx context.Context, b *strings.Builder, s *EntireSettings, sty statusStyles) { info := computeCheckpointSyncInfo(ctx, s) switch { + case info.PushDisabled: + b.WriteString("\n Automatic checkpoint pushing: disabled (push_sessions=false)") + return case info.Err != "": b.WriteString("\n") b.WriteString(sty.render(sty.yellow, " ! Checkpoints NOT syncing: "+info.Err)) @@ -851,6 +860,11 @@ type statusJSON struct { // CodexHooks reports effective discovery/trust warnings separately from // current-checkout installation and freshness semantics. CodexHooks *codexHooksStatusJSON `json:"codex_hooks,omitempty"` + // CheckpointPushDisabled is emitted only when Entire is enabled and the + // effective push_sessions setting is false. Its absence does not guarantee + // that a push can succeed. Sync destination, error, and count fields are + // omitted while pushing is disabled. + CheckpointPushDisabled bool `json:"checkpoint_push_disabled,omitempty"` // CheckpointSyncRemote is the elected checkpoint sync remote name, or the // org/repo slug in dedicated checkpoint_remote mode. Deliberately not named // checkpoint_remote, which is the existing GitHub-coupled setting. @@ -947,6 +961,7 @@ func runStatusJSON(ctx context.Context, w io.Writer) error { // Same computation as the text path (writeCheckpointSyncLines); // empty fields drop out via omitempty when nothing resolved. syncInfo := computeCheckpointSyncInfo(ctx, s) + result.CheckpointPushDisabled = syncInfo.PushDisabled result.CheckpointSyncRemote = syncInfo.Remote result.CheckpointSyncRemoteSource = syncInfo.Source result.CheckpointSyncError = syncInfo.Err diff --git a/cmd/entire/cli/status_test.go b/cmd/entire/cli/status_test.go index 8500458185..b38a6eec1e 100644 --- a/cmd/entire/cli/status_test.go +++ b/cmd/entire/cli/status_test.go @@ -26,6 +26,7 @@ import ( "github.com/entireio/cli/redact" "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing" "github.com/go-git/go-git/v6/plumbing/object" ) @@ -2288,6 +2289,208 @@ func TestRunStatus_PrintsBothReviewAndInvestigation(t *testing.T) { // --- Checkpoint sync visibility (single-remote gate observability) --- +func TestRunStatus_CheckpointPushDisabled(t *testing.T) { + testCheckpointPushDisabledFork(t, false) +} + +func TestRunStatusJSON_CheckpointPushDisabled(t *testing.T) { + testCheckpointPushDisabledFork(t, true) +} + +func testCheckpointPushDisabledFork(t *testing.T, jsonOutput bool) { + t.Helper() + // setupTestRepo changes CWD and git-config isolation changes process env. + testutil.IsolateGitConfigEnv(t) + setupTestRepo(t) + writeSettings(t, `{"enabled":true,"strategy_options":{"push_sessions":false,"checkpoint_push_remote":"fork"}}`) + testutil.AddRemote(t, ".", "origin", "https://github.com/org/repo.git") + testutil.AddRemote(t, ".", "fork", "https://github.com/user/repo.git") + head := checkpointSyncTestCommit(t, "a.txt", "one") + testutil.GitUpdateRef(t, ".", "refs/heads/"+paths.MetadataBranchName, head) + assertCheckpointPushDisabledStatus(t, jsonOutput, false) +} + +func assertCheckpointPushDisabledStatus(t *testing.T, jsonOutput, detailed bool) { + t.Helper() + var stdout bytes.Buffer + if err := runStatus(context.Background(), &stdout, detailed, jsonOutput); err != nil { + t.Fatalf("runStatus() error = %v", err) + } + t.Logf("status output:\n%s", stdout.String()) + if jsonOutput { + var result map[string]json.RawMessage + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + for _, key := range []string{"enabled", "checkpoint_push_disabled"} { + raw, exists := result[key] + if !exists { + t.Errorf("missing %s", key) + continue + } + var value bool + if err := json.Unmarshal(raw, &value); err != nil || !value { + t.Errorf("%s = %s, want true (decode error: %v)", key, raw, err) + } + } + for _, key := range []string{"checkpoint_sync_remote", "checkpoint_sync_remote_source", "checkpoint_sync_error", "unpushed_checkpoints", "checkpoint_remote_ignored", "checkpoint_remote_ignored_reason"} { + if value, exists := result[key]; exists { + t.Errorf("disabled pushing must omit %s, got %s", key, value) + } + } + return + } + if !strings.Contains(stdout.String(), "Automatic checkpoint pushing: disabled (push_sessions=false)") { + t.Error("missing automatic checkpoint pushing disabled message") + } + for _, unwanted := range []string{"Checkpoints sync to:", "Checkpoints NOT syncing:", "not yet", "next 'git push", "is not in use:"} { + if strings.Contains(stdout.String(), unwanted) { + t.Errorf("disabled pushing must not show %q", unwanted) + } + } +} + +func TestRunStatus_CheckpointPushDisabledDestinations(t *testing.T) { + // These subtests mutate CWD and environment and cannot run in parallel. + for _, backend := range []string{"git-branch", "git-refs"} { + for _, tc := range []struct { + name string + options string + origin string + }{ + {"origin", "", "https://github.com/org/repo.git"}, + {"explicit_fork", `,"checkpoint_push_remote":"fork"`, "https://github.com/org/repo.git"}, + {"dedicated", `,"checkpoint_remote":{"provider":"github","repo":"org/checkpoints"}`, "https://github.com/org/repo.git"}, + {"inherited_dedicated_rejected", `,"checkpoint_remote":{"provider":"github","repo":"org/checkpoints"}`, "https://github.com/other/repo.git"}, + {"no_remotes", "", ""}, + {"missing_configured_remote", `,"checkpoint_push_remote":"gone"`, "https://github.com/org/repo.git"}, + } { + t.Run(backend+"/"+tc.name, func(t *testing.T) { + testutil.IsolateGitConfigEnv(t) + setupTestRepo(t) + writeSettings(t, `{"enabled":true,"strategy_options":{"push_sessions":false`+tc.options+`},"checkpoints":{"primary":{"type":"`+backend+`"}}}`) + if tc.origin != "" { + testutil.AddRemote(t, ".", "origin", tc.origin) + } + if tc.name == "explicit_fork" { + testutil.AddRemote(t, ".", "fork", "https://github.com/user/repo.git") + } + head := checkpointSyncTestCommit(t, "a.txt", "one") + testutil.GitUpdateRef(t, ".", "refs/heads/"+paths.MetadataBranchName, head) + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + queue := checkpoint.NewPushQueue(filepath.Join(cwd, ".git")) + if backend == "git-refs" { + for _, ref := range []string{"refs/entire/checkpoints/aa/bb0000000001", "refs/entire/checkpoints/aa/bb0000000002"} { + if err := queue.Enqueue(plumbing.ReferenceName(ref)); err != nil { + t.Fatal(err) + } + } + } + before, err := queue.Peek() + if err != nil { + t.Fatal(err) + } + for _, jsonOutput := range []bool{false, true} { + assertCheckpointPushDisabledStatus(t, jsonOutput, false) + after, err := queue.Peek() + if err != nil { + t.Fatal(err) + } + if !slices.Equal(before, after) { + t.Errorf("status changed pending queue: before=%v after=%v", before, after) + } + } + }) + } + } +} + +func TestRunStatus_CheckpointPushDisabledSettingsPrecedence(t *testing.T) { + for _, tc := range []struct { + name string + shared string + local string + disabled bool + }{ + {"local_false_overrides_shared_true", `{"enabled":true,"strategy_options":{"push_sessions":true}}`, `{"strategy_options":{"push_sessions":false}}`, true}, + {"local_true_overrides_shared_false", `{"enabled":true,"strategy_options":{"push_sessions":false}}`, `{"strategy_options":{"push_sessions":true}}`, false}, + {"absent", `{"enabled":true}`, "", false}, + {"explicit_true", `{"enabled":true,"strategy_options":{"push_sessions":true}}`, "", false}, + } { + t.Run(tc.name, func(t *testing.T) { + testutil.IsolateGitConfigEnv(t) + setupTestRepo(t) + writeSettings(t, tc.shared) + if tc.local != "" { + testutil.WriteFile(t, ".", ".entire/settings.local.json", tc.local) + } + testutil.AddRemote(t, ".", "origin", "https://github.com/org/repo.git") + head := checkpointSyncTestCommit(t, "a.txt", "one") + testutil.GitUpdateRef(t, ".", "refs/heads/"+paths.MetadataBranchName, head) + for _, jsonOutput := range []bool{false, true} { + if tc.disabled { + assertCheckpointPushDisabledStatus(t, jsonOutput, true) + continue + } + var stdout bytes.Buffer + if err := runStatus(context.Background(), &stdout, false, jsonOutput); err != nil { + t.Fatal(err) + } + if jsonOutput { + var result map[string]json.RawMessage + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatal(err) + } + if _, exists := result["checkpoint_push_disabled"]; exists { + t.Errorf("enabled pushing must omit checkpoint_push_disabled: %s", stdout.String()) + } + if string(result["checkpoint_sync_remote"]) != `"origin"` || string(result["checkpoint_sync_remote_source"]) != `"default"` || string(result["unpushed_checkpoints"]) != "1" { + t.Errorf("enabled pushing lost existing destination/counter fields: %s", stdout.String()) + } + } else if strings.Contains(stdout.String(), "Automatic checkpoint pushing:") || !strings.Contains(stdout.String(), "Checkpoints sync to: origin") || !strings.Contains(stdout.String(), "next 'git push origin'") { + t.Errorf("enabled pushing changed existing output: %s", stdout.String()) + } + } + }) + } +} + +func TestRunStatus_CheckpointPushDisabledAbsentWithoutEnabledEntire(t *testing.T) { + for _, tc := range []struct { + name string + settings string + }{ + {"entire_disabled", `{"enabled":false,"strategy_options":{"push_sessions":false}}`}, + {"not_set_up", ""}, + {"invalid_settings", `{"enabled":true,"strategy_options":{"push_sessions":false},`}, + } { + t.Run(tc.name, func(t *testing.T) { + testutil.IsolateGitConfigEnv(t) + setupTestRepo(t) + if tc.settings != "" { + writeSettings(t, tc.settings) + } + for _, jsonOutput := range []bool{false, true} { + var stdout bytes.Buffer + err := runStatus(context.Background(), &stdout, false, jsonOutput) + if tc.name == "invalid_settings" && !jsonOutput { + if err == nil || !strings.Contains(err.Error(), "failed to load settings") { + t.Fatalf("invalid text settings error = %v", err) + } + } else if err != nil { + t.Fatal(err) + } + if strings.Contains(stdout.String(), "checkpoint_push_disabled") || strings.Contains(stdout.String(), "Automatic checkpoint pushing:") { + t.Errorf("inactive Entire must omit disabled-pushing status: %s", stdout.String()) + } + } + }) + } +} + // checkpointSyncTestCommit creates a commit in the cwd test repo and returns // its hash. setupTestRepo leaves the repo without commits, and both the v1 // counter and ref updates need at least one. From 69af387481b5b8219af4c3c4c3ed584c98f15ed5 Mon Sep 17 00:00:00 2001 From: Kai Ramuenke Date: Thu, 10 Sep 2026 10:26:00 +1000 Subject: [PATCH 19/59] coreapi: regenerate the client from the current core spec The control plane gained the branch-protection resource and several new operations since the last refresh. Two spec changes needed normalizer support before ogen would consume it: every operation now lists the interactive oauth2/oidc login schemes, which ogen cannot generate, and the repo read models gained required enum fields the CLI never reads. The normalizer drops the interactive schemes, loosens the new provider enums, and makes Repo.provider and Repo.capabilities optional, so the generated SecuritySource and the decoding tolerance stay as they were. Co-Authored-By: Claude Fable 5.1 --- internal/coreapi/UPSTREAM.md | 33 + internal/coreapi/client_test.go | 5 +- internal/coreapi/oas_cfg_gen.go | 1 + internal/coreapi/oas_client_gen.go | 5733 +++- internal/coreapi/oas_json_gen.go | 24303 +++++++++++----- internal/coreapi/oas_operations_gen.go | 152 +- internal/coreapi/oas_parameters_gen.go | 171 +- internal/coreapi/oas_request_encoders_gen.go | 102 +- internal/coreapi/oas_response_decoders_gen.go | 2691 +- internal/coreapi/oas_schemas_gen.go | 12926 +++++--- internal/coreapi/oas_security_gen.go | 298 +- internal/coreapi/oas_validators_gen.go | 1463 +- internal/coreapi/spec/core.gen.json | 7175 +++-- internal/coreapi/spec/core.openapi.json | 10782 +++++-- internal/coreapi/spec/normalize.go | 142 +- 15 files changed, 46461 insertions(+), 19516 deletions(-) diff --git a/internal/coreapi/UPSTREAM.md b/internal/coreapi/UPSTREAM.md index bf2dd04f41..4d370d8a34 100644 --- a/internal/coreapi/UPSTREAM.md +++ b/internal/coreapi/UPSTREAM.md @@ -51,6 +51,39 @@ loosened; request-body enums stay strict. Locked in by `TestListProjectRepos_UnknownEnumValuesPassThrough` in `client_test.go`. Retire the allowlist entries as upstream loosens the corresponding fields. +## 2b. New read-model fields ship as `required` + +**Symptom:** `Repo.provider` and `Repo.capabilities` were added as +`required`. ogen's decoder then fails the whole response when a field is +absent, so a core that predates the field, or a mixed-version roll, breaks +every `repo list` and repo-get call in a client that never reads either +field. + +**Fix upstream:** add read-model fields as optional until every deployment +sends them, then tighten. + +**Workaround:** `spec/normalize.go` (`loosenReadModelRequired`, allowlist +`readModelOptionalFields`) drops the listed fields from `required`. +`Repo.provider` is also in `readModelEnumFields`, since it is a display-only +enum. Remove an entry when the CLI starts reading the field. + +## 3. Every operation advertises the interactive login schemes + +**Symptom:** the spec lists four security alternatives on every operation +(`oauth2`, `oidc`, `bearerAuth`, `sessionAuth`). `oauth2` and `oidc` +describe how a browser or device obtains a token; a client that already +holds a bearer never drives them. ogen has no generator for `openIdConnect` +and aborts on it, so the spec cannot be consumed as published. + +**Fix upstream:** advertise `oauth2`/`oidc` in `components.securitySchemes` +for documentation, but list only `bearerAuth` and `sessionAuth` as the +per-operation requirements, since those are what a request actually carries. + +**Workaround:** `spec/normalize.go` (`dropInteractiveSecurity`, +`interactiveSecuritySchemes`) removes the two schemes from the components +and from every security list, so the generated `SecuritySource` keeps the +`BearerAuth` and `SessionAuth` methods the client implements. +