@nathapp/nax 0.22.1 → 0.22.3
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/CLAUDE.md +17 -0
- package/bin/nax.ts +3 -4
- package/docs/ROADMAP.md +55 -43
- package/docs/specs/central-run-registry.md +104 -0
- package/docs/specs/status-file-consolidation.md +93 -0
- package/nax/features/post-rearch-bugfix/prd.json +137 -0
- package/nax/features/status-file-consolidation/prd.json +61 -0
- package/nax/status.json +13 -12
- package/package.json +1 -1
- package/src/execution/crash-recovery.ts +7 -0
- package/src/execution/lifecycle/run-setup.ts +50 -42
- package/src/execution/lock.ts +30 -14
- package/src/execution/parallel.ts +4 -11
- package/src/execution/pipeline-result-handler.ts +1 -1
- package/src/execution/runner.ts +7 -4
- package/src/execution/status-writer.ts +4 -4
- package/src/pipeline/stages/acceptance.ts +5 -3
- package/src/pipeline/stages/autofix.ts +5 -3
- package/src/routing/strategies/llm.ts +11 -10
- package/src/verification/executor.ts +18 -6
- package/test/helpers/helpers.test.ts +2 -2
- package/test/integration/execution/status-file-integration.test.ts +20 -57
- package/test/integration/execution/status-writer.test.ts +1 -12
- package/test/unit/routing/strategies/llm.test.ts +64 -9
package/nax/status.json
CHANGED
|
@@ -1,27 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 1,
|
|
3
3
|
"run": {
|
|
4
|
-
"id": "run-2026-03-
|
|
5
|
-
"feature": "
|
|
6
|
-
"startedAt": "2026-03-
|
|
7
|
-
"status": "
|
|
4
|
+
"id": "run-2026-03-07T06-14-21-018Z",
|
|
5
|
+
"feature": "status-file-consolidation",
|
|
6
|
+
"startedAt": "2026-03-07T06:14:21.018Z",
|
|
7
|
+
"status": "completed",
|
|
8
8
|
"dryRun": false,
|
|
9
|
-
"pid":
|
|
9
|
+
"pid": 217461
|
|
10
10
|
},
|
|
11
11
|
"progress": {
|
|
12
|
-
"total":
|
|
13
|
-
"passed":
|
|
14
|
-
"failed":
|
|
12
|
+
"total": 4,
|
|
13
|
+
"passed": 4,
|
|
14
|
+
"failed": 0,
|
|
15
15
|
"paused": 0,
|
|
16
16
|
"blocked": 0,
|
|
17
17
|
"pending": 0
|
|
18
18
|
},
|
|
19
19
|
"cost": {
|
|
20
20
|
"spent": 0,
|
|
21
|
-
"limit":
|
|
21
|
+
"limit": 3
|
|
22
22
|
},
|
|
23
23
|
"current": null,
|
|
24
|
-
"iterations":
|
|
25
|
-
"updatedAt": "2026-03-
|
|
26
|
-
"durationMs":
|
|
24
|
+
"iterations": 0,
|
|
25
|
+
"updatedAt": "2026-03-07T06:19:54.528Z",
|
|
26
|
+
"durationMs": 1000,
|
|
27
|
+
"lastHeartbeat": "2026-03-07T06:19:34.987Z"
|
|
27
28
|
}
|
package/package.json
CHANGED
|
@@ -147,6 +147,12 @@ export function installCrashHandlers(ctx: CrashRecoveryContext): () => void {
|
|
|
147
147
|
|
|
148
148
|
// Signal handler
|
|
149
149
|
const handleSignal = async (signal: NodeJS.Signals) => {
|
|
150
|
+
// Hard deadline: force exit if any async operation hangs (FIX-H5)
|
|
151
|
+
const hardDeadline = setTimeout(() => {
|
|
152
|
+
process.exit(128 + getSignalNumber(signal));
|
|
153
|
+
}, 10_000);
|
|
154
|
+
if (hardDeadline.unref) hardDeadline.unref();
|
|
155
|
+
|
|
150
156
|
logger?.error("crash-recovery", `Received ${signal}, shutting down...`, { signal });
|
|
151
157
|
|
|
152
158
|
// Kill all spawned agent processes
|
|
@@ -166,6 +172,7 @@ export function installCrashHandlers(ctx: CrashRecoveryContext): () => void {
|
|
|
166
172
|
// Stop heartbeat
|
|
167
173
|
stopHeartbeat();
|
|
168
174
|
|
|
175
|
+
clearTimeout(hardDeadline);
|
|
169
176
|
// Exit cleanly
|
|
170
177
|
process.exit(128 + getSignalNumber(signal));
|
|
171
178
|
};
|
|
@@ -26,7 +26,7 @@ import type { PluginRegistry } from "../../plugins/registry";
|
|
|
26
26
|
import type { PRD } from "../../prd";
|
|
27
27
|
import { loadPRD } from "../../prd";
|
|
28
28
|
import { installCrashHandlers } from "../crash-recovery";
|
|
29
|
-
import { acquireLock, hookCtx } from "../helpers";
|
|
29
|
+
import { acquireLock, hookCtx, releaseLock } from "../helpers";
|
|
30
30
|
import { PidRegistry } from "../pid-registry";
|
|
31
31
|
import { StatusWriter } from "../status-writer";
|
|
32
32
|
|
|
@@ -37,7 +37,7 @@ export interface RunSetupOptions {
|
|
|
37
37
|
hooks: LoadedHooksConfig;
|
|
38
38
|
feature: string;
|
|
39
39
|
dryRun: boolean;
|
|
40
|
-
statusFile
|
|
40
|
+
statusFile: string;
|
|
41
41
|
logFilePath?: string;
|
|
42
42
|
runId: string;
|
|
43
43
|
startedAt: string;
|
|
@@ -157,48 +157,56 @@ export async function setupRun(options: RunSetupOptions): Promise<RunSetupResult
|
|
|
157
157
|
throw new LockAcquisitionError(workdir);
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
160
|
+
// Everything after lock acquisition is wrapped in try-catch to ensure
|
|
161
|
+
// the lock is released if any setup step fails (FIX-H16)
|
|
162
|
+
try {
|
|
163
|
+
// Load plugins (before try block so it's accessible in finally)
|
|
164
|
+
const globalPluginsDir = path.join(os.homedir(), ".nax", "plugins");
|
|
165
|
+
const projectPluginsDir = path.join(workdir, "nax", "plugins");
|
|
166
|
+
const configPlugins = config.plugins || [];
|
|
167
|
+
const pluginRegistry = await loadPlugins(globalPluginsDir, projectPluginsDir, configPlugins, workdir);
|
|
168
|
+
|
|
169
|
+
// Log plugins loaded
|
|
170
|
+
logger?.info("plugins", `Loaded ${pluginRegistry.plugins.length} plugins`, {
|
|
171
|
+
plugins: pluginRegistry.plugins.map((p) => ({ name: p.name, version: p.version, provides: p.provides })),
|
|
172
|
+
});
|
|
170
173
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
174
|
+
// Log run start
|
|
175
|
+
const routingMode = config.routing.llm?.mode ?? "hybrid";
|
|
176
|
+
logger?.info("run.start", `Starting feature: ${feature}`, {
|
|
177
|
+
runId,
|
|
178
|
+
feature,
|
|
179
|
+
workdir,
|
|
180
|
+
dryRun,
|
|
181
|
+
routingMode,
|
|
182
|
+
});
|
|
180
183
|
|
|
181
|
-
|
|
182
|
-
|
|
184
|
+
// Fire on-start hook
|
|
185
|
+
await fireHook(hooks, "on-start", hookCtx(feature), workdir);
|
|
183
186
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
187
|
+
// Initialize run: check agent, reconcile state, validate limits
|
|
188
|
+
const { initializeRun } = await import("./run-initialization");
|
|
189
|
+
const initResult = await initializeRun({
|
|
190
|
+
config,
|
|
191
|
+
prdPath,
|
|
192
|
+
workdir,
|
|
193
|
+
dryRun,
|
|
194
|
+
});
|
|
195
|
+
prd = initResult.prd;
|
|
196
|
+
const counts = initResult.storyCounts;
|
|
194
197
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
198
|
+
return {
|
|
199
|
+
statusWriter,
|
|
200
|
+
pidRegistry,
|
|
201
|
+
cleanupCrashHandlers,
|
|
202
|
+
pluginRegistry,
|
|
203
|
+
prd,
|
|
204
|
+
storyCounts: counts,
|
|
205
|
+
interactionChain,
|
|
206
|
+
};
|
|
207
|
+
} catch (error) {
|
|
208
|
+
// Release lock before re-throwing so the directory isn't permanently locked
|
|
209
|
+
await releaseLock(workdir);
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
204
212
|
}
|
package/src/execution/lock.ts
CHANGED
|
@@ -49,22 +49,38 @@ export async function acquireLock(workdir: string): Promise<boolean> {
|
|
|
49
49
|
if (exists) {
|
|
50
50
|
// Read lock data
|
|
51
51
|
const lockContent = await lockFile.text();
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
52
|
+
let lockData: { pid: number };
|
|
53
|
+
try {
|
|
54
|
+
lockData = JSON.parse(lockContent);
|
|
55
|
+
} catch {
|
|
56
|
+
// Corrupt/unparseable lock file — treat as stale and delete
|
|
57
|
+
const logger = getSafeLogger();
|
|
58
|
+
logger?.warn("execution", "Corrupt lock file detected, removing", {
|
|
59
|
+
lockPath,
|
|
60
|
+
});
|
|
61
|
+
const fs = await import("node:fs/promises");
|
|
62
|
+
await fs.unlink(lockPath).catch(() => {});
|
|
63
|
+
// Fall through to create a new lock
|
|
64
|
+
lockData = undefined as unknown as { pid: number };
|
|
59
65
|
}
|
|
60
66
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
if (lockData) {
|
|
68
|
+
const lockPid = lockData.pid;
|
|
69
|
+
|
|
70
|
+
// Check if the process is still alive
|
|
71
|
+
if (isProcessAlive(lockPid)) {
|
|
72
|
+
// Process is alive, lock is valid
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Process is dead, remove stale lock
|
|
77
|
+
const logger = getSafeLogger();
|
|
78
|
+
logger?.warn("execution", "Removing stale lock", {
|
|
79
|
+
pid: lockPid,
|
|
80
|
+
});
|
|
81
|
+
const fs = await import("node:fs/promises");
|
|
82
|
+
await fs.unlink(lockPath).catch(() => {});
|
|
83
|
+
}
|
|
68
84
|
}
|
|
69
85
|
|
|
70
86
|
// Create lock file atomically using exclusive create (O_CREAT | O_EXCL)
|
|
@@ -180,8 +180,7 @@ async function executeParallelBatch(
|
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
// Execute stories in parallel with concurrency limit
|
|
183
|
-
const executing
|
|
184
|
-
let activeCount = 0;
|
|
183
|
+
const executing = new Set<Promise<void>>();
|
|
185
184
|
|
|
186
185
|
for (const { story, worktreePath } of worktreeSetup) {
|
|
187
186
|
const routing = routeTask(story.title, story.description, story.acceptanceCriteria, story.tags, config);
|
|
@@ -205,19 +204,13 @@ async function executeParallelBatch(
|
|
|
205
204
|
}
|
|
206
205
|
})
|
|
207
206
|
.finally(() => {
|
|
208
|
-
|
|
209
|
-
// BUG-4 fix: Remove completed promise from executing array
|
|
210
|
-
const index = executing.indexOf(executePromise);
|
|
211
|
-
if (index > -1) {
|
|
212
|
-
executing.splice(index, 1);
|
|
213
|
-
}
|
|
207
|
+
executing.delete(executePromise);
|
|
214
208
|
});
|
|
215
209
|
|
|
216
|
-
executing.
|
|
217
|
-
activeCount++;
|
|
210
|
+
executing.add(executePromise);
|
|
218
211
|
|
|
219
212
|
// Wait if we've hit the concurrency limit
|
|
220
|
-
if (
|
|
213
|
+
if (executing.size >= maxConcurrency) {
|
|
221
214
|
await Promise.race(executing);
|
|
222
215
|
}
|
|
223
216
|
}
|
|
@@ -148,7 +148,7 @@ export async function handlePipelineFailure(
|
|
|
148
148
|
});
|
|
149
149
|
|
|
150
150
|
if (ctx.story.attempts !== undefined && ctx.story.attempts >= ctx.config.execution.rectification.maxRetries) {
|
|
151
|
-
pipelineEventBus.
|
|
151
|
+
await pipelineEventBus.emitAsync({
|
|
152
152
|
type: "human-review:requested",
|
|
153
153
|
storyId: ctx.story.id,
|
|
154
154
|
reason: pipelineResult.reason || "Max retries exceeded",
|
package/src/execution/runner.ts
CHANGED
|
@@ -47,8 +47,8 @@ export interface RunOptions {
|
|
|
47
47
|
parallel?: number;
|
|
48
48
|
/** Optional event emitter for TUI integration */
|
|
49
49
|
eventEmitter?: PipelineEventEmitter;
|
|
50
|
-
/** Path to write a machine-readable JSON status file
|
|
51
|
-
statusFile
|
|
50
|
+
/** Path to write a machine-readable JSON status file */
|
|
51
|
+
statusFile: string;
|
|
52
52
|
/** Path to JSONL log file (for crash recovery) */
|
|
53
53
|
logFilePath?: string;
|
|
54
54
|
/** Formatter verbosity mode for headless stdout (default: "normal") */
|
|
@@ -99,6 +99,9 @@ export async function run(options: RunOptions): Promise<RunResult> {
|
|
|
99
99
|
|
|
100
100
|
const logger = getSafeLogger();
|
|
101
101
|
|
|
102
|
+
// Declare prd before crash handler setup to avoid TDZ if SIGTERM arrives during setupRun
|
|
103
|
+
let prd: Awaited<ReturnType<typeof import("./lifecycle/run-setup").setupRun>>["prd"] | undefined;
|
|
104
|
+
|
|
102
105
|
// ── Execute initial setup phase ──────────────────────────────────────────────
|
|
103
106
|
const { setupRun } = await import("./lifecycle/run-setup");
|
|
104
107
|
const setupResult = await setupRun({
|
|
@@ -120,7 +123,7 @@ export async function run(options: RunOptions): Promise<RunResult> {
|
|
|
120
123
|
getIterations: () => iterations,
|
|
121
124
|
// BUG-017: Pass getters for run.complete event on SIGTERM
|
|
122
125
|
getStoriesCompleted: () => storiesCompleted,
|
|
123
|
-
getTotalStories: () => countStories(prd).total,
|
|
126
|
+
getTotalStories: () => (prd ? countStories(prd).total : 0),
|
|
124
127
|
});
|
|
125
128
|
|
|
126
129
|
const {
|
|
@@ -131,7 +134,7 @@ export async function run(options: RunOptions): Promise<RunResult> {
|
|
|
131
134
|
storyCounts: counts,
|
|
132
135
|
interactionChain,
|
|
133
136
|
} = setupResult;
|
|
134
|
-
|
|
137
|
+
prd = setupResult.prd;
|
|
135
138
|
|
|
136
139
|
try {
|
|
137
140
|
// ── Output run header in headless mode ─────────────────────────────────
|
|
@@ -50,7 +50,7 @@ export interface StatusWriterContext {
|
|
|
50
50
|
* await sw.update(totalCost, iterations);
|
|
51
51
|
*/
|
|
52
52
|
export class StatusWriter {
|
|
53
|
-
private readonly statusFile: string
|
|
53
|
+
private readonly statusFile: string;
|
|
54
54
|
private readonly costLimit: number | null;
|
|
55
55
|
private readonly ctx: StatusWriterContext;
|
|
56
56
|
|
|
@@ -60,7 +60,7 @@ export class StatusWriter {
|
|
|
60
60
|
private _currentStory: RunStateSnapshot["currentStory"] = null;
|
|
61
61
|
private _consecutiveWriteFailures = 0; // BUG-2: Track consecutive write failures
|
|
62
62
|
|
|
63
|
-
constructor(statusFile: string
|
|
63
|
+
constructor(statusFile: string, config: NaxConfig, ctx: StatusWriterContext) {
|
|
64
64
|
this.statusFile = statusFile;
|
|
65
65
|
this.costLimit = config.execution.costLimit === Number.POSITIVE_INFINITY ? null : config.execution.costLimit;
|
|
66
66
|
this.ctx = ctx;
|
|
@@ -107,7 +107,7 @@ export class StatusWriter {
|
|
|
107
107
|
/**
|
|
108
108
|
* Write the current status to disk (atomic via .tmp + rename).
|
|
109
109
|
*
|
|
110
|
-
* No-ops if
|
|
110
|
+
* No-ops if _prd has not been set.
|
|
111
111
|
* On failure, logs a warning/error and increments the BUG-2 failure counter.
|
|
112
112
|
* Counter resets to 0 on next successful write.
|
|
113
113
|
*
|
|
@@ -116,7 +116,7 @@ export class StatusWriter {
|
|
|
116
116
|
* @param overrides - Optional partial snapshot overrides (spread last)
|
|
117
117
|
*/
|
|
118
118
|
async update(totalCost: number, iterations: number, overrides: Partial<RunStateSnapshot> = {}): Promise<void> {
|
|
119
|
-
if (!this.
|
|
119
|
+
if (!this._prd) return;
|
|
120
120
|
const safeLogger = getSafeLogger();
|
|
121
121
|
try {
|
|
122
122
|
const base = this.getSnapshot(totalCost, iterations);
|
|
@@ -133,9 +133,11 @@ export const acceptanceStage: PipelineStage = {
|
|
|
133
133
|
stderr: "pipe",
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
-
const exitCode = await
|
|
137
|
-
|
|
138
|
-
|
|
136
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
137
|
+
proc.exited,
|
|
138
|
+
new Response(proc.stdout).text(),
|
|
139
|
+
new Response(proc.stderr).text(),
|
|
140
|
+
]);
|
|
139
141
|
|
|
140
142
|
// Combine stdout and stderr for parsing
|
|
141
143
|
const output = `${stdout}\n${stderr}`;
|
|
@@ -113,9 +113,11 @@ interface CommandResult {
|
|
|
113
113
|
async function runCommand(cmd: string, cwd: string): Promise<CommandResult> {
|
|
114
114
|
const parts = cmd.split(/\s+/);
|
|
115
115
|
const proc = Bun.spawn(parts, { cwd, stdout: "pipe", stderr: "pipe" });
|
|
116
|
-
const exitCode = await
|
|
117
|
-
|
|
118
|
-
|
|
116
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
117
|
+
proc.exited,
|
|
118
|
+
new Response(proc.stdout).text(),
|
|
119
|
+
new Response(proc.stderr).text(),
|
|
120
|
+
]);
|
|
119
121
|
return { exitCode, output: `${stdout}\n${stderr}` };
|
|
120
122
|
}
|
|
121
123
|
|
|
@@ -98,6 +98,8 @@ async function callLlmOnce(modelTier: string, prompt: string, config: NaxConfig,
|
|
|
98
98
|
reject(new Error(`LLM call timeout after ${timeoutMs}ms`));
|
|
99
99
|
}, timeoutMs);
|
|
100
100
|
});
|
|
101
|
+
// Prevent unhandled rejection if timer fires between race resolution and clearTimeout
|
|
102
|
+
timeoutPromise.catch(() => {});
|
|
101
103
|
|
|
102
104
|
const outputPromise = (async () => {
|
|
103
105
|
const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
|
@@ -116,17 +118,16 @@ async function callLlmOnce(modelTier: string, prompt: string, config: NaxConfig,
|
|
|
116
118
|
return result;
|
|
117
119
|
} catch (err) {
|
|
118
120
|
clearTimeout(timeoutId);
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
try {
|
|
125
|
-
proc.stderr.cancel();
|
|
126
|
-
} catch {
|
|
127
|
-
// ignore cancel errors
|
|
128
|
-
}
|
|
121
|
+
// Silence the floating outputPromise BEFORE killing the process.
|
|
122
|
+
// proc.kill() causes piped streams to error → Response.text() rejects →
|
|
123
|
+
// outputPromise rejects. The .catch() must be attached first to prevent
|
|
124
|
+
// an unhandled rejection that crashes nax via crash-recovery.
|
|
125
|
+
outputPromise.catch(() => {});
|
|
129
126
|
proc.kill();
|
|
127
|
+
// DO NOT call proc.stdout.cancel() / proc.stderr.cancel() here.
|
|
128
|
+
// The streams are locked by Response.text() readers. Per Web Streams spec,
|
|
129
|
+
// cancel() on a locked stream returns a rejected Promise (not a sync throw),
|
|
130
|
+
// which becomes an unhandled rejection. Let proc.kill() handle cleanup.
|
|
130
131
|
throw err;
|
|
131
132
|
}
|
|
132
133
|
}
|
|
@@ -34,8 +34,16 @@ async function drainWithDeadline(proc: Subprocess, deadlineMs: number): Promise<
|
|
|
34
34
|
if (o !== EMPTY) out += o;
|
|
35
35
|
if (e !== EMPTY) out += (out ? "\n" : "") + e;
|
|
36
36
|
} catch (error) {
|
|
37
|
-
//
|
|
38
|
-
|
|
37
|
+
// Expected: streams destroyed after kill (e.g. TypeError from closed ReadableStream)
|
|
38
|
+
const isExpectedStreamError =
|
|
39
|
+
error instanceof TypeError ||
|
|
40
|
+
(error instanceof Error && /abort|cancel|close|destroy|locked/i.test(error.message));
|
|
41
|
+
if (!isExpectedStreamError) {
|
|
42
|
+
const { getSafeLogger } = await import("../logger");
|
|
43
|
+
getSafeLogger()?.debug("executor", "Unexpected error draining process output", {
|
|
44
|
+
error: error instanceof Error ? error.message : String(error),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
39
47
|
}
|
|
40
48
|
return out;
|
|
41
49
|
}
|
|
@@ -93,15 +101,19 @@ export async function executeWithTimeout(
|
|
|
93
101
|
const timeoutMs = timeoutSeconds * 1000;
|
|
94
102
|
|
|
95
103
|
let timedOut = false;
|
|
104
|
+
const timer = { id: undefined as ReturnType<typeof setTimeout> | undefined };
|
|
96
105
|
|
|
97
|
-
const timeoutPromise =
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
106
|
+
const timeoutPromise = new Promise<void>((resolve) => {
|
|
107
|
+
timer.id = setTimeout(() => {
|
|
108
|
+
timedOut = true;
|
|
109
|
+
resolve();
|
|
110
|
+
}, timeoutMs);
|
|
111
|
+
});
|
|
101
112
|
|
|
102
113
|
const processPromise = proc.exited;
|
|
103
114
|
|
|
104
115
|
const raceResult = await Promise.race([processPromise, timeoutPromise]);
|
|
116
|
+
clearTimeout(timer.id);
|
|
105
117
|
|
|
106
118
|
if (timedOut) {
|
|
107
119
|
const pid = proc.pid;
|
|
@@ -283,9 +283,9 @@ describe("acquireLock and releaseLock", () => {
|
|
|
283
283
|
// Create invalid JSON lock file
|
|
284
284
|
await Bun.write(lockPath, "not valid json");
|
|
285
285
|
|
|
286
|
-
// Should
|
|
286
|
+
// Should treat corrupt lock as stale and acquire successfully
|
|
287
287
|
const acquired = await acquireLock(testDir);
|
|
288
|
-
expect(acquired).toBe(
|
|
288
|
+
expect(acquired).toBe(true);
|
|
289
289
|
});
|
|
290
290
|
|
|
291
291
|
test("handles release when lock file doesn't exist", async () => {
|
|
@@ -3,12 +3,11 @@
|
|
|
3
3
|
* Integration Tests: Status File — runner + CLI (T2)
|
|
4
4
|
*
|
|
5
5
|
* Verifies:
|
|
6
|
-
* - RunOptions.statusFile
|
|
7
|
-
* - Status file written at all 4 write points (dry-run path)
|
|
8
|
-
* - Status file NOT written when statusFile omitted
|
|
6
|
+
* - RunOptions.statusFile: string is required
|
|
7
|
+
* - Status file always written at all 4 write points (dry-run path)
|
|
9
8
|
* - Valid JSON at each stage, NaxStatusFile schema correct
|
|
10
9
|
* - completed status, progress counts, null current at end
|
|
11
|
-
* -
|
|
10
|
+
* - CLI automatically computes statusFile to <workdir>/nax/status.json
|
|
12
11
|
*/
|
|
13
12
|
|
|
14
13
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
|
|
@@ -53,13 +52,13 @@ class MockAgentAdapter implements AgentAdapter {
|
|
|
53
52
|
return [this.binary];
|
|
54
53
|
}
|
|
55
54
|
async run(_o: AgentRunOptions): Promise<AgentResult> {
|
|
56
|
-
return { success: true, exitCode: 0, output: "", durationMs: 10, estimatedCost: 0 };
|
|
55
|
+
return { success: true, exitCode: 0, output: "", durationMs: 10, estimatedCost: 0, rateLimited: false };
|
|
57
56
|
}
|
|
58
57
|
async plan(_o: PlanOptions): Promise<PlanResult> {
|
|
59
|
-
return { specContent: "# Feature\n"
|
|
58
|
+
return { specContent: "# Feature\n" };
|
|
60
59
|
}
|
|
61
60
|
async decompose(_o: DecomposeOptions): Promise<DecomposeResult> {
|
|
62
|
-
return { stories: []
|
|
61
|
+
return { stories: [] };
|
|
63
62
|
}
|
|
64
63
|
}
|
|
65
64
|
|
|
@@ -143,7 +142,7 @@ async function runWithStatus(feature: string, storyCount = 1, extraOpts: Partial
|
|
|
143
142
|
// RunOptions type-level checks
|
|
144
143
|
// ============================================================================
|
|
145
144
|
describe("RunOptions.statusFile", () => {
|
|
146
|
-
it("is
|
|
145
|
+
it("is required", () => {
|
|
147
146
|
const opts: RunOptions = {
|
|
148
147
|
prdPath: "/tmp/prd.json",
|
|
149
148
|
workdir: "/tmp",
|
|
@@ -151,52 +150,25 @@ describe("RunOptions.statusFile", () => {
|
|
|
151
150
|
hooks: { hooks: {} },
|
|
152
151
|
feature: "test",
|
|
153
152
|
dryRun: true,
|
|
153
|
+
statusFile: "/tmp/nax/status.json",
|
|
154
154
|
};
|
|
155
|
-
expect(opts.statusFile).
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it("accepts a string value", () => {
|
|
159
|
-
const opts: RunOptions = {
|
|
160
|
-
prdPath: "/tmp/prd.json",
|
|
161
|
-
workdir: "/tmp",
|
|
162
|
-
config: createTestConfig(),
|
|
163
|
-
hooks: { hooks: {} },
|
|
164
|
-
feature: "test",
|
|
165
|
-
dryRun: true,
|
|
166
|
-
statusFile: "/tmp/nax-status.json",
|
|
167
|
-
};
|
|
168
|
-
expect(opts.statusFile).toBe("/tmp/nax-status.json");
|
|
155
|
+
expect(opts.statusFile).toBe("/tmp/nax/status.json");
|
|
169
156
|
});
|
|
170
157
|
});
|
|
171
158
|
|
|
172
159
|
// ============================================================================
|
|
173
|
-
//
|
|
160
|
+
// Status file is always written when provided
|
|
174
161
|
// ============================================================================
|
|
175
|
-
describe("status file
|
|
162
|
+
describe("status file always written when provided", () => {
|
|
176
163
|
let tmpDir: string;
|
|
177
164
|
afterEach(async () => {
|
|
178
165
|
if (tmpDir) await fs.rm(tmpDir, { recursive: true, force: true });
|
|
179
166
|
});
|
|
180
167
|
|
|
181
|
-
it("
|
|
182
|
-
const setup = await
|
|
168
|
+
it("writes status file to provided path during dry-run", async () => {
|
|
169
|
+
const { setup, statusFilePath } = await runWithStatus("sf-always-written", 1);
|
|
183
170
|
tmpDir = setup.tmpDir;
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
await run({
|
|
187
|
-
prdPath: setup.prdPath,
|
|
188
|
-
workdir: setup.tmpDir,
|
|
189
|
-
config: createTestConfig(),
|
|
190
|
-
hooks: { hooks: {} },
|
|
191
|
-
feature: "no-sf",
|
|
192
|
-
featureDir: setup.featureDir,
|
|
193
|
-
dryRun: true, // no statusFile
|
|
194
|
-
skipPrecheck: true,
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
const after = await fs.readdir(setup.tmpDir);
|
|
198
|
-
const newJson = after.filter((f) => f.endsWith(".json") && !before.includes(f));
|
|
199
|
-
expect(newJson).toHaveLength(0);
|
|
171
|
+
expect(nodeFs.existsSync(statusFilePath)).toBe(true);
|
|
200
172
|
});
|
|
201
173
|
});
|
|
202
174
|
|
|
@@ -299,28 +271,19 @@ describe("status file written during dry-run", () => {
|
|
|
299
271
|
});
|
|
300
272
|
|
|
301
273
|
// ============================================================================
|
|
302
|
-
// CLI
|
|
274
|
+
// CLI status file wiring (type check only)
|
|
303
275
|
// ============================================================================
|
|
304
|
-
describe("CLI
|
|
305
|
-
it("RunOptions.statusFile is
|
|
306
|
-
const
|
|
307
|
-
prdPath: "/tmp/prd.json",
|
|
308
|
-
workdir: "/tmp",
|
|
309
|
-
config: createTestConfig(),
|
|
310
|
-
hooks: { hooks: {} },
|
|
311
|
-
feature: "test",
|
|
312
|
-
dryRun: false,
|
|
313
|
-
statusFile: "/tmp/status.json",
|
|
314
|
-
};
|
|
315
|
-
const withoutFile: RunOptions = {
|
|
276
|
+
describe("CLI auto-computed status file", () => {
|
|
277
|
+
it("RunOptions.statusFile is required and always provided", () => {
|
|
278
|
+
const opts: RunOptions = {
|
|
316
279
|
prdPath: "/tmp/prd.json",
|
|
317
280
|
workdir: "/tmp",
|
|
318
281
|
config: createTestConfig(),
|
|
319
282
|
hooks: { hooks: {} },
|
|
320
283
|
feature: "test",
|
|
321
284
|
dryRun: false,
|
|
285
|
+
statusFile: "/tmp/nax/status.json",
|
|
322
286
|
};
|
|
323
|
-
expect(
|
|
324
|
-
expect(withoutFile.statusFile).toBeUndefined();
|
|
287
|
+
expect(opts.statusFile).toBe("/tmp/nax/status.json");
|
|
325
288
|
});
|
|
326
289
|
});
|
|
@@ -77,14 +77,10 @@ function makeCtx(overrides: Partial<StatusWriterContext> = {}): StatusWriterCont
|
|
|
77
77
|
// ============================================================================
|
|
78
78
|
|
|
79
79
|
describe("StatusWriter construction", () => {
|
|
80
|
-
test("constructs without error
|
|
80
|
+
test("constructs without error with statusFile path", () => {
|
|
81
81
|
expect(() => new StatusWriter("/tmp/status.json", makeConfig(), makeCtx())).not.toThrow();
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
-
test("constructs without error when statusFile is undefined (no-op mode)", () => {
|
|
85
|
-
expect(() => new StatusWriter(undefined, makeConfig(), makeCtx())).not.toThrow();
|
|
86
|
-
});
|
|
87
|
-
|
|
88
84
|
test("costLimit Infinity → stored as null in snapshot", async () => {
|
|
89
85
|
const dir = await mkdtemp(join(tmpdir(), "sw-test-"));
|
|
90
86
|
const path = join(dir, "status.json");
|
|
@@ -214,13 +210,6 @@ describe("StatusWriter.getSnapshot", () => {
|
|
|
214
210
|
// ============================================================================
|
|
215
211
|
|
|
216
212
|
describe("StatusWriter.update no-op guards", () => {
|
|
217
|
-
test("no-op when statusFile is undefined (even with prd set)", async () => {
|
|
218
|
-
const sw = new StatusWriter(undefined, makeConfig(), makeCtx());
|
|
219
|
-
sw.setPrd(makePrd());
|
|
220
|
-
// Should not throw
|
|
221
|
-
await expect(sw.update(0, 0)).resolves.toBeUndefined();
|
|
222
|
-
});
|
|
223
|
-
|
|
224
213
|
test("no-op when prd not yet set", async () => {
|
|
225
214
|
const dir = await mkdtemp(join(tmpdir(), "sw-test-"));
|
|
226
215
|
const path = join(dir, "status.json");
|