From 8542c0d388d7c7e41c29e4a58f51db57e00e31c6 Mon Sep 17 00:00:00 2001 From: Chad Woolley Date: Sun, 22 Feb 2026 00:30:22 -0800 Subject: [PATCH] attractor status: add --verbose/-v flag with stage trace, checkpoint, and artifacts Enriches the one-shot status command with stage trace from progress.ndjson, completed nodes and retry counts from checkpoint.json, final commit SHA and CXDB context ID from final.json, and postmortem/review text from worktree artifacts. JSON output includes verbose fields automatically via struct serialization. Co-Authored-By: Claude Opus 4.6 --- cmd/kilroy/attractor_status.go | 7 +- cmd/kilroy/attractor_status_follow.go | 80 +++++- cmd/kilroy/main.go | 2 +- .../2026-02-22-verbose-status-command.md | 229 ++++++++++++++++++ internal/attractor/runstate/snapshot.go | 130 ++++++++++ internal/attractor/runstate/snapshot_test.go | 81 +++++++ internal/attractor/runstate/types.go | 24 ++ 7 files changed, 547 insertions(+), 6 deletions(-) create mode 100644 docs/plans/2026-02-22-verbose-status-command.md diff --git a/cmd/kilroy/attractor_status.go b/cmd/kilroy/attractor_status.go index 53278a5d..8c99b31e 100644 --- a/cmd/kilroy/attractor_status.go +++ b/cmd/kilroy/attractor_status.go @@ -26,6 +26,7 @@ func runAttractorStatus(args []string, stdout io.Writer, stderr io.Writer) int { var watch bool var latest bool var useCXDB bool + var verbose bool intervalSec := 2 for i := 0; i < len(args); i++ { @@ -49,6 +50,8 @@ func runAttractorStatus(args []string, stdout io.Writer, stderr io.Writer) int { latest = true case "--cxdb": useCXDB = true + case "--verbose", "-v": + verbose = true case "--interval": i++ if i >= len(args) { @@ -105,9 +108,9 @@ func runAttractorStatus(args []string, stdout io.Writer, stderr io.Writer) int { } if watch { - return runWatchStatus(logsRoot, stdout, stderr, asJSON, intervalSec) + return runWatchStatus(logsRoot, stdout, stderr, asJSON, verbose, intervalSec) } // Default: one-shot snapshot. - return printSnapshot(logsRoot, stdout, stderr, asJSON) + return printSnapshot(logsRoot, stdout, stderr, asJSON, verbose) } diff --git a/cmd/kilroy/attractor_status_follow.go b/cmd/kilroy/attractor_status_follow.go index d376b915..780d2f02 100644 --- a/cmd/kilroy/attractor_status_follow.go +++ b/cmd/kilroy/attractor_status_follow.go @@ -13,6 +13,7 @@ import ( "time" "github.com/danshapiro/kilroy/internal/attractor/procutil" + "github.com/danshapiro/kilroy/internal/attractor/runstate" ) // runFollowProgress tails progress.ndjson with formatted output until the run @@ -352,7 +353,7 @@ func latestRunLogsRoot() (string, error) { // runWatchStatus polls the snapshot every interval and reprints it with // screen clearing. Exits when the run reaches a terminal state. -func runWatchStatus(logsRoot string, stdout io.Writer, stderr io.Writer, asJSON bool, intervalSec int) int { +func runWatchStatus(logsRoot string, stdout io.Writer, stderr io.Writer, asJSON bool, verbose bool, intervalSec int) int { if intervalSec <= 0 { intervalSec = 2 } @@ -362,7 +363,7 @@ func runWatchStatus(logsRoot string, stdout io.Writer, stderr io.Writer, asJSON // Clear screen (ANSI escape). fmt.Fprint(stdout, "\033[2J\033[H") - code := printSnapshot(logsRoot, stdout, stderr, asJSON) + code := printSnapshot(logsRoot, stdout, stderr, asJSON, verbose) if code != 0 { return code } @@ -381,13 +382,20 @@ func runWatchStatus(logsRoot string, stdout io.Writer, stderr io.Writer, asJSON // printSnapshot loads and prints the current snapshot. Same as the one-shot // path in runAttractorStatus but extracted for reuse. -func printSnapshot(logsRoot string, stdout io.Writer, stderr io.Writer, asJSON bool) int { +func printSnapshot(logsRoot string, stdout io.Writer, stderr io.Writer, asJSON bool, verbose bool) int { snapshot, err := loadSnapshot(logsRoot) if err != nil { fmt.Fprintln(stderr, err) return 1 } + if verbose { + if err := runstate.ApplyVerbose(snapshot); err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + } + if asJSON { enc := json.NewEncoder(stdout) enc.SetIndent("", " ") @@ -410,5 +418,71 @@ func printSnapshot(logsRoot string, stdout io.Writer, stderr io.Writer, asJSON b if snapshot.FailureReason != "" { fmt.Fprintf(stdout, "failure_reason=%s\n", snapshot.FailureReason) } + + if verbose { + printVerboseSnapshot(stdout, snapshot) + } return 0 } + +func printVerboseSnapshot(w io.Writer, s *runstate.Snapshot) { + if len(s.CompletedNodes) > 0 { + fmt.Fprintf(w, "completed_nodes=%s\n", strings.Join(s.CompletedNodes, ",")) + } + if len(s.RetryCounts) > 0 { + parts := make([]string, 0, len(s.RetryCounts)) + for node, count := range s.RetryCounts { + parts = append(parts, fmt.Sprintf("%s:%d", node, count)) + } + sort.Strings(parts) + fmt.Fprintf(w, "retry_counts=%s\n", strings.Join(parts, ",")) + } + if s.FinalCommitSHA != "" { + fmt.Fprintf(w, "final_commit_sha=%s\n", s.FinalCommitSHA) + } + if s.CXDBContextID != "" { + fmt.Fprintf(w, "cxdb_context_id=%s\n", s.CXDBContextID) + } + + if len(s.StageTrace) > 0 || len(s.EdgeTrace) > 0 { + fmt.Fprintln(w, "\n--- stage trace ---") + si, ei := 0, 0 + for si < len(s.StageTrace) || ei < len(s.EdgeTrace) { + if si < len(s.StageTrace) { + sa := s.StageTrace[si] + line := fmt.Sprintf(" %-24s %-8s attempt %d/%d", sa.NodeID, sa.Status, sa.Attempt, sa.MaxAttempts) + if sa.FailureReason != "" { + line += " " + sa.FailureReason + } + fmt.Fprintln(w, line) + si++ + // Print any edges that follow this stage + for ei < len(s.EdgeTrace) && s.EdgeTrace[ei].From == sa.NodeID { + et := s.EdgeTrace[ei] + cond := "" + if et.Condition != "" { + cond = " (" + et.Condition + ")" + } + fmt.Fprintf(w, " → %-20s%s\n", et.To, cond) + ei++ + } + } else { + // Remaining edges + et := s.EdgeTrace[ei] + cond := "" + if et.Condition != "" { + cond = " (" + et.Condition + ")" + } + fmt.Fprintf(w, " → %-20s%s\n", et.To, cond) + ei++ + } + } + } + + if s.PostmortemText != "" { + fmt.Fprintf(w, "\n--- postmortem (worktree/.ai/postmortem_latest.md) ---\n%s\n", s.PostmortemText) + } + if s.ReviewText != "" { + fmt.Fprintf(w, "\n--- review (worktree/.ai/review_final.md) ---\n%s\n", s.ReviewText) + } +} diff --git a/cmd/kilroy/main.go b/cmd/kilroy/main.go index 8a28b880..4d7d606d 100644 --- a/cmd/kilroy/main.go +++ b/cmd/kilroy/main.go @@ -70,7 +70,7 @@ func usage() { fmt.Fprintln(os.Stderr, " kilroy attractor resume --logs-root ") fmt.Fprintln(os.Stderr, " kilroy attractor resume --cxdb --context-id ") fmt.Fprintln(os.Stderr, " kilroy attractor resume --run-branch [--repo ]") - fmt.Fprintln(os.Stderr, " kilroy attractor status [--logs-root | --latest] [--json] [--follow|-f] [--cxdb] [--raw] [--watch] [--interval ]") + fmt.Fprintln(os.Stderr, " kilroy attractor status [--logs-root | --latest] [--json] [-v|--verbose] [--follow|-f] [--cxdb] [--raw] [--watch] [--interval ]") fmt.Fprintln(os.Stderr, " kilroy attractor stop --logs-root [--grace-ms ] [--force]") fmt.Fprintln(os.Stderr, " kilroy attractor validate --graph ") fmt.Fprintln(os.Stderr, " kilroy attractor ingest [--output ] [--model ] [--skill ] [--repo ] [--max-turns ] ") diff --git a/docs/plans/2026-02-22-verbose-status-command.md b/docs/plans/2026-02-22-verbose-status-command.md new file mode 100644 index 00000000..2fcfed46 --- /dev/null +++ b/docs/plans/2026-02-22-verbose-status-command.md @@ -0,0 +1,229 @@ +# Verbose `attractor status` Command + +**Date:** 2026-02-22 +**Status:** Proposed +**Problem:** `kilroy attractor status` prints a 5-line summary (state, node, event, pid, +last_event_at). When a pipeline loops through postmortem → implement retries, the operator +has to manually find and read `worktree/.ai/postmortem_latest.md` to understand why. The +stage trace (which nodes passed/failed, retry loops, edge conditions) is buried in +`progress.ndjson` and requires manual parsing. + +## Goal + +Add a `--verbose` / `-v` flag to the one-shot status command that enriches the output with: + +1. **Stage trace** — ordered list of stage attempts with pass/fail and failure reasons +2. **Completed nodes and retry counts** — from `checkpoint.json` +3. **Final commit SHA** — from `final.json` (when run completes) +4. **Postmortem text** — from `worktree/.ai/postmortem_latest.md` (when pipeline loops) +5. **Review text** — from `worktree/.ai/review_final.md` (when semantic review completes) + +All data sources already exist on disk. No new data collection is needed. + +## Design + +### Level of effort: Small + +The `Snapshot` struct is the core data model for status output. Both `--json` and +key=value formatters already use it. Adding verbose fields to the struct and +populating them conditionally is straightforward. + +## Data sources + +All files live under `~/.local/state/kilroy/attractor/runs//`: + +| File | Data | Used for | +|------|------|----------| +| `checkpoint.json` | `completed_nodes[]`, `node_retries{}` | Node list, retry counts | +| `final.json` | `final_git_commit_sha`, `cxdb_context_id` | Outcome details | +| `progress.ndjson` | `stage_attempt_end` events | Stage trace with pass/fail | +| `worktree/.ai/postmortem_latest.md` | Markdown | Why the pipeline looped | +| `worktree/.ai/review_final.md` | Markdown | Semantic review findings | +| `worktree/.ai/implementation_log.md` | Markdown | What was implemented | + +## Changes + +### 1. Expand `Snapshot` struct + +**File:** `internal/attractor/runstate/types.go` + +Add verbose-only fields: + +```go +type StageAttempt struct { + NodeID string `json:"node_id"` + Status string `json:"status"` // success, fail + Attempt int `json:"attempt"` + MaxAttempts int `json:"max_attempts"` + FailureReason string `json:"failure_reason,omitempty"` +} + +type EdgeTransition struct { + From string `json:"from"` + To string `json:"to"` + Condition string `json:"condition,omitempty"` +} + +type Snapshot struct { + // ... existing fields unchanged ... + + // Verbose fields (populated only when requested via ApplyVerbose) + FinalCommitSHA string `json:"final_commit_sha,omitempty"` + CXDBContextID string `json:"cxdb_context_id,omitempty"` + CompletedNodes []string `json:"completed_nodes,omitempty"` + RetryCounts map[string]int `json:"retry_counts,omitempty"` + StageTrace []StageAttempt `json:"stage_trace,omitempty"` + EdgeTrace []EdgeTransition `json:"edge_trace,omitempty"` + PostmortemText string `json:"postmortem_text,omitempty"` + ReviewText string `json:"review_text,omitempty"` +} +``` + +### 2. Add verbose loaders + +**File:** `internal/attractor/runstate/snapshot.go` + +New exported function `ApplyVerbose(s *Snapshot) error` that calls: + +- **`applyCheckpointVerbose(s)`** — reads `checkpoint.json`: + ```go + type checkpointDoc struct { + CompletedNodes []string `json:"completed_nodes"` + NodeRetries map[string]int `json:"node_retries"` + } + ``` + Populates `s.CompletedNodes` and `s.RetryCounts`. + +- **`applyFinalVerbose(s)`** — reads `final.json` for additional fields: + ```go + type finalVerboseDoc struct { + FinalCommitSHA string `json:"final_git_commit_sha"` + CXDBContextID string `json:"cxdb_context_id"` + } + ``` + Populates `s.FinalCommitSHA` and `s.CXDBContextID`. + +- **`applyStageTrace(s)`** — scans `progress.ndjson` line by line, collects + `stage_attempt_end` events into `s.StageTrace` and `edge_selected` events into + `s.EdgeTrace`. Uses existing `bufio.Scanner` pattern from `readLastProgressEvent`. + +- **`applyWorktreeArtifacts(s)`** — reads markdown files if they exist: + - `worktree/.ai/postmortem_latest.md` → `s.PostmortemText` + - `worktree/.ai/review_final.md` → `s.ReviewText` + + Missing files are silently skipped (not an error). + +### 3. Wire the flag + +**File:** `cmd/kilroy/attractor_status.go` + +Add flag parsing in `runAttractorStatus()`: + +```go +var verbose bool + +// In the switch: +case "--verbose", "-v": + verbose = true +``` + +Pass `verbose` to `printSnapshot`: + +```go +return printSnapshot(logsRoot, stdout, stderr, asJSON, verbose) +``` + +### 4. Enhance `printSnapshot` + +**File:** `cmd/kilroy/attractor_status_follow.go` (function at line 384) + +After loading the snapshot, conditionally apply verbose data: + +```go +func printSnapshot(logsRoot string, stdout io.Writer, stderr io.Writer, asJSON bool, verbose bool) int { + snapshot, err := loadSnapshot(logsRoot) + if err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + + if verbose { + if err := runstate.ApplyVerbose(snapshot); err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + } + // ... existing output logic ... +``` + +For key=value output, append after the existing fields: + +``` +completed_nodes=start,expand_spec,check_expand_spec,implement,... +retry_counts=implement:0,postmortem:0 +final_commit_sha=c3bc0fae31e65c8721a84eb88ff50084b948658f +cxdb_context_id=12 + +--- stage trace --- + start success attempt 1/4 + expand_spec success attempt 1/4 + implement success attempt 1/4 + fix_fmt fail attempt 1/4 exit status 1 + verify_fmt fail attempt 1/4 exit status 1 + check_fmt fail attempt 1/4 exit status 1 + → postmortem (outcome=fail && context.failure_class=deterministic) + postmortem success attempt 1/4 + → implement (retry) + implement success attempt 1/4 + ... + review_consensus success attempt 1/4 + → exit (outcome=success) + +--- postmortem (worktree/.ai/postmortem_latest.md) --- +# Postmortem: check_fmt Failure +... + +--- review (worktree/.ai/review_final.md) --- +# Semantic Review +... +``` + +For `--json` output, the new struct fields serialize automatically — no additional +formatting code needed. + +### 5. Update usage string + +**File:** `cmd/kilroy/main.go` line 73 + +```go +// Before +"kilroy attractor status [--logs-root | --latest] [--json] [--follow|-f] [--cxdb] [--raw] [--watch] [--interval ]" + +// After +"kilroy attractor status [--logs-root | --latest] [--json] [-v|--verbose] [--follow|-f] [--cxdb] [--raw] [--watch] [--interval ]" +``` + +### 6. Update `printSnapshot` call sites + +The `printSnapshot` function is also called from `runWatchStatus` (line 346). Update +that call to pass `verbose` through as well — or pass `false` to keep watch mode +compact by default. + +## Files to modify + +| File | Change | +|------|--------| +| `internal/attractor/runstate/types.go` | Add `StageAttempt`, `EdgeTransition` types; add verbose fields to `Snapshot` | +| `internal/attractor/runstate/snapshot.go` | Add `ApplyVerbose()` and helper functions | +| `cmd/kilroy/attractor_status.go` | Parse `--verbose`/`-v` flag, pass to `printSnapshot` | +| `cmd/kilroy/attractor_status_follow.go` | Update `printSnapshot` signature, add verbose output formatting | +| `cmd/kilroy/main.go` | Update usage string | + +## Testing + +1. `go build -o kilroy ./cmd/kilroy/` — verify compilation +2. `./kilroy attractor status --latest --verbose` — verify stage trace and artifacts print +3. `./kilroy attractor status --latest --verbose --json | python3 -m json.tool` — verify JSON includes all verbose fields +4. `./kilroy attractor status --latest` (without `--verbose`) — verify existing output is unchanged +5. `go test ./internal/attractor/runstate/...` — verify any new unit tests pass +6. `go test ./...` — full test suite diff --git a/internal/attractor/runstate/snapshot.go b/internal/attractor/runstate/snapshot.go index f2e9710e..d0697f13 100644 --- a/internal/attractor/runstate/snapshot.go +++ b/internal/attractor/runstate/snapshot.go @@ -195,6 +195,136 @@ func readLastProgressEvent(path string) (map[string]any, bool, error) { return ev, true, nil } +// ApplyVerbose enriches a Snapshot with data from checkpoint, final, progress, +// and worktree artifact files. Missing files are silently skipped. +func ApplyVerbose(s *Snapshot) error { + if err := applyCheckpointVerbose(s); err != nil { + return err + } + if err := applyFinalVerbose(s); err != nil { + return err + } + if err := applyStageTrace(s); err != nil { + return err + } + applyWorktreeArtifacts(s) + return nil +} + +type checkpointDoc struct { + CompletedNodes []string `json:"completed_nodes"` + NodeRetries map[string]int `json:"node_retries"` +} + +func applyCheckpointVerbose(s *Snapshot) error { + path := filepath.Join(s.LogsRoot, "checkpoint.json") + b, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + var doc checkpointDoc + if err := json.Unmarshal(b, &doc); err != nil { + return fmt.Errorf("decode %s: %w", path, err) + } + s.CompletedNodes = doc.CompletedNodes + s.RetryCounts = doc.NodeRetries + return nil +} + +type finalVerboseDoc struct { + FinalCommitSHA string `json:"final_git_commit_sha"` + CXDBContextID string `json:"cxdb_context_id"` +} + +func applyFinalVerbose(s *Snapshot) error { + path := filepath.Join(s.LogsRoot, "final.json") + b, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + var doc finalVerboseDoc + if err := json.Unmarshal(b, &doc); err != nil { + return fmt.Errorf("decode %s: %w", path, err) + } + s.FinalCommitSHA = strings.TrimSpace(doc.FinalCommitSHA) + s.CXDBContextID = strings.TrimSpace(doc.CXDBContextID) + return nil +} + +func applyStageTrace(s *Snapshot) error { + path := filepath.Join(s.LogsRoot, "progress.ndjson") + f, err := os.Open(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + defer func() { _ = f.Close() }() + + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 0, 64*1024), 2*1024*1024) + + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" { + continue + } + var ev map[string]any + if err := json.Unmarshal([]byte(line), &ev); err != nil { + continue + } + switch eventString(ev["event"]) { + case "stage_attempt_end": + sa := StageAttempt{ + NodeID: eventString(ev["node_id"]), + Status: eventString(ev["status"]), + Attempt: eventInt(ev["attempt"]), + MaxAttempts: eventInt(ev["max"]), + FailureReason: eventString(ev["failure_reason"]), + } + s.StageTrace = append(s.StageTrace, sa) + case "edge_selected": + et := EdgeTransition{ + From: eventString(ev["from_node"]), + To: eventString(ev["to_node"]), + Condition: eventString(ev["condition"]), + } + s.EdgeTrace = append(s.EdgeTrace, et) + } + } + return sc.Err() +} + +func applyWorktreeArtifacts(s *Snapshot) { + if b, err := os.ReadFile(filepath.Join(s.LogsRoot, "worktree", ".ai", "postmortem_latest.md")); err == nil { + s.PostmortemText = string(b) + } + if b, err := os.ReadFile(filepath.Join(s.LogsRoot, "worktree", ".ai", "review_final.md")); err == nil { + s.ReviewText = string(b) + } +} + +func eventInt(v any) int { + switch t := v.(type) { + case nil: + return 0 + case float64: + return int(t) + case string: + n, _ := strconv.Atoi(t) + return n + default: + return 0 + } +} + func eventString(v any) string { switch t := v.(type) { case nil: diff --git a/internal/attractor/runstate/snapshot_test.go b/internal/attractor/runstate/snapshot_test.go index 16f3263e..12aee2d0 100644 --- a/internal/attractor/runstate/snapshot_test.go +++ b/internal/attractor/runstate/snapshot_test.go @@ -59,6 +59,87 @@ func TestLoadSnapshot_NilEventFieldsDoNotRenderAsNilString(t *testing.T) { } } +func TestApplyVerbose_PopulatesAllFields(t *testing.T) { + root := t.TempDir() + + // checkpoint.json + _ = os.WriteFile(filepath.Join(root, "checkpoint.json"), + []byte(`{"completed_nodes":["start","implement"],"node_retries":{"implement":2}}`), 0o644) + + // final.json with verbose fields + _ = os.WriteFile(filepath.Join(root, "final.json"), + []byte(`{"status":"success","run_id":"r1","final_git_commit_sha":"abc123","cxdb_context_id":"42"}`), 0o644) + + // progress.ndjson with stage and edge events + ndjson := `{"event":"stage_attempt_end","node_id":"start","status":"success","attempt":1,"max":4} +{"event":"edge_selected","from_node":"start","to_node":"implement","condition":"outcome=success"} +{"event":"stage_attempt_end","node_id":"implement","status":"fail","attempt":1,"max":4,"failure_reason":"exit status 1"} +` + _ = os.WriteFile(filepath.Join(root, "progress.ndjson"), []byte(ndjson), 0o644) + + // worktree artifacts + aiDir := filepath.Join(root, "worktree", ".ai") + _ = os.MkdirAll(aiDir, 0o755) + _ = os.WriteFile(filepath.Join(aiDir, "postmortem_latest.md"), []byte("# Postmortem\nfailed"), 0o644) + _ = os.WriteFile(filepath.Join(aiDir, "review_final.md"), []byte("# Review\nlgtm"), 0o644) + + s := &Snapshot{LogsRoot: root} + if err := ApplyVerbose(s); err != nil { + t.Fatalf("ApplyVerbose: %v", err) + } + + // checkpoint + if len(s.CompletedNodes) != 2 || s.CompletedNodes[0] != "start" { + t.Fatalf("completed_nodes=%v", s.CompletedNodes) + } + if s.RetryCounts["implement"] != 2 { + t.Fatalf("retry_counts=%v", s.RetryCounts) + } + + // final + if s.FinalCommitSHA != "abc123" { + t.Fatalf("final_commit_sha=%q", s.FinalCommitSHA) + } + if s.CXDBContextID != "42" { + t.Fatalf("cxdb_context_id=%q", s.CXDBContextID) + } + + // stage trace + if len(s.StageTrace) != 2 { + t.Fatalf("stage_trace len=%d want 2", len(s.StageTrace)) + } + if s.StageTrace[0].NodeID != "start" || s.StageTrace[0].Status != "success" { + t.Fatalf("stage_trace[0]=%+v", s.StageTrace[0]) + } + if s.StageTrace[1].FailureReason != "exit status 1" { + t.Fatalf("stage_trace[1]=%+v", s.StageTrace[1]) + } + + // edge trace + if len(s.EdgeTrace) != 1 || s.EdgeTrace[0].From != "start" || s.EdgeTrace[0].To != "implement" { + t.Fatalf("edge_trace=%+v", s.EdgeTrace) + } + + // worktree artifacts + if s.PostmortemText != "# Postmortem\nfailed" { + t.Fatalf("postmortem=%q", s.PostmortemText) + } + if s.ReviewText != "# Review\nlgtm" { + t.Fatalf("review=%q", s.ReviewText) + } +} + +func TestApplyVerbose_MissingFilesAreSkipped(t *testing.T) { + root := t.TempDir() + s := &Snapshot{LogsRoot: root} + if err := ApplyVerbose(s); err != nil { + t.Fatalf("ApplyVerbose on empty dir: %v", err) + } + if len(s.StageTrace) != 0 || len(s.CompletedNodes) != 0 || s.FinalCommitSHA != "" { + t.Fatal("expected all verbose fields empty for missing files") + } +} + func TestLoadSnapshot_TerminalStateIgnoresMalformedPIDFile(t *testing.T) { root := t.TempDir() _ = os.WriteFile(filepath.Join(root, "final.json"), []byte(`{"status":"success","run_id":"r1"}`), 0o644) diff --git a/internal/attractor/runstate/types.go b/internal/attractor/runstate/types.go index 4504bc9a..2e5ccbb6 100644 --- a/internal/attractor/runstate/types.go +++ b/internal/attractor/runstate/types.go @@ -11,6 +11,20 @@ const ( StateFail State = "fail" ) +type StageAttempt struct { + NodeID string `json:"node_id"` + Status string `json:"status"` + Attempt int `json:"attempt"` + MaxAttempts int `json:"max_attempts"` + FailureReason string `json:"failure_reason,omitempty"` +} + +type EdgeTransition struct { + From string `json:"from"` + To string `json:"to"` + Condition string `json:"condition,omitempty"` +} + type Snapshot struct { LogsRoot string `json:"logs_root"` RunID string `json:"run_id,omitempty"` @@ -21,4 +35,14 @@ type Snapshot struct { FailureReason string `json:"failure_reason,omitempty"` PID int `json:"pid,omitempty"` PIDAlive bool `json:"pid_alive"` + + // Verbose fields (populated only when requested via ApplyVerbose) + FinalCommitSHA string `json:"final_commit_sha,omitempty"` + CXDBContextID string `json:"cxdb_context_id,omitempty"` + CompletedNodes []string `json:"completed_nodes,omitempty"` + RetryCounts map[string]int `json:"retry_counts,omitempty"` + StageTrace []StageAttempt `json:"stage_trace,omitempty"` + EdgeTrace []EdgeTransition `json:"edge_trace,omitempty"` + PostmortemText string `json:"postmortem_text,omitempty"` + ReviewText string `json:"review_text,omitempty"` }