@deksden-com/dd-flow-cli 0.9.0-beta.13 → 0.9.0-beta.15

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.15
4
+
5
+ ### Patch Changes
6
+
7
+ - Add owned temporary HTTP service supervision with allocated ports and readiness receipts. Prevent duplicate workspace bootstrap processes, retain resources when process ownership is uncertain, and share safe stop behavior between explicit stops and orphan reconciliation.
8
+
9
+ ## 0.9.0-beta.14
10
+
11
+ ### Patch Changes
12
+
13
+ - Audit runtime recovery: distinguish process owners from children, retain live check resources, await failed check startup cleanup, share managed workspace bootstrap between CODE/MERGE, correct assigned repair examples, and reject publication without canonical build provenance.
14
+
3
15
  ## 0.9.0-beta.13
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # dd-flow-cli
2
2
 
3
+ ## Managed temporary HTTP services
4
+
5
+ For an interactive scenario, `runtime process start --run <RUN-ID>
6
+ --project-root <absolute-project> --command '<project command>' --ports api
7
+ --ready-port api --ready-path /health --json --progress-jsonl` runs a foreground
8
+ supervisor. The command receives `DD_FLOW_PORT_API`; use it instead of a fixed
9
+ port. Additional comma-separated names receive equivalent environment variables.
10
+ The ready event points to a receipt and logs under the RUN's `runtime/` folder.
11
+ Keep the tool invocation alive, run the scenario using the allocated ports,
12
+ then execute the exact `stop_command` in that receipt and await the supervisor.
13
+ Readiness is not a scenario pass. Keep semantic/scenario evidence separately.
14
+ Never use a broad `pkill`/`killall`. A failed owned stop retains resources.
15
+
3
16
  `dd-flow-cli` is the mechanical control layer for `dd-flow` workflows.
4
17
 
5
18
  It does not replace Memory Bank prompts and does not make product, design, merge, or verification judgments. Prompts own intent, route selection, planning depth, evidence meaning, and semantic readiness. The CLI owns explicit local state: projects, protocols, Codex session bindings, transitions, worktree records, lanes, locks, merge queue jobs, hook records, and audit events.
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.13",
4
- "cli_commit": "020ada108a59291f4462f7d52d3ea64bccd77b56",
5
- "built_at": "2026-09-05T08:17:23.882Z",
3
+ "cli_version": "0.9.0-beta.15",
4
+ "cli_commit": "e11a611fe87370d6203792e5c6a54d7205235a26",
5
+ "built_at": "2026-09-05T09:50:59.828Z",
6
6
  "built_with_canon": {
7
- "version": null,
8
- "commit": null,
9
- "flow_contract": null,
10
- "repo_root": null,
11
- "memorybank_root": null,
12
- "flow_root": null,
13
- "layout": null
7
+ "version": "4.0.4",
8
+ "commit": "c8cae271f844fd3b3c7492f164e13a60eaba4839",
9
+ "flow_contract": "dd-flow-canonical-2026-08",
10
+ "repo_root": "/Users/deksden/Documents/_Projects/dd-memorybank",
11
+ "memorybank_root": "/Users/deksden/Documents/_Projects/dd-memorybank/.memory-bank",
12
+ "flow_root": "/Users/deksden/Documents/_Projects/dd-memorybank/.memory-bank/dd-flow",
13
+ "layout": "dot_memory_bank"
14
14
  }
15
15
  }
@@ -50,7 +50,8 @@ import { finishStage, startStage } from "../services/stage-lifecycle.js";
50
50
  import { addWorkBatch, cancelWork, deleteWork, failWork, finishWork, listWorks, mutateWorkDeps, retryWork, showWork, startWork } from "../services/work-registry.js";
51
51
  import { createEvalBootstrapSnapshot, createEvalRunSnapshot, prepareVnextSpecifyRun, restoreEvalBootstrapSnapshot, restoreEvalRunSnapshot } from "../services/eval-snapshots.js";
52
52
  import { loadExternalStageContext } from "../services/stage-context.js";
53
- import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, reconcileExpiredManagedProcesses, registerManagedProcess } from "../services/managed-processes.js";
53
+ import { stopManagedProcess, confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, reconcileExpiredManagedProcesses, registerManagedProcess } from "../services/managed-processes.js";
54
+ import { startRuntimeService } from "../services/runtime-service.js";
54
55
  const defaultIo = {
55
56
  stdout: process.stdout,
56
57
  stderr: process.stderr,
@@ -517,7 +518,7 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
517
518
  return getCliVersionReport();
518
519
  }
519
520
  if (family === "runtime") {
520
- return await dispatchRuntime(context, command, parsed);
521
+ return await dispatchRuntime(context, command, parsed, commandProgress(io, output));
521
522
  }
522
523
  if (family === "canon") {
523
524
  return dispatchCanon(context, command, parsed);
@@ -1583,14 +1584,19 @@ function dispatchRun(context, command, parsed) {
1583
1584
  }
1584
1585
  throw new AppError("usage", `Unknown run command: ${command ?? "<empty>"}`, 2);
1585
1586
  }
1586
- async function dispatchRuntime(context, command, parsed) {
1587
+ async function dispatchRuntime(context, command, parsed, progress) {
1587
1588
  if (command !== "process")
1588
- throw new AppError("usage", "Usage: dd-flow runtime process <register|confirm|heartbeat|finish|status|reconcile>", 2);
1589
+ throw new AppError("usage", "Usage: dd-flow runtime process <start|stop|register|confirm|heartbeat|finish|status|reconcile>", 2);
1589
1590
  const action = requiredPosition(parsed, 0, "runtime process action");
1591
+ if (action === "start")
1592
+ return await startRuntimeService(context, { projectRoot: requiredOption(parsed, "project-root"), runId: requiredOption(parsed, "run"), command: requiredOption(parsed, "command"), ports: requiredOption(parsed, "ports").split(","), readyPort: requiredOption(parsed, "ready-port"), readyPath: optionalOption(parsed, "ready-path") ?? "/", timeoutMs: optionalPositiveNumber(parsed, "ready-timeout-ms", true) ?? 30_000, progress });
1593
+ if (action === "stop")
1594
+ return { ok: true, process: await stopManagedProcess(context, { id: requiredOption(parsed, "id"), leaseToken: requiredOption(parsed, "lease-token"), ...(optionalPositiveNumber(parsed, "grace-ms", true) ? { graceMs: optionalPositiveNumber(parsed, "grace-ms", true) } : {}) }) };
1590
1595
  if (action === "register") {
1591
1596
  const leaseMs = optionalPositiveNumber(parsed, "lease-ms", true);
1592
1597
  const process = registerManagedProcess(context, {
1593
1598
  kind: requiredOption(parsed, "kind"), ownerId: requiredOption(parsed, "owner"),
1599
+ ownerPid: optionalPositiveNumber(parsed, "owner-pid", true),
1594
1600
  projectId: optionalOption(parsed, "project-id") ?? null, runId: optionalOption(parsed, "run") ?? null,
1595
1601
  workId: optionalOption(parsed, "work") ?? null, checkId: optionalOption(parsed, "check") ?? null,
1596
1602
  operationId: optionalOption(parsed, "operation") ?? null, stdoutPath: optionalOption(parsed, "stdout") ?? null,
@@ -1601,7 +1607,7 @@ async function dispatchRuntime(context, command, parsed) {
1601
1607
  if (action === "confirm") {
1602
1608
  const leaseMs = optionalPositiveNumber(parsed, "lease-ms", true);
1603
1609
  const processGroupId = optionalPositiveNumber(parsed, "process-group-id", true);
1604
- return { ok: true, process: confirmManagedProcess(context, { id: requiredOption(parsed, "id"), leaseToken: requiredOption(parsed, "lease-token"), pid: optionalPositiveNumber(parsed, "pid", true) ?? 0, ...(processGroupId ? { processGroupId } : {}), ...(leaseMs ? { leaseMs } : {}) }) };
1610
+ return { ok: true, process: confirmManagedProcess(context, { id: requiredOption(parsed, "id"), leaseToken: requiredOption(parsed, "lease-token"), ownerPid: optionalPositiveNumber(parsed, "owner-pid", true), pid: optionalPositiveNumber(parsed, "pid", true) ?? 0, ...(processGroupId ? { processGroupId } : {}), ...(leaseMs ? { leaseMs } : {}) }) };
1605
1611
  }
1606
1612
  if (action === "heartbeat") {
1607
1613
  const leaseMs = optionalPositiveNumber(parsed, "lease-ms", true);
@@ -1617,7 +1623,7 @@ async function dispatchRuntime(context, command, parsed) {
1617
1623
  return { ok: true, processes: managedProcessStatus(context) };
1618
1624
  if (action === "reconcile")
1619
1625
  return { ok: true, processes: await reconcileExpiredManagedProcesses(context, requiredOption(parsed, "owner"), optionalPositiveNumber(parsed, "grace-ms", true)) };
1620
- throw new AppError("usage", "Usage: dd-flow runtime process <register|confirm|heartbeat|finish|status|reconcile>", 2);
1626
+ throw new AppError("usage", "Usage: dd-flow runtime process <start|stop|register|confirm|heartbeat|finish|status|reconcile>", 2);
1621
1627
  }
1622
1628
  function dispatchStat(context, command, parsed) {
1623
1629
  if (command === "usage") {
@@ -3,7 +3,7 @@ import { spawn, spawnSync } from "node:child_process";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { AppError } from "../shared/errors.js";
6
- import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, processIsAlive, registerManagedProcess, reservePorts } from "./managed-processes.js";
6
+ import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, managedOwnerIsAlive, processTreeIsAlive, registerManagedProcess, reservePorts } from "./managed-processes.js";
7
7
  const profileRelativePath = path.join(".memory-bank", "spec", "engineering", "code-check-profile.json");
8
8
  // Receipt reservation precedes child spawn so an interrupted CLI leaves an
9
9
  // auditable attempt. A second caller must not mistake that small handoff window
@@ -99,7 +99,7 @@ export async function runCodeChecks(context, input) {
99
99
  try {
100
100
  fs.mkdirSync(evidenceDir, { recursive: true });
101
101
  writeReceipt(receiptPath, receiptFor({ ...reserved, resources, result, status: "running", after: before, mutationPaths: [], artifacts: [] }));
102
- const managed = registerManagedProcess(context, { kind: "check", ownerId: `check:${id}`, projectId: input.projectId, runId: input.runId, workId: input.workId ?? null, checkId: id, stdoutPath, stderrPath, metadata: { command: group.command } });
102
+ const managed = registerManagedProcess(context, { kind: "check", ownerPid: process.pid, ownerId: `check:${id}`, projectId: input.projectId, runId: input.runId, workId: input.workId ?? null, checkId: id, stdoutPath, stderrPath, metadata: { command: group.command } });
103
103
  const allocation = await reservePorts(context, { ownerId: managed.owner_id, processId: managed.id, names: group.ports });
104
104
  resources = allocation.ports;
105
105
  input.progress?.(`check ${index + 1}/${groups.length} started: ${group.command}`);
@@ -109,13 +109,18 @@ export async function runCodeChecks(context, input) {
109
109
  result = await runCheck(group.command, input.workspaceRoot, { ...codeExecutionEnvironment(input.workspaceRoot), DD_FLOW_EVIDENCE_DIR: evidenceDir, DD_FLOW_CHECK_COMPLETION_FILE: completionPath, ...portEnvironment(resources) }, stdout, stderr, (elapsed) => input.progress?.(`check ${index + 1}/${groups.length} still running (${elapsed}s): ${group.command}`), (pid) => confirmManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, pid, processGroupId: process.platform === "win32" ? null : pid }), () => heartbeatManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token }));
110
110
  }
111
111
  finally {
112
- allocation.release();
113
112
  fs.closeSync(stdout);
114
113
  fs.closeSync(stderr);
115
- finishManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, state: result.aborted || result.exitCode !== 0 || Boolean(result.error) ? "failed" : "stopped", reason: result.error });
116
114
  }
115
+ const current = managedProcessStatus(context).find(item => item.id === managed.id);
116
+ if (processTreeIsAlive(current))
117
+ throw new AppError("check_in_progress", "The check shell exited but its process tree is still live; retain this receipt and its resources until settlement", 2, { process_id: managed.id, check_receipt_id: id });
118
+ allocation.release();
119
+ finishManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, state: result.aborted || result.exitCode !== 0 || Boolean(result.error) ? "failed" : "stopped", reason: result.error });
117
120
  }
118
121
  catch (error) {
122
+ if (error instanceof AppError && error.code === "check_in_progress")
123
+ throw error;
119
124
  result = { exitCode: null, error: error instanceof Error ? error.message : String(error), aborted: true };
120
125
  }
121
126
  const after = workspaceState(input.workspaceRoot, [input.runHome]);
@@ -158,7 +163,7 @@ export function reconcileUnfinishedChecks(context, input) {
158
163
  // A stale lease after host sleep is not permission to duplicate a living
159
164
  // check. Its durable completion marker or explicit reconciliation decides
160
165
  // the outcome before another invocation may start.
161
- if (process && ["starting", "running", "stopping"].includes(process.state) && (process.state === "starting" || processIsAlive(process))) {
166
+ if (process && ["starting", "running", "stopping"].includes(process.state) && (processTreeIsAlive(process) || (process.state === "starting" && (managedOwnerIsAlive(process) !== false || Date.parse(process.lease_expires_at) > Date.parse(context.now()))))) {
162
167
  throw new AppError("check_in_progress", "A previous check invocation is still running", 1, { check_receipt_id: pendingReceipt.id, process_id: process.id, pid: process.pid });
163
168
  }
164
169
  if (!process && !completion && Date.now() - Date.parse(pendingReceipt.started_at) < receiptStartingGraceMs) {
@@ -269,24 +274,65 @@ function collectRequiredArtifacts(root, required) { const missing = []; const it
269
274
  } return { complete: missing.length === 0, missing, items }; }
270
275
  function listFiles(root, current = root) { return fs.readdirSync(current, { withFileTypes: true }).flatMap((entry) => { if ([".git", "node_modules"].includes(entry.name))
271
276
  return []; const absolute = path.join(current, entry.name); return entry.isDirectory() ? listFiles(root, absolute) : [path.relative(root, absolute)]; }).sort(); }
272
- function runCheck(command, cwd, environment, stdout, stderr, heartbeat, onSpawn, renewLease) { return new Promise((resolve) => { const started = Date.now(); const child = spawn("/bin/sh", ["-lc", checkShellScript()], { cwd, env: { ...environment, DD_FLOW_CHECK_COMMAND: command }, stdio: ["ignore", stdout, stderr], detached: process.platform !== "win32" }); if (!child.pid) {
273
- resolve({ exitCode: null, error: "check process did not provide a PID", aborted: true });
274
- return;
275
- } try {
276
- onSpawn(child.pid);
277
+ export function runCheck(command, cwd, environment, stdout, stderr, heartbeat, onSpawn, renewLease) {
278
+ return new Promise((resolve) => {
279
+ const started = Date.now();
280
+ const child = spawn("/bin/sh", ["-lc", checkShellScript()], { cwd, env: { ...environment, DD_FLOW_CHECK_COMMAND: command }, stdio: ["ignore", stdout, stderr], detached: process.platform !== "win32" });
281
+ let error = null;
282
+ let stopping = false;
283
+ let progress;
284
+ let escalation;
285
+ const stop = (cause) => {
286
+ if (stopping)
287
+ return;
288
+ stopping = true;
289
+ error = cause instanceof Error ? cause.message : String(cause);
290
+ if (progress) {
291
+ clearInterval(progress);
292
+ progress = undefined;
293
+ }
294
+ if (child.pid) {
295
+ terminateProcessGroup(child.pid, "SIGTERM");
296
+ escalation = setTimeout(() => terminateProcessGroup(child.pid, "SIGKILL"), 5_000);
297
+ }
298
+ };
299
+ // Register handlers before checking pid or calling persistence callbacks.
300
+ // Failure is not completion until the spawned process has actually closed.
301
+ child.once("error", (value) => { error = value.message; });
302
+ child.once("close", (exitCode, signal) => {
303
+ if (progress)
304
+ clearInterval(progress);
305
+ if (escalation)
306
+ clearTimeout(escalation);
307
+ if (stopping)
308
+ terminateProcessGroup(child.pid, "SIGKILL");
309
+ resolve({ exitCode, error: error ?? (signal ? `terminated by ${signal}` : null), aborted: stopping || Boolean(signal) || Boolean(error) });
310
+ });
311
+ if (!child.pid) {
312
+ error = "check process did not provide a PID";
313
+ return;
314
+ }
315
+ try {
316
+ onSpawn(child.pid);
317
+ }
318
+ catch (cause) {
319
+ stop(cause);
320
+ return;
321
+ }
322
+ progress = setInterval(() => {
323
+ try {
324
+ if (!renewLease()) {
325
+ stop("managed process lease lost");
326
+ return;
327
+ }
328
+ heartbeat(Math.floor((Date.now() - started) / 1000));
329
+ }
330
+ catch (cause) {
331
+ stop(cause);
332
+ }
333
+ }, 15_000);
334
+ });
277
335
  }
278
- catch (error) {
279
- terminateProcessGroup(child.pid, "SIGTERM");
280
- resolve({ exitCode: null, error: error instanceof Error ? error.message : String(error), aborted: true });
281
- return;
282
- } let leaseLost = false; let error = null; let escalation; const progress = setInterval(() => { const elapsed = Math.floor((Date.now() - started) / 1000); if (!renewLease()) {
283
- error = "managed process lease lost";
284
- leaseLost = true;
285
- terminateProcessGroup(child.pid, "SIGTERM");
286
- escalation = setTimeout(() => terminateProcessGroup(child.pid, "SIGKILL"), 5_000);
287
- return;
288
- } heartbeat(elapsed); }, 15_000); child.once("error", (value) => { error = value.message; }); child.once("close", (exitCode, signal) => { clearInterval(progress); if (escalation)
289
- clearTimeout(escalation); resolve({ exitCode, error: error ?? (signal ? `terminated by ${signal}` : null), aborted: leaseLost || Boolean(signal) || Boolean(error) }); }); }); }
290
336
  function checkShellScript() {
291
337
  return [
292
338
  'completion="${DD_FLOW_CHECK_COMPLETION_FILE:-}"',
@@ -7,13 +7,33 @@ export function resourceHome(context) {
7
7
  return context.env?.DD_FLOW_RESOURCE_HOME ?? context.ddFlowHome ?? "/tmp/dd-flow-runtime";
8
8
  }
9
9
  export function registerManagedProcess(context, input) {
10
+ if (!input.uniqueActiveOperation)
11
+ return insertManagedProcess(context, input);
12
+ if (!input.operationId)
13
+ throw new Error("Unique managed operation requires operationId");
14
+ const db = registry(context);
15
+ db.exec("BEGIN IMMEDIATE");
16
+ try {
17
+ const existing = db.get("SELECT * FROM managed_processes WHERE operation_id = ? AND state IN ('starting','running','stopping','orphaned')", [input.operationId]);
18
+ if (existing)
19
+ throw Object.assign(new Error("The operation already owns a managed process; observe it instead of spawning a duplicate"), { code: "managed_operation_in_progress", details: { process_id: existing.id, operation_id: input.operationId } });
20
+ const result = insertManagedProcess(context, input);
21
+ db.exec("COMMIT");
22
+ return result;
23
+ }
24
+ catch (error) {
25
+ db.exec("ROLLBACK");
26
+ throw error;
27
+ }
28
+ }
29
+ function insertManagedProcess(context, input) {
10
30
  const db = registry(context);
11
31
  const now = context.now();
12
32
  const id = input.id ?? `PROC-${crypto.randomUUID()}`;
13
33
  const token = crypto.randomUUID();
14
34
  db.run(`INSERT INTO managed_processes
15
35
  (id, kind, pid, pid_started_at, owner_id, lease_token, lease_expires_at, project_id, run_id, work_id, check_id, operation_id, stdout_path, stderr_path, state, started_at, updated_at, finished_at, termination_reason, metadata_json)
16
- VALUES (?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'starting', ?, ?, NULL, NULL, ?)`, [id, input.kind, input.ownerId, token, leaseExpiry(now, input.leaseMs), input.projectId ?? null, input.runId ?? null, input.workId ?? null, input.checkId ?? null, input.operationId ?? null, input.stdoutPath ?? null, input.stderrPath ?? null, now, now, JSON.stringify(input.metadata ?? {})]);
36
+ VALUES (?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'starting', ?, ?, NULL, NULL, ?)`, [id, input.kind, input.ownerId, token, leaseExpiry(now, input.leaseMs), input.projectId ?? null, input.runId ?? null, input.workId ?? null, input.checkId ?? null, input.operationId ?? null, input.stdoutPath ?? null, input.stderrPath ?? null, now, now, JSON.stringify({ ...input.metadata, ...(input.ownerPid ? { owner_pid: input.ownerPid, owner_pid_started_at: processStartedAt(input.ownerPid) } : {}) })]);
17
37
  return requireProcess(db, id);
18
38
  }
19
39
  export function confirmManagedProcess(context, input) {
@@ -23,6 +43,10 @@ export function confirmManagedProcess(context, input) {
23
43
  const metadata = parseMetadata(current.metadata_json);
24
44
  if (input.processGroupId)
25
45
  metadata.process_group_id = input.processGroupId;
46
+ if (input.ownerPid) {
47
+ metadata.owner_pid = input.ownerPid;
48
+ metadata.owner_pid_started_at = processStartedAt(input.ownerPid);
49
+ }
26
50
  const result = db.run(`UPDATE managed_processes
27
51
  SET pid = ?, pid_started_at = ?, state = 'running', lease_expires_at = ?, updated_at = ?, metadata_json = ?
28
52
  WHERE id = ? AND lease_token = ? AND state = 'starting'`, [input.pid, processStartedAt(input.pid), leaseExpiry(now, input.leaseMs), now, JSON.stringify(metadata), input.id, input.leaseToken]);
@@ -36,6 +60,9 @@ export function heartbeatManagedProcess(context, input) {
36
60
  }
37
61
  export function finishManagedProcess(context, input) {
38
62
  const db = registry(context);
63
+ const existing = requireProcess(db, input.id);
64
+ if (existing.lease_token === input.leaseToken && existing.state === input.state)
65
+ return true;
39
66
  const now = context.now();
40
67
  const updated = db.run("UPDATE managed_processes SET state = ?, termination_reason = ?, finished_at = ?, updated_at = ?, lease_expires_at = ? WHERE id = ? AND lease_token = ? AND state IN ('starting','running','stopping','orphaned')", [input.state, input.reason ?? null, now, now, now, input.id, input.leaseToken]).changes === 1;
41
68
  if (updated)
@@ -48,12 +75,13 @@ export function processIsAlive(record) {
48
75
  try {
49
76
  process.kill(record.pid, 0);
50
77
  }
51
- catch {
52
- return false;
78
+ catch (error) {
79
+ return error.code !== "ESRCH";
53
80
  }
54
- return !record.pid_started_at || record.pid_started_at === processStartedAt(record.pid);
81
+ const started = processStartedAt(record.pid);
82
+ return !record.pid_started_at || !started || record.pid_started_at === started;
55
83
  }
56
- function processTreeIsAlive(record) {
84
+ export function processTreeIsAlive(record) {
57
85
  if (processIsAlive(record))
58
86
  return true;
59
87
  const group = parseMetadata(record.metadata_json).process_group_id;
@@ -62,12 +90,18 @@ function processTreeIsAlive(record) {
62
90
  process.kill(-group, 0);
63
91
  return true;
64
92
  }
65
- catch {
66
- return false;
93
+ catch (error) {
94
+ return error.code !== "ESRCH";
67
95
  }
68
96
  }
69
97
  return false;
70
98
  }
99
+ export function managedOwnerIsAlive(record) {
100
+ const metadata = parseMetadata(record.metadata_json);
101
+ if (typeof metadata.owner_pid !== "number")
102
+ return null;
103
+ return processIsAlive({ pid: metadata.owner_pid, pid_started_at: typeof metadata.owner_pid_started_at === "string" ? metadata.owner_pid_started_at : null });
104
+ }
71
105
  /**
72
106
  * Expiry proves that observation stopped, not that the owner died. A live PID
73
107
  * remains owned until its result is reconciled; only a dead process can be
@@ -81,7 +115,8 @@ export function claimExpiredManagedProcesses(context, ownerId) {
81
115
  const candidates = db.all("SELECT * FROM managed_processes WHERE state IN ('starting','running','stopping','orphaned') AND lease_expires_at < ? ORDER BY lease_expires_at, id", [now]);
82
116
  const claimed = [];
83
117
  for (const candidate of candidates) {
84
- if (processTreeIsAlive(candidate))
118
+ const ownerAlive = managedOwnerIsAlive(candidate);
119
+ if (ownerAlive === true || (ownerAlive === null && processTreeIsAlive(candidate)))
85
120
  continue;
86
121
  const token = crypto.randomUUID();
87
122
  const result = db.run("UPDATE managed_processes SET owner_id = ?, lease_token = ?, state = 'orphaned', lease_expires_at = ?, updated_at = ? WHERE id = ? AND lease_token = ? AND state IN ('starting','running','stopping','orphaned') AND lease_expires_at < ?", [ownerId, token, leaseExpiry(now), now, candidate.id, candidate.lease_token, now]);
@@ -99,6 +134,26 @@ export function claimExpiredManagedProcesses(context, ownerId) {
99
134
  export function managedProcessStatus(context) {
100
135
  return registry(context).all("SELECT * FROM managed_processes ORDER BY updated_at DESC, id");
101
136
  }
137
+ export async function stopManagedProcess(context, input) {
138
+ const db = registry(context), record = requireProcess(db, input.id);
139
+ if (record.lease_token !== input.leaseToken)
140
+ throw new Error("Managed process lease does not match");
141
+ if (["stopped", "failed"].includes(record.state) && !processTreeIsAlive(record))
142
+ return record;
143
+ db.run("UPDATE managed_processes SET state = 'stopping', updated_at = ? WHERE id = ? AND lease_token = ? AND state IN ('starting','running','orphaned')", [context.now(), record.id, record.lease_token]);
144
+ if (processTreeIsAlive(record)) {
145
+ terminateOwnedProcess(record, "SIGTERM");
146
+ await delay(input.graceMs ?? 1_000);
147
+ if (processTreeIsAlive(record)) {
148
+ terminateOwnedProcess(record, "SIGKILL");
149
+ await delay(250);
150
+ }
151
+ if (processTreeIsAlive(record))
152
+ throw Object.assign(new Error("Owned process tree could not be safely stopped; resources retained"), { code: "process_tree_not_settled", details: { process_id: record.id } });
153
+ }
154
+ finishManagedProcess(context, { id: record.id, leaseToken: record.lease_token, state: "stopped", reason: input.reason ?? "requested_stop" });
155
+ return requireProcess(db, record.id);
156
+ }
102
157
  /** Reconcile only records the system owns and has atomically claimed. */
103
158
  export async function reconcileExpiredManagedProcesses(context, ownerId, graceMs = 1_000) {
104
159
  const claimed = claimExpiredManagedProcesses(context, ownerId);
@@ -109,18 +164,15 @@ export async function reconcileExpiredManagedProcesses(context, ownerId, graceMs
109
164
  outcomes.push({ id: processRecord.id, outcome: "already_stopped" });
110
165
  continue;
111
166
  }
112
- terminateOwnedProcess(processRecord, "SIGTERM");
113
- await delay(graceMs);
114
- if (processTreeIsAlive(processRecord)) {
115
- terminateOwnedProcess(processRecord, "SIGKILL");
116
- await delay(Math.min(graceMs, 250));
167
+ try {
168
+ await stopManagedProcess(context, { id: processRecord.id, leaseToken: processRecord.lease_token, graceMs, reason: "orphan_reconciled" });
169
+ outcomes.push({ id: processRecord.id, outcome: "stopped" });
117
170
  }
118
- if (processTreeIsAlive(processRecord)) {
171
+ catch (error) {
172
+ if (error.code !== "process_tree_not_settled")
173
+ throw error;
119
174
  outcomes.push({ id: processRecord.id, outcome: "kill_failed" });
120
- continue;
121
175
  }
122
- finishManagedProcess(context, { id: processRecord.id, leaseToken: processRecord.lease_token, state: "stopped", reason: "orphan_reconciled" });
123
- outcomes.push({ id: processRecord.id, outcome: "stopped" });
124
176
  }
125
177
  return outcomes;
126
178
  }
@@ -160,7 +212,7 @@ function releasePortClaims(db, claims) { for (const claim of claims)
160
212
  function terminateOwnedProcess(record, signal) {
161
213
  // Never signal a group merely because its former leader PID was once ours.
162
214
  // PID reuse or a departed leader makes group ownership unprovable.
163
- if (!record.pid || !record.pid_started_at || !processIsAlive(record))
215
+ if (!record.pid || !record.pid_started_at || processStartedAt(record.pid) !== record.pid_started_at)
164
216
  return;
165
217
  const group = parseMetadata(record.metadata_json).process_group_id;
166
218
  try {
@@ -1263,7 +1263,7 @@ function upsertStage(index, stageRun) {
1263
1263
  }
1264
1264
  index.stage_runs.sort((a, b) => a.order - b.order);
1265
1265
  }
1266
- function resolveRun(context, projectId, idOrAlias) {
1266
+ export function resolveRun(context, projectId, idOrAlias) {
1267
1267
  if (isFullEntityId(idOrAlias)) {
1268
1268
  return requireRunById(context, projectId, idOrAlias);
1269
1269
  }
@@ -0,0 +1,92 @@
1
+ import fs from "node:fs";
2
+ import crypto from "node:crypto";
3
+ import path from "node:path";
4
+ import { AppError } from "../shared/errors.js";
5
+ import { requireProjectByRoot } from "./projects.js";
6
+ import { resolveRun } from "./runs.js";
7
+ import { codeExecutionEnvironment, runCheck } from "./code-checks.js";
8
+ import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, processTreeIsAlive, registerManagedProcess, reservePorts, resourceHome, stopManagedProcess } from "./managed-processes.js";
9
+ /** Foreground supervisor: the tool may yield while this command owns the service.
10
+ * Its ready receipt is emitted immediately; a separate stop ends this command.
11
+ * Readiness is infrastructure evidence, never a successful scenario verdict. */
12
+ export async function startRuntimeService(context, input) {
13
+ if (!Number.isFinite(input.timeoutMs) || input.timeoutMs <= 0)
14
+ throw new AppError("validation", "Readiness timeout must be positive and finite", 2);
15
+ if (!input.command.trim() || !input.ports.length || input.ports.some(name => !/^[a-z][a-z0-9_]*$/.test(name)) || new Set(input.ports).size !== input.ports.length || !input.ports.includes(input.readyPort) || !input.readyPath.startsWith("/") || input.readyPath.startsWith("//"))
16
+ throw new AppError("validation", "Declare unique lowercase port names, a ready-port from that list and a local /ready-path", 2);
17
+ const project = requireProjectByRoot(context, path.resolve(input.projectRoot));
18
+ const run = resolveRun(context, project.id, input.runId);
19
+ if (!run?.run_root || !run.workspace_root)
20
+ throw new AppError("not_found", "Registered RUN with a materialized workspace is required", 2);
21
+ input = { ...input, runId: run.id };
22
+ const id = `PROC-${crypto.randomUUID()}`;
23
+ const directory = path.join(run.run_root, "runtime", id);
24
+ fs.mkdirSync(directory, { recursive: true });
25
+ const record = registerManagedProcess(context, { id, kind: "runtime-service", ownerId: `run:${input.runId}`, ownerPid: process.pid, runId: input.runId, projectId: project.id, stdoutPath: path.join(directory, "stdout.log"), stderrPath: path.join(directory, "stderr.log"), metadata: { command: input.command } });
26
+ const receiptPath = path.join(directory, "service.json");
27
+ let stdout, stderr;
28
+ let settled = false, ready = false;
29
+ const receipt = { schema_id: "dd-flow/runtime-service@1", process_id: record.id, run_id: input.runId, status: "starting", started_at: context.now(), receipt_path: receiptPath, stdout_path: path.join(directory, "stdout.log"), stderr_path: path.join(directory, "stderr.log") };
30
+ const save = () => { const temporary = `${receiptPath}.${process.pid}.tmp`; fs.writeFileSync(temporary, JSON.stringify(receipt, null, 2)); fs.renameSync(temporary, receiptPath); };
31
+ try {
32
+ const allocation = await reservePorts(context, { ownerId: record.owner_id, processId: record.id, names: input.ports });
33
+ const environment = { ...codeExecutionEnvironment(run.workspace_root), ...Object.fromEntries(Object.entries(allocation.ports).map(([name, port]) => [`DD_FLOW_PORT_${name.toUpperCase()}`, String(port)])), DD_FLOW_EVIDENCE_DIR: directory, DD_FLOW_CHECK_COMPLETION_FILE: path.join(directory, "completion.json") };
34
+ const quoted = (value) => `'${value.replaceAll("'", "'\\''")}'`;
35
+ Object.assign(receipt, { ports: allocation.ports, environment: Object.fromEntries(Object.entries(environment).filter(([key]) => key.startsWith("DD_FLOW_PORT_"))), stop_command: `DD_FLOW_HOME=${quoted(context.ddFlowHome)} DD_FLOW_RESOURCE_HOME=${quoted(resourceHome(context))} dd-flow runtime process stop --id ${record.id} --lease-token ${record.lease_token} --json` });
36
+ save();
37
+ stdout = fs.openSync(String(receipt.stdout_path), "a");
38
+ stderr = fs.openSync(String(receipt.stderr_path), "a");
39
+ const running = runCheck(input.command, run.workspace_root, environment, stdout, stderr, elapsed => input.progress?.(`service ${record.id} alive (${elapsed}s); receipt: ${receiptPath}`), pid => confirmManagedProcess(context, { id: record.id, leaseToken: record.lease_token, pid, processGroupId: process.platform === "win32" ? null : pid }), () => heartbeatManagedProcess(context, { id: record.id, leaseToken: record.lease_token }));
40
+ void running.then(() => { settled = true; });
41
+ const deadline = performance.now() + input.timeoutMs;
42
+ while (!settled && performance.now() < deadline) {
43
+ try {
44
+ const response = await fetch(`http://127.0.0.1:${allocation.ports[input.readyPort]}${input.readyPath}`, { signal: AbortSignal.timeout(1_000), redirect: "error" });
45
+ await response.body?.cancel();
46
+ ready = response.ok;
47
+ }
48
+ catch { /* Startup is observed until the declared deadline. */ }
49
+ if (ready)
50
+ break;
51
+ await new Promise(resolve => setTimeout(resolve, 100));
52
+ }
53
+ if (!ready) {
54
+ await stopManagedProcess(context, { id: record.id, leaseToken: record.lease_token });
55
+ await running;
56
+ throw new AppError("service_not_ready", "Service did not pass its declared HTTP readiness check; inspect retained logs", 2, { receipt_path: receiptPath });
57
+ }
58
+ Object.assign(receipt, { status: "ready", ready_at: context.now() });
59
+ save();
60
+ input.progress?.(`service ready: ${JSON.stringify(receipt)}; keep this supervisor running, use its exact stop command when finished`);
61
+ const outcome = await running;
62
+ const current = managedProcessStatus(context).find(item => item.id === record.id);
63
+ if (current.state === "stopping") {
64
+ const settleBy = performance.now() + 5_000;
65
+ while (processTreeIsAlive(current) && performance.now() < settleBy)
66
+ await new Promise(resolve => setTimeout(resolve, 25));
67
+ }
68
+ if (processTreeIsAlive(current))
69
+ throw new AppError("process_tree_not_settled", "Service still owns live children; retain resources", 2, { process_id: record.id });
70
+ if (!["stopped", "failed"].includes(current.state))
71
+ finishManagedProcess(context, { id: record.id, leaseToken: record.lease_token, state: current.state === "stopping" || outcome.exitCode === 0 ? "stopped" : "failed", reason: current.state === "stopping" ? "requested_stop" : outcome.error });
72
+ Object.assign(receipt, { status: managedProcessStatus(context).find(item => item.id === record.id).state, finished_at: context.now(), exit_code: outcome.exitCode });
73
+ save();
74
+ if (receipt.status === "failed")
75
+ throw new AppError("service_failed", "Service exited unsuccessfully; inspect retained logs", 2, receipt);
76
+ return receipt;
77
+ }
78
+ catch (error) {
79
+ const current = managedProcessStatus(context).find(item => item.id === record.id);
80
+ if (!processTreeIsAlive(current))
81
+ finishManagedProcess(context, { id: record.id, leaseToken: record.lease_token, state: "failed", reason: error instanceof Error ? error.message : String(error) });
82
+ Object.assign(receipt, { status: "failed", error: error instanceof Error ? error.message : String(error), cleanup_pending: processTreeIsAlive(current) });
83
+ save();
84
+ throw error;
85
+ }
86
+ finally {
87
+ if (stdout !== undefined)
88
+ fs.closeSync(stdout);
89
+ if (stderr !== undefined)
90
+ fs.closeSync(stderr);
91
+ }
92
+ }
@@ -1,10 +1,10 @@
1
1
  import crypto from "node:crypto";
2
- import { spawn } from "node:child_process";
2
+ import { runWorkspaceBootstrap } from "./workspace-bootstrap.js";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { AppError } from "../shared/errors.js";
6
6
  import { resolveProjectRoot } from "../storage/paths.js";
7
- import { aggregateCheckDeclarations, checkReceipts, codeExecutionEnvironment, finalCodeCheckDeclarations, runCodeChecks, unchangedFinalGateFailures, workspaceChangedPaths, workspaceFingerprint } from "./code-checks.js";
7
+ import { aggregateCheckDeclarations, checkReceipts, finalCodeCheckDeclarations, runCodeChecks, unchangedFinalGateFailures, workspaceChangedPaths, workspaceFingerprint } from "./code-checks.js";
8
8
  import { requireProjectByRoot } from "./projects.js";
9
9
  import { appendFlowRunTimelineEvent, advanceFlowRun, attachFlowRunStage, completeFlowRunStage, completeFlowRun, getFlowRunVariables, gitFacts } from "./runs.js";
10
10
  import { validateSchema } from "./schema-validation.js";
@@ -653,50 +653,22 @@ async function ensureCodeWorkspaceReady(context, projectId, run, root, readiness
653
653
  throw new AppError("execution_profile_invalid", "CODE requires a frozen bootstrap command in the RUN execution profile", 1, { run_id: run.id });
654
654
  }
655
655
  const receiptPath = path.join(root, "workspace-readiness.json");
656
- const prior = readReadiness(receiptPath);
657
- if (prior?.workspace_root === run.workspace_root && prior.command === bootstrap.command && prior.status === "passed" && JSON.stringify(prior.check_declarations ?? []) === JSON.stringify(readinessChecks))
658
- return { ...prior, reused: true };
656
+ // Re-run the project bootstrap on stage entry: a prior receipt cannot prove
657
+ // that ignored dependencies or local services still exist.
659
658
  const startedAt = new Date().toISOString();
660
659
  progress?.(`workspace bootstrap started: ${bootstrap.command}`);
661
- const result = await runBootstrap(bootstrap.command, run.workspace_root, codeExecutionEnvironment(run.workspace_root), progress);
660
+ const result = await runWorkspaceBootstrap(context, { projectId, runId: run.id, runHome: requireHome(run), workspaceRoot: run.workspace_root, command: bootstrap.command, artifactDir: "05-code/readiness", progress });
662
661
  const bootstrapPassed = result.exit_code === 0 && !result.error;
663
662
  const checks = bootstrapPassed && readinessChecks.length
664
663
  ? await runCodeChecks(context, { projectId, runId: run.id, runHome: requireHome(run), workspaceRoot: run.workspace_root, scope: "aggregate", artifactDir: "05-code/readiness", checks: readinessChecks, ...(progress ? { progress } : {}) })
665
664
  : [];
666
- const receipt = { schema_id: "dd-flow/workspace-readiness@2", workspace_root: run.workspace_root, command: bootstrap.command, policy_ref: bootstrap.policy_ref, started_at: startedAt, finished_at: new Date().toISOString(), status: bootstrapPassed && checks.every((check) => check.status === "passed") ? "passed" : "failed", exit_code: result.exit_code, stdout: result.stdout, stderr: result.stderr, check_declarations: readinessChecks, checks: checks.map((check) => ({ id: check.id, status: check.status, receipt_path: check.receipt_path })), ...(result.error ? { error: result.error } : {}), receipt_path: receiptPath };
665
+ const receipt = { schema_id: "dd-flow/workspace-readiness@2", workspace_root: run.workspace_root, command: bootstrap.command, policy_ref: bootstrap.policy_ref, started_at: startedAt, finished_at: new Date().toISOString(), status: bootstrapPassed && checks.every((check) => check.status === "passed") ? "passed" : "failed", process_id: result.process_id, stdout_path: result.stdout_path, stderr_path: result.stderr_path, completion_path: result.completion_path, exit_code: result.exit_code, stdout: result.stdout, stderr: result.stderr, check_declarations: readinessChecks, checks: checks.map((check) => ({ id: check.id, status: check.status, receipt_path: check.receipt_path })), ...(result.error ? { error: result.error } : {}), receipt_path: receiptPath };
667
666
  fs.writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
668
667
  if (receipt.status !== "passed")
669
668
  throw new AppError("workspace_readiness_failed", "CODE workspace bootstrap failed", 1, receipt);
670
669
  progress?.("workspace bootstrap passed");
671
670
  return receipt;
672
671
  }
673
- function runBootstrap(command, cwd, environment, progress) {
674
- return new Promise((resolve) => {
675
- const started = Date.now();
676
- const child = spawn("/bin/sh", ["-lc", command], { cwd, env: environment, stdio: ["ignore", "pipe", "pipe"] });
677
- let stdout = "";
678
- let stderr = "";
679
- let error = null;
680
- child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout += chunk; });
681
- child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk; });
682
- const heartbeat = setInterval(() => progress?.(`workspace bootstrap still running (${Math.floor((Date.now() - started) / 1000)}s)`), 15_000);
683
- const timeout = setTimeout(() => child.kill("SIGTERM"), 15 * 60 * 1000);
684
- child.once("error", (value) => { error = value.message; });
685
- child.once("close", (exitCode, signal) => {
686
- clearInterval(heartbeat);
687
- clearTimeout(timeout);
688
- resolve({ exit_code: exitCode, stdout, stderr, error: error ?? (signal ? `terminated by ${signal}` : null) });
689
- });
690
- });
691
- }
692
- function readReadiness(file) {
693
- try {
694
- return JSON.parse(fs.readFileSync(file, "utf8"));
695
- }
696
- catch {
697
- return null;
698
- }
699
- }
700
672
  function requireHome(run) {
701
673
  if (!run.run_root)
702
674
  throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1);
@@ -1,4 +1,5 @@
1
1
  import crypto from "node:crypto";
2
+ import { runWorkspaceBootstrap } from "./workspace-bootstrap.js";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
4
5
  import { execFileSync, spawnSync } from "node:child_process";
@@ -220,7 +221,7 @@ export async function finishVnextMerge(context, input) {
220
221
  throw new AppError("merge_semantic_blocked", "A blocked semantic result cannot complete MERGE", 2, { result_path: resultPath });
221
222
  if (request.checkpoint === "apply_recorded") {
222
223
  input.progress?.("bootstrapping integrated target");
223
- runBootstrap(run, request.target_workspace);
224
+ await runBootstrap(context, run, request.target_workspace, request.merge_request_id, input.progress);
224
225
  context.db.run("UPDATE merge_requests SET checkpoint = 'bootstrap_ready', updated_at = ? WHERE merge_request_id = ?", [context.now(), request.merge_request_id]);
225
226
  request = requireRequest(context, request.merge_request_id);
226
227
  }
@@ -360,9 +361,14 @@ function protocolIds(home) { const root = path.join(home, "03-plan"); if (!fs.ex
360
361
  function workspaceRoute(home) { const file = path.join(home, "02-protocolize", "workspace-route.json"); const value = JSON.parse(fs.readFileSync(file, "utf8")); if (!value.policy?.integration_branch)
361
362
  throw new AppError("workspace_route_missing", "MERGE requires the frozen integration branch", 1, { file }); return { integration_branch: value.policy.integration_branch }; }
362
363
  function executionSettings(run) { return JSON.parse(run.index_json).execution_profile?.settings ?? {}; }
363
- function runBootstrap(run, cwd) { const command = executionSettings(run).code_bootstrap?.command; if (!command)
364
- return; const result = spawnSync("/bin/sh", ["-lc", command], { cwd, encoding: "utf8" }); if (result.status !== 0)
365
- throw new AppError("merge_bootstrap_failed", "Integrated target bootstrap failed", 2, { command, stdout: result.stdout, stderr: result.stderr, status: result.status }); }
364
+ async function runBootstrap(context, run, cwd, mergeId, progress) {
365
+ const command = executionSettings(run).code_bootstrap?.command;
366
+ if (!command)
367
+ throw new AppError("execution_profile_invalid", "MERGE requires a frozen workspace bootstrap command", 2);
368
+ const result = await runWorkspaceBootstrap(context, { projectId: run.project_id, runId: run.id, runHome: requireHome(run), workspaceRoot: cwd, command, artifactDir: `${stageDir}/${mergeId}/readiness`, progress });
369
+ if (result.exit_code !== 0 || result.error)
370
+ throw new AppError("merge_bootstrap_failed", "Integrated target bootstrap failed; inspect its retained logs", 2, { command, ...result });
371
+ }
366
372
  function verifyLocalDelivery(request) { const head = gitValue(request.target_workspace, ["rev-parse", request.target_branch]); if (!request.integration_commit || head !== request.integration_commit)
367
373
  throw new AppError("merge_delivery_unproven", "Local integration branch does not resolve to the accepted integration commit", 1, { target_branch: request.target_branch, expected: request.integration_commit, actual: head }); }
368
374
  function cleanupReceiptPath(run) { return path.join(requireHome(run), stageDir, "cleanup-receipt.json"); }
@@ -464,10 +464,10 @@ function validateCodeWorkResult(work, value, projectRoot, runHome, runId) {
464
464
  if (unchangedDocuments.length)
465
465
  throw new AppError("document_update_not_materialized", "Assigned durable document updates must exist and differ from their PLAN baseline", 2, { work_id: work.work_id, unchanged_paths: unchangedDocuments });
466
466
  const assigned = packet.repair?.review_findings?.map((finding) => finding.finding_ref) ?? [];
467
+ validateReviewResolutions(work.work_id, assigned, result.resolutions ?? []);
467
468
  if (assigned.length) {
468
469
  if ((result.changed_paths?.length ?? 0) === 0)
469
470
  throw new AppError("review_repair_no_change", "Review repair must materialize a project change; a no-op cannot resolve a finding", 2, { work_id: work.work_id, findings: assigned });
470
- validateReviewResolutions(work.work_id, assigned, result.resolutions ?? []);
471
471
  for (const resolution of result.resolutions ?? [])
472
472
  for (const ref of resolution.evidence_refs ?? [])
473
473
  assertPortableArtifactRef(ref, { workspaceRoot: projectRoot, runHome, runId });
@@ -491,28 +491,36 @@ function renderWorkerPrompt(context, work, run, dependencies) {
491
491
  const completionRepair = mergeWork
492
492
  ? ["A failed integration check is evidence for a source-repair cycle, not permission to repair the integration workspace. Follow the exact merge repair command returned by dd-flow."]
493
493
  : ["Read the failed receipt and its stdout/stderr. Fix project-owned source, migration, test, formatting, or configuration errors in this same Work, then call Finish again. Do not invent a cause that does not appear in the retained output."];
494
- const codeContext = packet ? ["<semantic_spine>", JSON.stringify(packet.semantic_spine, null, 2), "</semantic_spine>", "", ...(packet.repair ? ["<repair_context>", JSON.stringify(packet.repair, null, 2), "Read the failed receipt and its linked stdout/stderr before editing. Preserve the accepted origin context and fix only the evidenced failure.", "</repair_context>", ""] : []), "<accepted_requirements>", JSON.stringify(packet.requirements, null, 2), "</accepted_requirements>", "", "<write_boundary_invariant>", "For a mutation guarded by membership, ownership, authorization or parent lifecycle state, preserve that predicate in the write statement or make guard and write one explicit transaction with the needed lock. A prior read may diagnose an error but never proves a later write remains allowed. Apply this to create, update, delete and parent-state mutations.", "</write_boundary_invariant>", "", "<acceptance_context>", "The criteria below are end-to-end context. Complete this Work's semantic contribution and declared checks; another ordered Work may own a different acceptance surface.", JSON.stringify(packet.acceptance, null, 2), "</acceptance_context>", "", "<required_read>", "These are mandatory starting sources, not a read allowlist. Read any additional project files needed to implement the Work correctly.", ...packet.required_read.map((item) => `- ${resolveRunReferences(item, work.run_id, requireRunHome(run))}`), "</required_read>", "", "<discovery_boundary>", "These are likely discovery areas, not a hard boundary. Expand project-local investigation when required and report material additions.", ...packet.discovery_boundary.map((item) => `- ${item}`), "</discovery_boundary>", "", "<planned_write_areas>", "SOFT COORDINATION HINT ONLY. These paths help the coordinator avoid concurrent collisions. They do not grant or deny write permission and do not limit the files needed for this Work. You may create or change any project file under workspace_root that is necessary and in semantic scope; report every actual changed path.", ...(packet.planned_write_areas.length ? packet.planned_write_areas.map((item) => `- ${item}`) : ["- none predicted; derive the necessary files from the task"]), "</planned_write_areas>", "", ...(packet.provides_checks.length ? ["<provided_checks>", ...packet.provides_checks.map((item) => `- ${item.id}: materialize ${item.command}${item.definition ? ` as ${item.definition}` : ""}; it is not usable until this Work finishes.`), "Update the declared project command or alias before Work finish. The CLI verifies the materialization and then executes the check.", "</provided_checks>", ""] : []), "<verification>", ...packet.checks.map((item) => `- ${item.id} at ${item.run_at}: ${item.command} — ${item.purpose}`), "The CLI executes work-scoped checks and retains their receipts. Report semantic evidence only; do not rerun declared checks manually.", "</verification>", "", "<stop_conditions>", ...packet.stop_conditions.map((item) => `- ${item}`), "</stop_conditions>", ""] : [];
494
+ const codeContext = packet ? ["<semantic_spine>", JSON.stringify(packet.semantic_spine, null, 2), "</semantic_spine>", "", ...(packet.repair ? ["<repair_context>", JSON.stringify(packet.repair, null, 2), "Read the failed receipt and its linked stdout/stderr before editing. Preserve the accepted origin context and fix only the evidenced failure.", "</repair_context>", ""] : []), "<accepted_requirements>", JSON.stringify(packet.requirements, null, 2), "</accepted_requirements>", "", "<write_boundary_invariant>", "For a mutation guarded by membership, ownership, authorization or parent lifecycle state, preserve that predicate in the write statement or make guard and write one explicit transaction with the needed lock. A prior read may diagnose an error but never proves a later write remains allowed. Apply this to create, update, delete and parent-state mutations.", "</write_boundary_invariant>", "", "<acceptance_context>", "The criteria below are end-to-end context. Complete this Work's semantic contribution and declared checks; another ordered Work may own a different acceptance surface.", JSON.stringify(packet.acceptance, null, 2), "</acceptance_context>", "", "<required_read>", "These are mandatory starting sources, not a read allowlist. Read any additional project files needed to implement the Work correctly.", ...packet.required_read.map((item) => `- ${resolveRunReferences(item, work.run_id, requireRunHome(run))}`), "</required_read>", "", "<discovery_boundary>", "These are likely discovery areas, not a hard boundary. Expand project-local investigation when required and report material additions.", ...packet.discovery_boundary.map((item) => `- ${item}`), "</discovery_boundary>", "", "<planned_write_areas>", "SOFT COORDINATION HINT ONLY. These paths help the coordinator avoid concurrent collisions. They do not grant or deny write permission and do not limit the files needed for this Work. You may create or change any project file under workspace_root that is necessary and in semantic scope; report every actual changed path.", ...(packet.planned_write_areas.length ? packet.planned_write_areas.map((item) => `- ${item}`) : ["- none predicted; derive the necessary files from the task"]), "</planned_write_areas>", "", ...(packet.provides_checks.length ? ["<provided_checks>", ...packet.provides_checks.map((item) => `- ${item.id}: materialize ${item.command}${item.definition ? ` as ${item.definition}` : ""}; it is not usable until this Work finishes.`), "Update the declared project command or alias before Work finish. The CLI verifies materialization at Work finish. Execution occurs at its declared run_at gate, not necessarily in this Work.", "</provided_checks>", ""] : []), "<verification>", ...packet.checks.map((item) => `- ${item.id} at ${item.run_at}: ${item.command} — ${item.purpose}`), "The CLI executes work-scoped checks and retains their receipts. Report semantic evidence only; do not rerun declared checks manually.", "</verification>", "", "<stop_conditions>", ...packet.stop_conditions.map((item) => `- ${item}`), "</stop_conditions>", ""] : [];
495
495
  if (packet)
496
496
  codeContext.push("<document_updates>", JSON.stringify(packet.document_updates, null, 2), "Materialize every listed update. dd-flow verifies the resulting file against its PLAN-time baseline.", "</document_updates>", "", "<completion_contract>", "Successful completion requires empty deviations and blockers and every assigned document update in changed_paths. A necessary path outside planned_write_areas is normal coordination drift, not a blocker; include it in changed_paths and continue.", "</completion_contract>", "");
497
- return ["<work>", `- work_id: ${work.work_id}`, `- run_id: ${work.run_id}`, `- project_root: ${run.project_root}`, `- workspace_root: ${run.workspace_root}`, `- run_home: ${requireRunHome(run)}`, "</work>", "", "<hard_write_boundary>", ...writeBoundary, "</hard_write_boundary>", "", ...codeContext, "<dependency_results>", JSON.stringify(dependencies.filter(Boolean), null, 2), "</dependency_results>", "", "<task>", resolveRunReferences(work.task, work.run_id, requireRunHome(run)), "</task>", "", ...(work.result_schema ? ["<result_contract>", `Return JSON matching \`${work.result_schema}\`.`, ...resultSchemaGuidance(work, run.id), "Do not create result.json yourself. Send the JSON to dd-flow on stdin; it atomically validates and stores the canonical receipt.", "</result_contract>", ""] : []), "<completion>", "The CLI runs every declared required check before accepting this Work. A failed receipt means only that the check failed; it is not proof of an engine, harness, dependency, or environment blocker.", ...completionRepair, "Use Fail only for a concrete external blocker after deterministic bootstrap or a contradiction with an accepted requirement/non-goal. Never fail merely because a necessary project path was absent from planned_write_areas.", "Finish may run for several minutes. Preserve the shell tool's process/session handle and poll that same invocation until it exits; progress arrives as JSONL on stderr. Never reissue Finish merely because final stdout has not arrived.", `Finish as one standalone command, piping your JSON object to stdin: ${command} work finish ${work.work_id} --result-stdin --project-root ${JSON.stringify(run.project_root)} --json --progress-jsonl`, `Fail only for an evidenced external or semantic-contract blocker: ${command} work fail ${work.work_id} --reason "receipt path + exact external or semantic blocker" --project-root ${JSON.stringify(run.project_root)} --json`, "</completion>", ""].join("\n");
497
+ if (packet)
498
+ codeContext.push("<temporary_services>", "Prefer the declared check launcher: it already owns check resources. If the planned scenario genuinely requires an interactive HTTP service, use the managed supervisor below. This is a template: replace the project service command, port names and readiness path from the plan/project instructions; do not invent a fixed port.", `${command} runtime process start --run ${run.id} --project-root ${JSON.stringify(run.project_root)} --command '<project-service-command>' --ports api --ready-port api --ready-path /health --json --progress-jsonl`, "The service receives DD_FLOW_PORT_API (and equivalent variables for all declared names). The command stays running as its supervisor. Retain its tool handle; wait for the service ready event and read its service.json receipt. Pass those exact ports and the same project environment to reset/seed, API and browser operations.", "A ready receipt proves service readiness only. Record the scenario outcome and real evidence separately. After the scenario, execute the exact stop_command from that receipt, then wait for the supervisor to exit. Never use pkill/killall or stop a sibling's process. If cleanup fails, retain the process id and report the failure; do not claim the resource is free.", "</temporary_services>", "");
499
+ return ["<work>", `- work_id: ${work.work_id}`, `- run_id: ${work.run_id}`, `- project_root: ${run.project_root}`, `- workspace_root: ${run.workspace_root}`, `- run_home: ${requireRunHome(run)}`, "</work>", "", "<hard_write_boundary>", ...writeBoundary, "</hard_write_boundary>", "", ...codeContext, "<dependency_results>", JSON.stringify(dependencies.filter(Boolean), null, 2), "</dependency_results>", "", "<task>", resolveRunReferences(work.task, work.run_id, requireRunHome(run)), "</task>", "", ...(work.result_schema ? ["<result_contract>", `Return JSON matching \`${work.result_schema}\`.`, ...resultSchemaGuidance(work, run.id), "Do not create result.json yourself. Send the JSON to dd-flow on stdin; it atomically validates and stores the canonical receipt.", "</result_contract>", ""] : []), "<completion>", "Work finish runs only declared run_at=work checks. Stage finish owns readiness/code/merge gates; successful Work completion does not mean those gates have passed. A failed receipt means only that the check failed; it is not proof of an engine, harness, dependency, or environment blocker.", ...completionRepair, "Use Fail only for a concrete external blocker after deterministic bootstrap or a contradiction with an accepted requirement/non-goal. Never fail merely because a necessary project path was absent from planned_write_areas.", "Finish may run for several minutes. Preserve the shell tool's process/session handle and poll that same invocation until it exits; progress arrives as JSONL on stderr. Never reissue Finish merely because final stdout has not arrived.", `Finish as one standalone command, piping your JSON object to stdin: ${command} work finish ${work.work_id} --result-stdin --project-root ${JSON.stringify(run.project_root)} --json --progress-jsonl`, `Fail only for an evidenced external or semantic-contract blocker: ${command} work fail ${work.work_id} --reason "receipt path + exact external or semantic blocker" --project-root ${JSON.stringify(run.project_root)} --json`, "</completion>", ""].join("\n");
498
500
  }
499
- function resultSchemaGuidance(work, runId) {
501
+ export function resultSchemaGuidance(work, runId) {
500
502
  const schema = work.result_schema;
501
503
  const refs = `Evidence refs for project source are relative to workspace_root; RUN evidence uses run://${runId}/path/to/artifact.`;
502
504
  if (schema === "dd-flow/code-work-result@3") {
503
505
  const packet = codePacket(work);
504
- const obligationRef = packet?.repair?.check_receipt_id ? packet.checks[0]?.id ?? "CHK-assigned-check" : "AC-001";
505
- const obligationNote = packet?.repair?.check_receipt_id
506
- ? `This is a gate-repair Work: cite its assigned check id (${obligationRef}) in evidence. Do not substitute an unrelated acceptance criterion.`
507
- : "Cite an acceptance criterion or check explicitly assigned to this Work in evidence.";
508
- return [refs, obligationNote, "For a review repair, include exactly one resolution per assigned finding; explain what changed and cite evidence that demonstrates the required outcome:", "```json", JSON.stringify({ schema_id: schema, summary: "What was implemented.", changed_paths: ["project-relative/path"], evidence: [{ obligation_ref: obligationRef, refs: ["project-relative/evidence"] }], deviations: [], blockers: [], resolutions: [{ finding_ref: "WRK-000/FIND-001", summary: "How the required outcome was achieved.", evidence_refs: ["project-relative/evidence"] }] }, null, 2), "```"];
506
+ const obligations = [...new Set([
507
+ ...(packet?.acceptance ?? []).map((item) => item.criterion_id).filter((id) => Boolean(id)),
508
+ ...(packet?.checks ?? []).map((item) => item.id),
509
+ ...(packet?.repair?.review_findings ?? []).flatMap((finding) => finding.obligation_refs ?? [])
510
+ ])];
511
+ const findings = packet?.repair?.review_findings ?? [];
512
+ return [refs, `Assigned obligation refs: ${JSON.stringify(obligations)}. Cite only these in evidence.obligation_ref.`,
513
+ "This is a shape template, not a completed result. Populate changed_paths and evidence from the actual work and existing files; empty arrays below do not establish completion.",
514
+ "For a gate repair, cite the assigned check id. For a review repair, replace each resolution summary and fill evidence_refs with existing evidence proving its required outcome. Ordinary Work has resolutions: [].",
515
+ "```json", JSON.stringify({ schema_id: schema, summary: "Describe the actual implementation.", changed_paths: [], evidence: [], deviations: [], blockers: [], resolutions: findings.map((finding) => ({ finding_ref: finding.finding_ref, summary: "Describe how the assigned required outcome was achieved.", evidence_refs: [] })) }, null, 2), "```",
516
+ "Each evidence entry has exactly obligation_ref (one assigned ref) and refs (nonempty list of existing evidence paths). Each resolution has finding_ref, summary and nonempty evidence_refs. Do not invent file names or finding ids."];
509
517
  }
510
518
  if (schema === "dd-flow/code-review-result@1") {
511
- return [refs, "Assess every assigned aspect exactly once. Number findings locally inside this reviewer Work as FIND-001, FIND-002, ...; dd-flow adds the Work id to form the canonical reference.", "Use this complete minimal shape. Report only material, direct-evidence findings; taste and cosmetics are not findings:", "```json", JSON.stringify({ schema_id: schema, verdict: "pass | findings | blocked", summary: "Concise evidence-backed conclusion.", aspects: [{ aspect_id: "assigned_aspect_id", verdict: "pass | findings | blocked", summary: "Conclusion.", evidence_refs: ["path/to/file", `run://${runId}/05-code/checks/receipt.json`] }], findings: [{ finding_id: "FIND-001", aspect_id: "assigned_aspect_id", priority: "p0 | p1 | p2 | p3", problem: "Violated obligation or rule.", impact: "Concrete risk or failure.", required_outcome: "Smallest observable result that closes the defect.", evidence_refs: ["path/to/file"], obligation_refs: ["R-001 | AC-001 | policy ref"] }] }, null, 2), "```"];
519
+ return [refs, "Assess every assigned aspect exactly once. Number findings locally inside this reviewer Work as FIND-001, FIND-002, ...; dd-flow adds the Work id to form the canonical reference.", "This is a shape template: replace labels with assigned ids and observed verdicts, fill evidence_refs using existing files from the task packet, and remove findings when none exist. Report only material, direct-evidence findings; taste and cosmetics are not findings:", "```json", JSON.stringify({ schema_id: schema, verdict: "pass | findings | blocked", summary: "Concise evidence-backed conclusion.", aspects: [{ aspect_id: "assigned_aspect_id", verdict: "pass | findings | blocked", summary: "Conclusion.", evidence_refs: [] }], findings: [{ finding_id: "FIND-001", aspect_id: "assigned_aspect_id", priority: "p0 | p1 | p2 | p3", problem: "Violated obligation or rule.", impact: "Concrete risk or failure.", required_outcome: "Smallest observable result that closes the defect.", evidence_refs: ["path/to/file"], obligation_refs: ["R-001 | AC-001 | policy ref"] }] }, null, 2), "```"];
512
520
  }
513
521
  if (schema !== "dd-flow/plan-review-result@1")
514
522
  return [];
515
- return [refs, "Use this complete minimal shape; do not add fields. Number findings locally inside this reviewer Work as FIND-001, FIND-002, ...; dd-flow adds the Work id when the coordinator classifies them:", "```json", JSON.stringify({ schema_id: schema, plan_revision: 1, overall_verdict: "pass | watch | needs_changes | blocked", summary: "Concise evidence-backed conclusion.", aspects: [{ aspect_id: "assigned_aspect_id", verdict: "pass | watch | needs_changes | blocked", summary: "Conclusion for this aspect.", evidence_refs: ["path/to/file", `run://${runId}/03-plan/plan.json`], findings: [{ finding_id: "FIND-001", severity: "high | medium | low | info", summary: "Problem, if any.", evidence_refs: ["path/to/file"] }] }] }, null, 2), "```"];
523
+ return [refs, "This is a shape template: replace labels with assigned ids and observed verdicts, fill evidence_refs using existing files from the task packet, and remove findings when none exist. Do not add fields. Number findings locally inside this reviewer Work as FIND-001, FIND-002, ...; dd-flow adds the Work id when the coordinator classifies them:", "```json", JSON.stringify({ schema_id: schema, plan_revision: 1, overall_verdict: "pass | watch | needs_changes | blocked", summary: "Concise evidence-backed conclusion.", aspects: [{ aspect_id: "assigned_aspect_id", verdict: "pass | watch | needs_changes | blocked", summary: "Conclusion for this aspect.", evidence_refs: [], findings: [{ finding_id: "FIND-001", severity: "high | medium | low | info", summary: "Problem, if any.", evidence_refs: ["path/to/file"] }] }] }, null, 2), "```"];
516
524
  }
517
525
  export function validateCodeReviewResultIdentity(work, value) {
518
526
  const group = codeReviewGroup(work);
@@ -0,0 +1,50 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { AppError } from "../shared/errors.js";
5
+ import { codeExecutionEnvironment, runCheck } from "./code-checks.js";
6
+ import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedOwnerIsAlive, managedProcessStatus, processTreeIsAlive, registerManagedProcess } from "./managed-processes.js";
7
+ /** CODE and MERGE prepare their own checkout through the same managed executor.
8
+ * Bootstrap is a project operation, not an alias classified by the engine as a test. */
9
+ export async function runWorkspaceBootstrap(context, input) {
10
+ const operationId = `bootstrap:${crypto.createHash("sha256").update(JSON.stringify([input.runId, input.workspaceRoot, input.artifactDir, input.command])).digest("hex")}`;
11
+ const pending = managedProcessStatus(context).find(item => item.operation_id === operationId && ["starting", "running", "stopping"].includes(item.state));
12
+ if (pending) {
13
+ if (processTreeIsAlive(pending) || (pending.state === "starting" && managedOwnerIsAlive(pending) !== false)) {
14
+ throw new AppError("bootstrap_in_progress", "Workspace preparation is still running; inspect its logs and wait for the existing process, do not launch another copy", 2, { process_id: pending.id, stdout_path: pending.stdout_path, stderr_path: pending.stderr_path });
15
+ }
16
+ const completionPath = path.join(path.dirname(pending.stdout_path), "completion.json");
17
+ let completion = null;
18
+ try {
19
+ completion = JSON.parse(fs.readFileSync(completionPath, "utf8"));
20
+ }
21
+ catch { /* Interrupted shell has no terminal marker. */ }
22
+ const exitCode = typeof completion?.exit_code === "number" ? completion.exit_code : null;
23
+ const error = exitCode === null ? "bootstrap ended without a completion marker; inspect retained logs before retrying" : null;
24
+ finishManagedProcess(context, { id: pending.id, leaseToken: pending.lease_token, state: exitCode === 0 ? "stopped" : "failed", reason: error ?? "bootstrap_completion_reconciled" });
25
+ return result(pending.id, pending.stdout_path, pending.stderr_path, completionPath, exitCode, error);
26
+ }
27
+ const directory = path.join(input.runHome, input.artifactDir, `bootstrap-${crypto.randomUUID()}`);
28
+ fs.mkdirSync(directory, { recursive: true });
29
+ const stdoutPath = path.join(directory, "stdout.log"), stderrPath = path.join(directory, "stderr.log"), completionPath = path.join(directory, "completion.json");
30
+ const managed = registerManagedProcess(context, { kind: "workspace-bootstrap", ownerId: operationId, ownerPid: process.pid, operationId, uniqueActiveOperation: true, projectId: input.projectId, runId: input.runId, stdoutPath, stderrPath, metadata: { command: input.command, workspace_root: input.workspaceRoot } });
31
+ const stdout = fs.openSync(stdoutPath, "a"), stderr = fs.openSync(stderrPath, "a");
32
+ try {
33
+ input.progress?.(`workspace bootstrap started: ${input.command}; logs: ${directory}`);
34
+ const completed = await runCheck(input.command, input.workspaceRoot, { ...codeExecutionEnvironment(input.workspaceRoot), DD_FLOW_CHECK_COMPLETION_FILE: completionPath }, stdout, stderr, elapsed => input.progress?.(`workspace bootstrap still running (${elapsed}s); logs: ${directory}; next report in 15s`), pid => confirmManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, pid, processGroupId: process.platform === "win32" ? null : pid }), () => heartbeatManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token }));
35
+ const current = managedProcessStatus(context).find(item => item.id === managed.id);
36
+ if (processTreeIsAlive(current))
37
+ throw new AppError("bootstrap_tree_not_settled", "Bootstrap shell exited but still owns live processes; resources were retained for inspection", 2, { process_id: managed.id, stdout_path: stdoutPath, stderr_path: stderrPath });
38
+ finishManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, state: completed.exitCode === 0 && !completed.error ? "stopped" : "failed", reason: completed.error });
39
+ return result(managed.id, stdoutPath, stderrPath, completionPath, completed.exitCode, completed.error);
40
+ }
41
+ finally {
42
+ fs.closeSync(stdout);
43
+ fs.closeSync(stderr);
44
+ }
45
+ }
46
+ function result(id, stdout, stderr, completion, exitCode, error) {
47
+ // Full logs remain on disk; keep the returned prompt bounded.
48
+ const excerpt = (file) => fs.existsSync(file) ? fs.readFileSync(file, "utf8").slice(-64_000) : "";
49
+ return { exit_code: exitCode, stdout: excerpt(stdout), stderr: excerpt(stderr), error, process_id: id, stdout_path: stdout, stderr_path: stderr, completion_path: completion };
50
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.13",
3
+ "version": "0.9.0-beta.15",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {