@nathapp/nax 0.23.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/nax.ts +20 -2
- package/docs/ROADMAP.md +33 -15
- package/docs/specs/trigger-completion.md +145 -0
- package/nax/features/central-run-registry/prd.json +105 -0
- package/nax/features/trigger-completion/prd.json +150 -0
- package/nax/features/trigger-completion/progress.txt +7 -0
- package/nax/status.json +14 -24
- package/package.json +2 -2
- package/src/commands/index.ts +1 -0
- package/src/commands/logs.ts +87 -17
- package/src/commands/runs.ts +220 -0
- package/src/config/types.ts +3 -1
- package/src/execution/crash-recovery.ts +11 -0
- package/src/execution/executor-types.ts +1 -1
- package/src/execution/lifecycle/run-setup.ts +4 -0
- package/src/execution/sequential-executor.ts +49 -7
- package/src/interaction/plugins/auto.ts +10 -1
- package/src/pipeline/event-bus.ts +14 -1
- package/src/pipeline/stages/completion.ts +20 -0
- package/src/pipeline/stages/execution.ts +62 -0
- package/src/pipeline/stages/review.ts +25 -1
- package/src/pipeline/subscribers/events-writer.ts +121 -0
- package/src/pipeline/subscribers/hooks.ts +32 -0
- package/src/pipeline/subscribers/interaction.ts +36 -1
- package/src/pipeline/subscribers/registry.ts +73 -0
- package/src/routing/router.ts +3 -2
- package/src/routing/strategies/keyword.ts +2 -1
- package/src/routing/strategies/llm-prompts.ts +29 -28
- package/src/utils/git.ts +21 -0
- package/test/integration/cli/cli-logs.test.ts +40 -17
- package/test/integration/routing/plugin-routing-core.test.ts +1 -1
- package/test/unit/commands/logs.test.ts +63 -22
- package/test/unit/commands/runs.test.ts +303 -0
- package/test/unit/execution/sequential-executor.test.ts +235 -0
- package/test/unit/interaction/auto-plugin.test.ts +162 -0
- package/test/unit/interaction-plugins.test.ts +308 -1
- package/test/unit/pipeline/stages/completion-review-gate.test.ts +218 -0
- package/test/unit/pipeline/stages/execution-ambiguity.test.ts +311 -0
- package/test/unit/pipeline/stages/execution-merge-conflict.test.ts +218 -0
- package/test/unit/pipeline/stages/review.test.ts +201 -0
- package/test/unit/pipeline/subscribers/events-writer.test.ts +227 -0
- package/test/unit/pipeline/subscribers/hooks.test.ts +43 -4
- package/test/unit/pipeline/subscribers/interaction.test.ts +284 -2
- package/test/unit/pipeline/subscribers/registry.test.ts +149 -0
- package/test/unit/prd-auto-default.test.ts +2 -2
- package/test/unit/routing/routing-stability.test.ts +1 -1
- package/test/unit/routing-core.test.ts +5 -5
- package/test/unit/routing-strategies.test.ts +1 -3
- package/test/unit/utils/git.test.ts +50 -0
package/src/commands/logs.ts
CHANGED
|
@@ -6,13 +6,23 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { existsSync, readdirSync } from "node:fs";
|
|
9
|
+
import { readdir } from "node:fs/promises";
|
|
10
|
+
import { homedir } from "node:os";
|
|
9
11
|
import { join } from "node:path";
|
|
10
12
|
import chalk from "chalk";
|
|
11
13
|
import type { LogEntry, LogLevel } from "../logger/types";
|
|
12
14
|
import { type FormattedEntry, formatLogEntry, formatRunSummary } from "../logging/formatter";
|
|
13
15
|
import type { RunSummary, VerbosityMode } from "../logging/types";
|
|
16
|
+
import type { MetaJson } from "../pipeline/subscribers/registry";
|
|
14
17
|
import { type ResolveProjectOptions, resolveProject } from "./common";
|
|
15
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Swappable dependencies for testing (project convention: _deps over mock.module).
|
|
21
|
+
*/
|
|
22
|
+
export const _deps = {
|
|
23
|
+
getRunsDir: () => process.env.NAX_RUNS_DIR ?? join(homedir(), ".nax", "runs"),
|
|
24
|
+
};
|
|
25
|
+
|
|
16
26
|
/**
|
|
17
27
|
* Options for logs command
|
|
18
28
|
*/
|
|
@@ -43,12 +53,84 @@ const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
|
|
|
43
53
|
error: 3,
|
|
44
54
|
};
|
|
45
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Resolve log file path for a runId from the central registry (~/.nax/runs/).
|
|
58
|
+
*
|
|
59
|
+
* Scans all ~/.nax/runs/*\/meta.json entries for an exact or prefix match on runId.
|
|
60
|
+
* Returns the path to the matching run's JSONL file, or null if eventsDir/file is unavailable.
|
|
61
|
+
* Throws if the runId is not found in the registry at all.
|
|
62
|
+
*
|
|
63
|
+
* @param runId - Full or prefix run ID to look up
|
|
64
|
+
* @returns Absolute path to the JSONL log file, or null if unavailable
|
|
65
|
+
*/
|
|
66
|
+
async function resolveRunFileFromRegistry(runId: string): Promise<string | null> {
|
|
67
|
+
const runsDir = _deps.getRunsDir();
|
|
68
|
+
|
|
69
|
+
let entries: string[];
|
|
70
|
+
try {
|
|
71
|
+
entries = await readdir(runsDir);
|
|
72
|
+
} catch {
|
|
73
|
+
throw new Error(`Run not found in registry: ${runId}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let matched: MetaJson | null = null;
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
const metaPath = join(runsDir, entry, "meta.json");
|
|
79
|
+
try {
|
|
80
|
+
const meta: MetaJson = await Bun.file(metaPath).json();
|
|
81
|
+
if (meta.runId === runId || meta.runId.startsWith(runId)) {
|
|
82
|
+
matched = meta;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
// skip unreadable meta.json entries
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!matched) {
|
|
91
|
+
throw new Error(`Run not found in registry: ${runId}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!existsSync(matched.eventsDir)) {
|
|
95
|
+
console.log(`Log directory unavailable for run: ${runId}`);
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const files = readdirSync(matched.eventsDir)
|
|
100
|
+
.filter((f) => f.endsWith(".jsonl") && f !== "latest.jsonl")
|
|
101
|
+
.sort()
|
|
102
|
+
.reverse();
|
|
103
|
+
|
|
104
|
+
if (files.length === 0) {
|
|
105
|
+
console.log(`No log files found for run: ${runId}`);
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Look for the specific run file by runId, fall back to newest
|
|
110
|
+
const specificFile = files.find((f) => f === `${matched.runId}.jsonl`);
|
|
111
|
+
return join(matched.eventsDir, specificFile ?? files[0]);
|
|
112
|
+
}
|
|
113
|
+
|
|
46
114
|
/**
|
|
47
115
|
* Display logs with filtering and formatting
|
|
48
116
|
*
|
|
49
117
|
* @param options - Command options
|
|
50
118
|
*/
|
|
51
119
|
export async function logsCommand(options: LogsOptions): Promise<void> {
|
|
120
|
+
// When --run <runId> is provided, resolve via central registry
|
|
121
|
+
if (options.run) {
|
|
122
|
+
const runFile = await resolveRunFileFromRegistry(options.run);
|
|
123
|
+
if (!runFile) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (options.follow) {
|
|
127
|
+
await followLogs(runFile, options);
|
|
128
|
+
} else {
|
|
129
|
+
await displayLogs(runFile, options);
|
|
130
|
+
}
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
52
134
|
// Resolve project directory
|
|
53
135
|
const resolved = resolveProject({ dir: options.dir });
|
|
54
136
|
const naxDir = join(resolved.projectDir, "nax");
|
|
@@ -77,8 +159,8 @@ export async function logsCommand(options: LogsOptions): Promise<void> {
|
|
|
77
159
|
return;
|
|
78
160
|
}
|
|
79
161
|
|
|
80
|
-
// Determine which run to display
|
|
81
|
-
const runFile = await selectRunFile(runsDir
|
|
162
|
+
// Determine which run to display (latest by default — --run handled above via registry)
|
|
163
|
+
const runFile = await selectRunFile(runsDir);
|
|
82
164
|
|
|
83
165
|
if (!runFile) {
|
|
84
166
|
throw new Error("No runs found for this feature");
|
|
@@ -95,9 +177,9 @@ export async function logsCommand(options: LogsOptions): Promise<void> {
|
|
|
95
177
|
}
|
|
96
178
|
|
|
97
179
|
/**
|
|
98
|
-
* Select which run file to display
|
|
180
|
+
* Select which run file to display (always returns the latest run)
|
|
99
181
|
*/
|
|
100
|
-
async function selectRunFile(runsDir: string
|
|
182
|
+
async function selectRunFile(runsDir: string): Promise<string | null> {
|
|
101
183
|
const files = readdirSync(runsDir)
|
|
102
184
|
.filter((f) => f.endsWith(".jsonl") && f !== "latest.jsonl")
|
|
103
185
|
.sort()
|
|
@@ -107,19 +189,7 @@ async function selectRunFile(runsDir: string, runTimestamp?: string): Promise<st
|
|
|
107
189
|
return null;
|
|
108
190
|
}
|
|
109
191
|
|
|
110
|
-
|
|
111
|
-
if (!runTimestamp) {
|
|
112
|
-
return join(runsDir, files[0]);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Find matching run by partial timestamp
|
|
116
|
-
const matchingFile = files.find((f) => f.startsWith(runTimestamp));
|
|
117
|
-
|
|
118
|
-
if (!matchingFile) {
|
|
119
|
-
throw new Error(`Run not found: ${runTimestamp}`);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
return join(runsDir, matchingFile);
|
|
192
|
+
return join(runsDir, files[0]);
|
|
123
193
|
}
|
|
124
194
|
|
|
125
195
|
/**
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs Command
|
|
3
|
+
*
|
|
4
|
+
* Reads all ~/.nax/runs/*\/meta.json entries and displays a table of runs
|
|
5
|
+
* sorted by registeredAt descending. Resolves each statusPath to read the
|
|
6
|
+
* live NaxStatusFile for current state. Falls back to '[unavailable]' if
|
|
7
|
+
* the status file is missing or unreadable.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* nax runs [--project <name>] [--last <N>] [--status <status>]
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readdir } from "node:fs/promises";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import chalk from "chalk";
|
|
17
|
+
import type { NaxStatusFile } from "../execution/status-file";
|
|
18
|
+
import type { MetaJson } from "../pipeline/subscribers/registry";
|
|
19
|
+
|
|
20
|
+
const DEFAULT_LIMIT = 20;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Swappable dependencies for testing (project convention: _deps over mock.module).
|
|
24
|
+
*/
|
|
25
|
+
export const _deps = {
|
|
26
|
+
getRunsDir: () => join(homedir(), ".nax", "runs"),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export interface RunsOptions {
|
|
30
|
+
/** Filter by project name */
|
|
31
|
+
project?: string;
|
|
32
|
+
/** Limit number of runs displayed (default: 20) */
|
|
33
|
+
last?: number;
|
|
34
|
+
/** Filter by run status */
|
|
35
|
+
status?: "running" | "completed" | "failed" | "crashed";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface RunRow {
|
|
39
|
+
runId: string;
|
|
40
|
+
project: string;
|
|
41
|
+
feature: string;
|
|
42
|
+
status: string;
|
|
43
|
+
passed: number;
|
|
44
|
+
total: number;
|
|
45
|
+
durationMs: number;
|
|
46
|
+
registeredAt: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Format duration in milliseconds to human-readable string.
|
|
51
|
+
*/
|
|
52
|
+
function formatDuration(ms: number): string {
|
|
53
|
+
if (ms <= 0) return "-";
|
|
54
|
+
const minutes = Math.floor(ms / 60000);
|
|
55
|
+
const seconds = Math.floor((ms % 60000) / 1000);
|
|
56
|
+
if (minutes === 0) return `${seconds}s`;
|
|
57
|
+
return `${minutes}m ${seconds}s`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Format ISO date to short local date string.
|
|
62
|
+
*/
|
|
63
|
+
function formatDate(iso: string): string {
|
|
64
|
+
try {
|
|
65
|
+
const d = new Date(iso);
|
|
66
|
+
return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
|
67
|
+
} catch {
|
|
68
|
+
return iso;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Color a status string for terminal output.
|
|
74
|
+
*/
|
|
75
|
+
function colorStatus(status: string): string {
|
|
76
|
+
switch (status) {
|
|
77
|
+
case "completed":
|
|
78
|
+
return chalk.green(status);
|
|
79
|
+
case "failed":
|
|
80
|
+
return chalk.red(status);
|
|
81
|
+
case "running":
|
|
82
|
+
return chalk.yellow(status);
|
|
83
|
+
case "[unavailable]":
|
|
84
|
+
return chalk.dim(status);
|
|
85
|
+
default:
|
|
86
|
+
return chalk.dim(status);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Regex that matches ANSI escape sequences. */
|
|
91
|
+
const ANSI_RE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
|
|
92
|
+
|
|
93
|
+
/** Strip ANSI escape codes to compute visible string length. */
|
|
94
|
+
function visibleLength(str: string): number {
|
|
95
|
+
return str.replace(ANSI_RE, "").length;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Pad a string to a fixed width (left-aligned).
|
|
100
|
+
*/
|
|
101
|
+
function pad(str: string, width: number): string {
|
|
102
|
+
const padding = Math.max(0, width - visibleLength(str));
|
|
103
|
+
return str + " ".repeat(padding);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Display all registered runs from ~/.nax/runs/ as a table.
|
|
108
|
+
*
|
|
109
|
+
* @param options - Filter and limit options
|
|
110
|
+
*/
|
|
111
|
+
export async function runsCommand(options: RunsOptions = {}): Promise<void> {
|
|
112
|
+
const runsDir = _deps.getRunsDir();
|
|
113
|
+
|
|
114
|
+
let entries: string[];
|
|
115
|
+
try {
|
|
116
|
+
entries = await readdir(runsDir);
|
|
117
|
+
} catch {
|
|
118
|
+
console.log("No runs found.");
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const rows: RunRow[] = [];
|
|
123
|
+
|
|
124
|
+
for (const entry of entries) {
|
|
125
|
+
const metaPath = join(runsDir, entry, "meta.json");
|
|
126
|
+
let meta: MetaJson;
|
|
127
|
+
try {
|
|
128
|
+
meta = await Bun.file(metaPath).json();
|
|
129
|
+
} catch {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Apply project filter early
|
|
134
|
+
if (options.project && meta.project !== options.project) continue;
|
|
135
|
+
|
|
136
|
+
// Read live status file
|
|
137
|
+
let statusData: NaxStatusFile | null = null;
|
|
138
|
+
try {
|
|
139
|
+
statusData = await Bun.file(meta.statusPath).json();
|
|
140
|
+
} catch {
|
|
141
|
+
// statusPath missing or unreadable — handled gracefully below
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const runStatus = statusData ? statusData.run.status : "[unavailable]";
|
|
145
|
+
|
|
146
|
+
// Apply status filter
|
|
147
|
+
if (options.status && runStatus !== options.status) continue;
|
|
148
|
+
|
|
149
|
+
rows.push({
|
|
150
|
+
runId: meta.runId,
|
|
151
|
+
project: meta.project,
|
|
152
|
+
feature: meta.feature,
|
|
153
|
+
status: runStatus,
|
|
154
|
+
passed: statusData?.progress.passed ?? 0,
|
|
155
|
+
total: statusData?.progress.total ?? 0,
|
|
156
|
+
durationMs: statusData?.durationMs ?? 0,
|
|
157
|
+
registeredAt: meta.registeredAt,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Sort newest first
|
|
162
|
+
rows.sort((a, b) => new Date(b.registeredAt).getTime() - new Date(a.registeredAt).getTime());
|
|
163
|
+
|
|
164
|
+
// Apply limit
|
|
165
|
+
const limit = options.last ?? DEFAULT_LIMIT;
|
|
166
|
+
const displayed = rows.slice(0, limit);
|
|
167
|
+
|
|
168
|
+
if (displayed.length === 0) {
|
|
169
|
+
console.log("No runs found.");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Column widths (minimum per header)
|
|
174
|
+
const COL = {
|
|
175
|
+
runId: Math.max(6, ...displayed.map((r) => r.runId.length)),
|
|
176
|
+
project: Math.max(7, ...displayed.map((r) => r.project.length)),
|
|
177
|
+
feature: Math.max(7, ...displayed.map((r) => r.feature.length)),
|
|
178
|
+
status: Math.max(6, ...displayed.map((r) => visibleLength(r.status))),
|
|
179
|
+
stories: 7,
|
|
180
|
+
duration: 8,
|
|
181
|
+
date: 11,
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// Header
|
|
185
|
+
const header = [
|
|
186
|
+
pad(chalk.bold("RUN ID"), COL.runId),
|
|
187
|
+
pad(chalk.bold("PROJECT"), COL.project),
|
|
188
|
+
pad(chalk.bold("FEATURE"), COL.feature),
|
|
189
|
+
pad(chalk.bold("STATUS"), COL.status),
|
|
190
|
+
pad(chalk.bold("STORIES"), COL.stories),
|
|
191
|
+
pad(chalk.bold("DURATION"), COL.duration),
|
|
192
|
+
chalk.bold("DATE"),
|
|
193
|
+
].join(" ");
|
|
194
|
+
|
|
195
|
+
console.log();
|
|
196
|
+
console.log(header);
|
|
197
|
+
console.log(
|
|
198
|
+
chalk.dim("-".repeat(COL.runId + COL.project + COL.feature + COL.status + COL.stories + COL.duration + 11 + 12)),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
for (const row of displayed) {
|
|
202
|
+
const colored = colorStatus(row.status);
|
|
203
|
+
const line = [
|
|
204
|
+
pad(row.runId, COL.runId),
|
|
205
|
+
pad(row.project, COL.project),
|
|
206
|
+
pad(row.feature, COL.feature),
|
|
207
|
+
pad(colored, COL.status + (colored.length - visibleLength(colored))),
|
|
208
|
+
pad(`${row.passed}/${row.total}`, COL.stories),
|
|
209
|
+
pad(formatDuration(row.durationMs), COL.duration),
|
|
210
|
+
formatDate(row.registeredAt),
|
|
211
|
+
].join(" ");
|
|
212
|
+
console.log(line);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
console.log();
|
|
216
|
+
if (rows.length > limit) {
|
|
217
|
+
console.log(chalk.dim(`Showing ${limit} of ${rows.length} runs. Use --last <N> to see more.`));
|
|
218
|
+
console.log();
|
|
219
|
+
}
|
|
220
|
+
}
|
package/src/config/types.ts
CHANGED
|
@@ -309,7 +309,9 @@ export interface InteractionConfig {
|
|
|
309
309
|
fallback: "continue" | "skip" | "escalate" | "abort";
|
|
310
310
|
};
|
|
311
311
|
/** Enable/disable built-in triggers */
|
|
312
|
-
triggers: Partial<
|
|
312
|
+
triggers: Partial<
|
|
313
|
+
Record<string, boolean | { enabled: boolean; fallback?: string; timeout?: number; threshold?: number }>
|
|
314
|
+
>;
|
|
313
315
|
}
|
|
314
316
|
|
|
315
317
|
/** Test coverage context config */
|
|
@@ -32,6 +32,8 @@ export interface CrashRecoveryContext {
|
|
|
32
32
|
getStartTime?: () => number;
|
|
33
33
|
getTotalStories?: () => number;
|
|
34
34
|
getStoriesCompleted?: () => number;
|
|
35
|
+
/** Optional callback to emit run:errored event (fire-and-forget) */
|
|
36
|
+
emitError?: (reason: string) => void;
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
/**
|
|
@@ -171,6 +173,9 @@ export function installCrashHandlers(ctx: CrashRecoveryContext): () => void {
|
|
|
171
173
|
await ctx.pidRegistry.killAll();
|
|
172
174
|
}
|
|
173
175
|
|
|
176
|
+
// Emit run:errored event (fire-and-forget)
|
|
177
|
+
ctx.emitError?.(signal.toLowerCase());
|
|
178
|
+
|
|
174
179
|
// Write fatal log
|
|
175
180
|
await writeFatalLog(ctx.jsonlFilePath, signal);
|
|
176
181
|
|
|
@@ -209,6 +214,9 @@ export function installCrashHandlers(ctx: CrashRecoveryContext): () => void {
|
|
|
209
214
|
await ctx.pidRegistry.killAll();
|
|
210
215
|
}
|
|
211
216
|
|
|
217
|
+
// Emit run:errored event (fire-and-forget)
|
|
218
|
+
ctx.emitError?.("uncaughtException");
|
|
219
|
+
|
|
212
220
|
// Write fatal log with stack trace
|
|
213
221
|
await writeFatalLog(ctx.jsonlFilePath, "uncaughtException", error);
|
|
214
222
|
|
|
@@ -242,6 +250,9 @@ export function installCrashHandlers(ctx: CrashRecoveryContext): () => void {
|
|
|
242
250
|
await ctx.pidRegistry.killAll();
|
|
243
251
|
}
|
|
244
252
|
|
|
253
|
+
// Emit run:errored event (fire-and-forget)
|
|
254
|
+
ctx.emitError?.("unhandledRejection");
|
|
255
|
+
|
|
245
256
|
// Write fatal log
|
|
246
257
|
await writeFatalLog(ctx.jsonlFilePath, "unhandledRejection", error);
|
|
247
258
|
|
|
@@ -40,7 +40,7 @@ export interface SequentialExecutionResult {
|
|
|
40
40
|
storiesCompleted: number;
|
|
41
41
|
totalCost: number;
|
|
42
42
|
allStoryMetrics: StoryMetrics[];
|
|
43
|
-
exitReason: "completed" | "cost-limit" | "max-iterations" | "stalled" | "no-stories";
|
|
43
|
+
exitReason: "completed" | "cost-limit" | "max-iterations" | "stalled" | "no-stories" | "pre-merge-aborted";
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/**
|
|
@@ -21,6 +21,7 @@ import { fireHook } from "../../hooks";
|
|
|
21
21
|
import type { InteractionChain } from "../../interaction";
|
|
22
22
|
import { initInteractionChain } from "../../interaction";
|
|
23
23
|
import { getSafeLogger } from "../../logger";
|
|
24
|
+
import { pipelineEventBus } from "../../pipeline/event-bus";
|
|
24
25
|
import { loadPlugins } from "../../plugins/loader";
|
|
25
26
|
import type { PluginRegistry } from "../../plugins/registry";
|
|
26
27
|
import type { PRD } from "../../prd";
|
|
@@ -123,6 +124,9 @@ export async function setupRun(options: RunSetupOptions): Promise<RunSetupResult
|
|
|
123
124
|
getStartTime: () => options.startTime,
|
|
124
125
|
getTotalStories: options.getTotalStories,
|
|
125
126
|
getStoriesCompleted: options.getStoriesCompleted,
|
|
127
|
+
emitError: (reason: string) => {
|
|
128
|
+
pipelineEventBus.emit({ type: "run:errored", reason, feature: options.feature });
|
|
129
|
+
},
|
|
126
130
|
});
|
|
127
131
|
|
|
128
132
|
// Load PRD (before try block so it's accessible in finally for onRunEnd)
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
/** Sequential Story Executor (ADR-005, Phase 4) — main execution loop. */
|
|
2
2
|
|
|
3
|
+
import { checkCostExceeded, checkCostWarning, checkPreMerge, isTriggerEnabled } from "../interaction/triggers";
|
|
3
4
|
import { getSafeLogger } from "../logger";
|
|
4
5
|
import type { StoryMetrics } from "../metrics";
|
|
5
6
|
import { pipelineEventBus } from "../pipeline/event-bus";
|
|
6
7
|
import { runPipeline } from "../pipeline/runner";
|
|
7
8
|
import { postRunPipeline } from "../pipeline/stages";
|
|
9
|
+
import { wireEventsWriter } from "../pipeline/subscribers/events-writer";
|
|
8
10
|
import { wireHooks } from "../pipeline/subscribers/hooks";
|
|
9
11
|
import { wireInteraction } from "../pipeline/subscribers/interaction";
|
|
12
|
+
import { wireRegistry } from "../pipeline/subscribers/registry";
|
|
10
13
|
import { wireReporters } from "../pipeline/subscribers/reporters";
|
|
11
14
|
import type { PipelineContext } from "../pipeline/types";
|
|
12
15
|
import { generateHumanHaltSummary, isComplete, isStalled, loadPRD } from "../prd";
|
|
@@ -33,11 +36,14 @@ export async function executeSequential(
|
|
|
33
36
|
0,
|
|
34
37
|
];
|
|
35
38
|
const allStoryMetrics: StoryMetrics[] = [];
|
|
39
|
+
let warningSent = false;
|
|
36
40
|
|
|
37
41
|
pipelineEventBus.clear();
|
|
38
42
|
wireHooks(pipelineEventBus, ctx.hooks, ctx.workdir, ctx.feature);
|
|
39
43
|
wireReporters(pipelineEventBus, ctx.pluginRegistry, ctx.runId, ctx.startTime);
|
|
40
44
|
wireInteraction(pipelineEventBus, ctx.interactionChain, ctx.config);
|
|
45
|
+
wireEventsWriter(pipelineEventBus, ctx.feature, ctx.runId, ctx.workdir);
|
|
46
|
+
wireRegistry(pipelineEventBus, ctx.feature, ctx.runId, ctx.workdir);
|
|
41
47
|
|
|
42
48
|
const buildResult = (exitReason: SequentialExecutionResult["exitReason"]): SequentialExecutionResult => ({
|
|
43
49
|
prd,
|
|
@@ -65,6 +71,17 @@ export async function executeSequential(
|
|
|
65
71
|
prdDirty = false;
|
|
66
72
|
}
|
|
67
73
|
if (isComplete(prd)) {
|
|
74
|
+
// pre-merge trigger: prompt before completing the run
|
|
75
|
+
if (ctx.interactionChain && isTriggerEnabled("pre-merge", ctx.config)) {
|
|
76
|
+
const shouldProceed = await checkPreMerge(
|
|
77
|
+
{ featureName: ctx.feature, totalStories: prd.userStories.length, cost: totalCost },
|
|
78
|
+
ctx.config,
|
|
79
|
+
ctx.interactionChain,
|
|
80
|
+
);
|
|
81
|
+
if (!shouldProceed) {
|
|
82
|
+
return buildResult("pre-merge-aborted");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
68
85
|
pipelineEventBus.emit({
|
|
69
86
|
type: "run:completed",
|
|
70
87
|
totalStories: 0,
|
|
@@ -87,13 +104,24 @@ export async function executeSequential(
|
|
|
87
104
|
if (!ctx.useBatch) lastStoryId = selection.story.id;
|
|
88
105
|
|
|
89
106
|
if (totalCost >= ctx.config.execution.costLimit) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
107
|
+
const shouldProceed =
|
|
108
|
+
ctx.interactionChain && isTriggerEnabled("cost-exceeded", ctx.config)
|
|
109
|
+
? await checkCostExceeded(
|
|
110
|
+
{ featureName: ctx.feature, cost: totalCost, limit: ctx.config.execution.costLimit },
|
|
111
|
+
ctx.config,
|
|
112
|
+
ctx.interactionChain,
|
|
113
|
+
)
|
|
114
|
+
: false;
|
|
115
|
+
if (!shouldProceed) {
|
|
116
|
+
pipelineEventBus.emit({
|
|
117
|
+
type: "run:paused",
|
|
118
|
+
reason: `Cost limit reached: $${totalCost.toFixed(2)}`,
|
|
119
|
+
storyId: selection.story.id,
|
|
120
|
+
cost: totalCost,
|
|
121
|
+
});
|
|
122
|
+
return buildResult("cost-limit");
|
|
123
|
+
}
|
|
124
|
+
pipelineEventBus.emit({ type: "run:resumed", feature: ctx.feature });
|
|
97
125
|
}
|
|
98
126
|
|
|
99
127
|
pipelineEventBus.emit({
|
|
@@ -114,6 +142,20 @@ export async function executeSequential(
|
|
|
114
142
|
iter.prdDirty,
|
|
115
143
|
];
|
|
116
144
|
|
|
145
|
+
if (ctx.interactionChain && isTriggerEnabled("cost-warning", ctx.config) && !warningSent) {
|
|
146
|
+
const costLimit = ctx.config.execution.costLimit;
|
|
147
|
+
const triggerCfg = ctx.config.interaction?.triggers?.["cost-warning"];
|
|
148
|
+
const threshold = typeof triggerCfg === "object" ? (triggerCfg.threshold ?? 0.8) : 0.8;
|
|
149
|
+
if (totalCost >= costLimit * threshold) {
|
|
150
|
+
await checkCostWarning(
|
|
151
|
+
{ featureName: ctx.feature, cost: totalCost, limit: costLimit },
|
|
152
|
+
ctx.config,
|
|
153
|
+
ctx.interactionChain,
|
|
154
|
+
);
|
|
155
|
+
warningSent = true;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
117
159
|
if (iter.prdDirty) {
|
|
118
160
|
prd = await loadPRD(ctx.prdPath);
|
|
119
161
|
prdDirty = false;
|
|
@@ -38,6 +38,14 @@ interface DecisionResponse {
|
|
|
38
38
|
reasoning: string;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Module-level deps for testability (_deps pattern).
|
|
43
|
+
* Override callLlm in tests to avoid spawning the claude CLI.
|
|
44
|
+
*/
|
|
45
|
+
export const _deps = {
|
|
46
|
+
callLlm: null as ((request: InteractionRequest) => Promise<DecisionResponse>) | null,
|
|
47
|
+
};
|
|
48
|
+
|
|
41
49
|
/**
|
|
42
50
|
* Auto plugin for AI-powered interaction responses
|
|
43
51
|
*/
|
|
@@ -80,7 +88,8 @@ export class AutoInteractionPlugin implements InteractionPlugin {
|
|
|
80
88
|
}
|
|
81
89
|
|
|
82
90
|
try {
|
|
83
|
-
const
|
|
91
|
+
const callFn = _deps.callLlm ?? this.callLlm.bind(this);
|
|
92
|
+
const decision = await callFn(request);
|
|
84
93
|
|
|
85
94
|
// Check confidence threshold
|
|
86
95
|
if (decision.confidence < (this.config.confidenceThreshold ?? 0.7)) {
|
|
@@ -135,6 +135,17 @@ export interface StoryPausedEvent {
|
|
|
135
135
|
cost: number;
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
export interface RunResumedEvent {
|
|
139
|
+
type: "run:resumed";
|
|
140
|
+
feature: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface RunErroredEvent {
|
|
144
|
+
type: "run:errored";
|
|
145
|
+
reason: string;
|
|
146
|
+
feature?: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
138
149
|
/** Discriminated union of all pipeline events. */
|
|
139
150
|
export type PipelineEvent =
|
|
140
151
|
| StoryStartedEvent
|
|
@@ -150,7 +161,9 @@ export type PipelineEvent =
|
|
|
150
161
|
| HumanReviewRequestedEvent
|
|
151
162
|
| RunStartedEvent
|
|
152
163
|
| RunPausedEvent
|
|
153
|
-
| StoryPausedEvent
|
|
164
|
+
| StoryPausedEvent
|
|
165
|
+
| RunResumedEvent
|
|
166
|
+
| RunErroredEvent;
|
|
154
167
|
|
|
155
168
|
export type PipelineEventType = PipelineEvent["type"];
|
|
156
169
|
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { appendProgress } from "../../execution/progress";
|
|
16
|
+
import { checkReviewGate, isTriggerEnabled } from "../../interaction/triggers";
|
|
16
17
|
import { getLogger } from "../../logger";
|
|
17
18
|
import { collectBatchMetrics, collectStoryMetrics } from "../../metrics";
|
|
18
19
|
import { countStories, markStoryPassed, savePRD } from "../../prd";
|
|
@@ -72,6 +73,18 @@ export const completionStage: PipelineStage = {
|
|
|
72
73
|
modelTier: ctx.routing?.modelTier,
|
|
73
74
|
testStrategy: ctx.routing?.testStrategy,
|
|
74
75
|
});
|
|
76
|
+
|
|
77
|
+
// review-gate trigger: check if story needs re-review after passing
|
|
78
|
+
if (ctx.interaction && isTriggerEnabled("review-gate", ctx.config)) {
|
|
79
|
+
const shouldContinue = await _completionDeps.checkReviewGate(
|
|
80
|
+
{ featureName: ctx.prd.feature, storyId: completedStory.id },
|
|
81
|
+
ctx.config,
|
|
82
|
+
ctx.interaction,
|
|
83
|
+
);
|
|
84
|
+
if (!shouldContinue) {
|
|
85
|
+
logger.warn("completion", "Story marked for re-review", { storyId: completedStory.id });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
75
88
|
}
|
|
76
89
|
|
|
77
90
|
// Save PRD
|
|
@@ -89,3 +102,10 @@ export const completionStage: PipelineStage = {
|
|
|
89
102
|
return { action: "continue" };
|
|
90
103
|
},
|
|
91
104
|
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Swappable dependencies for testing (avoids mock.module() which leaks in Bun 1.x).
|
|
108
|
+
*/
|
|
109
|
+
export const _completionDeps = {
|
|
110
|
+
checkReviewGate,
|
|
111
|
+
};
|