@deksden-com/dd-flow-cli 0.9.0-beta.54 → 0.9.0-beta.56
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/CHANGELOG.md +12 -0
- package/README.md +22 -0
- package/dist/build-info.json +5 -5
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +32 -6
- package/dist/harness-runtime/lib/dd-zcode.mjs +101 -18
- package/dist/schemas/code-work-batch.schema.json +1 -1
- package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
- package/dist/services/code-checks.js +44 -11
- package/dist/services/lifecycle-invocations.js +24 -4
- package/dist/services/native-session-control.js +4 -4
- package/dist/services/run-control.js +4 -2
- package/dist/services/run-controller-process.js +12 -1
- package/dist/services/run-controller.js +27 -6
- package/dist/services/run-recovery.js +15 -4
- package/dist/services/runs.js +46 -18
- package/dist/services/vnext-code-review.js +3 -5
- package/dist/services/vnext-code.js +8 -9
- package/dist/services/vnext-merge.js +2 -2
- package/dist/services/vnext-plan.js +5 -2
- package/package.json +1 -1
- package/tools/repair-paused-run-status.mjs +59 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @deksden-com/dd-flow-cli
|
|
2
2
|
|
|
3
|
+
## 0.9.0-beta.56
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Keep RUN database and runtime status aligned with pause, stop, controller failures and trusted recovery. Preserve real Stage pauses and terminal results, reject late revival under a recovery fence, roll back status changes after persistence failures, and provide an explicit backed-up legacy status repair tool.
|
|
8
|
+
|
|
9
|
+
## 0.9.0-beta.55
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Require autonomous check evidence, preflight commands in their actual workspace, bind final retry arguments, and settle ZCode cancellation through verified native residency. Retain closed sessions without implicit resume and store growing transcripts in reusable content-addressed chunks.
|
|
14
|
+
|
|
3
15
|
## 0.9.0-beta.54
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -452,3 +452,25 @@ pnpm build
|
|
|
452
452
|
```
|
|
453
453
|
|
|
454
454
|
Manual smoke checks should use a temporary `DD_FLOW_HOME` when they do not intentionally inspect the operator's real local state.
|
|
455
|
+
|
|
456
|
+
### RUN status during recovery
|
|
457
|
+
|
|
458
|
+
A controller failure pauses the logical RUN without rewriting its Stage/Work
|
|
459
|
+
outcomes. Accepted pause/stop or recovery intent immediately persists `paused` in the RUN row,
|
|
460
|
+
`index_json`, and `run.json`. A trusted recovery acknowledgement restores the
|
|
461
|
+
operational status; an existing Stage/Work pause remains paused. Terminal RUNs
|
|
462
|
+
are never reopened. Physical tree settlement remains a separate control receipt.
|
|
463
|
+
|
|
464
|
+
Legacy stores with a fenced recovery may still contain `running`. After building,
|
|
465
|
+
inspect one exact RUN with:
|
|
466
|
+
|
|
467
|
+
```sh
|
|
468
|
+
node tools/repair-paused-run-status.mjs --home /path/to/dd-flow-home --project PRJ-001-project --run RUN-001-eval-subject
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
Add `--apply` to repair it. This requires the latest recovery to be draining, sealed or resuming, saves the original row and runtime JSON
|
|
472
|
+
beside `run.json`, and changes no schema, engine binding or recovery capture.
|
|
473
|
+
It does not claim physical settlement or alter its receipts. `--cancel-abandoned`
|
|
474
|
+
explicitly cancels an abandoned fixture only when the project directory is absent
|
|
475
|
+
and it has no Work, Session, controller or recovery history. Historical experiment
|
|
476
|
+
engine snapshots remain pinned; rebuild/release the engine for future executions.
|
package/dist/build-info.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"cli_package": "@deksden-com/dd-flow-cli",
|
|
3
|
-
"cli_version": "0.9.0-beta.
|
|
4
|
-
"cli_commit": "
|
|
5
|
-
"built_at": "2026-09-
|
|
3
|
+
"cli_version": "0.9.0-beta.56",
|
|
4
|
+
"cli_commit": "ae4bbaa5b2299aeedf3ee90bccc8ed3db19a6191",
|
|
5
|
+
"built_at": "2026-09-13T21:08:32.108Z",
|
|
6
6
|
"built_with_canon": {
|
|
7
|
-
"version": "4.1.
|
|
8
|
-
"commit": "
|
|
7
|
+
"version": "4.1.1",
|
|
8
|
+
"commit": "97f811d33c212ae3497020178b1ed825c7c3ebac",
|
|
9
9
|
"flow_contract": "dd-flow-canonical-2026-08",
|
|
10
10
|
"repo_root": "/home/runner/work/dd-flow-cli/dd-flow-cli/dd-memorybank",
|
|
11
11
|
"memorybank_root": "/home/runner/work/dd-flow-cli/dd-flow-cli/dd-memorybank/.memory-bank",
|
|
@@ -245,6 +245,7 @@ export class DaemonRuntime {
|
|
|
245
245
|
root_provider_session_id: existing?.root_provider_session_id ?? (parent ? parentRoot : result.provider_session_id),
|
|
246
246
|
topology: topology ?? existing?.topology ?? null,
|
|
247
247
|
completed_turns: existing?.completed_turns ?? 0,
|
|
248
|
+
close_receipt: result.close?.closed === true ? result : existing?.close_receipt ?? null,
|
|
248
249
|
}); }
|
|
249
250
|
if (result?.adapter_session_id) this.liveAdapters.add(result.adapter_session_id);
|
|
250
251
|
for (const child of topology?.running ?? []) {
|
|
@@ -254,6 +255,8 @@ export class DaemonRuntime {
|
|
|
254
255
|
}
|
|
255
256
|
|
|
256
257
|
treeRunning(result) {
|
|
258
|
+
const closed = this.sessions.get(result?.provider_session_id)?.close_receipt;
|
|
259
|
+
if (closed) return closed.settled !== true;
|
|
257
260
|
const evidence = result?.evidence ?? result;
|
|
258
261
|
const children = evidence?.subagents?.running;
|
|
259
262
|
return evidence?.read?.projection?.status !== "idle" || !Array.isArray(children) || children.length > 0 || Boolean(evidence?.subagents?.observation_error);
|
|
@@ -277,6 +280,7 @@ export class DaemonRuntime {
|
|
|
277
280
|
}
|
|
278
281
|
|
|
279
282
|
async requireSettled(params) {
|
|
283
|
+
if (this.sessions.get(params.sessionId)?.close_receipt) throw new DaemonError("session_closed", "Closed Session requires explicit recovery");
|
|
280
284
|
const known = this.sessions.get(params.sessionId);
|
|
281
285
|
// A freshly created ZCode Session has no prior Turn and is not yet exposed
|
|
282
286
|
// by the provider's subagent index. Its first prompt is therefore safe.
|
|
@@ -307,12 +311,18 @@ export class DaemonRuntime {
|
|
|
307
311
|
}
|
|
308
312
|
if (operation === "session.create") return await this.productive(operation, async () => await createSessionWithBridge(this.bridge, this.options(params), this.initialized));
|
|
309
313
|
if (operation === "session.prompt") {
|
|
314
|
+
if (this.sessions.get(params.sessionId)?.close_receipt) throw new DaemonError("session_closed", "Closed Session requires explicit recovery before another prompt");
|
|
310
315
|
const result = await this.productive(operation, async () => { await this.requireSettled(params); return await promptSessionWithBridge(this.bridge, this.options(params)); });
|
|
311
316
|
const session = this.sessions.get(result.provider_session_id); if (session) session.completed_turns = (session.completed_turns ?? 0) + 1;
|
|
312
317
|
await this.persist(); return result;
|
|
313
318
|
}
|
|
314
319
|
if (operation === "session.fork") return await this.productive(operation, async () => { await this.requireSettled(params); return await forkSessionWithBridge(this.bridge, this.options(params)); });
|
|
315
320
|
if (operation === "session.inspect" || operation === "session.resume") {
|
|
321
|
+
const closed = this.sessions.get(params.sessionId)?.close_receipt;
|
|
322
|
+
if (closed) {
|
|
323
|
+
if (operation === "session.resume") throw new DaemonError("session_closed", "Closed Session requires explicit recovery");
|
|
324
|
+
return { ...closed, read: { projection: { status: "stopped" } }, subagents: closed.after, settled: closed.settled === true };
|
|
325
|
+
}
|
|
316
326
|
const adapter = params.adapterSessionId ?? params.sessionId;
|
|
317
327
|
if (!this.liveAdapters.has(adapter) && this.activeProductive) {
|
|
318
328
|
throw new DaemonError(
|
|
@@ -334,10 +344,12 @@ export class DaemonRuntime {
|
|
|
334
344
|
}
|
|
335
345
|
if (operation === "session.cancel") {
|
|
336
346
|
if (!this.sessions.has(params.sessionId)) throw new DaemonError("session_identity_mismatch", "cancellation Session is not owned by this daemon");
|
|
347
|
+
const closed = this.sessions.get(params.sessionId)?.close_receipt;
|
|
348
|
+
if (closed) return closed;
|
|
337
349
|
this.controlGeneration = (this.controlGeneration ?? 0) + 1;
|
|
338
350
|
const result = await cancelSessionWithBridge(this.bridge, this.options(params));
|
|
339
351
|
this.track(result, result.after);
|
|
340
|
-
await this.persist({ active_tree: this.treeRunning(result) || Boolean(this.activeProductive) });
|
|
352
|
+
await this.persist({ active_tree: result.settled !== true && (this.treeRunning(result) || Boolean(this.activeProductive)) });
|
|
341
353
|
return result;
|
|
342
354
|
}
|
|
343
355
|
if (operation === "session.cancel-child") {
|
|
@@ -354,6 +366,10 @@ export class DaemonRuntime {
|
|
|
354
366
|
if (cancel) this.controlGeneration = (this.controlGeneration ?? 0) + 1;
|
|
355
367
|
const unsettled = [];
|
|
356
368
|
for (const session of this.sessions.values()) {
|
|
369
|
+
if (session.close_receipt) {
|
|
370
|
+
if (!session.close_receipt.settled) unsettled.push(session.provider_session_id);
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
357
373
|
const options = this.options({ sessionId: session.provider_session_id, adapterSessionId: session.adapter_session_id });
|
|
358
374
|
let observed;
|
|
359
375
|
if (!cancel) observed = await inspectSessionWithBridge(this.bridge, options);
|
|
@@ -363,11 +379,21 @@ export class DaemonRuntime {
|
|
|
363
379
|
let cancelled;
|
|
364
380
|
try { cancelled = await cancelSessionWithBridge(this.bridge, options); }
|
|
365
381
|
catch (error) { unsettled.push(session.provider_session_id); continue; }
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
382
|
+
this.track(cancelled, cancelled.after);
|
|
383
|
+
await this.persist({ active_tree: cancelled.settled !== true });
|
|
384
|
+
if (cancelled.close?.closed === true) {
|
|
385
|
+
if (!cancelled.settled) unsettled.push(session.provider_session_id);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
// A cooperative cancel needs a final read. A close receipt is
|
|
389
|
+
// terminal itself: native session/read correctly rejects it as no
|
|
390
|
+
// longer resident, so treating that rejection as unsettled would
|
|
391
|
+
// turn a successful close into an immortal daemon.
|
|
392
|
+
if (!cancelled.settled) {
|
|
393
|
+
try { observed = await inspectSessionWithBridge(this.bridge, options); }
|
|
394
|
+
catch (error) { unsettled.push(session.provider_session_id); continue; }
|
|
395
|
+
if (this.treeRunning(observed)) unsettled.push(session.provider_session_id);
|
|
396
|
+
}
|
|
371
397
|
}
|
|
372
398
|
}
|
|
373
399
|
}
|
|
@@ -2,7 +2,7 @@ import { observedTimeout } from "./observation-clock.mjs";
|
|
|
2
2
|
import { invokeNativeHook } from "./native-hook-command.mjs";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
|
-
import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
5
|
+
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import readline from "node:readline";
|
|
8
8
|
import { stopProcessGroup } from "./managed-daemon.mjs";
|
|
@@ -53,6 +53,7 @@ class Journal {
|
|
|
53
53
|
this.file = file ? absolute(file, "--journal") : null;
|
|
54
54
|
this.pending = this.file ? mkdir(path.dirname(this.file), { recursive: true }) : Promise.resolve();
|
|
55
55
|
this.order = 0;
|
|
56
|
+
this.artifacts = new Set();
|
|
56
57
|
}
|
|
57
58
|
write(kind, payload) {
|
|
58
59
|
if (!this.file) return;
|
|
@@ -60,9 +61,69 @@ class Journal {
|
|
|
60
61
|
this.pending = this.pending.then(() => appendFile(this.file, `${line}\n`));
|
|
61
62
|
return this.pending;
|
|
62
63
|
}
|
|
64
|
+
writeInbound(payload, method) {
|
|
65
|
+
if (!this.file || method !== "zcode/session/read" || !payload?.result || typeof payload.result !== "object") return this.write("inbound", payload);
|
|
66
|
+
const raw = JSON.stringify(payload.result);
|
|
67
|
+
const sha256 = createHash("sha256").update(raw).digest("hex");
|
|
68
|
+
const artifactDir = `${this.file}.artifacts`, artifact = path.join(artifactDir, `${sha256}.json`);
|
|
69
|
+
const blobs = new Map();
|
|
70
|
+
const messages = Array.isArray(payload.result.messages) ? payload.result.messages.map(message => {
|
|
71
|
+
const bytes = Buffer.from(JSON.stringify(message));
|
|
72
|
+
const chunks = [];
|
|
73
|
+
for (let offset = 0; offset < bytes.length; offset += 65536) {
|
|
74
|
+
const chunk = bytes.subarray(offset, offset + 65536);
|
|
75
|
+
const hash = createHash("sha256").update(chunk).digest("hex");
|
|
76
|
+
const file = path.join(artifactDir, `${hash}.chunk`);
|
|
77
|
+
blobs.set(file, chunk); chunks.push({ path: path.basename(file), sha256: hash, bytes: chunk.length });
|
|
78
|
+
}
|
|
79
|
+
return { chunks };
|
|
80
|
+
}) : null;
|
|
81
|
+
const manifest = JSON.stringify({ encoding: "dd-zcode/session-read@1", result: { ...payload.result, ...(messages ? { messages } : {}) } });
|
|
82
|
+
const result = { ...payload.result };
|
|
83
|
+
if (Array.isArray(result.messages)) result.messages = { omitted_count: result.messages.length };
|
|
84
|
+
const compact = { ...payload, result, raw_result: { sha256, bytes: Buffer.byteLength(raw), path: artifact, encoding: "dd-zcode/session-read@1" } };
|
|
85
|
+
const line = JSON.stringify({ order: ++this.order, observed_at: now(), kind: "inbound", payload: compact });
|
|
86
|
+
this.pending = this.pending.then(async () => {
|
|
87
|
+
await mkdir(artifactDir, { recursive: true });
|
|
88
|
+
for (const [file, bytes] of [...blobs, [artifact, manifest]]) {
|
|
89
|
+
if (this.artifacts.has(file)) continue;
|
|
90
|
+
try { await writeFile(file, bytes, { flag: "wx" }); }
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (error?.code !== "EEXIST") throw error;
|
|
93
|
+
if (!Buffer.from(await readFile(file)).equals(Buffer.from(bytes))) throw new Error(`Journal artifact conflict: ${file}`);
|
|
94
|
+
}
|
|
95
|
+
this.artifacts.add(file);
|
|
96
|
+
}
|
|
97
|
+
await appendFile(this.file, `${line}\n`);
|
|
98
|
+
});
|
|
99
|
+
return this.pending;
|
|
100
|
+
}
|
|
63
101
|
async flush() { await this.pending; }
|
|
64
102
|
}
|
|
65
103
|
|
|
104
|
+
/** Reconstruct a retained read, verifying every chunk and the original result. */
|
|
105
|
+
export async function readSessionJournalArtifact(reference) {
|
|
106
|
+
const stored = JSON.parse(await readFile(reference.path, "utf8"));
|
|
107
|
+
const result = reference.encoding === "dd-zcode/session-read@1" ? stored.result : stored;
|
|
108
|
+
if (reference.encoding === "dd-zcode/session-read@1" && Array.isArray(result.messages)) {
|
|
109
|
+
const messages = [];
|
|
110
|
+
for (const message of result.messages) {
|
|
111
|
+
const parts = [];
|
|
112
|
+
for (const chunk of message.chunks) {
|
|
113
|
+
if (chunk.path !== `${chunk.sha256}.chunk` || !/^[a-f0-9]{64}$/.test(chunk.sha256)) throw new Error("Invalid journal chunk reference");
|
|
114
|
+
const bytes = await readFile(path.join(path.dirname(reference.path), chunk.path));
|
|
115
|
+
if (bytes.length !== chunk.bytes || createHash("sha256").update(bytes).digest("hex") !== chunk.sha256) throw new Error("Journal chunk integrity mismatch");
|
|
116
|
+
parts.push(bytes);
|
|
117
|
+
}
|
|
118
|
+
messages.push(JSON.parse(Buffer.concat(parts).toString("utf8")));
|
|
119
|
+
}
|
|
120
|
+
result.messages = messages;
|
|
121
|
+
}
|
|
122
|
+
const raw = JSON.stringify(result);
|
|
123
|
+
if (Buffer.byteLength(raw) !== reference.bytes || createHash("sha256").update(raw).digest("hex") !== reference.sha256) throw new Error("Journal result integrity mismatch");
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
|
|
66
127
|
export class AcpBridge {
|
|
67
128
|
constructor(options) {
|
|
68
129
|
this.options = options;
|
|
@@ -118,7 +179,8 @@ export class AcpBridge {
|
|
|
118
179
|
receive(line) {
|
|
119
180
|
let message;
|
|
120
181
|
try { message = JSON.parse(line); } catch { this.journal.write("malformed", { line }); return; }
|
|
121
|
-
this.
|
|
182
|
+
const request = message.id !== undefined ? this.pending.get(message.id) : null;
|
|
183
|
+
this.journal.writeInbound(message, request?.method);
|
|
122
184
|
if (typeof message.params?.sessionId === "string" && isProductiveSessionNotification(message)) this.sessionActivity.set(message.params.sessionId, Date.now());
|
|
123
185
|
const update = message.params?.update;
|
|
124
186
|
// ZCode emits this as `session/update`; Grok Build wraps the same ACP
|
|
@@ -154,7 +216,7 @@ export class AcpBridge {
|
|
|
154
216
|
}
|
|
155
217
|
if (message.id !== undefined && message.method) { void this.answer(message).catch((error) => this.rejectAll(error)); return; }
|
|
156
218
|
if (message.id !== undefined) {
|
|
157
|
-
const pending = this.pending.get(message.id);
|
|
219
|
+
const pending = request ?? this.pending.get(message.id);
|
|
158
220
|
if (!pending) return;
|
|
159
221
|
clearTimeout(pending.timer); if (pending.idleTimer) clearInterval(pending.idleTimer); this.pending.delete(message.id);
|
|
160
222
|
if (message.error) {
|
|
@@ -367,7 +429,7 @@ export function latestAssistantText(read) {
|
|
|
367
429
|
return null;
|
|
368
430
|
}
|
|
369
431
|
|
|
370
|
-
async function cancelTree(bridge, sessionId, before) {
|
|
432
|
+
async function cancelTree(bridge, sessionId, before, providerSessionId) {
|
|
371
433
|
const cancellations = [];
|
|
372
434
|
for (const child of before.running ?? []) {
|
|
373
435
|
const taskId = child.taskId ?? child.agentId;
|
|
@@ -376,20 +438,41 @@ async function cancelTree(bridge, sessionId, before) {
|
|
|
376
438
|
catch (error) { cancellations.push({ taskId, cancelled: false, error: { code: error.code ?? "cancel_failed", message: error.message } }); }
|
|
377
439
|
}
|
|
378
440
|
}
|
|
379
|
-
// ZCode's stop is deliberately idempotent.
|
|
380
|
-
//
|
|
381
|
-
//
|
|
441
|
+
// ZCode's stop is deliberately idempotent. A stop acknowledgement is not a
|
|
442
|
+
// terminal receipt: native turns (and a root without children) can remain
|
|
443
|
+
// active after it. Observe a bounded drain, then close this owned Session.
|
|
382
444
|
bridge.notify("session/cancel", { sessionId });
|
|
383
|
-
let after = before;
|
|
384
|
-
|
|
445
|
+
let after = before, read = null, root_status = null;
|
|
446
|
+
const deadline = Date.now() + 6000;
|
|
447
|
+
for (let attempt = 0; attempt < 6 && Date.now() < deadline; attempt += 1) {
|
|
385
448
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
386
|
-
|
|
387
|
-
|
|
449
|
+
const timeout = Math.max(1, deadline - Date.now());
|
|
450
|
+
[after, read] = await Promise.all([
|
|
451
|
+
bridge.request("zcode/session/subagents", { sessionId }, timeout).catch(error => ({ observation_error: { code: error.code, message: error.message } })),
|
|
452
|
+
bridge.request("zcode/session/read", { sessionId }, timeout).catch(error => ({ observation_error: { code: error.code, message: error.message } })),
|
|
453
|
+
]);
|
|
454
|
+
root_status = read?.projection?.status ?? read?.session?.status ?? null;
|
|
455
|
+
if (after.observation_error || read?.observation_error) break;
|
|
456
|
+
if (Array.isArray(after.running) && !after.running.length && !(read?.projection?.activeToolCalls ?? []).length && ["idle", "completed", "cancelled", "failed", "stopped"].includes(root_status)) {
|
|
457
|
+
return { cancellations, before, after, root_status, cancellation_requested: false, settled: true, close_requested: false };
|
|
458
|
+
}
|
|
388
459
|
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
460
|
+
let close = null;
|
|
461
|
+
try { close = await bridge.request("zcode/session/close", { sessionId }); }
|
|
462
|
+
catch (error) { close = { closed: false, error: { code: error.code ?? "close_failed", message: error.message } }; }
|
|
463
|
+
const knownChildren = [...new Set([...(before.running ?? []), ...(after.running ?? [])].map(child => child.childSessionId).filter(Boolean))];
|
|
464
|
+
const residents = close?.closed === true ? await Promise.all([providerSessionId, ...knownChildren].map(async id => {
|
|
465
|
+
try {
|
|
466
|
+
const observed = await bridge.request("zcode/session/resident", { sessionId: id }, 5000);
|
|
467
|
+
if (observed.sessionId !== id) throw Object.assign(new Error("Residency evidence belongs to another Session"), { code: "session_identity_mismatch" });
|
|
468
|
+
return observed;
|
|
469
|
+
}
|
|
470
|
+
catch (error) { return { sessionId: id, observation_error: { code: error.code, message: error.message } }; }
|
|
471
|
+
})) : [];
|
|
472
|
+
const completeTopology = !before.observation_error && Array.isArray(before.running) && !after.observation_error && Array.isArray(after.running)
|
|
473
|
+
&& [...(before.running ?? []), ...after.running].every(child => typeof child.childSessionId === "string");
|
|
474
|
+
return { cancellations, before, after, root_status, cancellation_requested: true, close_requested: true, close, residents,
|
|
475
|
+
settled: close?.closed === true && completeTopology && residents.length > 0 && residents.every(item => item.resident === false) };
|
|
393
476
|
}
|
|
394
477
|
|
|
395
478
|
// Cancel one known child without cancelling its parent turn. The parent keeps
|
|
@@ -560,7 +643,7 @@ export function zcodeLifecycleQualification(sha256, bridgeCommit, harnessContrac
|
|
|
560
643
|
// Behavioural evidence: dd-eval production-invocations probe, 2026-09-13.
|
|
561
644
|
// Pin both providers of native event identity, not a string in the bundle.
|
|
562
645
|
const qualified = sha256 === "e9f1868c0fdb863537ed910ee3828b9be96b8c2fd805473f63b439e1113266b8"
|
|
563
|
-
&& bridgeCommit === "
|
|
646
|
+
&& bridgeCommit === "e0600fe3c46e215695f257e65aa6f674ced998d8" && harnessContract === ZCODE_HARNESS_CONTRACT;
|
|
564
647
|
return { status: qualified ? "qualified" : "unqualified", mechanism: "cli-invocation@1", sha256,
|
|
565
648
|
capabilities: qualified ? ["root", "concurrent_children", "child_continuation"] : [],
|
|
566
649
|
native_nested_children: false, reason: qualified ? null : "native_event_contract_unqualified" };
|
|
@@ -640,7 +723,7 @@ export async function promptSessionWithBridge(bridge, options) {
|
|
|
640
723
|
throw error;
|
|
641
724
|
}
|
|
642
725
|
if (!options.allowBackground && (evidence.subagents.running ?? []).length) {
|
|
643
|
-
await cancelTree(bridge, identity.adapterSessionId, evidence.subagents);
|
|
726
|
+
await cancelTree(bridge, identity.adapterSessionId, evidence.subagents, identity.providerSessionId);
|
|
644
727
|
fail("background ZCode subagents cannot outlive the one-shot dd-zcode connection; the live child tree was cancelled");
|
|
645
728
|
}
|
|
646
729
|
return receipt(options, { harness: "zcode-acp", provider_session_id: identity.providerSessionId, adapter_session_id: identity.adapterSessionId, assistant_text: bridge.assistantTextSince(identity.adapterSessionId, textCursor), turn, profile: profile_receipt, evidence: publicEvidence(evidence) });
|
|
@@ -666,7 +749,7 @@ export async function cancelSessionWithBridge(bridge, options) {
|
|
|
666
749
|
requireJournal(options);
|
|
667
750
|
const identity = await controlledIdentity(bridge, options);
|
|
668
751
|
const before = await bridge.request("zcode/session/subagents", { sessionId: identity.adapterSessionId }).catch(error => ({ observation_error: { code: error.code, message: error.message } }));
|
|
669
|
-
return receipt(options, { harness: "zcode-acp", provider_session_id: identity.providerSessionId, adapter_session_id: identity.adapterSessionId, ...await cancelTree(bridge, identity.adapterSessionId, before) });
|
|
752
|
+
return receipt(options, { harness: "zcode-acp", provider_session_id: identity.providerSessionId, adapter_session_id: identity.adapterSessionId, ...await cancelTree(bridge, identity.adapterSessionId, before, identity.providerSessionId) });
|
|
670
753
|
}
|
|
671
754
|
|
|
672
755
|
export async function forkSession(options) {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"spine": {"type": "object", "additionalProperties": false, "required": ["user_outcome", "component_responsibility", "must_preserve", "non_goals", "acceptance_contribution"], "properties": {"user_outcome": {"type": "string", "minLength": 1}, "component_responsibility": {"type": "string", "minLength": 1}, "must_preserve": {"$ref": "#/$defs/strings"}, "non_goals": {"$ref": "#/$defs/stringList"}, "acceptance_contribution": {"type": "string", "minLength": 1}}},
|
|
17
17
|
"obligation": {"type": "object", "additionalProperties": false, "required": ["id", "statement"], "properties": {"id": {"type": "string", "minLength": 1}, "statement": {"type": "string", "minLength": 1}}},
|
|
18
18
|
"acceptance": {"type": "object", "additionalProperties": false, "required": ["criterion_id", "path", "environment", "fixtures", "cleanup", "check_refs", "expected_evidence", "proof_limits", "gate"], "properties": {"criterion_id": {"type": "string", "pattern": "^AC-[0-9]+$"}, "path": {"type": "string", "minLength": 1}, "environment": {"type": "string", "minLength": 1}, "fixtures": {"$ref": "#/$defs/stringList"}, "cleanup": {"type": "string", "minLength": 1}, "check_refs": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "expected_evidence": {"$ref": "#/$defs/strings"}, "proof_limits": {"$ref": "#/$defs/strings"}, "gate": {"type": "string", "minLength": 1}}},
|
|
19
|
-
"check": {"type": "object", "additionalProperties": false, "required": ["id", "command", "purpose", "run_at", "availability"], "properties": {"id": {"type": "string", "pattern": "^CHK-[A-Za-z0-9-]+$"}, "command": {"type": "string", "minLength": 1}, "purpose": {"type": "string", "minLength": 1}, "run_at": {"enum": ["work", "code", "readiness", "merge", "release"
|
|
19
|
+
"check": {"type": "object", "additionalProperties": false, "required": ["id", "command", "purpose", "run_at", "availability"], "properties": {"id": {"type": "string", "pattern": "^CHK-[A-Za-z0-9-]+$"}, "command": {"type": "string", "minLength": 1}, "purpose": {"type": "string", "minLength": 1}, "run_at": {"enum": ["work", "code", "readiness", "merge", "release"]}, "availability": {"enum": ["available", "planned"]}, "provided_by": {"type": "string", "minLength": 1}, "definition": {"type": "string", "minLength": 1}, "required_artifacts": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "ports": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]*$"}}, "inputs": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "reuse": {"const": "deterministic"}}, "allOf": [{"if": {"properties": {"availability": {"const": "planned"}}, "required": ["availability"]}, "then": {"required": ["provided_by", "definition"], "properties": {"command": {"pattern": "^@check/"}}}}]},
|
|
20
20
|
"documentUpdate": {"type": "object", "additionalProperties": false, "required": ["path", "action", "owner", "reason", "baseline_sha256"], "properties": {"path": {"type": "string", "minLength": 1}, "action": {"enum": ["create", "update"]}, "owner": {"type": "string", "minLength": 1}, "reason": {"type": "string", "minLength": 1}, "baseline_sha256": {"type": ["string", "null"], "pattern": "^[a-f0-9]{64}$"}}},
|
|
21
21
|
"reviewFinding": {"type": "object", "additionalProperties": false, "required": ["finding_ref", "priority", "problem", "impact", "required_outcome", "evidence_refs", "obligation_refs", "decision_reason", "check_refs"], "properties": {"finding_ref": {"type": "string", "minLength": 1}, "priority": {"enum": ["p0", "p1", "p2", "p3"]}, "problem": {"type": "string", "minLength": 1}, "impact": {"type": "string", "minLength": 1}, "required_outcome": {"type": "string", "minLength": 1}, "evidence_refs": {"$ref": "#/$defs/strings"}, "obligation_refs": {"$ref": "#/$defs/strings"}, "decision_reason": {"type": "string", "minLength": 1}, "check_refs": {"$ref": "#/$defs/strings"}}},
|
|
22
22
|
"repair": {"type": "object", "additionalProperties": false, "required": ["origin_work_ids", "verification_check_refs"], "anyOf": [{"required": ["check_receipt_id", "failure_receipt_path"]}, {"required": ["review_findings"]}, {"required": ["semantic_unresolved", "verification_path"]}], "properties": {"origin_work_ids": {"$ref": "#/$defs/strings"}, "check_receipt_id": {"type": "string", "minLength": 1}, "failure_receipt_path": {"type": "string", "minLength": 1}, "review_findings": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/reviewFinding"}}, "semantic_unresolved": {"$ref": "#/$defs/strings"}, "verification_path": {"type": "string", "minLength": 1}, "verification_check_refs": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/check"}}}},
|
|
@@ -29,9 +29,9 @@
|
|
|
29
29
|
"documentUpdate": {"type": "object", "additionalProperties": false, "required": ["path", "action", "owner", "reason"], "properties": {"path": {"type": "string", "minLength": 1}, "action": {"enum": ["create", "update"]}, "owner": {"type": "string", "minLength": 1}, "reason": {"type": "string", "minLength": 1}}},
|
|
30
30
|
"spine": {"type": "object", "additionalProperties": false, "required": ["user_outcome", "component_responsibility", "must_preserve", "non_goals", "acceptance_contribution"], "properties": {"user_outcome": {"type": "string", "minLength": 1}, "component_responsibility": {"type": "string", "minLength": 1}, "must_preserve": {"$ref": "#/$defs/strings"}, "non_goals": {"type": "array", "items": {"type": "string", "minLength": 1}}, "acceptance_contribution": {"type": "string", "minLength": 1}}},
|
|
31
31
|
"execution": {"type": "object", "additionalProperties": false, "required": ["required_read", "discovery_boundary", "planned_write_areas", "stop_conditions"], "properties": {"required_read": {"$ref": "#/$defs/strings", "description": "Mandatory starting sources. This is not a read allowlist."}, "discovery_boundary": {"$ref": "#/$defs/strings", "description": "Likely discovery areas. A worker may inspect other project-local sources when necessary."}, "planned_write_areas": {"type": "array", "items": {"type": "string", "minLength": 1}, "description": "Optional file or directory hints used only to coordinate concurrent Works. They never grant or deny write permission."}, "stop_conditions": {"$ref": "#/$defs/strings", "description": "Semantic contradictions or external blockers that require the worker to stop."}}},
|
|
32
|
-
"check": {"type": "object", "additionalProperties": false, "required": ["id", "command", "purpose", "run_at", "availability"], "properties": {"id": {"type": "string", "pattern": "^CHK-[A-Za-z0-9-]+$"}, "command": {"type": "string", "minLength": 1}, "purpose": {"type": "string", "minLength": 1}, "run_at": {"enum": ["work", "code", "readiness", "merge", "release"
|
|
32
|
+
"check": {"type": "object", "additionalProperties": false, "required": ["id", "command", "purpose", "run_at", "availability"], "properties": {"id": {"type": "string", "pattern": "^CHK-[A-Za-z0-9-]+$"}, "command": {"type": "string", "minLength": 1}, "purpose": {"type": "string", "minLength": 1}, "run_at": {"enum": ["work", "code", "readiness", "merge", "release"]}, "availability": {"enum": ["available", "planned"]}, "provided_by": {"type": "string", "pattern": "^P[0-9]+$"}, "definition": {"type": "string", "minLength": 1}, "required_artifacts": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "ports": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]*$"}}, "inputs": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "reuse": {"const": "deterministic"}}, "allOf": [{"if": {"properties": {"availability": {"const": "planned"}}, "required": ["availability"]}, "then": {"required": ["provided_by", "definition"], "properties": {"command": {"pattern": "^@check/"}}}}, {"if": {"properties": {"command": {"pattern": "^@check/"}}}, "then": {"required": ["definition"]}}]},
|
|
33
33
|
"verification": {"type": "object", "additionalProperties": false, "required": ["check_refs"], "properties": {"check_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^CHK-[A-Za-z0-9-]+$"}}}},
|
|
34
34
|
"item": {"type": "object", "additionalProperties": false, "required": ["id", "title", "summary", "details", "depends_on", "requirement_refs", "semantic_spine", "execution_context", "verification"], "properties": {"id": {"type": "string", "pattern": "^P[0-9]+$"}, "title": {"type": "string", "minLength": 1}, "summary": {"type": "string", "minLength": 1}, "details": {"type": "string", "minLength": 1}, "depends_on": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^P[0-9]+$"}}, "requirement_refs": {"$ref": "#/$defs/strings"}, "semantic_spine": {"$ref": "#/$defs/spine"}, "execution_context": {"$ref": "#/$defs/execution"}, "verification": {"$ref": "#/$defs/verification"}}},
|
|
35
|
-
"acceptance": {"type": "object", "additionalProperties": false, "required": ["criterion_id", "plan_item_ids", "changed_surfaces", "path", "environment", "fixtures", "cleanup", "check_refs", "expected_evidence", "proof_limits", "gate"], "properties": {"criterion_id": {"type": "string", "pattern": "^AC-[0-9]+$"}, "plan_item_ids": {"type": "array", "minItems": 1, "items": {"type": "string", "pattern": "^P[0-9]+$"}}, "changed_surfaces": {"$ref": "#/$defs/strings"}, "path": {"type": "string", "minLength": 1}, "environment": {"type": "string", "minLength": 1}, "fixtures": {"type": "array", "items": {"type": "string", "minLength": 1}}, "cleanup": {"type": "string", "minLength": 1}, "check_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^CHK-[A-Za-z0-9-]+$"}}, "expected_evidence": {"$ref": "#/$defs/strings"}, "proof_limits": {"$ref": "#/$defs/strings"}, "gate": {"enum": ["work", "code", "readiness", "merge", "release"
|
|
35
|
+
"acceptance": {"type": "object", "additionalProperties": false, "required": ["criterion_id", "plan_item_ids", "changed_surfaces", "path", "environment", "fixtures", "cleanup", "check_refs", "expected_evidence", "proof_limits", "gate"], "properties": {"criterion_id": {"type": "string", "pattern": "^AC-[0-9]+$"}, "plan_item_ids": {"type": "array", "minItems": 1, "items": {"type": "string", "pattern": "^P[0-9]+$"}}, "changed_surfaces": {"$ref": "#/$defs/strings"}, "path": {"type": "string", "minLength": 1}, "environment": {"type": "string", "minLength": 1}, "fixtures": {"type": "array", "items": {"type": "string", "minLength": 1}}, "cleanup": {"type": "string", "minLength": 1}, "check_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^CHK-[A-Za-z0-9-]+$"}}, "expected_evidence": {"$ref": "#/$defs/strings"}, "proof_limits": {"$ref": "#/$defs/strings"}, "gate": {"enum": ["work", "code", "readiness", "merge", "release"]}}}
|
|
36
36
|
}
|
|
37
37
|
}
|
|
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import { spawn, spawnSync } from "node:child_process";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { parse } from "shell-quote";
|
|
5
6
|
import { assertRunMutationAllowed } from "./run-recovery.js";
|
|
6
7
|
import { AppError } from "../shared/errors.js";
|
|
7
8
|
import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, managedProcessRuntimeHome, managedOwnerIsAlive, processTreeIsAlive, registerManagedProcess, reservePorts } from "./managed-processes.js";
|
|
@@ -37,15 +38,17 @@ export function readCodeCheckProfile(workspaceRoot) {
|
|
|
37
38
|
}
|
|
38
39
|
export function validateCodeCheckCommands(workspaceRoot, commands) {
|
|
39
40
|
const { profile, file } = readCodeCheckProfile(workspaceRoot);
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
return commands.map((command) => {
|
|
42
|
+
if (command.startsWith("@check/")) {
|
|
43
|
+
if (!profile?.aliases[command]?.trim())
|
|
44
|
+
throw new AppError("unknown_code_check_alias", "CODE check alias is not defined by the project profile", 2, { alias: command, file });
|
|
45
|
+
return command;
|
|
46
|
+
}
|
|
47
|
+
for (const [prefix, alias] of Object.entries(profile?.require_alias_for ?? {}))
|
|
48
|
+
if (command === prefix || command.startsWith(`${prefix} `))
|
|
49
|
+
throw new AppError("raw_code_check_forbidden", "CODE check must use the project alias instead of a raw guarded command", 2, { command, required_alias: alias, file });
|
|
45
50
|
return command;
|
|
46
|
-
}
|
|
47
|
-
if (command === prefix || command.startsWith(`${prefix} `))
|
|
48
|
-
throw new AppError("raw_code_check_forbidden", "CODE check must use the project alias instead of a raw guarded command", 2, { command, required_alias: alias, file }); return command; });
|
|
51
|
+
});
|
|
49
52
|
}
|
|
50
53
|
export function resolveCodeCheckCommands(workspaceRoot, runId, commands) {
|
|
51
54
|
const validated = validateCodeCheckCommands(workspaceRoot, commands);
|
|
@@ -66,12 +69,14 @@ export function validateCheckPlacement(workspaceRoot, checks) { const { profile
|
|
|
66
69
|
if (check.run_at === "work" && aggregateCommands.has(check.command))
|
|
67
70
|
throw new AppError("aggregate_check_requires_code_gate", "A project aggregate check must run at CODE or readiness, not inside one scoped Work", 2, { check_id: check.id, command: check.command, run_at: check.run_at }); }
|
|
68
71
|
export function validateCheckDeclaration(workspaceRoot, check) {
|
|
69
|
-
if (
|
|
70
|
-
|
|
72
|
+
if (!["work", "code", "readiness", "merge", "release"].includes(check.run_at))
|
|
73
|
+
throw new AppError("unsupported_check_gate", "Check requires an autonomous executable gate; replan the incompatible declaration", 2, { check_id: check.id, run_at: check.run_at });
|
|
71
74
|
const { profile } = readCodeCheckProfile(workspaceRoot);
|
|
72
75
|
if (check.availability === "planned" && check.command.startsWith("@check/") && !profile?.aliases[check.command])
|
|
73
76
|
throw new AppError("planned_check_not_materialized", "A planned check alias was not materialized by its provider Work", 2, { check_id: check.id, command: check.command, provider: check.provided_by ?? null });
|
|
74
77
|
validateCodeCheckCommands(workspaceRoot, [check.command]);
|
|
78
|
+
if (check.availability === "available" && !check.command.startsWith("@check/"))
|
|
79
|
+
assertDirectCheckExecutable(workspaceRoot, check.command, check.id);
|
|
75
80
|
validateCheckInputs(workspaceRoot, check.inputs ?? []);
|
|
76
81
|
if (check.command.startsWith("@check/") && check.definition && profile?.aliases[check.command] !== check.definition)
|
|
77
82
|
throw new AppError("check_definition_drift", "The accepted check alias definition differs from the current project profile", 2, { check_id: check.id, alias: check.command, expected: check.definition, actual: profile?.aliases[check.command] ?? null });
|
|
@@ -84,6 +89,34 @@ export function validateCheckDeclaration(workspaceRoot, check) {
|
|
|
84
89
|
if (!profile?.aliases[check.command])
|
|
85
90
|
throw new AppError("planned_check_not_materialized", "A planned check alias was not materialized by its provider Work", 2, { check_id: check.id, command: check.command, provider: check.provided_by ?? null });
|
|
86
91
|
}
|
|
92
|
+
/** Reject a plan-time typo or a fictitious "manual" command before it becomes
|
|
93
|
+
* a late shell exit. Compound shell programs remain valid: only a simple,
|
|
94
|
+
* directly named executable is preflighted here. */
|
|
95
|
+
function assertDirectCheckExecutable(workspaceRoot, command, checkId) {
|
|
96
|
+
const trimmed = command.trim();
|
|
97
|
+
// A shell operator or expansion makes the launch path dynamic. Do not
|
|
98
|
+
// guess there; this preflight is deliberately limited to a direct program
|
|
99
|
+
// with ordinary arguments and optional literal environment assignments.
|
|
100
|
+
if (/[|&;<>()`$\n\r~]/.test(trimmed))
|
|
101
|
+
return;
|
|
102
|
+
const parsed = parse(trimmed);
|
|
103
|
+
if (parsed.some(word => typeof word !== "string"))
|
|
104
|
+
return;
|
|
105
|
+
const words = parsed;
|
|
106
|
+
let index = 0;
|
|
107
|
+
const env = codeExecutionEnvironment(workspaceRoot);
|
|
108
|
+
while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[index] ?? "")) {
|
|
109
|
+
const assignment = words[index++];
|
|
110
|
+
const equals = assignment.indexOf("=");
|
|
111
|
+
env[assignment.slice(0, equals)] = assignment.slice(equals + 1);
|
|
112
|
+
}
|
|
113
|
+
const program = words[index];
|
|
114
|
+
if (!program)
|
|
115
|
+
return;
|
|
116
|
+
const probe = spawnSync("/bin/sh", ["-c", "command -v -- \"$1\" >/dev/null", "dd-flow-check", program], { cwd: workspaceRoot, env, encoding: "utf8", timeout: 5000 });
|
|
117
|
+
if (probe.status !== 0)
|
|
118
|
+
throw new AppError("check_command_unavailable", "Available CODE check names an executable that is unavailable in this environment", 2, { check_id: checkId, command, executable: program });
|
|
119
|
+
}
|
|
87
120
|
export async function runCodeChecks(context, input) {
|
|
88
121
|
reconcileUnfinishedChecks(context, input);
|
|
89
122
|
reconcileWorkspaceOwnership(context, input.workspaceRoot);
|
|
@@ -503,7 +536,7 @@ catch {
|
|
|
503
536
|
return fallback;
|
|
504
537
|
} }
|
|
505
538
|
function isStringRecord(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.values(value).every((item) => typeof item === "string" && item.trim().length > 0); }
|
|
506
|
-
function isGateRecord(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.entries(value).every(([gate, aliases]) => ["work", "code", "readiness", "merge", "release"
|
|
539
|
+
function isGateRecord(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.entries(value).every(([gate, aliases]) => ["work", "code", "readiness", "merge", "release"].includes(gate) && Array.isArray(aliases) && aliases.every((item) => typeof item === "string" && item.startsWith("@check/"))); }
|
|
507
540
|
function isPortAliasRecord(value) { return value === undefined || (Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.entries(value).every(([alias, ports]) => alias.startsWith("@check/") && Array.isArray(ports) && ports.length > 0 && new Set(ports).size === ports.length && ports.every((port) => typeof port === "string" && /^[A-Za-z][A-Za-z0-9_]*$/.test(port)))); }
|
|
508
541
|
function isInputAliasRecord(workspaceRoot, value) { try {
|
|
509
542
|
return value === undefined || (Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.entries(value).every(([alias, inputs]) => alias.startsWith("@check/") && Array.isArray(inputs) && (validateCheckInputs(workspaceRoot, inputs), true)));
|
|
@@ -99,8 +99,18 @@ export function managedLifecycleCommand(context, command) {
|
|
|
99
99
|
return command;
|
|
100
100
|
const scope = JSON.parse(configured);
|
|
101
101
|
const parsed = invocation(command);
|
|
102
|
-
|
|
102
|
+
const suppliedId = commandOption(parsed, "invocation-id");
|
|
103
|
+
// An invocation id is authority, never decorative text. In particular,
|
|
104
|
+
// callers that add arguments after rendering must not be able to reuse an
|
|
105
|
+
// issued id for a different command.
|
|
106
|
+
if (suppliedId) {
|
|
107
|
+
const retained = load(context, suppliedId);
|
|
108
|
+
if (retained.scope_json !== JSON.stringify(scope)) {
|
|
109
|
+
throw new AppError("invocation_scope_mismatch", "Lifecycle attempt belongs to another managed scope", 1, { invocation_id: suppliedId });
|
|
110
|
+
}
|
|
111
|
+
checkCommand(retained, command);
|
|
103
112
|
return command;
|
|
113
|
+
}
|
|
104
114
|
if (!context.db.writable || context.env.DD_FLOW_INVOCATION_READONLY === "1") {
|
|
105
115
|
const retained = context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'")
|
|
106
116
|
? context.db.get("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? ORDER BY rowid DESC LIMIT 1", [JSON.stringify(scope), fingerprint(parsed)]) : undefined;
|
|
@@ -133,10 +143,20 @@ export function retryLifecycleInvocationCommand(context, id) {
|
|
|
133
143
|
const prior = load(context, id);
|
|
134
144
|
if (prior.status !== "executing")
|
|
135
145
|
throw new AppError("invocation_retry_not_authorized", "Only the executing lifecycle operation can publish its next attempt", 1);
|
|
136
|
-
const
|
|
137
|
-
|
|
146
|
+
const parsed = invocation(prior.command);
|
|
147
|
+
const argv = [...parsed.argv];
|
|
148
|
+
const at = argv.indexOf("--invocation-id");
|
|
149
|
+
if (at < 0 || argv[at + 1] !== id)
|
|
138
150
|
throw new AppError("invocation_command_invalid", "Retained issued command lost its invocation marker", 1);
|
|
139
|
-
|
|
151
|
+
// The parser proves the marker belongs to argv. Remove that one rendered
|
|
152
|
+
// token from the retained text so response-file/stdin/heredoc presentation
|
|
153
|
+
// remains byte-for-byte stable across a retry.
|
|
154
|
+
const marker = ` --invocation-id ${id}`;
|
|
155
|
+
const markerAt = prior.command.indexOf(marker);
|
|
156
|
+
if (markerAt < 0 || prior.command.indexOf(marker, markerAt + marker.length) >= 0)
|
|
157
|
+
throw new AppError("invocation_command_invalid", "Retained issued command has an ambiguous invocation marker", 1);
|
|
158
|
+
const command = `${prior.command.slice(0, markerAt)}${prior.command.slice(markerAt + marker.length)}`;
|
|
159
|
+
return managedLifecycleCommand({ ...context, env: { ...context.env, DD_FLOW_INVOCATION_SCOPE: prior.scope_json, DD_FLOW_CURRENT_INVOCATION: id } }, command);
|
|
140
160
|
}
|
|
141
161
|
/** Settle a conclusive rejection and publish its only legal successor atomically.
|
|
142
162
|
* A crash must leave either the old attempt executing, or both the settled
|
|
@@ -9,14 +9,14 @@ export async function cancelControlledNativeSession(input) {
|
|
|
9
9
|
throw new AppError("controller_operation_mismatch", "Cancellation observation belongs to another operation or Session", 1);
|
|
10
10
|
const result = input.observe ? reply.state === "completed" ? reply.result : null : reply;
|
|
11
11
|
if (!input.observe || reply.state === "completed")
|
|
12
|
-
assertNativeCancellationReceipt(result, input.sessionId);
|
|
12
|
+
assertNativeCancellationReceipt(result, input.sessionId, input.requireSettled);
|
|
13
13
|
input.assertCurrent();
|
|
14
14
|
return { status: result ? "completed" : "outcome_unknown", receipt: (result ?? reply) };
|
|
15
15
|
}
|
|
16
|
-
export function assertNativeCancellationReceipt(value, sessionId) {
|
|
16
|
+
export function assertNativeCancellationReceipt(value, sessionId, requireSettled = false) {
|
|
17
17
|
const identity = adapterSessionId(value);
|
|
18
|
-
if (!value || typeof value !== "object" || Array.isArray(value) || identity && identity !== sessionId)
|
|
19
|
-
throw new AppError("controller_operation_mismatch", "Cancellation receipt has no valid result for its target Session", 1);
|
|
18
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || identity && identity !== sessionId || (requireSettled && value.settled !== true))
|
|
19
|
+
throw new AppError("controller_operation_mismatch", requireSettled ? "Cancellation receipt has no terminal settlement for its target Session" : "Cancellation receipt has no valid result for its target Session", 1);
|
|
20
20
|
}
|
|
21
21
|
/** Native observation is shared by RUN and non-Work EVAL owners. */
|
|
22
22
|
export async function inspectControlledNativeSession(input) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { setFlowRunRecoveryPaused } from "./runs.js";
|
|
1
2
|
import crypto from "node:crypto";
|
|
2
3
|
import fs from "node:fs";
|
|
3
4
|
import { ensureRunControlStorage } from "../storage/database.js";
|
|
@@ -459,10 +460,10 @@ async function reconcileOwnedSessions(context, input, interrupt, boundary) {
|
|
|
459
460
|
try {
|
|
460
461
|
if (!inserted.changes && prior.status === "completed") {
|
|
461
462
|
const saved = JSON.parse(prior.receipt_json);
|
|
462
|
-
assertNativeCancellationReceipt(saved, session.id);
|
|
463
|
+
assertNativeCancellationReceipt(saved, session.id, harness === "zcode-acp");
|
|
463
464
|
return { operation_id: operationId, session_id: session.id, status: prior.status, receipt: saved, reused: true };
|
|
464
465
|
}
|
|
465
|
-
const { status, receipt } = await cancelControlledNativeSession({ executable, sessionId: session.id, stateDir: session.state_dir, env, operationId, observe: !inserted.changes, assertCurrent });
|
|
466
|
+
const { status, receipt } = await cancelControlledNativeSession({ executable, sessionId: session.id, stateDir: session.state_dir, env, operationId, observe: !inserted.changes, assertCurrent, requireSettled: harness === "zcode-acp" });
|
|
466
467
|
context.db.run("UPDATE run_control_operations SET status = ?, receipt_json = ?, error_json = NULL, updated_at = ? WHERE operation_id = ?", [status, JSON.stringify(receipt), context.now(), operationId]);
|
|
467
468
|
return { operation_id: operationId, session_id: session.id, status, receipt };
|
|
468
469
|
}
|
|
@@ -746,6 +747,7 @@ export function recordRunControlIntent(context, input) {
|
|
|
746
747
|
FROM runs r, json_each(r.index_json, '$.stage_runs') stage
|
|
747
748
|
WHERE r.project_id = ? AND r.id = ? AND json_extract(stage.value, '$.status') = 'running'
|
|
748
749
|
AND EXISTS (SELECT 1 FROM run_control_admissions a JOIN sessions s ON s.session_id = a.session_id AND s.project_id = r.project_id WHERE a.control_id = ? AND s.current_stage = json_extract(stage.value, '$.stage'))`, [controlId, controlId, project.id, input.runId, controlId]);
|
|
750
|
+
setFlowRunRecoveryPaused(context, project.id, input.runId, true);
|
|
749
751
|
context.db.exec("COMMIT");
|
|
750
752
|
return { ok: true, control_id: controlId, recovery_id: recoveryId, generation, action: input.action, status: "requested", settled: false };
|
|
751
753
|
}
|
|
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { AppError } from "../shared/errors.js";
|
|
7
7
|
import { getResourceDatabase } from "../storage/database.js";
|
|
8
8
|
import { finishManagedProcess, managedProcessRuntimeHome, managedProcessStatus, processIsAlive, processStartedAt, processTreeIsAlive, registerManagedProcess } from "./managed-processes.js";
|
|
9
|
+
import { setFlowRunRecoveryPaused } from "./runs.js";
|
|
9
10
|
import { assertRunMutationAllowed, recoveryGuard } from "./run-recovery.js";
|
|
10
11
|
import { requireRun, requireController, controllerReceipt, appendEvent, digest, errorRecord } from "./run-controller-state.js";
|
|
11
12
|
const launchId = (row) => `${row.controller_id}:owner-launch:${digest(row.owner_token)}`;
|
|
@@ -172,7 +173,17 @@ export async function spawnController(context, row) {
|
|
|
172
173
|
return controllerReceipt(requireController(context, controllerId));
|
|
173
174
|
}
|
|
174
175
|
catch (error) {
|
|
175
|
-
context.db.
|
|
176
|
+
context.db.exec("SAVEPOINT controller_spawn_status");
|
|
177
|
+
try {
|
|
178
|
+
const changed = context.db.run("UPDATE run_controllers SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE controller_id = ? AND owner_token = ? AND status = 'starting' AND owner_pid IS NULL", [JSON.stringify(errorRecord(error)), context.now(), controllerId, token]);
|
|
179
|
+
if (changed.changes && (recoveryGuard(context, row.project_id, row.run_id)?.generation ?? 0) === row.generation)
|
|
180
|
+
setFlowRunRecoveryPaused(context, row.project_id, row.run_id, true);
|
|
181
|
+
context.db.exec("RELEASE controller_spawn_status");
|
|
182
|
+
}
|
|
183
|
+
catch (failure) {
|
|
184
|
+
context.db.exec("ROLLBACK TO controller_spawn_status; RELEASE controller_spawn_status");
|
|
185
|
+
throw failure;
|
|
186
|
+
}
|
|
176
187
|
throw error;
|
|
177
188
|
}
|
|
178
189
|
}
|
|
@@ -10,7 +10,7 @@ import { AppError } from "../shared/errors.js";
|
|
|
10
10
|
import { parseRuntimeBudget } from "./runtime-budget.js";
|
|
11
11
|
import { requireProjectByRoot } from "./projects.js";
|
|
12
12
|
import { assertRunMutationAllowed, recoveryGuard } from "./run-recovery.js";
|
|
13
|
-
import { getFlowRunStatus } from "./runs.js";
|
|
13
|
+
import { getFlowRunStatus, setFlowRunRecoveryPaused } from "./runs.js";
|
|
14
14
|
import { loadExternalStageContext } from "./stage-context.js";
|
|
15
15
|
import { flowCommand } from "./stage-pause.js";
|
|
16
16
|
import { resolveStageTransition, sameSessionProfile, runtimeHarness } from "./execution-policy.js";
|
|
@@ -202,7 +202,17 @@ export async function serveRunController(context, input) {
|
|
|
202
202
|
};
|
|
203
203
|
const assertOwner = () => { assertPhysicalOwner(); assertRunMutationAllowed(context, row.project_id, row.run_id, row.generation); };
|
|
204
204
|
try {
|
|
205
|
-
|
|
205
|
+
context.db.exec("SAVEPOINT controller_ready_status");
|
|
206
|
+
try {
|
|
207
|
+
markControllerDispatchReady(context, row);
|
|
208
|
+
if (!state.recovery && (recoveryGuard(context, row.project_id, row.run_id)?.generation ?? 0) === row.generation && requireRun(context, row.project_id, row.run_id).status === "paused")
|
|
209
|
+
setFlowRunRecoveryPaused(context, row.project_id, row.run_id, false);
|
|
210
|
+
context.db.exec("RELEASE controller_ready_status");
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
context.db.exec("ROLLBACK TO controller_ready_status; RELEASE controller_ready_status");
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
206
216
|
appendEvent(context, row.controller_id, "owner_started", { pid: process.pid, generation: row.generation });
|
|
207
217
|
if (state.recovery)
|
|
208
218
|
await resumeControllerSession(context, row, manifest, state, assertPhysicalOwner);
|
|
@@ -221,10 +231,21 @@ export async function serveRunController(context, input) {
|
|
|
221
231
|
return controllerReceipt(requireController(context, row.controller_id));
|
|
222
232
|
}
|
|
223
233
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
234
|
+
context.db.exec("SAVEPOINT controller_failure_status");
|
|
235
|
+
try {
|
|
236
|
+
// A failed capture must not reopen admission when this process exits.
|
|
237
|
+
const changed = context.db.run("UPDATE run_controllers SET status = CASE WHEN status IN ('capturing','capture_failed') THEN 'capture_failed' ELSE 'recovery_required' END, last_error_json = ?, updated_at = ? WHERE controller_id = ? AND owner_token = ? AND status NOT IN ('completed','cancelled','superseded')", [JSON.stringify(errorRecord(error)), context.now(), row.controller_id, row.owner_token]);
|
|
238
|
+
context.db.run("UPDATE merge_requests SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE project_id = ? AND run_id = ? AND dispatch_owner = ? AND status IN ('dispatching','active')", [JSON.stringify(errorRecord(error)), context.now(), row.project_id, row.run_id, row.controller_id]);
|
|
239
|
+
if (changed.changes && (recoveryGuard(context, row.project_id, row.run_id)?.generation ?? 0) === row.generation) {
|
|
240
|
+
setFlowRunRecoveryPaused(context, row.project_id, row.run_id, true);
|
|
241
|
+
}
|
|
242
|
+
appendEvent(context, row.controller_id, "recovery_required", errorRecord(error));
|
|
243
|
+
context.db.exec("RELEASE controller_failure_status");
|
|
244
|
+
}
|
|
245
|
+
catch (failure) {
|
|
246
|
+
context.db.exec("ROLLBACK TO controller_failure_status; RELEASE controller_failure_status");
|
|
247
|
+
throw failure;
|
|
248
|
+
}
|
|
228
249
|
throw error;
|
|
229
250
|
}
|
|
230
251
|
finally {
|
|
@@ -5,7 +5,7 @@ import path from "node:path";
|
|
|
5
5
|
import { AppError } from "../shared/errors.js";
|
|
6
6
|
import { resolveProjectRoot } from "../storage/paths.js";
|
|
7
7
|
import { requireProjectByRoot } from "./projects.js";
|
|
8
|
-
import { appendFlowRunTimelineEvent } from "./runs.js";
|
|
8
|
+
import { appendFlowRunTimelineEvent, setFlowRunRecoveryPaused } from "./runs.js";
|
|
9
9
|
import { verifyRecoverySnapshot } from "./eval-snapshots.js";
|
|
10
10
|
import { findRecentMatchingHookEvent, hookSessionIdentity, recoveryAcceptMatchKey } from "./hooks.js";
|
|
11
11
|
import { flowCommand } from "./stage-pause.js";
|
|
@@ -123,6 +123,7 @@ export function beginRunRecovery(context, input) {
|
|
|
123
123
|
context.db.run("UPDATE run_recovery_bindings SET status = 'abandoned' WHERE recovery_id = ? AND status = 'prepared'", [prior.recovery_id]);
|
|
124
124
|
}
|
|
125
125
|
context.db.run("INSERT INTO run_recovery_guards (recovery_id, project_id, run_id, generation, status, interruption_json, settlement_json, capture_path, created_at, updated_at, sealed_at, resumed_at) VALUES (?, ?, ?, ?, 'draining', ?, NULL, NULL, ?, ?, NULL, NULL)", [recoveryId, project.id, run.id, generation, JSON.stringify(interruption), now, now]);
|
|
126
|
+
setFlowRunRecoveryPaused(context, project.id, run.id, true);
|
|
126
127
|
context.db.exec("COMMIT");
|
|
127
128
|
}
|
|
128
129
|
catch (error) {
|
|
@@ -147,9 +148,18 @@ export function sealRunRecovery(context, input) {
|
|
|
147
148
|
throw new AppError("recovery_invalid_state", "Recovery is not draining", 1, { recovery_id: guard.recovery_id, status: guard.status });
|
|
148
149
|
materializePendingWorkResults(context, project.id, run.id);
|
|
149
150
|
const now = context.now();
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
151
|
+
context.db.exec("SAVEPOINT seal_run_status");
|
|
152
|
+
try {
|
|
153
|
+
const updated = context.db.run("UPDATE run_recovery_guards SET status = 'sealed', settlement_json = ?, sealed_at = ?, updated_at = ? WHERE recovery_id = ? AND status = 'draining' AND generation = (SELECT MAX(generation) FROM run_recovery_guards WHERE project_id = ? AND run_id = ?)", [JSON.stringify(settlement), now, now, guard.recovery_id, project.id, run.id]);
|
|
154
|
+
if (updated.changes !== 1)
|
|
155
|
+
throw new AppError("recovery_state_changed", "Recovery state changed before it could be sealed", 1, { recovery_id: guard.recovery_id });
|
|
156
|
+
setFlowRunRecoveryPaused(context, project.id, run.id, true);
|
|
157
|
+
context.db.exec("RELEASE seal_run_status");
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
context.db.exec("ROLLBACK TO seal_run_status; RELEASE seal_run_status");
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
153
163
|
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "run_recovery_sealed", recovery_id: guard.recovery_id, generation: guard.generation, settlement });
|
|
154
164
|
return { ok: true, recovery: { recovery_id: guard.recovery_id, run_id: run.id, generation: guard.generation, status: "sealed", settlement } };
|
|
155
165
|
}
|
|
@@ -286,6 +296,7 @@ export function acceptRunRecovery(context, input) {
|
|
|
286
296
|
}
|
|
287
297
|
context.db.run("UPDATE run_recovery_bindings SET status = 'accepted', hook_event_key = ?, accepted_at = ? WHERE recovery_id = ?", [eventKey, now, binding.recovery_id]);
|
|
288
298
|
context.db.run("UPDATE run_recovery_guards SET status = 'recovered', resumed_at = ?, updated_at = ? WHERE recovery_id = ?", [now, now, guard.recovery_id]);
|
|
299
|
+
setFlowRunRecoveryPaused(context, project.id, run.id, false);
|
|
289
300
|
context.db.exec("COMMIT");
|
|
290
301
|
}
|
|
291
302
|
catch (error) {
|
package/dist/services/runs.js
CHANGED
|
@@ -5,7 +5,7 @@ import { spawnSync } from "node:child_process";
|
|
|
5
5
|
import { formatFullId, isFullEntityId, isShortEntityId, parseFullEntityId } from "../domain/entity-ids.js";
|
|
6
6
|
import { checksumForFlowFlagValues, isFlowFlagDowngrade, loadCanonicalFlowContract, loadProjectFlowContract, resolveFlowFlags } from "../domain/flow-contract.js";
|
|
7
7
|
import { AppError } from "../shared/errors.js";
|
|
8
|
-
import { ensureDir, projectRunHome, projectRunJsonPath, resolveProjectRoot } from "../storage/paths.js";
|
|
8
|
+
import { ensureDir, canonicalPath, projectRunHome, projectRunJsonPath, resolveProjectRoot } from "../storage/paths.js";
|
|
9
9
|
import { appendAudit } from "./audit.js";
|
|
10
10
|
import { registerProject, requireProjectByRoot } from "./projects.js";
|
|
11
11
|
import { buildRunFlowGuidance } from "./flow-guidance.js";
|
|
@@ -788,8 +788,24 @@ function operationalRunStatus(context, projectId, runId) {
|
|
|
788
788
|
const leaves = unfinished.filter((work) => !parents.has(work.work_id));
|
|
789
789
|
return leaves.length > 0 && leaves.every((work) => work.status === "paused") ? "paused" : "running";
|
|
790
790
|
}
|
|
791
|
+
/** Keep the logical RUN projection aligned with recovery authority. Stage/Work
|
|
792
|
+
* evidence and verdicts survive a reversible stop; terminal runs never reopen. */
|
|
793
|
+
export function setFlowRunRecoveryPaused(context, projectId, runId, paused) {
|
|
794
|
+
const run = resolveRun(context, projectId, runId);
|
|
795
|
+
if (!["running", "paused"].includes(run.status))
|
|
796
|
+
return;
|
|
797
|
+
const index = authoritativeIndex(run);
|
|
798
|
+
const status = paused || index.stage_runs.some(stage => ["paused", "waiting_for_user", "blocked"].includes(stage.status))
|
|
799
|
+
? "paused" : operationalRunStatus(context, projectId, runId);
|
|
800
|
+
if (run.status === status && index.status === status)
|
|
801
|
+
return;
|
|
802
|
+
index.status = status;
|
|
803
|
+
index.updated_at = context.now();
|
|
804
|
+
const project = requireProjectByRoot(context, run.project_root);
|
|
805
|
+
persistRunState(context, project, run, index);
|
|
806
|
+
}
|
|
791
807
|
export function completeFlowRun(context, input) {
|
|
792
|
-
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
808
|
+
const project = requireProjectByRoot(context, input.manualOverrideReason ? canonicalPath(input.projectRoot, false) : resolveProjectRoot(input.projectRoot));
|
|
793
809
|
const run = resolveRun(context, project.id, input.runId);
|
|
794
810
|
assertRunMutationAllowed(context, project.id, run.id);
|
|
795
811
|
const index = authoritativeIndex(run);
|
|
@@ -856,8 +872,8 @@ export function advanceFlowRun(context, input) {
|
|
|
856
872
|
index.next_action = control ? null : input.nextAction ?? null;
|
|
857
873
|
index.updated_at = now;
|
|
858
874
|
persistRunState(context, project, run, index);
|
|
859
|
-
appendAudit(context, { projectId: project.id, eventType: "flow_run.progressed", payload: { run_id: run.id, status:
|
|
860
|
-
appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_progressed", run_id: run.id, status:
|
|
875
|
+
appendAudit(context, { projectId: project.id, eventType: "flow_run.progressed", payload: { run_id: run.id, status: index.status, verdict: input.verdict, next_action: index.next_action } });
|
|
876
|
+
appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_progressed", run_id: run.id, status: index.status, next_action: index.next_action });
|
|
861
877
|
const updatedRun = requireRunById(context, project.id, run.id);
|
|
862
878
|
return { ok: true, ...(control ? { control } : {}), run: flowRunSummary(updatedRun), index, flow_guidance: guidanceForRun(context, updatedRun, index) };
|
|
863
879
|
}
|
|
@@ -1189,6 +1205,10 @@ function vnextSpecifyGuidance(run, index) {
|
|
|
1189
1205
|
};
|
|
1190
1206
|
}
|
|
1191
1207
|
function persistRunState(context, project, run, index, options = {}) {
|
|
1208
|
+
// Fenced productive admission is a logical pause, even while physical work
|
|
1209
|
+
// drains. Late admitted Stage results must not revive a stopped RUN.
|
|
1210
|
+
if (index.status === "running" && ["draining", "sealed", "resuming"].includes(recoveryGuard(context, project.id, run.id)?.status ?? ""))
|
|
1211
|
+
index.status = "paused";
|
|
1192
1212
|
const runtimePath = run.runtime_path || projectRunJsonPath(context.ddFlowHome, project.id, run.id);
|
|
1193
1213
|
const previousRuntime = readRuntimeSnapshot(runtimePath);
|
|
1194
1214
|
const runtimeRevision = Math.max(index.runtime_revision ?? 0, previousRuntime?.runtime_revision ?? 0) + 1;
|
|
@@ -1199,22 +1219,30 @@ function persistRunState(context, project, run, index, options = {}) {
|
|
|
1199
1219
|
}
|
|
1200
1220
|
const persistedIndex = persistedVnextIndex(index);
|
|
1201
1221
|
ensureDir(path.dirname(runtimePath));
|
|
1202
|
-
|
|
1203
|
-
|
|
1222
|
+
context.db.exec("SAVEPOINT persist_run_state");
|
|
1223
|
+
try {
|
|
1224
|
+
context.db.run(`UPDATE runs
|
|
1204
1225
|
SET status = ?, verdict = ?, next_action = ?, index_json = ?, runtime_path = ?, run_index_path = ?,
|
|
1205
1226
|
updated_at = ?, completed_at = ?
|
|
1206
1227
|
WHERE project_id = ? AND id = ?`, [
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1228
|
+
index.status,
|
|
1229
|
+
index.verdict,
|
|
1230
|
+
index.next_action,
|
|
1231
|
+
JSON.stringify(persistedIndex),
|
|
1232
|
+
runtimePath,
|
|
1233
|
+
runtimePath,
|
|
1234
|
+
index.updated_at,
|
|
1235
|
+
index.completed_at ?? null,
|
|
1236
|
+
project.id,
|
|
1237
|
+
run.id
|
|
1238
|
+
]);
|
|
1239
|
+
writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, runtimeRevision, history));
|
|
1240
|
+
context.db.exec("RELEASE persist_run_state");
|
|
1241
|
+
}
|
|
1242
|
+
catch (error) {
|
|
1243
|
+
context.db.exec("ROLLBACK TO persist_run_state; RELEASE persist_run_state");
|
|
1244
|
+
throw error;
|
|
1245
|
+
}
|
|
1218
1246
|
}
|
|
1219
1247
|
/** vNext stores derived stage/work state only once: stage_runs, Works and Sessions. */
|
|
1220
1248
|
function persistedVnextIndex(index) {
|
|
@@ -1576,5 +1604,5 @@ export function gitFacts(workspaceRoot) {
|
|
|
1576
1604
|
status: status === null ? "unavailable" : status.length > 0 ? "dirty" : "clean"
|
|
1577
1605
|
};
|
|
1578
1606
|
}
|
|
1579
|
-
import { assertRunMutationAllowed, assertStageSettlementAllowed } from "./run-recovery.js";
|
|
1607
|
+
import { assertRunMutationAllowed, assertStageSettlementAllowed, recoveryGuard } from "./run-recovery.js";
|
|
1580
1608
|
import { runControlDirective } from "./run-control.js";
|
|
@@ -103,9 +103,7 @@ export function addVnextCodeReviewRepair(context, input) {
|
|
|
103
103
|
const unknown = requestedCheckRefs.filter((ref) => !checks.has(ref));
|
|
104
104
|
if (unknown.length)
|
|
105
105
|
throw new AppError("review_check_reference_unknown", "CODE-REVIEW finding cites a check absent from the accepted CODE handoff", 2, { check_refs: unknown });
|
|
106
|
-
const reviewChecks = requestedCheckRefs.map((ref) => checks.get(ref))
|
|
107
|
-
if (!reviewChecks.length)
|
|
108
|
-
throw new AppError("review_repair_executable_check_required", "CODE-REVIEW repair requires at least one executable causal check; external-only evidence cannot validate a repair Work", 2, { finding_ids: input.findingIds, check_refs: requestedCheckRefs });
|
|
106
|
+
const reviewChecks = requestedCheckRefs.map((ref) => checks.get(ref));
|
|
109
107
|
return addVnextCodeRepair(context, {
|
|
110
108
|
projectRoot,
|
|
111
109
|
runId: run.id,
|
|
@@ -192,7 +190,7 @@ export async function finishVnextCodeReview(context, input) {
|
|
|
192
190
|
retry_after_workspace_change: true,
|
|
193
191
|
workspace_fingerprint: failed[0].workspace_fingerprint,
|
|
194
192
|
repair_command: `${flowCommand(context)} work repair add --run ${run.id} --from-check ${failed[0].id} --origin-work <WORK-ID> --task-stdin --project-root ${JSON.stringify(projectRoot)} --json`,
|
|
195
|
-
retry_command:
|
|
193
|
+
retry_command: finishCommand(context, run.id, projectRoot, decisionFile, failed[0].id)
|
|
196
194
|
});
|
|
197
195
|
const stopTarget = executionStopTarget(run);
|
|
198
196
|
const nextAction = stopTarget === "merge_completed" ? "start_merge" : "code_review_completed";
|
|
@@ -421,7 +419,7 @@ function requireRun(context, projectId, runId) { const run = context.db.get("SEL
|
|
|
421
419
|
throw new AppError("not_found", "RUN is not registered", 1); return run; }
|
|
422
420
|
function requireHome(run) { if (!run.run_root)
|
|
423
421
|
throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1); return run.run_root; }
|
|
424
|
-
function finishCommand(context, runId, projectRoot, decision) { return managedLifecycleCommand(context, `${flowCommand(context)} stage finish ${runId} --stage code-review --decision-file ${JSON.stringify(decision)} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`); }
|
|
422
|
+
export function finishCommand(context, runId, projectRoot, decision, retryCheckId) { const retry = retryCheckId ? ` --retry-check ${retryCheckId} --reason "<environment recovery evidence>"` : ""; return managedLifecycleCommand(context, `${flowCommand(context)} stage finish ${runId} --stage code-review --decision-file ${JSON.stringify(decision)} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl${retry}`); }
|
|
425
423
|
function findFiles(root, name) { if (!fs.existsSync(root))
|
|
426
424
|
return []; const out = []; for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
427
425
|
const file = path.join(root, entry.name);
|
|
@@ -179,7 +179,7 @@ export async function finishVnextCode(context, input) {
|
|
|
179
179
|
retry_after_workspace_change: true,
|
|
180
180
|
workspace_fingerprint: failed[0].workspace_fingerprint,
|
|
181
181
|
repair_command: `${flowCommand(context)} work repair add --run ${run.id} --from-check ${failed[0].id} --origin-work <WORK-ID> --task-stdin --project-root ${JSON.stringify(projectRoot)} --json`,
|
|
182
|
-
retry_command:
|
|
182
|
+
retry_command: finishCommand(context, run.id, projectRoot, input.verificationFile, failed[0].id)
|
|
183
183
|
});
|
|
184
184
|
}
|
|
185
185
|
const finalReceipts = receipts;
|
|
@@ -495,18 +495,16 @@ export function verificationProjection(works, receipts, options = {}) {
|
|
|
495
495
|
return [item.criterion_id, { evidenceRefs, historical }];
|
|
496
496
|
}));
|
|
497
497
|
const hasEvidence = (criterionId) => (acceptanceEvidence.get(criterionId)?.evidenceRefs.length ?? 0) > 0;
|
|
498
|
-
const isPassed = (checkId
|
|
498
|
+
const isPassed = (checkId) => receiptByDeclaration.get(checkId)?.status === "passed";
|
|
499
499
|
return {
|
|
500
500
|
checks: declarations.map((check) => {
|
|
501
501
|
const receipt = receiptByDeclaration.get(check.id);
|
|
502
|
-
|
|
503
|
-
return { id: check.id, run_at: check.run_at, status: receipt?.status ?? (externallyEvidenced ? "evidence_recorded" : ["work", "code", "readiness"].includes(check.run_at) ? "failed" : "not_due"), receipt_ref: receipt?.receipt_path ?? null };
|
|
502
|
+
return { id: check.id, run_at: check.run_at, status: receipt?.status ?? (["work", "code", "readiness"].includes(check.run_at) ? "failed" : "not_due"), receipt_ref: receipt?.receipt_path ?? null };
|
|
504
503
|
}),
|
|
505
504
|
acceptance: acceptance.map((item) => {
|
|
506
|
-
const
|
|
507
|
-
const confirmed = item.check_refs.every((id) => isPassed(id, item.criterion_id)) && hasEvidence(item.criterion_id);
|
|
505
|
+
const confirmed = item.check_refs.every((id) => isPassed(id)) && hasEvidence(item.criterion_id);
|
|
508
506
|
const evidence = acceptanceEvidence.get(item.criterion_id) ?? { evidenceRefs: [], historical: [] };
|
|
509
|
-
return { criterion_id: item.criterion_id, gate: item.gate, status: confirmed ?
|
|
507
|
+
return { criterion_id: item.criterion_id, gate: item.gate, status: confirmed ? "confirmed" : ["work", "code", "readiness"].includes(item.gate) ? "unresolved" : "not_due", check_refs: item.check_refs, evidence_refs: evidence.evidenceRefs, ...(evidence.historical.length ? { reported_evidence_refs: evidence.historical } : {}) };
|
|
510
508
|
})
|
|
511
509
|
};
|
|
512
510
|
}
|
|
@@ -604,8 +602,9 @@ function requireHome(run) {
|
|
|
604
602
|
throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1);
|
|
605
603
|
return run.run_root;
|
|
606
604
|
}
|
|
607
|
-
function finishCommand(context, runId, projectRoot, verificationFile) {
|
|
608
|
-
|
|
605
|
+
export function finishCommand(context, runId, projectRoot, verificationFile, retryCheckId) {
|
|
606
|
+
const retry = retryCheckId ? ` --retry-check ${retryCheckId} --reason "<environment recovery evidence>"` : "";
|
|
607
|
+
return managedLifecycleCommand(context, `${flowCommand(context)} stage finish ${runId} --stage code --verification-file ${JSON.stringify(verificationFile)} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl${retry}`);
|
|
609
608
|
}
|
|
610
609
|
function readVerification(context, input) {
|
|
611
610
|
const file = path.resolve(input.file);
|
|
@@ -260,7 +260,7 @@ export async function finishVnextMerge(context, input) {
|
|
|
260
260
|
const failed = receipts.filter((receipt) => receipt.status !== "passed");
|
|
261
261
|
if (failed.length) {
|
|
262
262
|
context.db.run("UPDATE merge_requests SET status = 'action_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "merge_gate_failed", failures: failed }), context.now(), request.merge_request_id]);
|
|
263
|
-
throw new AppError("merge_gate_failed", "Integrated target checks failed. Classify retained evidence: source defects use source repair; restored environment uses retry.", 2, { failures: failed, repair_command: `${flowCommand(context)} merge repair ${request.merge_request_id} --project-root ${JSON.stringify(projectRoot)} --json`, retry_command:
|
|
263
|
+
throw new AppError("merge_gate_failed", "Integrated target checks failed. Classify retained evidence: source defects use source repair; restored environment uses retry.", 2, { failures: failed, repair_command: `${flowCommand(context)} merge repair ${request.merge_request_id} --project-root ${JSON.stringify(projectRoot)} --json`, retry_command: finishCommand(context, run.id, request, projectRoot, failed[0].id) });
|
|
264
264
|
}
|
|
265
265
|
const passedRefs = new Set(receipts.flatMap((receipt) => receipt.check_refs));
|
|
266
266
|
const missingRefs = frozenGateRefs(gate).filter((ref) => !passedRefs.has(ref));
|
|
@@ -561,4 +561,4 @@ function findRootWork(context, projectId, runId) { const work = context.db.get("
|
|
|
561
561
|
function requireRootWork(context, projectId, runId) { const work = findRootWork(context, projectId, runId); if (work.status !== "running")
|
|
562
562
|
throw new AppError("runtime_missing", "vNext RUN has no running root Work", 1); return work; }
|
|
563
563
|
function applyCommand(context, request, projectRoot) { return `${flowCommand(context)} merge apply ${request.merge_request_id} --work ${request.executor_work_id} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`; }
|
|
564
|
-
function finishCommand(context, runId, request, projectRoot) { return managedLifecycleCommand(context, `${flowCommand(context)} stage finish ${runId} --stage merge --request ${request.merge_request_id} --work ${request.executor_work_id} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`); }
|
|
564
|
+
export function finishCommand(context, runId, request, projectRoot, retryCheckId) { const retry = retryCheckId ? ` --retry-check ${retryCheckId} --reason "<environment recovery evidence>"` : ""; return managedLifecycleCommand(context, `${flowCommand(context)} stage finish ${runId} --stage merge --request ${request.merge_request_id} --work ${request.executor_work_id} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl${retry}`); }
|
|
@@ -93,10 +93,13 @@ export function startVnextPlan(context, input) {
|
|
|
93
93
|
? [`This RUN must reach MERGE. Project policy already supplies the mandatory merge gate${policyMergeAliases.length === 1 ? "" : "s"}: ${policyMergeAliases.join(", ")}. Do not duplicate them in semantic checks[]. Add another merge check only when the task genuinely needs additional evidence.`]
|
|
94
94
|
: ["This RUN must reach MERGE and project policy supplies no merge gate. Select at least one real top-level checks[] entry with run_at: merge. It may use an existing project alias or a planned alias materialised by a named P* provider Work. This is a planning obligation: do not defer it to CODE-REVIEW or MERGE."]), "The CLI validates the effective merge gate but never invents one or migrates an incompatible project policy.", "</merge_gate_contract>", ""]
|
|
95
95
|
: [];
|
|
96
|
-
const prompt = ["<stage_identity>", `- RUN: ${run.id}`, `- Work: ${planWorkId}`, "- stage: plan", "</stage_identity>", "", "<trusted_runtime_context>", "These facts were collected by dd-flow. Trust them; do not repeat CLI, Git, compatibility or permission discovery.", `- Project root: ${projectRoot}`, `- Workspace: ${run.workspace_root}`, `- Stage workspace: ${root}`, `- Git: ${JSON.stringify(gitFacts(run.workspace_root))}`, capacityContext, "</trusted_runtime_context>", "", "<workspace_contract>", `- route: ${workspaceRoute.route}`, `- feature branch: ${workspaceRoute.feature_branch ?? "not applicable"}`, `- base commit: ${workspaceRoute.base_ref ?? "not applicable"}`, `- write workspace: ${run.workspace_root}`, "The CLI has verified this frozen route. All project reads and writes for PLAN and later CODE happen in the write workspace; project root is only the stable runtime identity for lifecycle commands. Do not create, switch, merge or delete branches/worktrees.", "Keep the task runner's current cwd. Use the absolute paths in this packet instead of trying to set the provisioned workspace as a tool workdir.", "</workspace_contract>", "", "<accepted_inputs>", `- ${path.join(home, "01-specify", "specify.json")}`, `- ${path.join(home, "02-protocolize", "protocolize-result.json")}`, ...protocols.map((id) => `- ${path.join(run.workspace_root, ".memory-bank", "protocol", id, "summary.md")}`), "</accepted_inputs>", "", ...(fs.existsSync(checkProfile) ? ["<code_check_policy>", "You, not the CLI, select evidence for every accepted requirement and acceptance criterion. The profile only lists reusable aliases, mandatory project policy gates and guarded raw command prefixes. Inspect relevant package/test manifests before choosing a check. Do not classify checks by weight and do not omit a needed check because it looks expensive.", fs.readFileSync(checkProfile, "utf8").trim(), "</code_check_policy>", ""] : []), ...mergeContract, "<artifacts>", "The CLI has already materialized every artifact below as a partially filled draft. Edit these files in place; do not create replacements elsewhere.", "Prefilled and CLI-owned plan fields: schema_id, plan_id, protocol_id, initial revision and source_refs.", "Prefilled and CLI-owned aspect-map fields: schema_id, protocol_id, plan_id, plan revision, catalog_ref and every catalog aspect_id.", "You own the remaining semantic fields. Empty or missing semantic values are intentional draft markers and must be completed before validation.", ...planPaths.map((value) => `- partially filled plan: ${value}`), ...mapPaths.map((value) => `- partially filled aspect map: ${value}`), "</artifacts>", "", "<output_contract>", "Complete every named plan and aspect map in place. Do not create or edit code-work-batch.json: dd-flow derives it after validation.", "The CLI owns schema_id, plan_id, protocol_id, revision and source_refs. Preserve them exactly.", "Use protocol-plan@6. Its top-level checks[] is the single check catalog. Every check has id, command, purpose, run_at and availability. available means executable now. planned means one named P* Work first creates a NEW @check/... alias: planned therefore always needs provided_by and the exact alias definition. Every semantic @check alias, including an existing one, repeats its exact accepted profile command in definition so later stages can detect drift. Items and acceptance entries use check_refs only; never duplicate command declarations.", "For each R-* and AC-*, choose an actually relevant proof: an existing focused test, a new planned alias plus its provider Work, a project policy gate, or an
|
|
96
|
+
const prompt = ["<stage_identity>", `- RUN: ${run.id}`, `- Work: ${planWorkId}`, "- stage: plan", "</stage_identity>", "", "<trusted_runtime_context>", "These facts were collected by dd-flow. Trust them; do not repeat CLI, Git, compatibility or permission discovery.", `- Project root: ${projectRoot}`, `- Workspace: ${run.workspace_root}`, `- Stage workspace: ${root}`, `- Git: ${JSON.stringify(gitFacts(run.workspace_root))}`, capacityContext, "</trusted_runtime_context>", "", "<workspace_contract>", `- route: ${workspaceRoute.route}`, `- feature branch: ${workspaceRoute.feature_branch ?? "not applicable"}`, `- base commit: ${workspaceRoute.base_ref ?? "not applicable"}`, `- write workspace: ${run.workspace_root}`, "The CLI has verified this frozen route. All project reads and writes for PLAN and later CODE happen in the write workspace; project root is only the stable runtime identity for lifecycle commands. Do not create, switch, merge or delete branches/worktrees.", "Keep the task runner's current cwd. Use the absolute paths in this packet instead of trying to set the provisioned workspace as a tool workdir.", "</workspace_contract>", "", "<accepted_inputs>", `- ${path.join(home, "01-specify", "specify.json")}`, `- ${path.join(home, "02-protocolize", "protocolize-result.json")}`, ...protocols.map((id) => `- ${path.join(run.workspace_root, ".memory-bank", "protocol", id, "summary.md")}`), "</accepted_inputs>", "", ...(fs.existsSync(checkProfile) ? ["<code_check_policy>", "You, not the CLI, select evidence for every accepted requirement and acceptance criterion. The profile only lists reusable aliases, mandatory project policy gates and guarded raw command prefixes. Inspect relevant package/test manifests before choosing a check. Do not classify checks by weight and do not omit a needed check because it looks expensive.", fs.readFileSync(checkProfile, "utf8").trim(), "</code_check_policy>", ""] : []), ...mergeContract, "<artifacts>", "The CLI has already materialized every artifact below as a partially filled draft. Edit these files in place; do not create replacements elsewhere.", "Prefilled and CLI-owned plan fields: schema_id, plan_id, protocol_id, initial revision and source_refs.", "Prefilled and CLI-owned aspect-map fields: schema_id, protocol_id, plan_id, plan revision, catalog_ref and every catalog aspect_id.", "You own the remaining semantic fields. Empty or missing semantic values are intentional draft markers and must be completed before validation.", ...planPaths.map((value) => `- partially filled plan: ${value}`), ...mapPaths.map((value) => `- partially filled aspect map: ${value}`), "</artifacts>", "", "<output_contract>", "Complete every named plan and aspect map in place. Do not create or edit code-work-batch.json: dd-flow derives it after validation.", "The CLI owns schema_id, plan_id, protocol_id, revision and source_refs. Preserve them exactly.", "Use protocol-plan@6. Its top-level checks[] is the single check catalog. Every check has id, command, purpose, run_at and availability. available means executable now. planned means one named P* Work first creates a NEW @check/... alias: planned therefore always needs provided_by and the exact alias definition. Every semantic @check alias, including an existing one, repeats its exact accepted profile command in definition so later stages can detect drift. Items and acceptance entries use check_refs only; never duplicate command declarations.", "For each R-* and AC-*, choose an actually relevant proof: an existing focused test, a new planned alias plus its provider Work, a project policy gate, or an autonomous executable check. Every plan item needs at least one check_ref. The CLI validates ids, provider ordering, materialization and guarded command policy; it never chooses a check for you. A provider Work may verify itself with the alias it has just created. A consumer must depend on that provider.", "Each plan item must name concrete existing source/test paths in required_read. planned_write_areas is optional: use stable component directories or files only when they help coordinate parallel Work; it is never a write allowlist. Reference every owned R-* and AC-* in one or more items; every AC-* needs an observable acceptance proof.", "For every selected check, inspect its command's launch path and the runtime entrypoints it starts. The fixture/reset process, service process and client process must observe one intended environment and data world. If a required runtime entrypoint needs a code change, make that change explicit in the Work task and its verification. Use planned_write_areas only to advertise likely concurrent overlap; do not treat it as ownership or assume another Work will repair an omitted change. If an independent infrastructure Work is clearer, plan that Work explicitly and order consumers after it.", reviewGroupingRule, "Complete compact contract and schema paths:", `- protocol plan schema: ${path.join(run.workspace_root, ".memory-bank", "dd-flow", "schemas", "vnext-protocol-plan.schema.json")}`, `- aspect map schema: ${path.join(run.workspace_root, ".memory-bank", "dd-flow", "schemas", "plan-aspect-map.schema.json")}`, "Minimal valid protocol-plan shape:", "```json", JSON.stringify(planExample(protocols[0]), null, 2), "```", "Minimal valid aspect-map shape:", "```json", JSON.stringify(aspectMapExample(protocols[0]), null, 2), "```", "</output_contract>", "", "<execution_commands>", "PLAN never launches independent reviewers or registers CODE Work.", "If PLAN needs a material user decision with no reasonable default, run this exact one-command heredoc, replacing only its placeholder body. The heredoc is the permitted stdin form; do not use cat, a pipe, a temporary file or a second shell command:", "```sh", pauseCommandTemplate, "```", "Ask the returned user_message, stop, and resume this same PLAN Work with the exact returned command.", "Validate both partially filled drafts after completing their semantic fields:", ...validationCommands.map((command) => `- ${command}`), "Finish PLAN only after all questions are resolved and both validation commands pass:", finishCommand, "The response returns the only PLAN-REVIEW start command. Follow it; do not start CODE directly.", "</execution_commands>", "", "<stage_instructions>", template, "</stage_instructions>", ""].join("\n");
|
|
97
97
|
const artifactMaterialization = { status: "materialized", completeness: "partially_filled", plan_paths: planPaths, aspect_map_paths: mapPaths, cli_owned_plan_fields: ["schema_id", "plan_id", "protocol_id", "revision", "source_refs"], cli_owned_aspect_map_fields: ["schema_id", "protocol_id", "plan_id", "plan_revision", "catalog_ref", "aspects[].aspect_id"], validation_commands: validationCommands };
|
|
98
98
|
const promptPath = path.join(root, "stage-prompt.md");
|
|
99
|
-
|
|
99
|
+
// This explicit final rule supersedes historical pack wording: a flow gate
|
|
100
|
+
// has only executable autonomous evidence. Human or external confirmation
|
|
101
|
+
// is neither a check nor a permitted completion condition.
|
|
102
|
+
fs.writeFileSync(promptPath, `${prompt}\n<verification_rule>Every flow check must be an autonomous executable command. Do not declare external/manual proof or a human review as a check, evidence substitute, DEF, or gate. The only user interaction supported by this flow is an explicit stage pause for a material unanswered question.</verification_rule>\n`);
|
|
100
103
|
const externalContext = applyExternalStageContext({ stageRoot: root, promptPath, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
|
|
101
104
|
fs.writeFileSync(path.join(root, "work-context.json"), JSON.stringify({ schema_id: "dd-flow/work-context@1", system: { run_id: run.id, work_id: planWorkId, stage: "plan" }, workspace: { project_root: projectRoot, workspace_root: run.workspace_root, stage_root: root }, artifacts: artifactMaterialization, input: { protocols, owned_obligations: Object.fromEntries(owned) } }, null, 2));
|
|
102
105
|
const binding = bindStageCoordinatorWork(context, { workId: planWorkId, hookEventId: input.hookEventId, stage: "plan", promptPath, resultPath: path.join(root, "stage-report.json"), ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
|
package/package.json
CHANGED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* global process, console */
|
|
3
|
+
// Explicit repair of legacy fenced RUN projections; no migrations or process control.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import { parseArgs } from 'node:util';
|
|
8
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
9
|
+
import { registerStoreWriter } from '../dist/storage/writer-contract.js';
|
|
10
|
+
import { setFlowRunRecoveryPaused, completeFlowRun } from '../dist/services/runs.js';
|
|
11
|
+
|
|
12
|
+
const { values } = parseArgs({ options: { home: { type: 'string' }, run: { type: 'string' }, project: { type: 'string' }, apply: { type: 'boolean', default: false }, 'cancel-abandoned': { type: 'boolean', default: false } } });
|
|
13
|
+
if (!values.home || !values.run || !values.project) throw new Error('Usage: --home <runtime> --project <project-id> --run <run-id> [--apply]');
|
|
14
|
+
const home = fs.realpathSync(values.home), file = path.join(home, 'db.sqlite');
|
|
15
|
+
const db = new DatabaseSync(file, { readOnly: !values.apply });
|
|
16
|
+
let runtimePath, original, backup, report, changed = false;
|
|
17
|
+
try {
|
|
18
|
+
db.exec('PRAGMA busy_timeout=4000');
|
|
19
|
+
if (values.apply) { registerStoreWriter(db); db.exec('BEGIN IMMEDIATE'); }
|
|
20
|
+
const run = db.prepare('SELECT * FROM runs WHERE project_id=? AND id=?').get(values.project, values.run);
|
|
21
|
+
if (!run) throw new Error('RUN not found');
|
|
22
|
+
const guard = db.prepare('SELECT * FROM run_recovery_guards WHERE project_id=? AND run_id=? ORDER BY generation DESC LIMIT 1').get(values.project, values.run);
|
|
23
|
+
const cancel = values['cancel-abandoned'];
|
|
24
|
+
if (cancel) {
|
|
25
|
+
if (fs.existsSync(run.project_root)) throw new Error('Abandoned-fixture cancellation requires an absent project directory');
|
|
26
|
+
for (const table of ['works', 'sessions', 'run_controllers']) {
|
|
27
|
+
if (!db.prepare("SELECT 1 FROM sqlite_schema WHERE type='table' AND name=?").get(table)) continue;
|
|
28
|
+
if (db.prepare(`SELECT 1 FROM ${table} WHERE project_id=? AND run_id=? LIMIT 1`).get(values.project, values.run)) throw new Error('Abandoned-fixture cancellation requires no Work, Session or controller history');
|
|
29
|
+
}
|
|
30
|
+
if (guard) throw new Error('A recovery-owned RUN requires recovery reconciliation');
|
|
31
|
+
}
|
|
32
|
+
if (!cancel && !["draining", "sealed", "resuming"].includes(guard?.status)) throw new Error("Repair requires the current recovery to fence productive admission");
|
|
33
|
+
if (run.status !== 'running') {
|
|
34
|
+
report = { run_id: run.id, status: run.status, changed: false };
|
|
35
|
+
} else {
|
|
36
|
+
runtimePath = fs.realpathSync(run.runtime_path);
|
|
37
|
+
const relative = path.relative(home, runtimePath);
|
|
38
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('Runtime file is outside the selected home');
|
|
39
|
+
original = fs.readFileSync(runtimePath);
|
|
40
|
+
if (JSON.parse(original).status !== 'running' || JSON.parse(run.index_json).status !== 'running') throw new Error('RUN projections disagree; manual review required');
|
|
41
|
+
if (values.apply) {
|
|
42
|
+
backup = `${runtimePath}.status-repair-${crypto.randomUUID()}.json`;
|
|
43
|
+
fs.writeFileSync(backup, JSON.stringify({ recovery_id: guard?.recovery_id ?? null, row: run, runtime: original.toString('utf8') }), { flag: 'wx', mode: 0o600 });
|
|
44
|
+
const context = { ddFlowHome: home, env: { ...process.env, DD_FLOW_HOME: home }, now: () => new Date().toISOString(), db: {
|
|
45
|
+
path: file, writable: true, get: (sql, params = []) => db.prepare(sql).get(...params), all: (sql, params = []) => db.prepare(sql).all(...params), run: (sql, params = []) => db.prepare(sql).run(...params), exec: sql => db.exec(sql)
|
|
46
|
+
} };
|
|
47
|
+
changed = true;
|
|
48
|
+
if (cancel) completeFlowRun(context, { projectRoot: run.project_root, runId: run.id, status: 'cancelled', manualOverrideReason: 'Operator requested cleanup of abandoned fixture: project removed; no Work, Session or controller history.' });
|
|
49
|
+
else setFlowRunRecoveryPaused(context, values.project, values.run, true);
|
|
50
|
+
}
|
|
51
|
+
report = { run_id: run.id, from: 'running', to: cancel ? 'cancelled' : 'paused', recovery_id: guard?.recovery_id ?? null, applied: values.apply, backup };
|
|
52
|
+
}
|
|
53
|
+
if (values.apply) db.exec('COMMIT');
|
|
54
|
+
console.log(JSON.stringify(report));
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (values.apply && db.isTransaction) db.exec('ROLLBACK');
|
|
57
|
+
if (changed && original) { const temporary = `${runtimePath}.repair-rollback`; fs.writeFileSync(temporary, original); fs.renameSync(temporary, runtimePath); }
|
|
58
|
+
throw error;
|
|
59
|
+
} finally { db.close(); }
|