diff --git a/internal/cli/cache_contract_test.go b/internal/cli/cache_contract_test.go index c6b0d8e..7595311 100644 --- a/internal/cli/cache_contract_test.go +++ b/internal/cli/cache_contract_test.go @@ -249,10 +249,24 @@ func TestRunAutoReusesPlanWhenRunInputsChange(t *testing.T) { } harness.mu.Lock() chunkCalls := harness.chunkCalls + sessions := append([]string(nil), harness.sessionIDs...) harness.mu.Unlock() if chunkCalls != 1 { t.Fatalf("chunk calls across changed run inputs = %d, want 1", chunkCalls) } + rawInput, err := os.ReadFile(roots.input) + if err != nil { + t.Fatal(err) + } + wantSessionID, err := resolvePromptSessionID("", "test/input", rawInput) + if err != nil { + t.Fatal(err) + } + for _, sessionID := range sessions { + if sessionID != wantSessionID { + t.Fatalf("session IDs across reference changes = %#v, want %q", sessions, wantSessionID) + } + } assertFile(t, filepath.Join(roots.plans, strings.TrimPrefix(stateTestDigest, "sha256:"), "plan.json")) assertAnyFile(t, roots.output) } diff --git a/internal/cli/run.go b/internal/cli/run.go index 578b4e2..9888e4e 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -418,6 +418,10 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i if err != nil { return failPipelineCommand(stderr, commandState, terminalWriter, err) } + invocation.SessionID = effectiveSessionID + if err := writeSummary(summary, func() error { return summary.WriteInvocation(invocation) }); err != nil { + return failPipelineCommand(stderr, commandState, terminalWriter, fmt.Errorf("write debug invocation metadata: %w", err)) + } chunkPlans, err := chunkPlanStoreForRun(effective.Config.Cache.ChunkPlans, opts) if err != nil { return failPipelineCommand(stderr, commandState, terminalWriter, err) diff --git a/internal/cli/run_contract_test.go b/internal/cli/run_contract_test.go index 5522907..336a534 100644 --- a/internal/cli/run_contract_test.go +++ b/internal/cli/run_contract_test.go @@ -515,7 +515,7 @@ func TestRunSessionIDUsesEffectiveValueForPromptRequests(t *testing.T) { } } harness := newStateTestHarness() - args := append([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass"}, tt.args...) + args := append([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass", "--debug"}, tt.args...) var stdout, stderr bytes.Buffer code := RunWithOptions(args, &stdout, &stderr, harness.options()) if code != 0 || stderr.Len() != 0 { @@ -532,6 +532,11 @@ func TestRunSessionIDUsesEffectiveValueForPromptRequests(t *testing.T) { t.Fatalf("session IDs = %#v, want %q", sessions, want) } } + var manifest artifacts.RunManifest + readStateTestSummaryJSON(t, onlyChildDir(t, roots.debug), "run-manifest.json", &manifest) + if session, ok := manifest.Metadata["session_id"]; !ok || session != want { + t.Fatalf("manifest session = %#v, want %q; metadata = %#v", session, want, manifest.Metadata) + } }) } } diff --git a/internal/cli/state_hardening_test.go b/internal/cli/state_hardening_test.go index 137cacc..7c2c242 100644 --- a/internal/cli/state_hardening_test.go +++ b/internal/cli/state_hardening_test.go @@ -329,11 +329,25 @@ func TestRunUsesOneInjectedIdentityForDebugOutputAndManifest(t *testing.T) { if manifest.StartedAt == nil || !manifest.StartedAt.Equal(wantStartedAt) { t.Fatalf("manifest started at = %v, want %v", manifest.StartedAt, wantStartedAt) } + rawInput, err := os.ReadFile(roots.input) + if err != nil { + t.Fatal(err) + } + wantSessionID, err := resolvePromptSessionID("", "test/input", rawInput) + if err != nil { + t.Fatal(err) + } + if sessionID, ok := manifest.Metadata["session_id"]; !ok || sessionID != wantSessionID { + t.Fatalf("manifest session = %#v, want %q; metadata = %#v", sessionID, wantSessionID, manifest.Metadata) + } var invocation debugbundle.Invocation readStateTestSummaryJSON(t, debugPath, "invocation.json", &invocation) if invocation.RunID != runID || !invocation.StartedAt.Equal(wantStartedAt) { t.Fatalf("debug invocation identity = %#v, want run %q at %v", invocation, runID, wantStartedAt) } + if invocation.SessionID != wantSessionID { + t.Fatalf("debug invocation session = %q, want %q", invocation.SessionID, wantSessionID) + } report := readStateTestRunReport(t, debugPath) if !report.Succeeded || report.RunID != runID || report.PipelineID != "sample" || report.OutputPath != outputPath || report.DebugPath != debugPath || report.OutputCount != 1 || report.RejectedCount != 0 || report.WarningCount != 0 || report.ValidationStatus != "approved" { t.Fatalf("success report = %#v", report) @@ -343,6 +357,45 @@ func TestRunUsesOneInjectedIdentityForDebugOutputAndManifest(t *testing.T) { } } +func TestRunCheckpointReuseRequiresSameEffectiveSession(t *testing.T) { + roots := newStateTestRoots(t) + harness := newStateTestHarness() + run := func(extra ...string) stateTestResult { + args := []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass"} + args = append(args, extra...) + var stdout, stderr bytes.Buffer + return stateTestResult{code: RunWithOptions(args, &stdout, &stderr, harness.options()), stdout: stdout.String(), stderr: stderr.String()} + } + + if result := run(); result.code != 0 { + t.Fatalf("initial run code=%d stdout=%q stderr=%q", result.code, result.stdout, result.stderr) + } + harness.mu.Lock() + initialExtractCalls := harness.extractCalls + harness.mu.Unlock() + if initialExtractCalls != 1 { + t.Fatalf("initial extract calls = %d, want 1", initialExtractCalls) + } + if result := run("--resume"); result.code != 0 { + t.Fatalf("same-session resume code=%d stdout=%q stderr=%q", result.code, result.stdout, result.stderr) + } + harness.mu.Lock() + reusedExtractCalls := harness.extractCalls + harness.mu.Unlock() + if reusedExtractCalls != initialExtractCalls { + t.Fatalf("extract calls after same-session resume = %d, want %d", reusedExtractCalls, initialExtractCalls) + } + if result := run("--resume", "--session-id", "different-session"); result.code != 0 { + t.Fatalf("different-session resume code=%d stdout=%q stderr=%q", result.code, result.stdout, result.stderr) + } + harness.mu.Lock() + differentSessionExtractCalls := harness.extractCalls + harness.mu.Unlock() + if differentSessionExtractCalls != initialExtractCalls+1 { + t.Fatalf("extract calls after different-session resume = %d, want %d", differentSessionExtractCalls, initialExtractCalls+1) + } +} + func TestRunWritesTerminalArtifactsForResolutionPipelineAndOutputFailures(t *testing.T) { for _, tc := range []struct { name string diff --git a/internal/core/debugbundle/bundle_test.go b/internal/core/debugbundle/bundle_test.go index f4c0ce0..7906246 100644 --- a/internal/core/debugbundle/bundle_test.go +++ b/internal/core/debugbundle/bundle_test.go @@ -179,6 +179,30 @@ func TestWriteInvocationPreservesReasoningEffortOverrideStates(t *testing.T) { }) } } + +func TestWriteInvocationOmitsEmptySessionID(t *testing.T) { + for _, sessionID := range []string{"", "session-123"} { + bundle, err := Allocate(t.TempDir(), testBundleRunID, time.Unix(0, 42)) + if err != nil { + t.Fatal(err) + } + if err := bundle.Summary().WriteInvocation(Invocation{Operation: "run", SessionID: sessionID}); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(bundle.SummaryRoot(), ArtifactInvocationMetadata)) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatal(err) + } + value, found := payload["session_id"] + if found != (sessionID != "") || (found && value != sessionID) { + t.Fatalf("session found=%t value=%#v, want found=%t value=%q; JSON=%s", found, value, sessionID != "", sessionID, data) + } + } +} func TestSummaryWriterInternalWritesConfineArtifacts(t *testing.T) { bundle, err := Allocate(t.TempDir(), testBundleRunID, time.Unix(0, 42)) if err != nil { diff --git a/internal/core/debugbundle/summary.go b/internal/core/debugbundle/summary.go index c937b0b..2ac8053 100644 --- a/internal/core/debugbundle/summary.go +++ b/internal/core/debugbundle/summary.go @@ -40,6 +40,7 @@ type Invocation struct { OnlyLanes []string `json:"only_lanes,omitempty"` ChunkCacheOverride string `json:"chunk_cache_override,omitempty"` ReasoningEffortOverride *string `json:"reasoning_effort_override,omitempty"` + SessionID string `json:"session_id,omitempty"` RunID string `json:"run_id"` StartedAt time.Time `json:"started_at"` }