diff --git a/README.md b/README.md index f34e096..f86c639 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,3 @@ Patterns and ideas borrowed from [Gastown](https://github.com/steveyegge/gastown ## License [Apache License 2.0](LICENSE) - ---- - -_Made with ❤️ by [Vortex Dispatch](https://github.com/tzone85/vortex-dispatch) (NXD is the offline-first sibling). Remove this line freely._ diff --git a/docs/demo.tape b/docs/demo.tape index 2ee825d..4fab563 100644 --- a/docs/demo.tape +++ b/docs/demo.tape @@ -111,7 +111,7 @@ Type "echo ''" Enter Type "echo 'NXD: Offline-first AI agent orchestration powered by Ollama.'" Enter -Type "echo 'Same pipeline as VXD, no cloud APIs needed.'" +Type "echo 'Full multi-agent pipeline, no cloud APIs needed.'" Enter Type "echo ''" Enter diff --git a/internal/cli/resume.go b/internal/cli/resume.go index 92443a4..995ce21 100644 --- a/internal/cli/resume.go +++ b/internal/cli/resume.go @@ -323,7 +323,7 @@ func runResume(cmd *cobra.Command, args []string) error { controller := engine.NewController(s.Config.Controller, nil, s.Events, s.Proj) // Wire devdb Lifecycle if configured. Nil when provider is "" or "null". - devdbLifecycle := newDevDBLifecycle(s.Config, s.Events) + devdbLifecycle := newDevDBLifecycle(s.Config, s.Events, s.Proj) if devdbLifecycle != nil { log.Printf("[startup] devdb enabled: provider=%s", s.Config.DevDB.Provider) } @@ -557,7 +557,7 @@ func newDevDBProvider(cfg config.Config) (devdb.Provider, error) { // newDevDBLifecycle builds a Lifecycle from config. Returns nil when devdb is // disabled (provider == "" or "null"). -func newDevDBLifecycle(cfg config.Config, events state.EventStore) *devdb.Lifecycle { +func newDevDBLifecycle(cfg config.Config, events state.EventStore, projector devdb.EventProjector) *devdb.Lifecycle { if cfg.DevDB.Provider == "" || cfg.DevDB.Provider == "null" { return nil } @@ -571,7 +571,7 @@ func newDevDBLifecycle(cfg config.Config, events state.EventStore) *devdb.Lifecy Template: cfg.DevDB.Template, KeepDBOnFail: cfg.DevDB.OnFailure.KeepDB, RetainHours: time.Duration(cfg.DevDB.OnFailure.RetainHours) * time.Hour, - }) + }).WithProjector(projector) } // runDevDBOrphanRecovery finds and deletes DB containers left behind by diff --git a/internal/cli/resume_devdb_test.go b/internal/cli/resume_devdb_test.go index 31fb0d2..5ddde76 100644 --- a/internal/cli/resume_devdb_test.go +++ b/internal/cli/resume_devdb_test.go @@ -73,7 +73,7 @@ func TestNewDevDBLifecycle_NullDisabled(t *testing.T) { cfg := config.Config{} cfg.DevDB.Provider = "null" - lc := newDevDBLifecycle(cfg, nil) + lc := newDevDBLifecycle(cfg, nil, nil) if lc != nil { t.Error("expected nil lifecycle for null provider") } @@ -83,7 +83,7 @@ func TestNewDevDBLifecycle_EmptyDisabled(t *testing.T) { cfg := config.Config{} cfg.DevDB.Provider = "" - lc := newDevDBLifecycle(cfg, nil) + lc := newDevDBLifecycle(cfg, nil, nil) if lc != nil { t.Error("expected nil lifecycle when devdb section absent") } @@ -101,7 +101,7 @@ func TestNewDevDBLifecycle_DockerReturnsNonNil(t *testing.T) { cfg.DevDB.Provider = "docker" cfg.DevDB.Docker.Image = "postgres:16" - lc := newDevDBLifecycle(cfg, es) + lc := newDevDBLifecycle(cfg, es, nil) if lc == nil { t.Fatal("expected non-nil lifecycle for docker provider") } @@ -111,7 +111,7 @@ func TestNewDevDBLifecycle_UnsupportedReturnsNil(t *testing.T) { cfg := config.Config{} cfg.DevDB.Provider = "ghost" - lc := newDevDBLifecycle(cfg, nil) + lc := newDevDBLifecycle(cfg, nil, nil) if lc != nil { t.Error("expected nil lifecycle on unsupported provider (graceful degrade)") } diff --git a/internal/dashboard/app.go b/internal/dashboard/app.go index 40b9478..3d7b813 100644 --- a/internal/dashboard/app.go +++ b/internal/dashboard/app.go @@ -232,7 +232,7 @@ func (m Model) renderStatusBar() string { } bar := left + middle + strings.Repeat(" ", gap) + right - attribution := lipgloss.NewStyle().Foreground(colorDimGray).Render(" Made with ♥ by Vortex Dispatch ") + attribution := lipgloss.NewStyle().Foreground(colorDimGray).Render(" Made with ♥ by Nexus Dispatch ") return statusBarStyle.Width(m.width).Render(bar) + "\n" + attribution } diff --git a/internal/devdb/lifecycle.go b/internal/devdb/lifecycle.go index 74e8c3e..624f6e7 100644 --- a/internal/devdb/lifecycle.go +++ b/internal/devdb/lifecycle.go @@ -27,13 +27,22 @@ type EventAppender interface { Append(state.Event) error } +// EventProjector projects an emitted event into the read model. The Lifecycle +// emits STORY_DB_* events directly (callers never see them), so it must drive +// the projection itself — otherwise the story_databases table stays empty in +// production even though the projection switch handles those event types. +type EventProjector interface { + Project(state.Event) error +} + // Lifecycle orchestrates a Provider + event emission + worktree file writes. // Engine code uses Lifecycle, not Provider directly. type Lifecycle struct { - provider Provider - events EventAppender - cfg Config - clock func() time.Time + provider Provider + events EventAppender + projector EventProjector + cfg Config + clock func() time.Time } // NewLifecycle wires a Lifecycle with the supplied Provider, event appender, @@ -47,6 +56,25 @@ func NewLifecycle(p Provider, ea EventAppender, cfg Config) *Lifecycle { } } +// WithProjector attaches a projector so emitted STORY_DB_* events also update +// the read model. Returns the receiver for chaining. When nil (the default), +// events are appended only. +func (l *Lifecycle) WithProjector(p EventProjector) *Lifecycle { + l.projector = p + return l +} + +// emit appends an event and, when a projector is wired, projects it so the +// read model reflects the DB lifecycle. Append failures are swallowed (events +// are best-effort telemetry, matching the prior behaviour); a projection +// failure is non-fatal too — the append already durably recorded the event. +func (l *Lifecycle) emit(evt state.Event) { + _ = l.events.Append(evt) + if l.projector != nil { + _ = l.projector.Project(evt) + } +} + // Provider exposes the underlying provider (used for orphan recovery, ping). func (l *Lifecycle) Provider() Provider { return l.provider } @@ -90,12 +118,17 @@ func (l *Lifecycle) Release(ctx context.Context, db DB, outcome StoryOutcome) er status = "retained" } + // The DB name encodes the story ID (FormatDBName). Recover it so the + // emitted events carry StoryID — projectStoryDBDeleted/Failed key their + // UPDATE on story_id, so an empty value would silently match zero rows. + storyID := ParseStoryID(PrefixNXD, db.Name) + if !keep { if err := l.provider.Delete(ctx, db.ID); err != nil { // Emit a failed-release event so GC can pick up later. We do not // return the error after the event is emitted — callers don't // need to block pipeline progress on release failures. - l.emitFailed("", db.Name, fmt.Sprintf("release: %v", err)) + l.emitFailed(storyID, db.Name, fmt.Sprintf("release: %v", err)) return fmt.Errorf("devdb release: %w", err) } } @@ -111,8 +144,9 @@ func (l *Lifecycle) Release(ctx context.Context, db DB, outcome StoryOutcome) er "status": status, } data, _ := json.Marshal(payload) - _ = l.events.Append(state.Event{ + l.emit(state.Event{ Type: state.EventStoryDBDeleted, + StoryID: storyID, Timestamp: l.clock(), Payload: data, }) @@ -129,7 +163,7 @@ func (l *Lifecycle) emitCreated(storyID string, db DB) { "conn_string_hash": "sha256:" + hex.EncodeToString(h[:]), } data, _ := json.Marshal(payload) - _ = l.events.Append(state.Event{ + l.emit(state.Event{ Type: state.EventStoryDBCreated, StoryID: storyID, Timestamp: l.clock(), @@ -145,7 +179,7 @@ func (l *Lifecycle) emitFailed(storyID, name, errMsg string) { "error": errMsg, } data, _ := json.Marshal(payload) - _ = l.events.Append(state.Event{ + l.emit(state.Event{ Type: state.EventStoryDBFailed, StoryID: storyID, Timestamp: l.clock(), diff --git a/internal/devdb/lifecycle_test.go b/internal/devdb/lifecycle_test.go index e21e1ee..23a4d25 100644 --- a/internal/devdb/lifecycle_test.go +++ b/internal/devdb/lifecycle_test.go @@ -159,3 +159,92 @@ func TestLifecycle_Release_FailedWithoutKeepDB_Deletes(t *testing.T) { t.Errorf("expected one delete call, got %v", rp.deleted) } } + +// fakeProjector records events the lifecycle drives into the read model. +type fakeProjector struct { + projected []state.Event +} + +func (f *fakeProjector) Project(evt state.Event) error { + f.projected = append(f.projected, evt) + return nil +} + +func TestLifecycle_Provision_ProjectsCreatedEvent(t *testing.T) { + es := &fakeEventStore{} + pr := &fakeProjector{} + lc := devdb.NewLifecycle(null.New(), es, devdb.Config{Provider: "null"}).WithProjector(pr) + + if _, err := lc.Provision(context.Background(), "story-1", "myproj", t.TempDir()); err != nil { + t.Fatalf("Provision: %v", err) + } + if len(pr.projected) != 1 || pr.projected[0].Type != state.EventStoryDBCreated { + t.Fatalf("STORY_DB_CREATED must be projected, got %+v", pr.projected) + } + if pr.projected[0].StoryID != "story-1" { + t.Errorf("projected created story_id = %q, want story-1", pr.projected[0].StoryID) + } +} + +// Release must recover the story ID from the DB name and project the deletion; +// projectStoryDBDeleted keys its UPDATE on story_id, so an empty value would +// silently match zero rows (regression guard for the missing-StoryID bug). +func TestLifecycle_Release_ProjectsDeletedEventWithStoryID(t *testing.T) { + es := &fakeEventStore{} + pr := &fakeProjector{} + lc := devdb.NewLifecycle(null.New(), es, devdb.Config{Provider: "null"}).WithProjector(pr) + + db := devdb.DB{ID: "null-nxd-myproj-story-1", Name: "nxd-myproj-story-1"} + if err := lc.Release(context.Background(), db, devdb.OutcomeSuccess); err != nil { + t.Fatalf("Release: %v", err) + } + if len(pr.projected) != 1 || pr.projected[0].Type != state.EventStoryDBDeleted { + t.Fatalf("STORY_DB_DELETED must be projected, got %+v", pr.projected) + } + if pr.projected[0].StoryID != "story-1" { + t.Errorf("deleted event story_id = %q, want story-1 (recovered from db name)", pr.projected[0].StoryID) + } +} + +// End-to-end: provisioning then releasing must create and then transition a +// story_databases row in a real projection store. Before the wiring fix the +// table stayed empty in production. +func TestLifecycle_ProjectsRoundTripIntoSQLiteStore(t *testing.T) { + es := &fakeEventStore{} + store, err := state.NewSQLiteStore(":memory:") + if err != nil { + t.Fatalf("NewSQLiteStore: %v", err) + } + defer store.Close() + + lc := devdb.NewLifecycle(null.New(), es, devdb.Config{Provider: "null"}).WithProjector(store) + + db, err := lc.Provision(context.Background(), "story-1", "myproj", t.TempDir()) + if err != nil { + t.Fatalf("Provision: %v", err) + } + rows, err := store.ListStoryDatabases(state.StoryDBFilter{}) + if err != nil { + t.Fatalf("ListStoryDatabases: %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected a story_databases row after Provision, got %d", len(rows)) + } + if rows[0].Status != "created" { + t.Errorf("status after provision = %q, want created", rows[0].Status) + } + + if err := lc.Release(context.Background(), db, devdb.OutcomeSuccess); err != nil { + t.Fatalf("Release: %v", err) + } + rows, err = store.ListStoryDatabases(state.StoryDBFilter{}) + if err != nil { + t.Fatalf("ListStoryDatabases after release: %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 row after Release, got %d", len(rows)) + } + if rows[0].Status != "deleted" { + t.Errorf("status after release = %q, want deleted (StoryID-keyed UPDATE must match)", rows[0].Status) + } +} diff --git a/internal/devdb/naming_test.go b/internal/devdb/naming_test.go index 450e7d5..96705ee 100644 --- a/internal/devdb/naming_test.go +++ b/internal/devdb/naming_test.go @@ -68,7 +68,7 @@ func TestParseStoryID_Roundtrip(t *testing.T) { } func TestParseStoryID_WrongPrefix(t *testing.T) { - got := devdb.ParseStoryID("vxd", "nxd-myproj-story-1") + got := devdb.ParseStoryID("abc", "nxd-myproj-story-1") if got != "" { t.Errorf("ParseStoryID with wrong prefix = %q, want empty", got) } diff --git a/internal/engine/checkpoint_internal_test.go b/internal/engine/checkpoint_internal_test.go index 942ead5..9557937 100644 --- a/internal/engine/checkpoint_internal_test.go +++ b/internal/engine/checkpoint_internal_test.go @@ -56,8 +56,8 @@ func TestWriteCheckpoint_MultipleAgents(t *testing.T) { Phase: PhaseMonitoring, WaveNumber: 3, ActiveAgents: []CheckpointAgent{ - {StoryID: "s-a", SessionName: "vxd-s-a", WorktreePath: "/tmp/a", RuntimeName: "claude", Branch: "feat/a"}, - {StoryID: "s-b", SessionName: "vxd-s-b", WorktreePath: "/tmp/b", RuntimeName: "gemini", Branch: "feat/b"}, + {StoryID: "s-a", SessionName: "sess-s-a", WorktreePath: "/tmp/a", RuntimeName: "claude", Branch: "feat/a"}, + {StoryID: "s-b", SessionName: "sess-s-b", WorktreePath: "/tmp/b", RuntimeName: "gemini", Branch: "feat/b"}, }, Timestamp: time.Now(), PID: 42, diff --git a/internal/engine/dispatcher.go b/internal/engine/dispatcher.go index 3e544d3..8e7fc27 100644 --- a/internal/engine/dispatcher.go +++ b/internal/engine/dispatcher.go @@ -119,6 +119,9 @@ func (d *Dispatcher) DispatchWave(dag *graph.DAG, completed map[string]bool, req if err := d.eventStore.Append(spawnEvt); err != nil { return nil, fmt.Errorf("emit agent spawned: %w", err) } + if err := d.projStore.Project(spawnEvt); err != nil { + return nil, fmt.Errorf("project agent spawned: %w", err) + } // Emit assignment event assignEvt := state.NewEvent(state.EventStoryAssigned, agentID, story.ID, map[string]any{ diff --git a/internal/engine/dispatcher_test.go b/internal/engine/dispatcher_test.go index 78be6fb..83def21 100644 --- a/internal/engine/dispatcher_test.go +++ b/internal/engine/dispatcher_test.go @@ -109,6 +109,50 @@ func TestDispatcher_DispatchWave(t *testing.T) { } } +func TestDispatcher_ProjectsSpawnedAgents(t *testing.T) { + es, ps, cleanup := newTestStores(t) + defer cleanup() + + evt := state.NewEvent(state.EventStoryCreated, "tech-lead", "s-001", map[string]any{ + "id": "s-001", "req_id": "r-001", "title": "Task", "description": "desc", "complexity": 2, + }) + ps.Project(evt) + + dispatcher := engine.NewDispatcher(config.DefaultConfig(), es, ps) + dag := graph.New() + dag.AddNode("s-001") + + assignments, err := dispatcher.DispatchWave(dag, map[string]bool{}, "r-001", + []engine.PlannedStory{{ID: "s-001", Title: "Task", Complexity: 2}}, 1) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if len(assignments) != 1 { + t.Fatalf("expected 1 assignment, got %d", len(assignments)) + } + + // The dispatcher must project AGENT_SPAWNED so downstream consumers + // (`nxd agents`, dashboard, crash recovery) can see the dispatched agent. + sqlStore := ps.(*state.SQLiteStore) + agents, err := sqlStore.ListAgents(state.AgentFilter{}) + if err != nil { + t.Fatalf("ListAgents: %v", err) + } + if len(agents) != 1 { + t.Fatalf("expected dispatched agent in projection, got %d", len(agents)) + } + a := agents[0] + if a.ID != assignments[0].AgentID { + t.Errorf("agent id = %q, want %q", a.ID, assignments[0].AgentID) + } + if a.SessionName != assignments[0].SessionName || a.SessionName == "" { + t.Errorf("agent session = %q, want %q", a.SessionName, assignments[0].SessionName) + } + if a.CurrentStoryID != "s-001" { + t.Errorf("agent current_story_id = %q, want s-001", a.CurrentStoryID) + } +} + func TestDispatcher_EmptyWave(t *testing.T) { es, ps, cleanup := newTestStores(t) defer cleanup() @@ -287,7 +331,7 @@ func TestDispatchWave_RejectsUnsafeStoryID(t *testing.T) { "story&evil", "story|pipe", "story\nnewline", - "story evil", // space + "story evil", // space "../traversal", } diff --git a/internal/engine/monitor.go b/internal/engine/monitor.go index 95e9e06..f2dcb2b 100644 --- a/internal/engine/monitor.go +++ b/internal/engine/monitor.go @@ -435,7 +435,7 @@ func (m *Monitor) postExecutionPipeline(ctx context.Context, ag ActiveAgent, rep // trigger conflict-resolver "prompt too long" errors on Ollama models. stripBinariesFromBranch(ag.WorktreePath, storyID) - // VXD Phase 1.1: scrub LLM reasoning preamble from committed source + // Scrub LLM reasoning preamble from committed source // files. Agents (especially small models) sometimes prepend "Looking at // the code, I'll add..." as the first lines of a file. We strip that // before the reviewer sees the diff. @@ -452,7 +452,7 @@ func (m *Monitor) postExecutionPipeline(ctx context.Context, ag ActiveAgent, rep } } - // VXD Phase 1.1: validate that no merge conflict markers leaked + // Validate that no merge conflict markers leaked // through. If they did, reset to draft — running review/QA against a // half-merged file produces useless output. if conflicted := validateNoConflictMarkers(ag.WorktreePath); len(conflicted) > 0 { @@ -461,7 +461,7 @@ func (m *Monitor) postExecutionPipeline(ctx context.Context, ag ActiveAgent, rep return } - // VXD Phase 1.2: non-blocking build validation. Failures are logged + // Non-blocking build validation. Failures are logged // (and surface to the reviewer via the diff context) but do not abort // the pipeline — the review/QA gates are the canonical merge blockers. if !m.dryRun { @@ -547,7 +547,7 @@ func (m *Monitor) postExecutionPipeline(ctx context.Context, ag ActiveAgent, rep } } - // VXD Phase 1.4: pass git ls-files so reviewer can verify file + // Pass git ls-files so reviewer can verify file // existence claims instead of hallucinating about missing files. fileTree := captureFileTree(ag.WorktreePath) result, err := m.reviewer.Review(ctx, storyID, storyTitle, storyAC, diff, blastRadius, fileTree) @@ -1709,7 +1709,7 @@ func autoCommit(worktreePath, storyID string) { // they are not already present, preventing CLAUDE.md, .nxd-prompts/, // .serena/, and other tool artifacts from being committed by agents. // -// VXD Phase 1.3: extended pattern list to cover post-completion artifacts +// Extended pattern list to cover post-completion artifacts // (WAVE_CONTEXT.md, .nxd-fix-gaps.md) and the NXD config itself, which // agents sometimes echo back into the worktree. func ensureGitignorePatterns(worktreePath string) { diff --git a/internal/engine/monitor_cleanup.go b/internal/engine/monitor_cleanup.go index 7bb6077..cf70a27 100644 --- a/internal/engine/monitor_cleanup.go +++ b/internal/engine/monitor_cleanup.go @@ -21,7 +21,11 @@ func danglingBranchesToClean(stories []state.Story, baseBranch string) []string } branch := s.Branch if branch == "" { - branch = "vxd/" + s.ID + // Mirror the dispatcher's canonical branch naming + // (see Dispatcher.DispatchWave: "nxd/"). Using any other + // prefix here points cleanup at a branch that never existed, so the + // real dangling branch is silently left behind. + branch = "nxd/" + s.ID } if branch == "" || branch == baseBranch || seen[branch] { continue diff --git a/internal/engine/monitor_cleanup_test.go b/internal/engine/monitor_cleanup_test.go index e840c62..b4f9586 100644 --- a/internal/engine/monitor_cleanup_test.go +++ b/internal/engine/monitor_cleanup_test.go @@ -9,17 +9,19 @@ import ( func TestDanglingBranchesToClean(t *testing.T) { stories := []state.Story{ - {ID: "a-s-001", Status: "merged", Branch: "vxd/a-s-001"}, - {ID: "a-s-002", Status: "pr_submitted", Branch: "vxd/a-s-002"}, + {ID: "a-s-001", Status: "merged", Branch: "nxd/a-s-001"}, + {ID: "a-s-002", Status: "pr_submitted", Branch: "nxd/a-s-002"}, {ID: "a-s-003", Status: "draft", Branch: ""}, {ID: "a-s-004", Status: "split"}, - {ID: "a-s-005", Status: "failed", Branch: "vxd/a-s-005"}, - {ID: "a-s-002", Status: "pr_submitted", Branch: "vxd/a-s-002"}, + {ID: "a-s-005", Status: "failed", Branch: "nxd/a-s-005"}, + {ID: "a-s-002", Status: "pr_submitted", Branch: "nxd/a-s-002"}, } got := danglingBranchesToClean(stories, "master") sort.Strings(got) - want := []string{"vxd/a-s-002", "vxd/a-s-003", "vxd/a-s-005"} + // a-s-003 has an empty Branch, so it exercises the canonical-prefix + // fallback: it must resolve to "nxd/a-s-003", matching the dispatcher. + want := []string{"nxd/a-s-002", "nxd/a-s-003", "nxd/a-s-005"} if len(got) != len(want) { t.Fatalf("got %v, want %v", got, want) @@ -34,7 +36,7 @@ func TestDanglingBranchesToClean(t *testing.T) { func TestDanglingBranchesToClean_NeverDeletesBaseBranch(t *testing.T) { stories := []state.Story{ {ID: "x", Status: "pr_submitted", Branch: "master"}, - {ID: "y", Status: "pr_submitted", Branch: "vxd/y"}, + {ID: "y", Status: "pr_submitted", Branch: "nxd/y"}, } got := danglingBranchesToClean(stories, "master") for _, b := range got { @@ -42,15 +44,15 @@ func TestDanglingBranchesToClean_NeverDeletesBaseBranch(t *testing.T) { t.Fatalf("base branch must never be cleaned, got %v", got) } } - if len(got) != 1 || got[0] != "vxd/y" { - t.Fatalf("expected only vxd/y, got %v", got) + if len(got) != 1 || got[0] != "nxd/y" { + t.Fatalf("expected only nxd/y, got %v", got) } } func TestDanglingBranchesToClean_AllMergedIsEmpty(t *testing.T) { stories := []state.Story{ - {ID: "a", Status: "merged", Branch: "vxd/a"}, - {ID: "b", Status: "merged", Branch: "vxd/b"}, + {ID: "a", Status: "merged", Branch: "nxd/a"}, + {ID: "b", Status: "merged", Branch: "nxd/b"}, } if got := danglingBranchesToClean(stories, "master"); len(got) != 0 { t.Fatalf("all-merged requirement should have nothing to clean, got %v", got) diff --git a/internal/engine/planner.go b/internal/engine/planner.go index 044b291..b8e7779 100644 --- a/internal/engine/planner.go +++ b/internal/engine/planner.go @@ -427,10 +427,13 @@ architecture and conventions when planning stories.`, profileContext) } } - // Emit requirement planned - if err := p.eventStore.Append(state.NewEvent(state.EventReqPlanned, "tech-lead", "", map[string]any{ + // Emit requirement planned. Must project (like REQ_SUBMITTED and + // STORY_CREATED above) or the requirement row stays at "submitted" in + // the projection on the default `nxd req` path — breaking `nxd pause` + // (rejects un-planned reqs) and the dashboard/status buckets. + if err := p.emitAndProject(state.EventReqPlanned, "tech-lead", "", map[string]any{ "id": reqID, - })); err != nil { + }); err != nil { return PlanResult{}, fmt.Errorf("emit req planned: %w", err) } } diff --git a/internal/engine/planner_test.go b/internal/engine/planner_test.go index 3b4135e..5c6c220 100644 --- a/internal/engine/planner_test.go +++ b/internal/engine/planner_test.go @@ -82,6 +82,17 @@ func TestPlanner_Plan(t *testing.T) { t.Fatalf("expected projected story title 'Add user model', got %s", story.Title) } + // REQ_PLANNED must be projected, not just appended: the requirement row + // has to transition to "planned" on the default plan path (otherwise + // `nxd pause` and the status buckets read a stale "submitted"). + req, err := projStore.GetRequirement("r-001") + if err != nil { + t.Fatalf("get requirement: %v", err) + } + if req.Status != "planned" { + t.Fatalf("expected requirement status 'planned' after Plan, got %q", req.Status) + } + // Verify LLM was called exactly once if client.CallCount() != 1 { t.Fatalf("expected 1 LLM call, got %d", client.CallCount()) diff --git a/internal/engine/reviewer.go b/internal/engine/reviewer.go index c9c7310..2c6cbfe 100644 --- a/internal/engine/reviewer.go +++ b/internal/engine/reviewer.go @@ -61,7 +61,7 @@ func NewReviewer(client llm.Client, provider, model string, maxTokens int, es st // additional LLM call. A separate text-only LLM call is made only when // the provider does not support tools. // -// VXD Phase 1.4 (M10): the variadic `extra` parameter accepts up to two +// The variadic `extra` parameter accepts up to two // optional strings — extra[0] is the blast-radius context (existing) and // extra[1] is the worktree file tree (`git ls-files` output) so the // reviewer doesn't hallucinate "missing file X" when X is in the repo @@ -77,7 +77,7 @@ func (r *Reviewer) Review(ctx context.Context, storyID, title, acceptanceCriteri blastRadiusCtx = "\n" + extra[0] + "\n" } - // VXD Phase 1.4: optional worktree file tree. + // Optional worktree file tree. fileTreeCtx := "" if len(extra) > 1 && extra[1] != "" { fileTreeCtx = "\n\nWorktree files (git ls-files):\n" + extra[1] + "\n" diff --git a/internal/engine/sanitize_output.go b/internal/engine/sanitize_output.go index 58018df..d7be246 100644 --- a/internal/engine/sanitize_output.go +++ b/internal/engine/sanitize_output.go @@ -13,10 +13,8 @@ import ( "time" ) -// VXD Phase 1.1, 1.2, 1.4 ports. -// -// captureFileTree, hallucinationLinePatterns, scrubFile, validateBuild are -// adapted from the VXD sibling at ~/Sites/misc/vortex-dispatch/. The patterns +// captureFileTree, hallucinationLinePatterns, scrubFile, validateBuild guard +// against LLM output artifacts leaking into committed source. The patterns // list was tuned over ~2 months of production use against Claude / Sonnet // outputs; we keep them verbatim because small-model (Gemma) preambles look // nearly identical. @@ -233,10 +231,10 @@ func validateNoConflictMarkers(worktreePath string) []string { // observed (output truncated to 4 KB). Returns nil + log message if no // known project type is detected. // -// VXD Phase 1.2: this is a non-blocking signal. Callers log the failure -// rather than aborting the pipeline, because the reviewer / QA stage will -// still flag the broken build and a hint is more useful than a blocked -// merge for stories that intentionally split refactor work. +// This is a non-blocking signal. Callers log the failure rather than aborting +// the pipeline, because the reviewer / QA stage will still flag the broken +// build and a hint is more useful than a blocked merge for stories that +// intentionally split refactor work. func validateBuild(ctx context.Context, worktreePath string) error { type check struct { marker string diff --git a/internal/repolearn/scanner_test.go b/internal/repolearn/scanner_test.go index f8017d9..812ecd2 100644 --- a/internal/repolearn/scanner_test.go +++ b/internal/repolearn/scanner_test.go @@ -215,10 +215,10 @@ func TestParseMakefileTargets(t *testing.T) { dir := t.TempDir() writeFile(t, dir, "Makefile", `.PHONY: build test lint clean install -BINARY=vxd +BINARY=myapp build: - go build -o $(BINARY) ./cmd/vxd/ + go build -o $(BINARY) ./cmd/myapp/ test: go test ./... -race @@ -474,7 +474,7 @@ func TestProfileSummary(t *testing.T) { Structure: RepoStructure{ TotalFiles: 100, SourceFiles: 50, - EntryPoints: []EntryPoint{{Path: "cmd/vxd/main.go", Kind: "cmd"}}, + EntryPoints: []EntryPoint{{Path: "cmd/myapp/main.go", Kind: "cmd"}}, }, Conventions: Conventions{ ContributorCount: 3, @@ -494,7 +494,7 @@ func TestProfileSummary(t *testing.T) { mustContain(t, summary, "make lint") mustContain(t, summary, "make test") mustContain(t, summary, "github_actions") - mustContain(t, summary, "cmd/vxd/main.go") + mustContain(t, summary, "cmd/myapp/main.go") mustContain(t, summary, "Contributors: 3") mustContain(t, summary, "conventional") } diff --git a/internal/state/sqlite.go b/internal/state/sqlite.go index 294b987..0b03bc7 100644 --- a/internal/state/sqlite.go +++ b/internal/state/sqlite.go @@ -215,6 +215,8 @@ func (s *SQLiteStore) Project(evt Event) error { case EventStoryCreated: return s.projectStoryCreated(payload) + case EventAgentSpawned: + return s.projectAgentSpawned(evt, payload) case EventStoryEstimated: return s.updateStoryStatus(evt.StoryID, "estimated") case EventStoryAssigned: @@ -745,8 +747,33 @@ func (s *SQLiteStore) BackfillAcceptanceCriteria(events []Event) { } } +// projectAgentSpawned records a dispatched agent in the agents table from an +// AGENT_SPAWNED event. Without this the table stays empty in production and +// every consumer of ListAgents (`nxd agents`, the dashboard agents panel, and +// crash recovery's session→story map) sees nothing. Idempotent so projection +// replay is safe. +func (s *SQLiteStore) projectAgentSpawned(evt Event, payload map[string]any) error { + role := payloadStr(payload, "role") + sessionName := payloadStr(payload, "session_name") + _, err := s.db.Exec( + `INSERT INTO agents (id, type, status, current_story_id, session_name) + VALUES (?, ?, 'idle', ?, ?) + ON CONFLICT(id) DO UPDATE SET + type = excluded.type, + current_story_id = excluded.current_story_id, + session_name = excluded.session_name, + updated_at = CURRENT_TIMESTAMP`, + evt.AgentID, role, evt.StoryID, sessionName, + ) + if err != nil { + return fmt.Errorf("project agent spawned: %w", err) + } + return nil +} + // InsertAgent inserts an agent record directly into the agents table. -// This is used by test helpers since AGENT_SPAWNED events are not projected. +// Convenience for tests and direct seeding; live runs populate the table via +// the AGENT_SPAWNED projection (projectAgentSpawned). func (s *SQLiteStore) InsertAgent(id, agentType, model, runtime, sessionName string) error { _, err := s.db.Exec( `INSERT OR IGNORE INTO agents (id, type, model, runtime, status, session_name) VALUES (?, ?, ?, ?, 'idle', ?)`, diff --git a/internal/state/sqlite_test.go b/internal/state/sqlite_test.go index 6a813c4..fe29f45 100644 --- a/internal/state/sqlite_test.go +++ b/internal/state/sqlite_test.go @@ -328,6 +328,41 @@ func TestSQLiteStore_ListRequirements(t *testing.T) { } } +func TestSQLiteStore_ProjectAgentSpawned(t *testing.T) { + db, _ := state.NewSQLiteStore(":memory:") + defer db.Close() + + evt := state.NewEvent(state.EventAgentSpawned, "dev-req1-1", "story-1", map[string]any{ + "role": "dev", + "session_name": "nxd-req1-dev-1", + }) + if err := db.Project(evt); err != nil { + t.Fatalf("Project(AGENT_SPAWNED): %v", err) + } + + agents, err := db.ListAgents(state.AgentFilter{}) + if err != nil { + t.Fatalf("ListAgents: %v", err) + } + if len(agents) != 1 { + t.Fatalf("AGENT_SPAWNED must populate the agents table; got %d agents", len(agents)) + } + a := agents[0] + if a.ID != "dev-req1-1" || a.Type != "dev" || + a.SessionName != "nxd-req1-dev-1" || a.CurrentStoryID != "story-1" { + t.Errorf("projected agent = %+v; want id/type/session/current_story populated", a) + } + + // Projection replay must be idempotent (no duplicate rows). + if err := db.Project(evt); err != nil { + t.Fatalf("re-Project(AGENT_SPAWNED): %v", err) + } + again, _ := db.ListAgents(state.AgentFilter{}) + if len(again) != 1 { + t.Fatalf("re-projecting AGENT_SPAWNED duplicated the agent: got %d", len(again)) + } +} + func TestSQLiteStore_InsertAgent(t *testing.T) { db, _ := state.NewSQLiteStore(":memory:") defer db.Close() diff --git a/internal/web/static/index.html b/internal/web/static/index.html index dd45d6c..c137b6f 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -132,7 +132,7 @@

Recovery Log