@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.
- package/CHANGELOG.md +10 -1
- package/dist/build-info.json +5 -5
- package/dist/cli/help.js +42 -1
- package/dist/cli/run-cli.js +145 -17
- package/dist/harness-runtime/bin/dd-agy.mjs +3 -2
- package/dist/harness-runtime/bin/dd-codex.mjs +4 -3
- package/dist/harness-runtime/bin/dd-droid.mjs +3 -2
- package/dist/harness-runtime/bin/dd-grok.mjs +4 -3
- package/dist/harness-runtime/bin/dd-opencode.mjs +4 -3
- package/dist/harness-runtime/bin/dd-zcode.mjs +4 -3
- package/dist/harness-runtime/lib/daemon-operations.mjs +48 -9
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +49 -12
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +57 -16
- package/dist/harness-runtime/lib/dd-codex.mjs +10 -2
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +3 -2
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +12 -6
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +4 -3
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +8 -5
- package/dist/harness-runtime/lib/managed-daemon.mjs +125 -6
- package/dist/harness-runtime/lib/process-json.mjs +1 -1
- package/dist/harness-runtime/lib/session-settlement.mjs +4 -2
- package/dist/schemas/run-control-receipt.schema.json +99 -0
- package/dist/schemas/run-control-request.schema.json +36 -0
- package/dist/services/cleanup.js +7 -4
- package/dist/services/cli-operation-classifier.js +3 -1
- package/dist/services/code-checks.js +33 -7
- package/dist/services/controller-fanout.js +105 -0
- package/dist/services/engines.js +2 -0
- package/dist/services/eval-snapshots.js +347 -36
- package/dist/services/execution-policy.js +122 -6
- package/dist/services/external-work-launch.js +33 -12
- package/dist/services/harness-adapter.js +49 -2
- package/dist/services/managed-daemon-binding.js +38 -0
- package/dist/services/managed-processes.js +54 -7
- package/dist/services/merge-server.js +53 -30
- package/dist/services/native-daemon-history.js +49 -0
- package/dist/services/native-session-control.js +31 -0
- package/dist/services/protocols.js +9 -0
- package/dist/services/recovery-snapshot-database.js +103 -0
- package/dist/services/run-control-receipt.js +56 -0
- package/dist/services/run-control-worker.js +297 -0
- package/dist/services/run-control.js +752 -0
- package/dist/services/run-controller-adapter.js +44 -0
- package/dist/services/run-controller-capture.js +117 -0
- package/dist/services/run-controller-process.js +178 -0
- package/dist/services/run-controller-recovery.js +219 -0
- package/dist/services/run-controller-state.js +31 -0
- package/dist/services/run-controller.js +538 -0
- package/dist/services/run-recovery-runtime.js +69 -0
- package/dist/services/run-recovery.js +33 -9
- package/dist/services/runs.js +65 -13
- package/dist/services/runtime-budget.js +362 -0
- package/dist/services/runtime-scope-capture.js +50 -0
- package/dist/services/runtime-scope-control.js +431 -0
- package/dist/services/runtime-scope-resume.js +543 -0
- package/dist/services/runtime-scope-stop.js +95 -0
- package/dist/services/runtime-scope-worker.js +165 -0
- package/dist/services/runtime-service.js +4 -0
- package/dist/services/vnext-code-review.js +1 -1
- package/dist/services/vnext-code.js +1 -1
- package/dist/services/vnext-execution-profile.js +2 -35
- package/dist/services/vnext-fanout.js +23 -7
- package/dist/services/vnext-plan-review.js +12 -9
- package/dist/services/vnext-plan.js +1 -1
- package/dist/services/vnext-protocolize.js +10 -4
- package/dist/services/vnext-specify.js +12 -5
- package/dist/services/work-registry.js +26 -8
- package/dist/services/workspace-bootstrap.js +10 -1
- package/dist/storage/database.js +160 -13
- package/dist/storage/writer-contract.js +76 -0
- package/dist/storage/writer-migration.js +102 -0
- 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
|
-
|
|
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
|
-
|
|
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(
|
|
130
|
+
return await commandJson(ddFlowBin, args, {
|
|
16
131
|
cwd: config.cwd,
|
|
17
|
-
env: { ...(config.env ?? {}), DD_FLOW_HOME:
|
|
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
|
|
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
|
-
|
|
22
|
-
|
|
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
|
+
}
|
package/dist/services/cleanup.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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", ["-
|
|
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
|
+
}
|
package/dist/services/engines.js
CHANGED
|
@@ -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);
|