@deksden-com/dd-flow-cli 0.9.0-beta.37 → 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 +16 -0
- 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 +73 -20
- 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
|
@@ -5,7 +5,7 @@ import { chmod, lstat, mkdir, open, readFile, rename, unlink, writeFile } from "
|
|
|
5
5
|
import net from "node:net";
|
|
6
6
|
import { durableDaemonDispatch } from "./daemon-operations.mjs";
|
|
7
7
|
import path from "node:path";
|
|
8
|
-
import { cleanupFailedStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, heartbeatDaemonProcess, registerDaemonProcess, stopProcessGroup } from "./managed-daemon.mjs";
|
|
8
|
+
import { prepareRuntimeOwner, cleanupFailedStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, heartbeatDaemonProcess, registerDaemonProcess, stopProcessGroup } from "./managed-daemon.mjs";
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
11
|
AcpBridge,
|
|
@@ -135,6 +135,7 @@ export async function startDaemon(options) {
|
|
|
135
135
|
env: options.ddFlowHome ? { DD_FLOW_HOME: absolute(options.ddFlowHome, "--dd-flow-home") } : {},
|
|
136
136
|
livenessTimeoutMs: options.livenessTimeoutMs ?? DEFAULT_LIVENESS_TIMEOUT_MS,
|
|
137
137
|
};
|
|
138
|
+
await prepareRuntimeOwner(config, paths.dir);
|
|
138
139
|
await mkdir(paths.dir, { recursive: true, mode: 0o700 });
|
|
139
140
|
await chmod(paths.dir, 0o700);
|
|
140
141
|
try {
|
|
@@ -243,7 +244,9 @@ export class DaemonRuntime {
|
|
|
243
244
|
}
|
|
244
245
|
|
|
245
246
|
treeRunning(result) {
|
|
246
|
-
|
|
247
|
+
const evidence = result?.evidence ?? result;
|
|
248
|
+
const children = evidence?.subagents?.running;
|
|
249
|
+
return evidence?.read?.projection?.status !== "idle" || !Array.isArray(children) || children.length > 0 || Boolean(evidence?.subagents?.observation_error);
|
|
247
250
|
}
|
|
248
251
|
|
|
249
252
|
async productive(name, task) {
|
|
@@ -293,7 +296,7 @@ export class DaemonRuntime {
|
|
|
293
296
|
await this.persist(); return result;
|
|
294
297
|
}
|
|
295
298
|
if (operation === "session.fork") return await this.productive(operation, async () => { await this.requireSettled(params); return await forkSessionWithBridge(this.bridge, this.options(params)); });
|
|
296
|
-
if (operation === "session.inspect") {
|
|
299
|
+
if (operation === "session.inspect" || operation === "session.resume") {
|
|
297
300
|
const adapter = params.adapterSessionId ?? params.sessionId;
|
|
298
301
|
if (!this.liveAdapters.has(adapter) && this.activeProductive) {
|
|
299
302
|
throw new DaemonError(
|
|
@@ -311,7 +314,7 @@ export class DaemonRuntime {
|
|
|
311
314
|
if (this.sessions.has(result.provider_session_id)) this.track(result, result.subagents);
|
|
312
315
|
else if (result.adapter_session_id) this.liveAdapters.add(result.adapter_session_id);
|
|
313
316
|
await this.persist({ active_tree: this.treeRunning(result) || Boolean(this.activeProductive) });
|
|
314
|
-
return result;
|
|
317
|
+
return { ...result, settled: !this.treeRunning(result) && !this.activeProductive };
|
|
315
318
|
}
|
|
316
319
|
if (operation === "session.cancel") {
|
|
317
320
|
if (!this.sessions.has(params.sessionId)) throw new DaemonError("session_identity_mismatch", "cancellation Session is not owned by this daemon");
|
|
@@ -393,7 +396,7 @@ export async function serveDaemon(stateDir) {
|
|
|
393
396
|
try {
|
|
394
397
|
request = JSON.parse(line);
|
|
395
398
|
if (request.schema_id !== REQUEST_SCHEMA || !request.id || !request.operation) throw new DaemonError("daemon_protocol_mismatch", "invalid daemon request");
|
|
396
|
-
const result = await durableDaemonDispatch(paths.dir, request, () => runtime.dispatch(request.operation, request.params ?? {}));
|
|
399
|
+
const result = await durableDaemonDispatch(paths.dir, request, () => runtime.dispatch(request.operation, request.params ?? {}), () => runtime.dispatch("session.inspect", request.params ?? {}));
|
|
397
400
|
connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: true, result: { ...result, _shutdown: undefined } })}\n`);
|
|
398
401
|
if (result?._shutdown) setImmediate(() => void runtime.shutdown().then(() => process.exit(0)));
|
|
399
402
|
} catch (error) {
|
|
@@ -1,25 +1,143 @@
|
|
|
1
|
-
import { lstat } from "node:fs/promises";
|
|
1
|
+
import { lstat, readFile, readdir, mkdir } from "node:fs/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import path from "node:path";
|
|
2
4
|
import { commandJson } from "./process-json.mjs";
|
|
3
5
|
import { errorRecord } from "./operation-errors.mjs";
|
|
6
|
+
import { writeJsonAtomic } from "./runner-events.mjs";
|
|
4
7
|
|
|
5
8
|
const leases = new Map();
|
|
6
9
|
|
|
7
|
-
|
|
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=$?",
|