@deksden-com/dd-flow-cli 0.9.0-beta.123 → 0.9.0-beta.124
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 -0
- package/README.md +23 -0
- package/dist/build-info.json +4 -4
- package/dist/cli/run-cli.js +1 -1
- package/dist/domain/stage-catalog.js +1 -1
- package/dist/harness-runtime/lib/codex-capacity-policy.d.mts +10 -0
- package/dist/harness-runtime/lib/codex-capacity-policy.mjs +89 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +100 -3
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +66 -16
- package/dist/harness-runtime/lib/dd-agy.mjs +13 -3
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +39 -10
- package/dist/harness-runtime/lib/dd-codex.mjs +11 -25
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +19 -12
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +42 -10
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +57 -14
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +25 -23
- package/dist/harness-runtime/lib/dd-zcode.mjs +9 -10
- package/dist/harness-runtime/lib/driver-recovery.mjs +12 -2
- package/dist/harness-runtime/lib/managed-daemon.d.mts +2 -0
- package/dist/harness-runtime/lib/managed-daemon.mjs +100 -32
- package/dist/schemas/flow-run.schema.json +1 -0
- package/dist/schemas/plan-review-decision.schema.json +3 -3
- package/dist/services/code-checks.js +14 -18
- package/dist/services/codex-capacity.js +149 -48
- package/dist/services/controller-fanout.js +1 -0
- package/dist/services/external-work-launch.js +10 -3
- package/dist/services/harness-adapter.js +21 -15
- package/dist/services/managed-processes.js +14 -6
- package/dist/services/merge-server.js +10 -4
- package/dist/services/review-deferral.js +23 -0
- package/dist/services/run-controller-adapter.js +8 -5
- package/dist/services/run-controller-recovery.js +17 -0
- package/dist/services/run-controller.js +42 -14
- package/dist/services/run-recovery-runtime.js +49 -3
- package/dist/services/runs.js +1 -1
- package/dist/services/verification-matrix.js +30 -1
- package/dist/services/vnext-code-review.js +16 -4
- package/dist/services/vnext-merge.js +20 -2
- package/dist/services/vnext-plan-review.js +81 -18
- package/dist/services/work-registry.js +9 -2
- package/package.json +1 -1
|
@@ -3,9 +3,9 @@ import { spawn } from "node:child_process";
|
|
|
3
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
4
|
import { chmod, lstat, mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
5
5
|
import net from "node:net";
|
|
6
|
-
import { durableDaemonDispatch } from "./daemon-operations.mjs";
|
|
6
|
+
import { durableDaemonDispatch, completeDaemonShutdown, commitDaemonShutdown, assertDaemonDispatch, assertDaemonRestartable, trackDaemonConnections, retireDaemonEndpoint, retireFailedDaemon } from "./daemon-operations.mjs";
|
|
7
7
|
import path from "node:path";
|
|
8
|
-
import { prepareRuntimeOwner, cleanupFailedDaemonStart,
|
|
8
|
+
import { prepareRuntimeOwner, cleanupFailedDaemonStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
11
|
AcpBridge,
|
|
@@ -163,13 +163,14 @@ export async function startDaemon(options) {
|
|
|
163
163
|
await prepareRuntimeOwner(config, paths.dir);
|
|
164
164
|
await mkdir(paths.dir, { recursive: true, mode: 0o700 });
|
|
165
165
|
await chmod(paths.dir, 0o700);
|
|
166
|
+
let previous = await readState(paths.dir); assertDaemonRestartable(previous);
|
|
166
167
|
try {
|
|
167
168
|
const status = await callDaemon(paths.dir, "daemon.status", {}, 1000);
|
|
168
169
|
if (!sameConfig(status, config)) throw new DaemonError("daemon_config_mismatch", "a daemon is already running with different configuration");
|
|
169
170
|
return { ...status, already_running: true };
|
|
170
171
|
} catch (error) { if (error.code !== "daemon_not_running" && error.code !== "daemon_timeout") throw error; }
|
|
171
172
|
|
|
172
|
-
|
|
173
|
+
previous = await readState(paths.dir); assertDaemonRestartable(previous);
|
|
173
174
|
if (previous?.shutdown_state === "clean") {
|
|
174
175
|
if (!sameNativeRouting(previous.config, config)) throw new DaemonError("daemon_config_mismatch", "retained ZCode native storage routing changed");
|
|
175
176
|
await authorizeRetainedDaemonResume(paths.dir, previous, options.sessionId, config);
|
|
@@ -227,7 +228,7 @@ export async function startDaemon(options) {
|
|
|
227
228
|
export async function stopDaemon(options) {
|
|
228
229
|
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
229
230
|
const { socket } = locations(options.stateDir);
|
|
230
|
-
return await confirmDaemonStopped(() => callDaemon(options.stateDir, "daemon.stop", { cancelTree: options.cancelTree === true }, timeoutMs), socket, timeoutMs);
|
|
231
|
+
return await confirmDaemonStopped(() => callDaemon(options.stateDir, "daemon.stop", { cancelTree: options.cancelTree === true }, timeoutMs), socket, timeoutMs, { readState: () => readState(options.stateDir) });
|
|
231
232
|
}
|
|
232
233
|
|
|
233
234
|
export class DaemonRuntime {
|
|
@@ -241,7 +242,7 @@ export class DaemonRuntime {
|
|
|
241
242
|
persist(patch = {}) {
|
|
242
243
|
Object.assign(this.state, patch, { pid: process.pid, updated_at: new Date().toISOString(), sessions: [...this.sessions.values()] });
|
|
243
244
|
const snapshot = structuredClone(this.state);
|
|
244
|
-
this.persisting = this.persisting.then(() => writeState(this.paths.state, snapshot));
|
|
245
|
+
this.persisting = this.persisting.catch(() => {}).then(() => writeState(this.paths.state, snapshot));
|
|
245
246
|
return this.persisting;
|
|
246
247
|
}
|
|
247
248
|
|
|
@@ -448,6 +449,7 @@ export class DaemonRuntime {
|
|
|
448
449
|
}
|
|
449
450
|
|
|
450
451
|
async dispatch(operation, params) {
|
|
452
|
+
if (["session.create", "session.prompt", "session.start", "session.fork", "session.resume"].includes(operation)) assertDaemonDispatch(this.paths.dir);
|
|
451
453
|
if (operation === "daemon.status") return {
|
|
452
454
|
daemon_id: this.state.daemon_id, pid: process.pid, socket: this.paths.socket,
|
|
453
455
|
cwd: this.state.cwd, journal: this.state.journal, versions: this.state.versions,
|
|
@@ -548,12 +550,17 @@ export class DaemonRuntime {
|
|
|
548
550
|
await this.persist({ active_tree: this.treeRunning(result) || Boolean(this.activeProductive) });
|
|
549
551
|
return result;
|
|
550
552
|
}
|
|
551
|
-
if (operation === "daemon.stop") return await this.prepareStop(params.cancelTree === true)
|
|
553
|
+
if (operation === "daemon.stop") return await completeDaemonShutdown(this.paths.dir, this.state, patch => this.persist(patch), () => this.prepareStop(params.cancelTree === true), [
|
|
554
|
+
["provider_resource", () => finishDaemonProcess(this.state.config, this.state.provider_process)],
|
|
555
|
+
["daemon_resource", () => finishDaemonProcess(this.state.config, this.state.resource_process)]
|
|
556
|
+
]);
|
|
552
557
|
throw new DaemonError("unknown_operation", `unknown daemon operation: ${operation}`);
|
|
553
558
|
}
|
|
554
559
|
|
|
555
560
|
async prepareStop(cancel) {
|
|
561
|
+
if (this.activeProductive && !cancel) throw new DaemonError("tree_not_settled", "productive operation is running");
|
|
556
562
|
if (cancel) this.controlGeneration = (this.controlGeneration ?? 0) + 1;
|
|
563
|
+
if (!this.state.native_stop_proof || this.state.native_stop_proof.daemon_id !== this.state.daemon_id) {
|
|
557
564
|
const unsettled = [];
|
|
558
565
|
for (const session of this.sessions.values()) {
|
|
559
566
|
if (session.close_receipt) {
|
|
@@ -603,6 +610,10 @@ export class DaemonRuntime {
|
|
|
603
610
|
}
|
|
604
611
|
}
|
|
605
612
|
if (unsettled.length) throw new DaemonError("tree_not_settled", "daemon still owns running Sessions", false, { sessions: unsettled });
|
|
613
|
+
commitDaemonShutdown(this.paths.dir);
|
|
614
|
+
await this.persist({ native_stop_proof: { daemon_id: this.state.daemon_id, sessions: [...this.sessions.keys()], observed_at: new Date().toISOString() } });
|
|
615
|
+
}
|
|
616
|
+
commitDaemonShutdown(this.paths.dir);
|
|
606
617
|
// A cooperative close can make the native Session non-resident while its
|
|
607
618
|
// original ACP prompt is still pending. For an explicitly whole-RUN
|
|
608
619
|
// cancellation this daemon owns the bridge process group, so retire that
|
|
@@ -614,7 +625,7 @@ export class DaemonRuntime {
|
|
|
614
625
|
if (typeof this.bridge.close === "function") await this.bridge.close();
|
|
615
626
|
bridgeClosed = true;
|
|
616
627
|
} catch (error) {
|
|
617
|
-
throw new DaemonError(error.code ?? "provider_process_stop_incomplete", error.message ?? String(error), false);
|
|
628
|
+
throw new DaemonError(error.code ?? "provider_process_stop_incomplete", error.message ?? String(error), false, error.details);
|
|
618
629
|
}
|
|
619
630
|
}
|
|
620
631
|
const deadline = Date.now() + 5000;
|
|
@@ -625,24 +636,11 @@ export class DaemonRuntime {
|
|
|
625
636
|
// A bridge without a close method occurs only in in-memory diagnostics: it
|
|
626
637
|
// owns no OS process boundary. Production AcpBridge always has one.
|
|
627
638
|
try { if (!bridgeClosed && typeof this.bridge.close === "function") await this.bridge.close(); }
|
|
628
|
-
catch (error) { throw new DaemonError(error.code ?? "provider_process_stop_incomplete", error.message ?? String(error), false); }
|
|
639
|
+
catch (error) { throw new DaemonError(error.code ?? "provider_process_stop_incomplete", error.message ?? String(error), false, error.details); }
|
|
629
640
|
await this.persist({ active_tree: false, shutdown_state: "stopping", active_operation: null, active_request: null, physical_settlement: { confirmed_at: new Date().toISOString(), bridge_pid: this.bridge.child?.pid ?? null } });
|
|
630
641
|
return { daemon_id: this.state.daemon_id, stopped: true, clean: true, physical_settlement: this.state.physical_settlement, _shutdown: true };
|
|
631
642
|
}
|
|
632
643
|
|
|
633
|
-
async shutdown() {
|
|
634
|
-
// prepareStop has already closed the owned bridge. Signal shutdown may get
|
|
635
|
-
// here directly, so close remains idempotent.
|
|
636
|
-
await closeManagedBridge(this.state.config, this.state, this.bridge);
|
|
637
|
-
await this.persist({ active_tree: false, shutdown_state: "clean", stopped_at: new Date().toISOString() });
|
|
638
|
-
const resourceProcess = this.state.resource_process;
|
|
639
|
-
if (resourceProcess?.id && resourceProcess.lease_token) {
|
|
640
|
-
try { await finishDaemonProcess(this.state.config, resourceProcess, "stopped", "daemon_stopped"); }
|
|
641
|
-
catch { /* Keep the state; the next reconciler owns stale processes. */ }
|
|
642
|
-
}
|
|
643
|
-
await new Promise((resolve) => this.server.close(resolve));
|
|
644
|
-
await removeSocket(this.paths.socket, this.paths.dir);
|
|
645
|
-
}
|
|
646
644
|
}
|
|
647
645
|
|
|
648
646
|
export function zcodeBridgeEnv(config, daemonId) {
|
|
@@ -660,11 +658,13 @@ export async function serveDaemon(stateDir) {
|
|
|
660
658
|
scheduleDaemonProcessHeartbeat(state.config, state.resource_process);
|
|
661
659
|
await removeSocket(paths.socket, paths.dir);
|
|
662
660
|
const server = net.createServer((connection) => {
|
|
663
|
-
connection.setEncoding("utf8"); let buffer = "";
|
|
661
|
+
connection.setEncoding("utf8"); let buffer = "", consumed = false;
|
|
664
662
|
connection.on("data", (chunk) => {
|
|
663
|
+
if (consumed) return;
|
|
665
664
|
buffer += chunk;
|
|
666
665
|
const newline = buffer.indexOf("\n");
|
|
667
666
|
if (newline < 0) return;
|
|
667
|
+
consumed = true;
|
|
668
668
|
const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1);
|
|
669
669
|
void (async () => {
|
|
670
670
|
let request;
|
|
@@ -672,15 +672,17 @@ export async function serveDaemon(stateDir) {
|
|
|
672
672
|
request = JSON.parse(line);
|
|
673
673
|
if (request.schema_id !== REQUEST_SCHEMA || !request.id || !request.operation) throw new DaemonError("daemon_protocol_mismatch", "invalid daemon request");
|
|
674
674
|
const result = await durableDaemonDispatch(paths.dir, request, () => runtime.dispatch(request.operation, request.params ?? {}), () => runtime.dispatch("session.inspect", request.params ?? {}));
|
|
675
|
+
if (result?._shutdown) retireDaemonEndpoint(server);
|
|
675
676
|
connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: true, result: { ...result, _shutdown: undefined } })}\n`);
|
|
676
|
-
if (result?._shutdown) setImmediate(() => void runtime.shutdown().then(() => process.exit(0)));
|
|
677
677
|
} catch (error) {
|
|
678
|
+
await retireFailedDaemon(server, state, error, patch => runtime.persist(patch));
|
|
678
679
|
connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request?.id ?? null, ok: false, error: errorPayload(error) })}\n`);
|
|
679
680
|
}
|
|
680
681
|
})();
|
|
681
682
|
});
|
|
682
683
|
});
|
|
683
684
|
runtime.server = server;
|
|
685
|
+
trackDaemonConnections(server);
|
|
684
686
|
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(paths.socket, resolve); });
|
|
685
687
|
await chmod(paths.socket, 0o600);
|
|
686
688
|
await runtime.persist({ shutdown_state: "running", active_tree: false });
|
|
@@ -7,7 +7,8 @@ import { createHash } from "node:crypto";
|
|
|
7
7
|
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import readline from "node:readline";
|
|
10
|
-
import {
|
|
10
|
+
import { closeBridgeProcess } from "./managed-daemon.mjs";
|
|
11
|
+
import { errorRecord } from "./operation-errors.mjs";
|
|
11
12
|
import { checkObservedProfile, observeModel, observedFields } from "./model-observations.mjs";
|
|
12
13
|
|
|
13
14
|
import { readZcodeUsage } from "./zcode-usage.mjs";
|
|
@@ -423,13 +424,10 @@ export class AcpBridge {
|
|
|
423
424
|
async close() {
|
|
424
425
|
// Productive notification failures belong to flush()/the original Turn.
|
|
425
426
|
// Draining them must not relabel a successful physical shutdown as failed.
|
|
426
|
-
await
|
|
427
|
-
|
|
428
|
-
this.
|
|
429
|
-
|
|
430
|
-
await stopProcessGroup(this.child, 2_000);
|
|
431
|
-
}
|
|
432
|
-
await this.journal.flush();
|
|
427
|
+
return await closeBridgeProcess(this, async () => {
|
|
428
|
+
await Promise.allSettled([...this.lifecycleNotifications, this.notifications]);
|
|
429
|
+
await this.journal.flush();
|
|
430
|
+
});
|
|
433
431
|
}
|
|
434
432
|
}
|
|
435
433
|
|
|
@@ -721,9 +719,10 @@ function requireJournal(options) { absolute(options.journal, "--journal"); }
|
|
|
721
719
|
function receipt(options, value) { return options.daemonId ? { daemon_id: options.daemonId, ...value } : value; }
|
|
722
720
|
|
|
723
721
|
async function withBridge(options, operation) {
|
|
724
|
-
const bridge = new AcpBridge(options);
|
|
722
|
+
const bridge = new AcpBridge(options); let primary;
|
|
725
723
|
try { const initialized = await bridge.start(); const result = await operation(bridge, initialized); await bridge.flush(); return result; }
|
|
726
|
-
|
|
724
|
+
catch (error) { primary = error; throw error; }
|
|
725
|
+
finally { try { await bridge.close(); } catch (error) { if (!primary) throw error; primary.cleanup_error = errorRecord(error); primary.details = { ...primary.details, cleanup_error: primary.cleanup_error }; } }
|
|
727
726
|
}
|
|
728
727
|
|
|
729
728
|
function flowForwarder(options, rootProviderSessionId, bridge) {
|
|
@@ -7,11 +7,21 @@ import { reportedError } from "./operation-errors.mjs";
|
|
|
7
7
|
import { ObservationClock } from "./observation-clock.mjs";
|
|
8
8
|
import { runtimeProcess } from "./managed-daemon.mjs";
|
|
9
9
|
|
|
10
|
+
function cleanShutdownConfirmed(state) {
|
|
11
|
+
const proof = state.shutdown;
|
|
12
|
+
return state.shutdown_state === "clean" && state.active_tree === false
|
|
13
|
+
&& proof?.schema_id === "dd-flow/daemon-shutdown@1" && proof.daemon_id === state.daemon_id
|
|
14
|
+
&& proof.result?.clean === true && Array.isArray(proof.required_phases)
|
|
15
|
+
&& proof.required_phases.length >= 3 && proof.required_phases.includes("tree")
|
|
16
|
+
&& proof.required_phases.includes("daemon_resource")
|
|
17
|
+
&& proof.required_phases.every(phase => typeof phase === "string" && proof.phases?.[phase] === true);
|
|
18
|
+
}
|
|
19
|
+
|
|
10
20
|
/** Explicit same-Session restart retains native stores and the old daemon
|
|
11
21
|
* receipt; it does not turn a terminal directory into a new execution. */
|
|
12
22
|
export async function authorizeRetainedDaemonResume(root, previous, sessionId, config) {
|
|
13
23
|
const sessions = [...(previous.sessions ?? []), ...(previous.root_session_id ? [previous.root_session_id] : [])];
|
|
14
|
-
if (previous
|
|
24
|
+
if (!cleanShutdownConfirmed(previous) || typeof sessionId !== "string" || !sessions.some(session => (typeof session === "string" ? session : session.provider_session_id) === sessionId)) throw Object.assign(new Error("terminal daemon restart requires a retained, settled Session identity and complete shutdown proof"), { code: "daemon_state_terminal" });
|
|
15
25
|
for (const key of ["cwd", "projectRoot", "ddFlowHome", "ddFlowBin", "provider", "model", "reasoning", "mode", "variant", "agent", "bin", "noFlow"]) {
|
|
16
26
|
if ((previous.config?.[key] ?? null) !== (config[key] ?? null)) throw Object.assign(new Error(`retained daemon configuration changed: ${key}`), { code: "daemon_config_mismatch" });
|
|
17
27
|
}
|
|
@@ -43,7 +53,7 @@ export async function recoverySettlement(driversRoot) {
|
|
|
43
53
|
}
|
|
44
54
|
else if (entry.name === "daemon.json") {
|
|
45
55
|
const state = JSON.parse(await readFile(file, "utf8"));
|
|
46
|
-
if (state
|
|
56
|
+
if (!cleanShutdownConfirmed(state)) throw Object.assign(new Error("provider tree has no complete clean shutdown receipt"), { code: "recovery_settlement_unconfirmed" });
|
|
47
57
|
await assertDaemonReplaceable(directory);
|
|
48
58
|
await reconcileDriverReplies(directory);
|
|
49
59
|
if (state.config?.resourceHome && state.resource_process?.id) {
|
|
@@ -286,43 +286,111 @@ export async function cleanupFailedDaemonStart(config, daemonRecord, daemonChild
|
|
|
286
286
|
}
|
|
287
287
|
}
|
|
288
288
|
|
|
289
|
-
|
|
289
|
+
const processStops = new WeakMap();
|
|
290
|
+
export async function closeBridgeProcess(bridge, drain) {
|
|
291
|
+
if (bridge.closePending) return await bridge.closePending;
|
|
292
|
+
const pending = (async () => {
|
|
293
|
+
let primary;
|
|
294
|
+
try {
|
|
295
|
+
if (!bridge.physicallyClosed && bridge.child) {
|
|
296
|
+
const child = bridge.child;
|
|
297
|
+
if (!bridge.stdinEnded && !child.stdin?.destroyed) { bridge.stdinEnded = true; child.stdin?.end(); }
|
|
298
|
+
if (child.exitCode == null && child.signalCode == null) {
|
|
299
|
+
await new Promise(resolve => {
|
|
300
|
+
const done = () => { clearTimeout(timer); child.removeListener("exit", done); resolve(); };
|
|
301
|
+
const timer = setTimeout(done, 2_000); child.once("exit", done);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
await stopProcessGroup(child, 2_000);
|
|
305
|
+
bridge.physicallyClosed = true;
|
|
306
|
+
}
|
|
307
|
+
} catch (error) { primary = error; }
|
|
308
|
+
let drainTimer;
|
|
309
|
+
try { await Promise.race([drain(), new Promise((_, reject) => { drainTimer = setTimeout(() => reject(Object.assign(new Error("bridge drain did not finish"), { code: "bridge_drain_timeout" })), 10_000); })]); }
|
|
310
|
+
catch (error) { if (primary) { primary.cleanup_error = errorRecord(error); primary.details = { ...primary.details, cleanup_error: primary.cleanup_error }; } else primary = error; }
|
|
311
|
+
finally { clearTimeout(drainTimer); }
|
|
312
|
+
if (primary) throw primary;
|
|
313
|
+
})();
|
|
314
|
+
bridge.closePending = pending;
|
|
315
|
+
try { return await pending; }
|
|
316
|
+
finally { bridge.closePending = null; }
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export async function stopProcessGroup(child, graceMs = 1_000, { detached = true } = {}) {
|
|
290
320
|
if (!child?.pid) return;
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
321
|
+
if (processStops.has(child)) return await processStops.get(child);
|
|
322
|
+
const pending = stopOwnedChild(child, graceMs, detached);
|
|
323
|
+
processStops.set(child, pending);
|
|
324
|
+
try { return await pending; } finally { processStops.delete(child); }
|
|
325
|
+
}
|
|
326
|
+
async function stopOwnedChild(child, graceMs, detached) {
|
|
327
|
+
const target = process.platform === "win32" || !detached ? child.pid : -child.pid;
|
|
328
|
+
const details = signal => ({ pid: child.pid, pgid: target < 0 ? child.pid : null, signal, platform: process.platform, exit_code: child.exitCode ?? null, signal_code: child.signalCode ?? null, observed_at: new Date().toISOString() });
|
|
295
329
|
const alive = () => { try { process.kill(target, 0); return true; } catch (error) { if (error.code === "ESRCH") return false; if (error.code === "EPERM") return true; throw error; } };
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
330
|
+
const gone = async ms => {
|
|
331
|
+
const deadline = performance.now() + ms;
|
|
332
|
+
while (alive() && performance.now() < deadline) await new Promise(resolve => setTimeout(resolve, 25));
|
|
333
|
+
return !alive();
|
|
334
|
+
};
|
|
335
|
+
const signal = async name => {
|
|
336
|
+
try { process.kill(target, name); return true; }
|
|
337
|
+
catch (error) {
|
|
338
|
+
if (error.code === "ESRCH") return false;
|
|
339
|
+
error.details = { ...error.details, ...details(name), syscall: error.syscall ?? "kill" };
|
|
340
|
+
// Permission denial is not permission to escalate. Observe only the retained target.
|
|
341
|
+
let absent = false;
|
|
342
|
+
try { absent = await gone(Math.min(250, Math.max(25, graceMs))); }
|
|
343
|
+
catch (observation) { error.details.observation_error = errorRecord(observation); }
|
|
344
|
+
if (absent) {
|
|
345
|
+
process.stderr.write(`${JSON.stringify({ cleanup_reobserved: errorRecord(error), physical_exit: "ESRCH" })}\n`);
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
};
|
|
308
351
|
if (!alive()) return;
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
if (
|
|
352
|
+
const leaderExited = () => child.exitCode != null || child.signalCode != null;
|
|
353
|
+
const observeLeaderless = async () => {
|
|
354
|
+
if (await gone(Math.max(1_000, graceMs))) return;
|
|
355
|
+
throw Object.assign(new Error("process group remains after its leader exited; ownership cannot be proven from this child handle"), { code: "process_group_ownership_unknown", details: details(null) });
|
|
356
|
+
};
|
|
357
|
+
if (leaderExited()) return await observeLeaderless();
|
|
358
|
+
if (!await signal("SIGTERM")) return;
|
|
359
|
+
if (await gone(graceMs)) return;
|
|
360
|
+
if (leaderExited()) return await observeLeaderless();
|
|
361
|
+
if (!await signal("SIGKILL")) return;
|
|
362
|
+
if (!await gone(Math.max(1_000, graceMs))) throw Object.assign(new Error(`process group ${child.pid} did not stop`), { code: "process_group_stop_incomplete", details: details("SIGKILL") });
|
|
316
363
|
}
|
|
317
364
|
|
|
318
365
|
/** A daemon acknowledgement is not a completed stop until its endpoint closes. */
|
|
319
|
-
export async function confirmDaemonStopped(request, socket, timeoutMs = 30_000) {
|
|
320
|
-
const
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
366
|
+
export async function confirmDaemonStopped(request, socket, timeoutMs = 30_000, { readState } = {}) {
|
|
367
|
+
const deadline = performance.now() + timeoutMs;
|
|
368
|
+
const expected = await readState?.();
|
|
369
|
+
const identity = state => state?.daemon_id ?? state?.config?.daemonId;
|
|
370
|
+
if (readState && (!identity(expected) || !Number.isSafeInteger(expected?.pid) || expected.pid < 1)) throw Object.assign(new Error('daemon stop has no retained incarnation/PID proof'), { code: 'daemon_cleanup_identity_mismatch' });
|
|
371
|
+
const failure = async observer => {
|
|
372
|
+
const state = await readState?.();
|
|
373
|
+
if (expected && identity(state) !== identity(expected)) throw Object.assign(new Error("daemon incarnation changed during stop"), { code: "daemon_identity_mismatch" });
|
|
374
|
+
const primary = state?.shutdown_state === "cleanup_failed" ? state.cleanup_error : null;
|
|
375
|
+
if (primary) throw Object.assign(new Error(primary.message), primary, { details: { ...primary.details, socket, phase: "confirm_daemon_stop", observer_error: errorRecord(observer) } });
|
|
376
|
+
throw observer;
|
|
377
|
+
};
|
|
378
|
+
let result;
|
|
379
|
+
try { result = await request(); }
|
|
380
|
+
catch (error) { return await failure(error); }
|
|
381
|
+
if (result?.stopped !== true) return await failure(Object.assign(new Error("stop acknowledgement does not prove shutdown"), { code: "daemon_stop_incomplete" }));
|
|
382
|
+
for (;;) {
|
|
383
|
+
const state = await readState?.();
|
|
384
|
+
if (expected && identity(state) !== identity(expected)) return await failure(Object.assign(new Error("daemon incarnation changed"), { code: "daemon_identity_mismatch" }));
|
|
385
|
+
let endpointPresent = true;
|
|
386
|
+
try { await lstat(socket); } catch (error) { if (error.code === "ENOENT") endpointPresent = false; else throw error; }
|
|
387
|
+
let daemonPresent = false;
|
|
388
|
+
if (expected?.pid) { try { process.kill(expected.pid, 0); daemonPresent = true; } catch (error) { if (error.code !== "ESRCH") daemonPresent = true; } }
|
|
389
|
+
if (!endpointPresent && !daemonPresent) {
|
|
390
|
+
if (readState && result.clean === true && (state?.shutdown_state !== 'clean' || state.active_tree !== false)) return await failure(Object.assign(new Error('daemon clean ACK conflicts with its retained state'), { code: 'daemon_stop_incomplete' }));
|
|
391
|
+
return result;
|
|
392
|
+
}
|
|
393
|
+
if (performance.now() >= deadline) return await failure(Object.assign(new Error("daemon stop could not confirm endpoint retirement and owned PID exit"), { code: "daemon_stop_incomplete", details: { socket, daemon_pid: expected?.pid ?? null, endpoint_present: endpointPresent, daemon_present: daemonPresent } }));
|
|
394
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
326
395
|
}
|
|
327
|
-
throw Object.assign(new Error("daemon acknowledged stop but its socket remained live"), { code: "daemon_stop_incomplete", details: { socket } });
|
|
328
396
|
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"subject": {"$ref": "#/definitions/identity"},
|
|
14
14
|
"flow_kind": {"type": "string", "minLength": 1},
|
|
15
15
|
"verification_matrix_contract": {"const": "dd-flow/verification-matrix@1"},
|
|
16
|
+
"final_check_coverage_contract": {"const": "dd-flow/final-check-coverage@1"},
|
|
16
17
|
"status": {"enum": ["created", "running", "paused", "waiting_for_user", "done", "failed", "blocked", "cancelled", "discarded"]},
|
|
17
18
|
"verdict": {"type": ["string", "null"]},
|
|
18
19
|
"started_at": {"type": "string", "format": "date-time"},
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
-
"$id": "dd-flow/plan-review-decision@
|
|
3
|
+
"$id": "dd-flow/plan-review-decision@4",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"additionalProperties": false,
|
|
6
6
|
"required": ["schema_id", "outcome", "summary", "finding_decisions", "correction"],
|
|
7
7
|
"properties": {
|
|
8
|
-
"schema_id": {"const": "dd-flow/plan-review-decision@
|
|
8
|
+
"schema_id": {"const": "dd-flow/plan-review-decision@4"},
|
|
9
9
|
"outcome": {"enum": ["accepted", "failed", "cancelled"]},
|
|
10
10
|
"summary": {"type": "string", "minLength": 1},
|
|
11
|
-
"finding_decisions": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["finding_ref", "decision", "reason"], "properties": {"finding_ref": {"type": "string", "pattern": "^WRK-[^/]+/FIND-[0-9]{3}$"}, "decision": {"enum": ["accepted_fix", "rejected", "deferred_as_DEF", "requires_user", "duplicate"]}, "reason": {"type": "string", "minLength": 1}}}},
|
|
11
|
+
"finding_decisions": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["finding_ref", "decision", "reason"], "properties": {"finding_ref": {"type": "string", "pattern": "^WRK-[^/]+/FIND-[0-9]{3}$"}, "decision": {"enum": ["accepted_fix", "rejected", "deferred_as_DEF", "requires_user", "duplicate"]}, "reason": {"type": "string", "minLength": 1}, "duplicate_of": {"type": "string", "pattern": "^WRK-[^/]+/FIND-[0-9]{3}$"}, "def_id": {"type": "string", "pattern": "^DEF-[A-Za-z0-9][A-Za-z0-9_-]*$"}}}},
|
|
12
12
|
"correction": {"type": "object", "additionalProperties": false, "required": ["status", "previous_plan_revision", "changed_paths", "summary"], "properties": {"status": {"enum": ["applied", "not_required"]}, "previous_plan_revision": {"type": "integer", "minimum": 1}, "changed_paths": {"type": "array", "items": {"type": "string", "minLength": 1}}, "summary": {"type": "string", "minLength": 1}}}
|
|
13
13
|
}
|
|
14
14
|
}
|
|
@@ -6,7 +6,8 @@ import path from "node:path";
|
|
|
6
6
|
import { isDeepStrictEqual } from "node:util";
|
|
7
7
|
import { parse } from "shell-quote";
|
|
8
8
|
import { assertRunMutationAllowed } from "./run-recovery.js";
|
|
9
|
-
import { AppError } from "../shared/errors.js";
|
|
9
|
+
import { AppError, errorRecord } from "../shared/errors.js";
|
|
10
|
+
import { stopProcessGroup } from "../harness-runtime/lib/managed-daemon.mjs";
|
|
10
11
|
import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, managedProcessRuntimeHome, managedOwnerIsAlive, processTreeIsAlive, registerManagedProcess, reservePorts } from "./managed-processes.js";
|
|
11
12
|
import { admitRuntimeCheck, runtimeBudgetForRun } from "./runtime-budget.js";
|
|
12
13
|
import { getResourceDatabase } from "../storage/database.js";
|
|
@@ -340,7 +341,8 @@ async function runCodeChecksOwned(context, input, workspaceGateId, generation) {
|
|
|
340
341
|
if (processTreeIsAlive(current))
|
|
341
342
|
throw new AppError("check_in_progress", "The check shell exited but its process tree is still live; retain this receipt and its resources until settlement", 2, { process_id: managed.id, check_receipt_id: id });
|
|
342
343
|
allocation.release();
|
|
343
|
-
|
|
344
|
+
if (!result.cleanupUnconfirmed)
|
|
345
|
+
finishManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, state: result.aborted || result.exitCode !== 0 || Boolean(result.error) ? "failed" : "stopped", reason: result.error });
|
|
344
346
|
}
|
|
345
347
|
catch (error) {
|
|
346
348
|
if (error instanceof AppError && error.code === "check_in_progress")
|
|
@@ -825,7 +827,8 @@ export function runCheck(command, cwd, environment, stdout, stderr, heartbeat, o
|
|
|
825
827
|
let error = null;
|
|
826
828
|
let stopping = false;
|
|
827
829
|
let progress;
|
|
828
|
-
let
|
|
830
|
+
let cleanup;
|
|
831
|
+
let cleanupUnconfirmed = false;
|
|
829
832
|
const stop = (cause) => {
|
|
830
833
|
if (stopping)
|
|
831
834
|
return;
|
|
@@ -835,23 +838,21 @@ export function runCheck(command, cwd, environment, stdout, stderr, heartbeat, o
|
|
|
835
838
|
clearInterval(progress);
|
|
836
839
|
progress = undefined;
|
|
837
840
|
}
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
841
|
+
cleanup = stopProcessGroup(child, 5_000).catch(cause => {
|
|
842
|
+
cleanupUnconfirmed = true;
|
|
843
|
+
error = `${error}; cleanup: ${JSON.stringify(errorRecord(cause))}`;
|
|
844
|
+
resolve({ exitCode: null, error, aborted: true, cleanupUnconfirmed });
|
|
845
|
+
});
|
|
842
846
|
};
|
|
843
847
|
// Register handlers before checking pid or calling persistence callbacks.
|
|
844
848
|
// Failure is not completion until the spawned process has actually closed.
|
|
845
849
|
child.once("error", (value) => { error = value.message; });
|
|
846
850
|
child.stdin.on("error", stop);
|
|
847
|
-
child.once("close", (exitCode, signal) => {
|
|
851
|
+
child.once("close", async (exitCode, signal) => {
|
|
848
852
|
if (progress)
|
|
849
853
|
clearInterval(progress);
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
if (stopping)
|
|
853
|
-
terminateProcessGroup(child.pid, "SIGKILL");
|
|
854
|
-
resolve({ exitCode, error: error ?? (signal ? `terminated by ${signal}` : null), aborted: stopping || Boolean(signal) || Boolean(error) });
|
|
854
|
+
await cleanup;
|
|
855
|
+
resolve({ exitCode, error: error ?? (signal ? `terminated by ${signal}` : null), aborted: stopping || Boolean(signal) || Boolean(error), ...(cleanupUnconfirmed ? { cleanupUnconfirmed } : {}) });
|
|
855
856
|
});
|
|
856
857
|
if (!child.pid) {
|
|
857
858
|
error = "check process did not provide a PID";
|
|
@@ -890,8 +891,3 @@ function checkShellScript() {
|
|
|
890
891
|
'exit "$code"'
|
|
891
892
|
].join("\n");
|
|
892
893
|
}
|
|
893
|
-
function terminateProcessGroup(pid, signal) { if (!pid)
|
|
894
|
-
return; try {
|
|
895
|
-
process.kill(process.platform === "win32" ? pid : -pid, signal);
|
|
896
|
-
}
|
|
897
|
-
catch { /* already exited */ } }
|