@deksden-com/dd-flow-cli 0.9.0-beta.38 → 0.9.0-beta.40

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.
Files changed (73) hide show
  1. package/CHANGELOG.md +22 -1
  2. package/dist/build-info.json +5 -5
  3. package/dist/cli/help.js +42 -1
  4. package/dist/cli/run-cli.js +147 -17
  5. package/dist/harness-runtime/bin/dd-agy.mjs +3 -2
  6. package/dist/harness-runtime/bin/dd-codex.mjs +4 -3
  7. package/dist/harness-runtime/bin/dd-droid.mjs +3 -2
  8. package/dist/harness-runtime/bin/dd-grok.mjs +4 -3
  9. package/dist/harness-runtime/bin/dd-opencode.mjs +4 -3
  10. package/dist/harness-runtime/bin/dd-zcode.mjs +4 -3
  11. package/dist/harness-runtime/lib/daemon-operations.mjs +48 -9
  12. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +49 -12
  13. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +57 -16
  14. package/dist/harness-runtime/lib/dd-codex.mjs +10 -2
  15. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +3 -2
  16. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +12 -6
  17. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +4 -3
  18. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +8 -5
  19. package/dist/harness-runtime/lib/managed-daemon.mjs +125 -6
  20. package/dist/harness-runtime/lib/process-json.mjs +1 -1
  21. package/dist/harness-runtime/lib/session-settlement.mjs +4 -2
  22. package/dist/schemas/run-control-receipt.schema.json +99 -0
  23. package/dist/schemas/run-control-request.schema.json +36 -0
  24. package/dist/services/cleanup.js +7 -4
  25. package/dist/services/cli-operation-classifier.js +3 -1
  26. package/dist/services/code-checks.js +33 -7
  27. package/dist/services/controller-fanout.js +105 -0
  28. package/dist/services/engines.js +2 -0
  29. package/dist/services/eval-snapshots.js +379 -36
  30. package/dist/services/execution-policy.js +122 -6
  31. package/dist/services/external-work-launch.js +33 -12
  32. package/dist/services/harness-adapter.js +49 -2
  33. package/dist/services/hooks.js +5 -5
  34. package/dist/services/managed-daemon-binding.js +38 -0
  35. package/dist/services/managed-processes.js +54 -7
  36. package/dist/services/merge-server.js +53 -30
  37. package/dist/services/native-daemon-history.js +49 -0
  38. package/dist/services/native-session-control.js +31 -0
  39. package/dist/services/protocols.js +9 -0
  40. package/dist/services/recovery-snapshot-database.js +103 -0
  41. package/dist/services/run-control-receipt.js +56 -0
  42. package/dist/services/run-control-worker.js +297 -0
  43. package/dist/services/run-control.js +752 -0
  44. package/dist/services/run-controller-adapter.js +79 -0
  45. package/dist/services/run-controller-capture.js +117 -0
  46. package/dist/services/run-controller-process.js +178 -0
  47. package/dist/services/run-controller-recovery.js +219 -0
  48. package/dist/services/run-controller-state.js +31 -0
  49. package/dist/services/run-controller.js +539 -0
  50. package/dist/services/run-recovery-runtime.js +69 -0
  51. package/dist/services/run-recovery.js +33 -9
  52. package/dist/services/runs.js +65 -13
  53. package/dist/services/runtime-budget.js +362 -0
  54. package/dist/services/runtime-scope-capture.js +50 -0
  55. package/dist/services/runtime-scope-control.js +431 -0
  56. package/dist/services/runtime-scope-resume.js +543 -0
  57. package/dist/services/runtime-scope-stop.js +95 -0
  58. package/dist/services/runtime-scope-worker.js +165 -0
  59. package/dist/services/runtime-service.js +4 -0
  60. package/dist/services/vnext-code-review.js +1 -1
  61. package/dist/services/vnext-code.js +1 -1
  62. package/dist/services/vnext-execution-profile.js +2 -35
  63. package/dist/services/vnext-fanout.js +23 -7
  64. package/dist/services/vnext-plan-review.js +12 -9
  65. package/dist/services/vnext-plan.js +1 -1
  66. package/dist/services/vnext-protocolize.js +10 -4
  67. package/dist/services/vnext-specify.js +12 -5
  68. package/dist/services/work-registry.js +26 -8
  69. package/dist/services/workspace-bootstrap.js +10 -1
  70. package/dist/storage/database.js +160 -13
  71. package/dist/storage/writer-contract.js +76 -0
  72. package/dist/storage/writer-migration.js +102 -0
  73. package/package.json +1 -1
@@ -5,7 +5,7 @@ import { chmod, lstat, mkdir, open, readFile, rename, unlink, writeFile } from "
5
5
  import net from "node:net";
6
6
  import { durableDaemonDispatch } from "./daemon-operations.mjs";
7
7
  import path from "node:path";
8
- import { cleanupFailedStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, heartbeatDaemonProcess, registerDaemonProcess, stopProcessGroup } from "./managed-daemon.mjs";
8
+ import { prepareRuntimeOwner, cleanupFailedStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, heartbeatDaemonProcess, registerDaemonProcess, stopProcessGroup } from "./managed-daemon.mjs";
9
9
 
10
10
  import {
11
11
  AcpBridge,
@@ -135,6 +135,7 @@ export async function startDaemon(options) {
135
135
  env: options.ddFlowHome ? { DD_FLOW_HOME: absolute(options.ddFlowHome, "--dd-flow-home") } : {},
136
136
  livenessTimeoutMs: options.livenessTimeoutMs ?? DEFAULT_LIVENESS_TIMEOUT_MS,
137
137
  };
138
+ await prepareRuntimeOwner(config, paths.dir);
138
139
  await mkdir(paths.dir, { recursive: true, mode: 0o700 });
139
140
  await chmod(paths.dir, 0o700);
140
141
  try {
@@ -243,7 +244,9 @@ export class DaemonRuntime {
243
244
  }
244
245
 
245
246
  treeRunning(result) {
246
- return result?.read?.projection?.status === "running" || (result?.subagents?.running ?? result?.evidence?.subagents?.running ?? result?.after?.running ?? []).length > 0;
247
+ const evidence = result?.evidence ?? result;
248
+ const children = evidence?.subagents?.running;
249
+ return evidence?.read?.projection?.status !== "idle" || !Array.isArray(children) || children.length > 0 || Boolean(evidence?.subagents?.observation_error);
247
250
  }
248
251
 
249
252
  async productive(name, task) {
@@ -293,7 +296,7 @@ export class DaemonRuntime {
293
296
  await this.persist(); return result;
294
297
  }
295
298
  if (operation === "session.fork") return await this.productive(operation, async () => { await this.requireSettled(params); return await forkSessionWithBridge(this.bridge, this.options(params)); });
296
- if (operation === "session.inspect") {
299
+ if (operation === "session.inspect" || operation === "session.resume") {
297
300
  const adapter = params.adapterSessionId ?? params.sessionId;
298
301
  if (!this.liveAdapters.has(adapter) && this.activeProductive) {
299
302
  throw new DaemonError(
@@ -311,7 +314,7 @@ export class DaemonRuntime {
311
314
  if (this.sessions.has(result.provider_session_id)) this.track(result, result.subagents);
312
315
  else if (result.adapter_session_id) this.liveAdapters.add(result.adapter_session_id);
313
316
  await this.persist({ active_tree: this.treeRunning(result) || Boolean(this.activeProductive) });
314
- return result;
317
+ return { ...result, settled: !this.treeRunning(result) && !this.activeProductive };
315
318
  }
316
319
  if (operation === "session.cancel") {
317
320
  if (!this.sessions.has(params.sessionId)) throw new DaemonError("session_identity_mismatch", "cancellation Session is not owned by this daemon");
@@ -393,7 +396,7 @@ export async function serveDaemon(stateDir) {
393
396
  try {
394
397
  request = JSON.parse(line);
395
398
  if (request.schema_id !== REQUEST_SCHEMA || !request.id || !request.operation) throw new DaemonError("daemon_protocol_mismatch", "invalid daemon request");
396
- const result = await durableDaemonDispatch(paths.dir, request, () => runtime.dispatch(request.operation, request.params ?? {}));
399
+ const result = await durableDaemonDispatch(paths.dir, request, () => runtime.dispatch(request.operation, request.params ?? {}), () => runtime.dispatch("session.inspect", request.params ?? {}));
397
400
  connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: true, result: { ...result, _shutdown: undefined } })}\n`);
398
401
  if (result?._shutdown) setImmediate(() => void runtime.shutdown().then(() => process.exit(0)));
399
402
  } catch (error) {
@@ -1,25 +1,143 @@
1
- import { lstat } from "node:fs/promises";
1
+ import { lstat, readFile, readdir, mkdir } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
3
+ import path from "node:path";
2
4
  import { commandJson } from "./process-json.mjs";
3
5
  import { errorRecord } from "./operation-errors.mjs";
6
+ import { writeJsonAtomic } from "./runner-events.mjs";
4
7
 
5
8
  const leases = new Map();
6
9
 
7
- export function assertDaemonOwnership() {
10
+ /** Freeze resource ownership independently of whether Subject hooks are enabled. */
11
+ export function bindRuntimeOwner(config, env = process.env) {
12
+ const raw = Object.hasOwn(config, "runtime_owner") ? config.runtime_owner === null ? undefined : JSON.stringify(config.runtime_owner) : env.DD_FLOW_RUNTIME_OWNER;
13
+ if (raw === undefined) return config.runtime_owner = null;
14
+ let owner;
15
+ try { if (raw.length > 16_384) throw new Error(); owner = JSON.parse(raw); }
16
+ catch { throw Object.assign(new Error("runtime ownership descriptor is invalid"), { code: "runtime_owner_invalid" }); }
17
+ const fields = ["schema_id", "owner_id", "role", "project_id", "run_id", "work_id", "operation_id", "state_dir", "dd_flow_home", "dd_flow_bin", "resource_home", "adapter_executable", "budget"];
18
+ const text = value => typeof value === "string" && value.trim().length > 0;
19
+ if (!owner || typeof owner !== "object" || Array.isArray(owner) || Object.keys(owner).some(key => !fields.includes(key))
20
+ || owner.schema_id !== "dd-flow/runtime-owner@1" || !text(owner.owner_id) || !text(owner.operation_id)
21
+ || !["subject", "worker", "merge", "judge", "probe"].includes(owner.role)
22
+ || ["state_dir", "dd_flow_home", "dd_flow_bin", "resource_home"].some(key => !text(owner[key]) || !path.isAbsolute(owner[key]))
23
+ || (owner.adapter_executable !== undefined && (!text(owner.adapter_executable) || !path.isAbsolute(owner.adapter_executable)))
24
+ || ["project_id", "run_id", "work_id"].some(key => owner[key] !== undefined && !text(owner[key]))
25
+ || (["subject", "worker", "merge"].includes(owner.role) && (!text(owner.project_id) || !text(owner.run_id)))
26
+ || (["worker", "merge"].includes(owner.role) && !text(owner.work_id))
27
+ || (["judge", "probe"].includes(owner.role) && owner.work_id !== undefined)) {
28
+ throw Object.assign(new Error("runtime ownership descriptor does not match its role and scope"), { code: "runtime_owner_invalid" });
29
+ }
30
+ if (owner.budget !== undefined) {
31
+ const budget = owner.budget, names = ["codex-desktop", "zcode-acp", "grok-acp", "antigravity-cli", "opencode-server", "droid-cli"];
32
+ if (!budget || typeof budget !== "object" || Array.isArray(budget) || Object.keys(budget).some(key => !["schema_id", "scope_id", "per_harness"].includes(key)) || budget.schema_id !== "dd-flow/runtime-budget@1" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(budget.scope_id ?? "") || !budget.per_harness || typeof budget.per_harness !== "object" || Array.isArray(budget.per_harness) || Object.entries(budget.per_harness).some(([name, limit]) => !names.includes(name) || !Number.isSafeInteger(limit) || limit < 1 || limit > 1024)) throw Object.assign(new Error("runtime owner budget is invalid"), { code: "runtime_budget_invalid" });
33
+ }
34
+ config.runtime_owner = Object.fromEntries(fields.filter(key => owner[key] !== undefined).map(key => [key, owner[key]]));
35
+ return config.runtime_owner;
36
+ }
37
+
38
+ /** Reject rebinding before provider setup, including the already-running path. */
39
+ export async function prepareRuntimeOwner(config, stateDir, env = process.env) {
40
+ const owner = bindRuntimeOwner(config, env);
41
+ if (owner && path.resolve(owner.state_dir) !== path.resolve(stateDir)) {
42
+ throw Object.assign(new Error("runtime owner belongs to another state directory"), { code: "runtime_owner_mismatch" });
43
+ }
44
+ let previous;
45
+ try { previous = JSON.parse(await readFile(path.join(stateDir, "daemon.json"), "utf8")); }
46
+ catch (error) { if (error.code === "ENOENT") return owner; throw error; }
47
+ const retained = bindRuntimeOwner({ runtime_owner: previous?.config?.runtime_owner ?? null }, {});
48
+ if (JSON.stringify(retained) !== JSON.stringify(owner)) {
49
+ throw Object.assign(new Error("retained daemon ownership differs; use explicit recovery instead of rebinding its sessions"), { code: "runtime_owner_mismatch" });
50
+ }
51
+ return owner;
52
+ }
53
+
54
+ export async function assertDaemonOwnership(stateDir, dispatch = {}, assertCurrent = () => {}) {
8
55
  for (const lease of leases.values()) if (lease.error) throw lease.error;
56
+ const state = JSON.parse(await readFile(path.join(stateDir, "daemon.json"), "utf8"));
57
+ const owner = bindRuntimeOwner({ runtime_owner: state.config?.runtime_owner ?? null }, {});
58
+ if (!owner) return;
59
+ const record = state.resource_process;
60
+ if (owner.state_dir !== stateDir || !record?.id || !record.lease_token) throw Object.assign(new Error("owned daemon has no confirmed resource binding"), { code: "process_ownership_unknown" });
61
+ for (;;) {
62
+ assertCurrent();
63
+ const result = await runtimeProcess(state.config, "admission", { id: record.id, "lease-token": record.lease_token, owner: owner.owner_id, "state-dir": stateDir, "daemon-id": state.daemon_id ?? state.config?.daemonId, ...dispatch });
64
+ assertCurrent();
65
+ if (result?.ok === true && result.admitted === true && result.process_id === record.id) return;
66
+ if (result?.ok !== true || result.reason !== "provider_capacity" || result.process_id !== record.id) throw Object.assign(new Error("productive runtime admission was not confirmed"), { code: "process_ownership_unknown" });
67
+ // The daemon, not its disposable socket client, owns this queue. Recheck
68
+ // the RUN fence and process lease on every admission attempt.
69
+ await new Promise(resolve => setTimeout(resolve, 250));
70
+ }
71
+ }
72
+
73
+ /** Snapshot terminal operations BEFORE observing native idle. A later Turn
74
+ * cannot be released by an older observation of the same Session. */
75
+ export async function budgetObservationScope(stateDir, sessionId) {
76
+ const state = await budgetState(stateDir);
77
+ if (!state?.config?.runtime_owner?.budget || !sessionId) return null;
78
+ const daemonId = state.daemon_id ?? state.config?.daemonId, operationIds = [];
79
+ for (const name of await readdir(path.join(stateDir, "operations")).catch(error => { if (error.code === "ENOENT") return []; throw error; })) {
80
+ const directory = path.join(stateDir, "operations", name);
81
+ try {
82
+ const requested = JSON.parse(await readFile(path.join(directory, "requested.json"), "utf8"));
83
+ const result = JSON.parse(await readFile(path.join(directory, "result.json"), "utf8"));
84
+ if (requested.daemon_id === daemonId && requested.session_id === sessionId && ["completed", "failed"].includes(result.state) && ["session.prompt", "session.start", "session.resume"].includes(requested.operation)) operationIds.push(requested.operation_id);
85
+ } catch (error) { if (error.code !== "ENOENT") throw error; }
86
+ }
87
+ return { stateDir, state, daemonId, sessionId, operationIds };
88
+ }
89
+
90
+ export async function releaseBudgetObservation(scope, observation) {
91
+ if (!scope || !scope.operationIds.length || observation?.settled !== true) return;
92
+ const id = observation.provider_session_id ?? observation.session_id ?? observation.adapter_session_id;
93
+ if (id !== scope.sessionId) throw Object.assign(new Error("budget observation belongs to another Session"), { code: "session_identity_mismatch" });
94
+ const directory = path.join(scope.stateDir, "budget-observations");
95
+ await mkdir(directory, { recursive: true, mode: 0o700 });
96
+ const file = path.join(directory, `${randomUUID()}.json`);
97
+ await writeJsonAtomic(file, { daemon_id: scope.daemonId, session_id: scope.sessionId, settled: true, operation_ids: scope.operationIds, observed_at: new Date().toISOString() });
98
+ await releaseBudget(scope.state, { "observation-file": file });
99
+ }
100
+
101
+ export async function completeBudgetOperation(stateDir, request, result, observe) {
102
+ const state = await budgetState(stateDir);
103
+ if (!state?.config?.runtime_owner?.budget) return;
104
+ if (["session.create", "session.fork"].includes(request.operation)) return await releaseBudget(state, { "operation-id": request.id });
105
+ const scope = await budgetObservationScope(stateDir, request.params?.sessionId);
106
+ if (!scope?.operationIds.length) return;
107
+ const observation = observe ? await observe() : result;
108
+ await releaseBudgetObservation(scope, observation);
109
+ }
110
+
111
+ async function budgetState(stateDir) {
112
+ try { return JSON.parse(await readFile(path.join(stateDir, "daemon.json"), "utf8")); }
113
+ catch (error) { if (error.code === "ENOENT") return null; throw error; }
114
+ }
115
+
116
+ async function releaseBudget(state, options) {
117
+ const record = state.resource_process, owner = state.config.runtime_owner;
118
+ const receipt = await runtimeProcess(state.config, "release-turn", { id: record.id, "lease-token": record.lease_token, owner: owner.owner_id, "daemon-id": state.daemon_id ?? state.config?.daemonId, ...options });
119
+ if (receipt?.ok !== true) throw Object.assign(new Error("runtime budget release was not confirmed"), { code: "runtime_budget_release_unconfirmed" });
9
120
  }
10
121
 
11
122
  export async function runtimeProcess(config, action, options = {}) {
12
- if (!config.resourceHome || !config.ddFlowHome || !config.ddFlowBin) return null;
123
+ const owner = config.runtime_owner;
124
+ const resourceHome = owner?.resource_home ?? config.resourceHome;
125
+ const ddFlowHome = owner?.dd_flow_home ?? config.ddFlowHome;
126
+ const ddFlowBin = owner?.dd_flow_bin ?? config.ddFlowBin;
127
+ if (!resourceHome || !ddFlowHome || !ddFlowBin) return null;
13
128
  const args = ["runtime", "process", action];
14
129
  for (const [key, value] of Object.entries(options)) if (value !== null && value !== undefined) args.push(`--${key}`, String(value));
15
- return await commandJson(config.ddFlowBin, args, {
130
+ return await commandJson(ddFlowBin, args, {
16
131
  cwd: config.cwd,
17
- env: { ...(config.env ?? {}), DD_FLOW_HOME: config.ddFlowHome, DD_FLOW_RESOURCE_HOME: config.resourceHome }
132
+ env: { ...(config.env ?? {}), DD_FLOW_HOME: ddFlowHome, DD_FLOW_RESOURCE_HOME: resourceHome }
18
133
  });
19
134
  }
20
135
 
21
136
  export async function registerDaemonProcess(config, input) {
22
- const result = await runtimeProcess(config, "register", { ...input, "owner-pid": process.pid, "lease-ms": 300_000 });
137
+ const owner = bindRuntimeOwner(config);
138
+ const harness = ({ "codex-daemon": "codex-desktop", "zcode-daemon": "zcode-acp", "grok-daemon": "grok-acp", "agy-daemon": "antigravity-cli", "opencode-daemon": "opencode-server", "droid-daemon": "droid-cli" })[input.kind];
139
+ const scope = owner ? { owner: owner.owner_id, "project-id": owner.project_id, run: owner.run_id, work: owner.work_id, role: owner.role, "state-dir": owner.state_dir, "adapter-executable": owner.adapter_executable, ...(owner.budget ? { "budget-json": JSON.stringify(owner.budget), harness } : {}), ...(owner.project_id && owner.run_id || owner.budget ? { "dispatch-barrier": "run-generation@1" } : {}), operation: `${owner.operation_id}:${input.operation ?? input.kind}` } : {};
140
+ const result = await runtimeProcess(config, "register", { ...input, ...scope, "owner-pid": process.pid, "lease-ms": 300_000 });
23
141
  return result?.process ? { id: result.process.id, lease_token: result.process.lease_token, kind: input.kind } : null;
24
142
  }
25
143
 
@@ -98,6 +216,7 @@ export async function stopProcessGroup(child, graceMs = 1_000) {
98
216
  process.kill(target, "SIGTERM");
99
217
  await new Promise((resolve) => setTimeout(resolve, graceMs));
100
218
  if (!alive()) return;
219
+ if (child.exitCode != null || child.signalCode != null) return stopProcessGroup(child, graceMs);
101
220
  process.kill(target, "SIGKILL");
102
221
  // Grace controls escalation, not the OS acknowledgement budget. A caller
103
222
  // testing immediate escalation must still allow the kernel to reap children.
@@ -7,7 +7,7 @@ function failure(message, code) {
7
7
 
8
8
  function reportedFailure(stdout, stderr, fallback) {
9
9
  for (const text of [stdout, stderr]) {
10
- for (const line of text.trim().split(/\n/).reverse()) {
10
+ for (const line of [text.trim(), ...text.trim().split(/\n/).reverse()]) {
11
11
  try {
12
12
  const value = JSON.parse(line);
13
13
  const error = value?.error;
@@ -18,11 +18,13 @@ export async function waitForSettlement({ observe, cancel, knownSessions = () =>
18
18
  state = { sessions: knownSessions(), active: true, observation_error: errorRecord(error) };
19
19
  }
20
20
  if (!state.active && state.sessions.length === 0) return { ...state, cleanup_errors: errors };
21
- if (!cancel || performance.now() >= deadline) throw Object.assign(new Error("daemon still owns an unsettled Session tree"), { code: "tree_not_settled", details: { ...state, cleanup_errors: errors } });
22
- for (const id of state.sessions) if (!cancelled.has(id)) {
21
+ // The observation budget must not skip a newly discovered owned target.
22
+ // Attempt its cancellation once, then leave physical settlement pending.
23
+ for (const id of state.sessions) if (cancel && !cancelled.has(id)) {
23
24
  cancelled.add(id);
24
25
  try { await cancel(id); } catch (error) { errors.push({ phase: "cancel", session_id: id, ...errorRecord(error) }); }
25
26
  }
27
+ if (!cancel || performance.now() >= deadline) throw Object.assign(new Error("daemon still owns an unsettled Session tree"), { code: "tree_not_settled", details: { ...state, cleanup_errors: errors } });
26
28
  await new Promise(resolve => setTimeout(resolve, 50));
27
29
  }
28
30
  }
@@ -0,0 +1,99 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "dd-flow/run-control-receipt@1",
4
+ "type": "object",
5
+ "additionalProperties": false,
6
+ "required": ["schema_id", "ok", "request_id", "control_id", "scope", "generation", "requested_mode", "applied_mode", "state", "settled", "nodes", "unsettled_reasons", "resumability"],
7
+ "properties": {
8
+ "schema_id": { "const": "dd-flow/run-control-receipt@1" },
9
+ "ok": { "const": true },
10
+ "request_id": { "type": ["string", "null"], "minLength": 1, "maxLength": 128 },
11
+ "control_id": { "type": ["string", "null"], "pattern": "^CTL-[A-Za-z0-9][A-Za-z0-9._-]*$" },
12
+ "scope": {
13
+ "type": "object", "additionalProperties": false,
14
+ "required": ["project_id", "run_id"],
15
+ "properties": {
16
+ "project_id": { "type": "string", "minLength": 1 },
17
+ "run_id": { "type": "string", "pattern": "^RUN-[A-Za-z0-9][A-Za-z0-9._-]*$" }
18
+ }
19
+ },
20
+ "generation": { "type": "integer", "minimum": 0 },
21
+ "requested_mode": { "enum": [null, "pause", "stop", "resume"] },
22
+ "applied_mode": { "enum": [null, "pause", "stop", "resume"] },
23
+ "state": { "enum": ["not_requested", "pause_requested", "paused", "stop_requested", "stopped", "resuming", "running", "terminal_noop", "superseded", "recovery_required"] },
24
+ "settled": { "type": "boolean" },
25
+ "nodes": {
26
+ "type": "array",
27
+ "items": {
28
+ "type": "object", "additionalProperties": false,
29
+ "required": ["id", "role", "status", "acknowledged", "settled", "reason"],
30
+ "properties": {
31
+ "id": { "type": "string", "minLength": 1 },
32
+ "role": { "enum": ["subject", "worker", "merge", "judge", "probe", "check", "process", "unknown"] },
33
+ "status": { "enum": ["pending", "unknown", "idle", "stopped", "completed"] },
34
+ "acknowledged": { "type": "boolean" },
35
+ "settled": { "type": "boolean" },
36
+ "reason": { "type": ["string", "null"] },
37
+ "receipt_path": { "type": "string", "minLength": 1 },
38
+ "details": { "type": "object" }
39
+ },
40
+ "allOf": [{
41
+ "if": { "properties": { "settled": { "const": true } } },
42
+ "then": { "properties": { "status": { "enum": ["idle", "stopped", "completed"] } } }
43
+ }]
44
+ }
45
+ },
46
+ "unsettled_reasons": { "type": "array", "uniqueItems": true, "items": { "type": "string", "minLength": 1 } },
47
+ "resumability": { "enum": ["pending", "same_session", "replacement_allowed", "recovery_required", "not_applicable"] },
48
+ "recovery_id": { "type": ["string", "null"] },
49
+ "reused": { "type": "boolean" },
50
+ "next_cursor": { "type": "integer", "minimum": 0 },
51
+ "run_status": { "type": "string" },
52
+ "control": { "type": ["object", "null"] },
53
+ "operations": { "type": "array", "items": { "type": "object" } },
54
+ "worker": { "type": ["object", "null"] },
55
+ "resume_operation": { "type": ["object", "null"] },
56
+ "controller": { "type": "object" },
57
+ "operation_id": { "type": "string", "minLength": 1 },
58
+ "status": { "type": "string" },
59
+ "action": { "enum": ["pause", "stop", "resume"] },
60
+ "progress": {
61
+ "type": "array",
62
+ "items": { "type": "object", "additionalProperties": false, "required": ["phase", "message", "at"], "properties": { "phase": { "enum": ["start", "running", "done", "failed"] }, "message": { "type": "string" }, "at": { "type": "string" }, "detail": { "type": "object" } } }
63
+ },
64
+ "warnings": { "type": "array", "items": { "type": "object" } },
65
+ "response_file": { "type": "string", "minLength": 1 },
66
+ "response_file_error": { "type": "string" }
67
+ },
68
+ "allOf": [
69
+ {
70
+ "if": { "properties": { "state": { "not": { "enum": ["not_requested", "terminal_noop"] } } } },
71
+ "then": { "properties": { "control_id": { "type": "string" }, "request_id": { "type": "string" }, "requested_mode": { "type": "string" } } }
72
+ },
73
+ {
74
+ "if": { "properties": { "state": { "enum": ["paused", "stopped"] } } },
75
+ "then": { "properties": { "settled": { "const": true } } }
76
+ },
77
+ {
78
+ "if": { "properties": { "state": { "const": "paused" } } },
79
+ "then": { "properties": { "applied_mode": { "const": "pause" } } }
80
+ },
81
+ {
82
+ "if": { "properties": { "state": { "const": "stopped" } } },
83
+ "then": { "properties": { "applied_mode": { "const": "stop" } } }
84
+ },
85
+ {
86
+ "if": { "properties": { "settled": { "const": true } } },
87
+ "then": {
88
+ "properties": {
89
+ "nodes": { "items": { "properties": { "settled": { "const": true } } } },
90
+ "unsettled_reasons": { "maxItems": 0 }
91
+ }
92
+ }
93
+ },
94
+ {
95
+ "if": { "properties": { "settled": { "const": false } } },
96
+ "then": { "properties": { "unsettled_reasons": { "minItems": 1 }, "resumability": { "enum": ["pending", "recovery_required", "not_applicable"] } } }
97
+ }
98
+ ]
99
+ }
@@ -0,0 +1,36 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "dd-flow/run-control-request@1",
4
+ "type": "object",
5
+ "additionalProperties": false,
6
+ "required": ["schema_id", "request_id", "project_root", "run_id", "action"],
7
+ "properties": {
8
+ "schema_id": { "const": "dd-flow/run-control-request@1" },
9
+ "request_id": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" },
10
+ "project_root": { "type": "string", "minLength": 1, "pattern": "^(?:/|[A-Za-z]:[\\\\/]|\\\\\\\\)" },
11
+ "run_id": { "type": "string", "pattern": "^RUN-[A-Za-z0-9][A-Za-z0-9._-]*$" },
12
+ "action": { "enum": ["pause", "stop", "resume"] },
13
+ "wait_ms": { "type": "integer", "minimum": 0, "maximum": 60000, "default": 0 },
14
+ "grace_ms": { "type": "integer", "minimum": 0, "maximum": 3600000, "default": 30000 },
15
+ "escalate": { "const": "interrupt" },
16
+ "force": { "type": "boolean" },
17
+ "from": { "type": "string", "pattern": "^CTL-[A-Za-z0-9][A-Za-z0-9._-]*$" }
18
+ },
19
+ "allOf": [
20
+ {
21
+ "if": { "properties": { "action": { "const": "pause" } } },
22
+ "then": { "not": { "anyOf": [{ "required": ["force"] }, { "required": ["from"] }] } }
23
+ },
24
+ {
25
+ "if": { "properties": { "action": { "const": "stop" } } },
26
+ "then": { "not": { "anyOf": [{ "required": ["grace_ms"] }, { "required": ["escalate"] }, { "required": ["from"] }] } }
27
+ },
28
+ {
29
+ "if": { "properties": { "action": { "const": "resume" } } },
30
+ "then": {
31
+ "required": ["from"],
32
+ "not": { "anyOf": [{ "required": ["grace_ms"] }, { "required": ["escalate"] }, { "required": ["force"] }] }
33
+ }
34
+ }
35
+ ]
36
+ }
@@ -4,10 +4,10 @@ import path from "node:path";
4
4
  import { AppError } from "../shared/errors.js";
5
5
  import { parseJsonObject } from "../shared/json.js";
6
6
  import { ensureReadableFile } from "../storage/database.js";
7
- import { projectCheckoutRoot, resolveProjectRoot } from "../storage/paths.js";
7
+ import { projectCheckoutRoot, resolveProjectRoot, runtimeStateJsonPath } from "../storage/paths.js";
8
8
  import { appendAudit } from "./audit.js";
9
9
  import { requireProjectByRoot } from "./projects.js";
10
- import { protocolRunDiagnostics, readProtocolRuntimeState, requireProtocol } from "./protocols.js";
10
+ import { protocolRunDiagnostics, readProtocolRuntimeState, repairProtocolRuntimeState, requireProtocol } from "./protocols.js";
11
11
  import { publicSessionIdentity } from "./session-identity.js";
12
12
  const actionKinds = new Set([
13
13
  "repair_protocol_state",
@@ -25,7 +25,8 @@ export function cleanupScan(context, input) {
25
25
  const protocols = context.db.all("SELECT id, status, stage, state_path FROM protocols WHERE project_id = ? ORDER BY id ASC", [project.id]);
26
26
  for (const protocol of protocols) {
27
27
  const terminal = ["closed", "cancelled"].includes(protocol.status) || ["closed", "cancelled"].includes(protocol.stage);
28
- if (!fs.existsSync(protocol.state_path)) {
28
+ const requiresMigration = protocol.state_path !== runtimeStateJsonPath(context.ddFlowHome, project.id, protocol.id);
29
+ if (!fs.existsSync(protocol.state_path) || requiresMigration) {
29
30
  pushFinding(findings, actions, "missing_protocol_runtime_state", "warning", {
30
31
  protocol_id: protocol.id,
31
32
  path: protocol.state_path,
@@ -43,6 +44,8 @@ export function cleanupScan(context, input) {
43
44
  }
44
45
  }
45
46
  const fullProtocol = requireProtocol(context, protocol.id, project.id);
47
+ if (requiresMigration)
48
+ continue;
46
49
  const state = readProtocolRuntimeState(context, fullProtocol).state;
47
50
  const diagnostics = protocolRunDiagnostics(context, fullProtocol, state).diagnostics;
48
51
  for (const diagnostic of diagnostics.filter((entry) => entry.code === "protocol_run_stage_mismatch")) {
@@ -283,7 +286,7 @@ function applyAction(context, action, reason, force, now) {
283
286
  const protocol = requireProtocol(context, action.protocol_id, action.project_id);
284
287
  const beforePath = protocol.state_path;
285
288
  const existed = fs.existsSync(beforePath);
286
- readProtocolRuntimeState(context, protocol);
289
+ repairProtocolRuntimeState(context, protocol);
287
290
  return {
288
291
  kind: action.kind,
289
292
  changed: !existed || beforePath !== protocol.state_path,
@@ -101,6 +101,8 @@ function isRouterNative(args) {
101
101
  return true;
102
102
  if (["engine", "version", "schema"].includes(first))
103
103
  return true;
104
+ if (first === "storage" && args[1] === "migrate-writer")
105
+ return true;
104
106
  return (first === "codex" && args[1] === "hook" && args[2] === "handle")
105
107
  || (first === "zcode" && args[1] === "event" && args[2] === "handle")
106
108
  || (first === "zcode" && args[1] === "usage" && args[2] === "ingest")
@@ -127,7 +129,7 @@ function isReadOnlyDiagnostic(family, command, action) {
127
129
  if (family === "plan")
128
130
  return command === "status";
129
131
  if (family === "run")
130
- return command === "status" || command === "list";
132
+ return command === "status" || command === "list" || (["drive", "control"].includes(command ?? "") && action === "status");
131
133
  if (family === "lane") {
132
134
  return (command === "status" ||
133
135
  command === "waiters" ||
@@ -2,8 +2,10 @@ 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 { assertRunMutationAllowed } from "./run-recovery.js";
5
6
  import { AppError } from "../shared/errors.js";
6
7
  import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, managedOwnerIsAlive, processTreeIsAlive, registerManagedProcess, reservePorts } from "./managed-processes.js";
8
+ import { admitRuntimeCheck, runtimeBudgetForRun } from "./runtime-budget.js";
7
9
  const profileRelativePath = path.join(".memory-bank", "spec", "engineering", "code-check-profile.json");
8
10
  // Receipt reservation precedes child spawn so an interrupted CLI leaves an
9
11
  // auditable attempt. A second caller must not mistake that small handoff window
@@ -79,6 +81,7 @@ export function validateCheckDeclaration(workspaceRoot, check) {
79
81
  }
80
82
  export async function runCodeChecks(context, input) {
81
83
  reconcileUnfinishedChecks(context, input);
84
+ const generation = assertRunMutationAllowed(context, input.projectId, input.runId);
82
85
  for (const check of input.checks)
83
86
  validateCheckDeclaration(input.workspaceRoot, check);
84
87
  const { hash: profileHash, profile } = readCodeCheckProfile(input.workspaceRoot);
@@ -92,21 +95,35 @@ export async function runCodeChecks(context, input) {
92
95
  const startedAt = context.now();
93
96
  const inputHash = crypto.createHash("sha256").update(JSON.stringify({ gate: group.gate, command: group.command, environment: String(codeExecutionEnvironment(input.workspaceRoot).DD_FLOW_LOCAL_DATABASE_SUFFIX), required_artifacts: group.requiredArtifacts, ports: [...group.ports].sort() })).digest("hex");
94
97
  const epoch = crypto.createHash("sha256").update(`${group.gate}\0${before.fingerprint}`).digest("hex");
95
- const reserved = reserveReceipt(context, { ...input, group, before, profileHash, inputHash, epoch, startedAt });
98
+ const reserved = reserveReceipt(context, { ...input, generation, group, before, profileHash, inputHash, epoch, startedAt });
96
99
  const { id, evidenceDir, stdoutPath, stderrPath, receiptPath, completionPath } = reserved;
97
100
  let resources = {};
98
101
  let result = { exitCode: null, error: "check did not start", aborted: true };
99
102
  try {
100
103
  fs.mkdirSync(evidenceDir, { recursive: true });
101
104
  writeReceipt(receiptPath, receiptFor({ ...reserved, resources, result, status: "running", after: before, mutationPaths: [], artifacts: [] }));
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 } });
105
+ const budget = runtimeBudgetForRun(context, input.projectId, input.runId);
106
+ 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, role: "check", budget } });
103
107
  const allocation = await reservePorts(context, { ownerId: managed.owner_id, processId: managed.id, names: group.ports });
104
108
  resources = allocation.ports;
105
- input.progress?.(`check ${index + 1}/${groups.length} started: ${group.command}`);
109
+ try {
110
+ input.progress?.(`check ${index + 1}/${groups.length} preparing: ${group.command}`);
111
+ assertRunMutationAllowed(context, input.projectId, input.runId, generation);
112
+ }
113
+ catch (error) {
114
+ allocation.release();
115
+ finishManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, state: "failed", reason: "dispatch_fenced_before_spawn" });
116
+ throw error;
117
+ }
106
118
  const stdout = fs.openSync(stdoutPath, "a");
107
119
  const stderr = fs.openSync(stderrPath, "a");
108
120
  try {
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 }));
121
+ 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) => {
122
+ confirmManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, pid, processGroupId: process.platform === "win32" ? null : pid });
123
+ assertRunMutationAllowed(context, input.projectId, input.runId, generation);
124
+ if (budget)
125
+ admitRuntimeCheck(context, managed.id, managed.lease_token);
126
+ }, () => heartbeatManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token }));
110
127
  }
111
128
  finally {
112
129
  fs.closeSync(stdout);
@@ -149,15 +166,19 @@ export async function runCodeChecks(context, input) {
149
166
  * a dead/terminal process becomes an explicit aborted receipt that can retry.
150
167
  */
151
168
  export function reconcileUnfinishedChecks(context, input) {
152
- const where = input.workId ? "project_id = ? AND run_id = ? AND work_id = ? AND status = 'running'" : "project_id = ? AND run_id = ? AND status = 'running'";
169
+ let where = input.workId ? "project_id = ? AND run_id = ? AND work_id = ? AND status = 'running'" : "project_id = ? AND run_id = ? AND status = 'running'";
153
170
  const params = input.workId ? [input.projectId, input.runId, input.workId] : [input.projectId, input.runId];
171
+ if (input.receiptId) {
172
+ where += " AND id = ?";
173
+ params.push(input.receiptId);
174
+ }
154
175
  const pending = context.db.all(`SELECT id, receipt_path, started_at FROM check_receipts WHERE ${where}`, params);
155
176
  if (pending.length === 0)
156
177
  return [];
157
178
  const processes = managedProcessStatus(context);
158
179
  const reconciled = [];
159
180
  for (const pendingReceipt of pending) {
160
- const process = processes.filter((item) => item.check_id === pendingReceipt.id).at(-1);
181
+ const process = processes.filter((item) => item.check_id === pendingReceipt.id && item.project_id === input.projectId && item.run_id === input.runId).at(-1);
161
182
  const receipt = readReceipt(pendingReceipt.receipt_path);
162
183
  const completion = receipt ? readCompletion(receipt.completion_path || path.join(path.dirname(pendingReceipt.receipt_path), "completion.json")) : null;
163
184
  // A stale lease after host sleep is not permission to duplicate a living
@@ -189,6 +210,7 @@ export function reconcileUnfinishedChecks(context, input) {
189
210
  function reserveReceipt(context, input) {
190
211
  context.db.exec("BEGIN IMMEDIATE");
191
212
  try {
213
+ assertRunMutationAllowed(context, input.projectId, input.runId, input.generation);
192
214
  const rows = context.db.all("SELECT id FROM check_receipts WHERE work_id IS ? AND run_id = ?", [input.workId ?? null, input.runId]);
193
215
  const ordinal = rows.reduce((max, row) => Math.max(max, Number(/\/RCP-(\d+)$/.exec(row.id)?.[1] ?? 0)), 0) + 1;
194
216
  const localId = `RCP-${String(ordinal).padStart(3, "0")}`;
@@ -321,7 +343,7 @@ function listFiles(root, current = root) { return fs.readdirSync(current, { with
321
343
  export function runCheck(command, cwd, environment, stdout, stderr, heartbeat, onSpawn, renewLease) {
322
344
  return new Promise((resolve) => {
323
345
  const started = Date.now();
324
- const child = spawn("/bin/sh", ["-lc", checkShellScript()], { cwd, env: { ...environment, DD_FLOW_CHECK_COMMAND: command }, stdio: ["ignore", stdout, stderr], detached: process.platform !== "win32" });
346
+ const child = spawn("/bin/sh", ["-c", checkShellScript()], { cwd, env: { ...environment, DD_FLOW_CHECK_COMMAND: command }, stdio: ["pipe", stdout, stderr], detached: process.platform !== "win32" });
325
347
  let error = null;
326
348
  let stopping = false;
327
349
  let progress;
@@ -343,6 +365,7 @@ export function runCheck(command, cwd, environment, stdout, stderr, heartbeat, o
343
365
  // Register handlers before checking pid or calling persistence callbacks.
344
366
  // Failure is not completion until the spawned process has actually closed.
345
367
  child.once("error", (value) => { error = value.message; });
368
+ child.stdin.on("error", stop);
346
369
  child.once("close", (exitCode, signal) => {
347
370
  if (progress)
348
371
  clearInterval(progress);
@@ -363,6 +386,7 @@ export function runCheck(command, cwd, environment, stdout, stderr, heartbeat, o
363
386
  stop(cause);
364
387
  return;
365
388
  }
389
+ child.stdin.end("admitted\n");
366
390
  progress = setInterval(() => {
367
391
  try {
368
392
  if (!renewLease()) {
@@ -379,6 +403,8 @@ export function runCheck(command, cwd, environment, stdout, stderr, heartbeat, o
379
403
  }
380
404
  function checkShellScript() {
381
405
  return [
406
+ // The actual command cannot run before PID ownership and admission persist.
407
+ 'IFS= read -r admission && [ "$admission" = admitted ] || exit 125',
382
408
  'completion="${DD_FLOW_CHECK_COMPLETION_FILE:-}"',
383
409
  '/bin/sh -lc "$DD_FLOW_CHECK_COMMAND"',
384
410
  "code=$?",