diff --git a/cmd/kilroy/main.go b/cmd/kilroy/main.go index a0528b6c..a461bc6a 100644 --- a/cmd/kilroy/main.go +++ b/cmd/kilroy/main.go @@ -551,6 +551,17 @@ func attractorRun(args []string) { childArgs = append(childArgs, "--force-model", spec) } + // Pre-register the run in the DB with status=running so that + // `runs list`, `runs show`, and `runs wait --latest --label ...` can + // find the run immediately — before the child process calls + // RecordRunStart inside the engine. The child will overwrite this row + // (INSERT OR REPLACE) with full metadata once it starts. + detachRepoPath := workspace + if detachRepoPath == "" { + detachRepoPath = gitDetectDir + } + registerDetachedRunInDB(runID, graphPath, logsRoot, detachRepoPath, labels, inputs, os.Args) + if err := launchDetached(childArgs, logsRoot); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) diff --git a/cmd/kilroy/run_detach_db.go b/cmd/kilroy/run_detach_db.go new file mode 100644 index 00000000..5f29e29f --- /dev/null +++ b/cmd/kilroy/run_detach_db.go @@ -0,0 +1,63 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/danshapiro/kilroy/internal/attractor/engine" + "github.com/danshapiro/kilroy/internal/attractor/rundb" +) + +// registerDetachedRunInDB writes a status=running row to the run database +// before the detached child process starts. This ensures that +// `kilroy attractor runs list`, `runs show`, and `runs wait` can find the run +// immediately after the parent exits — they no longer have to wait for the child +// to reach its own RecordRunStart call inside the engine. +// +// The child's engine.RunWithConfig path will later call InsertRun +// (INSERT OR REPLACE) to overwrite this row with complete metadata (worktreeDir, +// runBranch, dotSource, etc.). This function is best-effort: failures emit a +// stderr warning but never abort the detach launch. +func registerDetachedRunInDB(runID, graphPath, logsRoot, repoPath string, labels map[string]string, inputs map[string]any, invocation []string) { + db, err := rundb.Open(rundb.DefaultPath()) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: could not open run database to pre-register run: %v\n", err) + return + } + defer db.Close() + + // Read the graph file to populate graphName, goal, and dotSource. + // Failures here are non-fatal — we write whatever we can. + var graphName, goal, dotSrc string + if raw, readErr := os.ReadFile(graphPath); readErr == nil { + dotSrc = string(raw) + if g, _, parseErr := engine.Prepare(raw); parseErr == nil && g != nil { + graphName = g.Name + goal = g.Attrs["goal"] + } + } + // Fall back to filename if DOT parsing failed. + if graphName == "" { + base := filepath.Base(graphPath) + graphName = strings.TrimSuffix(base, filepath.Ext(base)) + } + + if err := db.InsertRun(rundb.RunRecord{ + RunID: runID, + GraphName: graphName, + Goal: goal, + Status: "running", + LogsRoot: logsRoot, + RepoPath: repoPath, + DotSource: dotSrc, + Labels: labels, + Inputs: inputs, + Invocation: invocation, + StartedAt: time.Now(), + }); err != nil { + fmt.Fprintf(os.Stderr, "warning: could not pre-register run in database: %v\n", err) + } +} diff --git a/cmd/kilroy/run_detach_db_test.go b/cmd/kilroy/run_detach_db_test.go new file mode 100644 index 00000000..bd7a1f65 --- /dev/null +++ b/cmd/kilroy/run_detach_db_test.go @@ -0,0 +1,151 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/danshapiro/kilroy/internal/attractor/rundb" +) + +// TestRegisterDetachedRunInDB_AppearsBefore_TerminalState verifies the key +// invariant of the detach-registration fix: after registerDetachedRunInDB +// returns (and before the child process calls RecordRunStart), the run row +// exists in the DB with status=running and is discoverable via ListRuns with +// label filters — exactly the query used by `runs wait --latest --label ...`. +func TestRegisterDetachedRunInDB_AppearsBefore_TerminalState(t *testing.T) { + // Redirect the DB to a temp directory so we don't pollute the real DB. + t.Setenv("XDG_STATE_HOME", t.TempDir()) + + // Write a minimal DOT graph file. + graphDir := t.TempDir() + graphFile := filepath.Join(graphDir, "test_detach.dot") + dotContent := []byte(`digraph test_detach_graph { + graph [goal="Test detach registration"] + start [shape=Mdiamond] + done [shape=Msquare] + start -> done +}`) + if err := os.WriteFile(graphFile, dotContent, 0o644); err != nil { + t.Fatalf("write graph file: %v", err) + } + + const runID = "detach-register-test-001" + logsRoot := t.TempDir() + labels := map[string]string{"env": "test", "task": "detach-db-test"} + invocation := []string{"kilroy", "attractor", "run", "--detach", "--graph", graphFile, "--label", "env=test"} + + // This is the call the parent makes before forking the child. + registerDetachedRunInDB(runID, graphFile, logsRoot, "/tmp/repo", labels, nil, invocation) + + // Verify the run appears in the DB. + db, err := rundb.Open(rundb.DefaultPath()) + if err != nil { + t.Fatalf("open rundb: %v", err) + } + defer db.Close() + + // GetRun must return the row. + run, err := db.GetRun(runID) + if err != nil { + t.Fatalf("GetRun: %v", err) + } + if run == nil { + t.Fatal("run not found in DB: GetRun returned nil (bug: detached runs not pre-registered)") + } + + // Status must be running (not yet terminal). + if run.Status != "running" { + t.Errorf("status = %q, want \"running\"", run.Status) + } + // Graph name parsed from DOT source. + if run.GraphName != "test_detach_graph" { + t.Errorf("graph_name = %q, want \"test_detach_graph\"", run.GraphName) + } + // Labels populated. + if run.Labels["env"] != "test" { + t.Errorf("labels[env] = %q, want \"test\"", run.Labels["env"]) + } + if run.Labels["task"] != "detach-db-test" { + t.Errorf("labels[task] = %q, want \"detach-db-test\"", run.Labels["task"]) + } + // LogsRoot set. + if run.LogsRoot != logsRoot { + t.Errorf("logs_root = %q, want %q", run.LogsRoot, logsRoot) + } + // started_at is a recent timestamp. + if run.StartedAt.IsZero() || run.StartedAt.After(time.Now().Add(time.Second)) { + t.Errorf("unexpected started_at: %v", run.StartedAt) + } + + // ListRuns with label filter — this is what `runs wait --latest --label env=test` uses. + runs, err := db.ListRuns(rundb.ListFilter{Labels: map[string]string{"task": "detach-db-test"}, Limit: 1}) + if err != nil { + t.Fatalf("ListRuns: %v", err) + } + if len(runs) == 0 { + t.Fatal("ListRuns: run not found by label filter (bug: detached runs not pre-registered)") + } + if runs[0].RunID != runID { + t.Errorf("ListRuns[0].RunID = %q, want %q", runs[0].RunID, runID) + } + if runs[0].Status != "running" { + t.Errorf("ListRuns[0].Status = %q, want \"running\"", runs[0].Status) + } + + // Simulate the engine completing the run (what the child does at the end). + // The child first calls INSERT OR REPLACE (which is still status=running), + // then calls CompleteRun. Verify the status transitions correctly. + if err := db.CompleteRun(runID, "success", "", "", nil); err != nil { + t.Fatalf("CompleteRun: %v", err) + } + completed, err := db.GetRun(runID) + if err != nil { + t.Fatalf("GetRun after complete: %v", err) + } + if completed == nil { + t.Fatal("run disappeared after CompleteRun") + } + if completed.Status != "success" { + t.Errorf("post-complete status = %q, want \"success\"", completed.Status) + } + if completed.CompletedAt == nil { + t.Error("completed_at is nil after CompleteRun") + } +} + +// TestRegisterDetachedRunInDB_FallsBackToFilename verifies that when the graph +// file cannot be parsed (or is absent), registerDetachedRunInDB still writes the +// row using the filename stem as a fallback graph name. +func TestRegisterDetachedRunInDB_FallsBackToFilename(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + + // Point to a non-existent graph file. + const runID = "detach-fallback-test-001" + logsRoot := t.TempDir() + labels := map[string]string{"test": "fallback"} + + registerDetachedRunInDB(runID, "/nonexistent/path/my_graph.dot", logsRoot, "", labels, nil, nil) + + db, err := rundb.Open(rundb.DefaultPath()) + if err != nil { + t.Fatalf("open rundb: %v", err) + } + defer db.Close() + + run, err := db.GetRun(runID) + if err != nil { + t.Fatalf("GetRun: %v", err) + } + if run == nil { + t.Fatal("run not found in DB: fallback path failed") + } + if run.Status != "running" { + t.Errorf("status = %q, want \"running\"", run.Status) + } + // Filename stem used when DOT parsing fails. + if run.GraphName != "my_graph" { + t.Errorf("graph_name = %q, want \"my_graph\"", run.GraphName) + } +}