@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 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.
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.54",
4
- "cli_commit": "eb2cb9746a9ee386d40f63791f5dbe0847bf5017",
5
- "built_at": "2026-09-13T12:29:37.211Z",
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.0",
8
- "commit": "ef349bf47cba1c987468e51d73a0dbadbd48dc1f",
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
- // Cancellation acceptance is not settlement. Re-read the same
367
- // controlled tree before letting the daemon close its endpoint.
368
- try { observed = await inspectSessionWithBridge(this.bridge, options); }
369
- catch (error) { unsettled.push(session.provider_session_id); continue; }
370
- if (!cancelled.settled || this.treeRunning(observed)) unsettled.push(session.provider_session_id);
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.journal.write("inbound", message);
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. Send it once: session/read
380
- // returns the full transcript, so polling it while the turn is stopping can
381
- // make cancellation slower than the work we are trying to interrupt.
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
- for (let attempt = 0; attempt < 6; attempt += 1) {
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
- after = await bridge.request("zcode/session/subagents", { sessionId }).catch(error => ({ observation_error: { code: error.code, message: error.message } }));
387
- if (after.observation_error || !(after.running ?? []).length) break;
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
- const read = await bridge.request("zcode/session/read", { sessionId }).catch(error => ({ observation_error: { code: error.code, message: error.message } }));
390
- const root_status = read?.projection?.status ?? read?.session?.status ?? null;
391
- const settled = !after.observation_error && !(after.running ?? []).length && ["idle", "completed", "cancelled", "failed", "stopped"].includes(root_status);
392
- return { cancellations, before, after, root_status, cancellation_requested: !settled, settled };
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 === "43f654bccdbb1aa4f4fb4617f7315c4336dbcde0" && harnessContract === ZCODE_HARNESS_CONTRACT;
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", "external"]}, "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/"}}}}]},
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", "external"]}, "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"]}}]},
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", "external"]}}}
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
- if (!profile)
41
- return [...commands];
42
- return commands.map((command) => { 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 });
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
- } for (const [prefix, alias] of Object.entries(profile.require_alias_for))
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 (check.run_at === "external")
70
- return;
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", "external"].includes(gate) && Array.isArray(aliases) && aliases.every((item) => typeof item === "string" && item.startsWith("@check/"))); }
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
- if (commandOption(parsed, "invocation-id"))
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 marker = ` --invocation-id ${id}`;
137
- if (!prior.command.includes(marker))
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
- return managedLifecycleCommand({ ...context, env: { ...context.env, DD_FLOW_INVOCATION_SCOPE: prior.scope_json, DD_FLOW_CURRENT_INVOCATION: id } }, prior.command.replace(marker, ""));
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.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]);
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
- markControllerDispatchReady(context, row);
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
- // A failed capture must not reopen admission when this process exits.
225
- 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 = ?", [JSON.stringify(errorRecord(error)), context.now(), row.controller_id, row.owner_token]);
226
- 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]);
227
- appendEvent(context, row.controller_id, "recovery_required", errorRecord(error));
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
- 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]);
151
- if (updated.changes !== 1)
152
- throw new AppError("recovery_state_changed", "Recovery state changed before it could be sealed", 1, { recovery_id: guard.recovery_id });
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) {
@@ -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: input.status, verdict: input.verdict, next_action: index.next_action } });
860
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_progressed", run_id: run.id, status: input.status, next_action: index.next_action });
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
- writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, runtimeRevision, history));
1203
- context.db.run(`UPDATE runs
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
- index.status,
1208
- index.verdict,
1209
- index.next_action,
1210
- JSON.stringify(persistedIndex),
1211
- runtimePath,
1212
- runtimePath,
1213
- index.updated_at,
1214
- index.completed_at ?? null,
1215
- project.id,
1216
- run.id
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)).filter((check) => check.run_at !== "external");
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: `${finishCommand(context, run.id, projectRoot, decisionFile)} --retry-check ${failed[0].id} --reason "<environment recovery evidence>"`
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: `${finishCommand(context, run.id, projectRoot, input.verificationFile)} --retry-check ${failed[0].id} --reason "<environment recovery evidence>"`
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, criterionId) => receiptByDeclaration.get(checkId)?.status === "passed" || (declarations.find((check) => check.id === checkId)?.run_at === "external" && hasEvidence(criterionId));
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
- const externallyEvidenced = check.run_at === "external" && acceptance.some((item) => item.check_refs.includes(check.id) && hasEvidence(item.criterion_id));
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 external = item.check_refs.some((id) => declarations.find((check) => check.id === id)?.run_at === "external");
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 ? (external ? "confirmed_with_external_evidence" : "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 } : {}) };
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
- return managedLifecycleCommand(context, `${flowCommand(context)} stage finish ${runId} --stage code --verification-file ${JSON.stringify(verificationFile)} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`);
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: `${finishCommand(context, run.id, request, projectRoot)} --retry-check ${failed[0].id} --reason "<environment recovery evidence>"` });
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 honestly limited external/manual proof. 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");
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
- fs.writeFileSync(promptPath, prompt);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.54",
3
+ "version": "0.9.0-beta.56",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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(); }