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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions cmd/kilroy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
63 changes: 63 additions & 0 deletions cmd/kilroy/run_detach_db.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
151 changes: 151 additions & 0 deletions cmd/kilroy/run_detach_db_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading