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

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 (72) hide show
  1. package/CHANGELOG.md +10 -1
  2. package/dist/build-info.json +5 -5
  3. package/dist/cli/help.js +42 -1
  4. package/dist/cli/run-cli.js +145 -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 +347 -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/managed-daemon-binding.js +38 -0
  34. package/dist/services/managed-processes.js +54 -7
  35. package/dist/services/merge-server.js +53 -30
  36. package/dist/services/native-daemon-history.js +49 -0
  37. package/dist/services/native-session-control.js +31 -0
  38. package/dist/services/protocols.js +9 -0
  39. package/dist/services/recovery-snapshot-database.js +103 -0
  40. package/dist/services/run-control-receipt.js +56 -0
  41. package/dist/services/run-control-worker.js +297 -0
  42. package/dist/services/run-control.js +752 -0
  43. package/dist/services/run-controller-adapter.js +44 -0
  44. package/dist/services/run-controller-capture.js +117 -0
  45. package/dist/services/run-controller-process.js +178 -0
  46. package/dist/services/run-controller-recovery.js +219 -0
  47. package/dist/services/run-controller-state.js +31 -0
  48. package/dist/services/run-controller.js +538 -0
  49. package/dist/services/run-recovery-runtime.js +69 -0
  50. package/dist/services/run-recovery.js +33 -9
  51. package/dist/services/runs.js +65 -13
  52. package/dist/services/runtime-budget.js +362 -0
  53. package/dist/services/runtime-scope-capture.js +50 -0
  54. package/dist/services/runtime-scope-control.js +431 -0
  55. package/dist/services/runtime-scope-resume.js +543 -0
  56. package/dist/services/runtime-scope-stop.js +95 -0
  57. package/dist/services/runtime-scope-worker.js +165 -0
  58. package/dist/services/runtime-service.js +4 -0
  59. package/dist/services/vnext-code-review.js +1 -1
  60. package/dist/services/vnext-code.js +1 -1
  61. package/dist/services/vnext-execution-profile.js +2 -35
  62. package/dist/services/vnext-fanout.js +23 -7
  63. package/dist/services/vnext-plan-review.js +12 -9
  64. package/dist/services/vnext-plan.js +1 -1
  65. package/dist/services/vnext-protocolize.js +10 -4
  66. package/dist/services/vnext-specify.js +12 -5
  67. package/dist/services/work-registry.js +26 -8
  68. package/dist/services/workspace-bootstrap.js +10 -1
  69. package/dist/storage/database.js +160 -13
  70. package/dist/storage/writer-contract.js +76 -0
  71. package/dist/storage/writer-migration.js +102 -0
  72. package/package.json +1 -1
@@ -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=$?",
@@ -0,0 +1,105 @@
1
+ import { AppError } from "../shared/errors.js";
2
+ import { getVnextFanoutStatus, observeVnextNativeChildren, reconcileVnextFanout, subagentCapacityKey } from "./vnext-fanout.js";
3
+ import { getFlowRunVariables } from "./runs.js";
4
+ import { dispatchVnextPlanReview } from "./vnext-plan-review.js";
5
+ import { resolveExecutionPolicy } from "./execution-policy.js";
6
+ /** The shared controller owns this former eval-only Work-graph decision. */
7
+ export async function nextControllerFanout(context, input) {
8
+ const children = input.children.filter(child => child.status !== "settled_by_root");
9
+ const observations = { harness_id: input.harness, parent_session_id: input.sessionId, children };
10
+ if (children.length) {
11
+ const observed = observeVnextNativeChildren(context, { ...input, observations });
12
+ const issues = observed.issues;
13
+ if (issues.length)
14
+ throw new AppError("fanout_reconciliation_required", "Native children have unresolved lifecycle ownership", 1, { issues });
15
+ }
16
+ let fanout = getVnextFanoutStatus(context, input).orchestration;
17
+ if (!fanout)
18
+ return { kind: "none" };
19
+ if (children.length) {
20
+ const reconciled = await reconcileVnextFanout(context, { ...input, observations });
21
+ const issues = reconciled.issues.filter(issue => issue.code !== "child_check_in_progress");
22
+ if (issues.length)
23
+ throw new AppError("fanout_reconciliation_required", "Native child outcome requires reconciliation, not replay", 1, { issues });
24
+ if (children.some(child => child.status === "running" || child.status === "unknown"))
25
+ return { kind: "wait", reason: "native_children_unsettled" };
26
+ fanout = getVnextFanoutStatus(context, input).orchestration;
27
+ }
28
+ if (fanout.state === "dispatch_required") {
29
+ dispatchVnextPlanReview(context, input);
30
+ fanout = getVnextFanoutStatus(context, input).orchestration;
31
+ if (fanout.state === "dispatch_required" || fanout.state === "capacity_required")
32
+ return { kind: "wait", reason: "qualified_capacity_required" };
33
+ }
34
+ const prompt = (text) => ({ kind: "prompt", prompt: text, fingerprint: JSON.stringify({ stage: input.stage, parent: fanout.parent_work_id, works: fanout.works, text }) });
35
+ if (fanout.state === "coordinator_required")
36
+ return prompt(`Continue the current ${input.stage} Stage using its authoritative packet. Materialize only its required Work graph with real dependencies. Do not launch children, finish the Stage, or start a successor. Return after registering the graph; the runtime will dispatch its ready Work.`);
37
+ if (fanout.works.failed || fanout.works.cancelled)
38
+ return prompt(`Some ${input.stage} child Work failed or was cancelled. Inspect its recorded receipts. Choose a legal scoped repair/retry or block the Stage with the concrete reason. Do not relaunch settled siblings or infer success from a child that omitted work finish.`);
39
+ if (fanout.works.ready.length) {
40
+ const choices = fanout.works.ready.map(work => {
41
+ const payload = context.db.get("SELECT payload_json FROM works WHERE work_id = ?", [work.work_id])?.payload_json;
42
+ const override = payload ? JSON.parse(payload).execution_override : undefined;
43
+ return { work, resolved: resolveExecutionPolicy({ ddFlowHome: context.ddFlowHome, policy: input.policy, profiles: input.profiles, stage: input.stage, ...(override ? { work: override } : {}) }) };
44
+ });
45
+ const external = choices.filter(choice => choice.resolved.delegation.mode === "external");
46
+ if (external.length) {
47
+ const maximum = Math.min(...external.map(choice => choice.resolved.delegation.max_parallel ?? 1));
48
+ return { kind: "external", works: external.slice(0, maximum).map(choice => choice.work) };
49
+ }
50
+ const variables = getFlowRunVariables(context, input);
51
+ const capacity = variables.variables[subagentCapacityKey];
52
+ if (typeof capacity !== "number" || !Number.isInteger(capacity) || capacity < 1)
53
+ return { kind: "wait", reason: "qualified_capacity_required" };
54
+ const wave = choices.slice(0, capacity).map(choice => choice.work);
55
+ return prompt([
56
+ `Launch exactly ${wave.length} direct native children for the current ${input.stage} Stage; the recorded capacity is ${capacity}.`,
57
+ "Use this harness's native child mechanism, not external roots or forks. Wait for these children to settle; do not cancel siblings because one failed. Do not finish this Stage or start a successor in this Turn.",
58
+ ...wave.map(work => {
59
+ if (!work.work_id || !work.start_command)
60
+ throw new AppError("fanout_contract_invalid", "Ready Work omits its trusted start command", 1);
61
+ return `Assign one leaf child Work ${work.work_id}. ${work.launch_policy === "fresh_agent_required" ? "Use empty, non-inherited context when supported; a new Session id alone does not prove empty context." : "Inherit context only through the harness's declared mechanism."}\nIts first technical action must be this exact standalone command:\n${work.start_command}\nUse only the Work packet returned by it. Complete its declared result and exact work finish command, then stop. Do not create children or start another Work. Missing facts go through the Work's failure/result contract; only the coordinator handles Stage HITL.`;
62
+ })
63
+ ].join("\n\n"));
64
+ }
65
+ if (fanout.works.running)
66
+ return { kind: "wait", reason: "work_receipts_pending" };
67
+ if (fanout.works.created)
68
+ throw new AppError("fanout_graph_stalled", "Created Work has no ready or running dependency path", 1);
69
+ return prompt(`All declared ${input.stage} child Work has settled (${fanout.works.completed} completed). Read the accepted result receipts and continue this Stage's semantic decision under its original packet. Execute an exact repair command returned by a rejected stage finish; a rejection alone does not create repair Work. Return after finish or after declaring a repair graph. Do not start a successor Stage.`);
70
+ }
71
+ /** Normalize provider-observed topology only; never parse assistant prose. */
72
+ export function controllerNativeChildren(receipt, rootSessionId) {
73
+ const candidates = [];
74
+ const object = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
75
+ const list = (value) => Array.isArray(value) ? value : [];
76
+ const text = (value, keys) => { for (const key of keys)
77
+ if (typeof value[key] === "string" && value[key])
78
+ return value[key]; return null; };
79
+ const add = (raw, source, scopedParent = null, fallback = "unknown") => {
80
+ const value = object(raw);
81
+ const id = text(value, ["provider_session_id", "session_id", "sessionId", "childSessionId", "conversation_id", "conversationId", "id", "subagentId"]);
82
+ const parent = text(value, ["parent_provider_session_id", "parent_session_id", "parentSessionId", "parentID", "parentId"]) ?? scopedParent;
83
+ if (!id || parent !== rootSessionId)
84
+ return;
85
+ const state = String(value.status ?? value.state ?? fallback).toLowerCase();
86
+ const status = ["completed", "complete", "done", "idle", "success", "succeeded"].includes(state) ? "completed" : state === "settled_by_root" ? "settled_by_root" : ["failed", "error"].includes(state) ? "failed" : ["cancelled", "canceled"].includes(state) ? "cancelled" : ["running", "active", "pending"].includes(state) ? "running" : fallback;
87
+ candidates.push({ session_id: id, parent_session_id: parent, status, source });
88
+ };
89
+ for (const value of list(receipt.descendants))
90
+ add(value, "adapter.descendants");
91
+ for (const value of list(receipt.children))
92
+ add(value, "opencode.session.children");
93
+ const subagents = object(object(receipt.evidence).subagents ?? receipt.subagents);
94
+ for (const value of list(subagents.running))
95
+ add(value, "zcode/session/subagents", rootSessionId, "running");
96
+ for (const value of [...list(subagents.completed), ...list(object(subagents.ended).items)])
97
+ add(value, "zcode/session/subagents", rootSessionId, "completed");
98
+ const unique = new Map();
99
+ for (const child of candidates) {
100
+ const previous = unique.get(child.session_id);
101
+ if (!previous || previous.status === "unknown" || previous.status === "running" && child.status !== "running")
102
+ unique.set(child.session_id, child);
103
+ }
104
+ return [...unique.values()].sort((left, right) => left.session_id.localeCompare(right.session_id));
105
+ }
@@ -276,6 +276,8 @@ function runIdForArgs(args) {
276
276
  const [family, command, ...rest] = args;
277
277
  const parsed = parseLightArgs(rest);
278
278
  if (family === "run" && command !== "start" && command !== "list") {
279
+ if (["drive", "control", "recovery", "capacity"].includes(command ?? ""))
280
+ return option(parsed, "run");
279
281
  if (command === "flags")
280
282
  return positional(parsed, 1);
281
283
  return positional(parsed, 0);