@deksden-com/dd-flow-cli 0.9.0-beta.122 → 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 +18 -0
- package/README.md +23 -0
- package/dist/build-info.json +4 -4
- package/dist/cli/run-cli.js +3 -0
- 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 +117 -21
- package/dist/harness-runtime/lib/dd-agy.mjs +13 -3
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +58 -13
- package/dist/harness-runtime/lib/dd-codex.mjs +16 -26
- 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 +24 -14
- 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 -31
- package/dist/harness-runtime/lib/model-observations.mjs +16 -9
- package/dist/harness-runtime/lib/tool-observations.d.mts +1 -0
- package/dist/harness-runtime/lib/tool-observations.mjs +52 -11
- 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 +251 -31
- package/dist/services/controller-fanout.js +1 -0
- package/dist/services/eval-snapshots.js +13 -8
- package/dist/services/external-work-launch.js +36 -11
- package/dist/services/harness-adapter.js +21 -15
- package/dist/services/managed-processes.js +14 -6
- package/dist/services/merge-server.js +35 -12
- package/dist/services/review-copy.js +21 -0
- package/dist/services/review-deferral.js +23 -0
- package/dist/services/run-controller-adapter.js +33 -8
- package/dist/services/run-controller-recovery.js +26 -2
- package/dist/services/run-controller.js +91 -24
- package/dist/services/run-observations.js +7 -2
- package/dist/services/run-recovery-runtime.js +49 -3
- package/dist/services/runs.js +1 -1
- package/dist/services/stage-report-renderer.js +25 -2
- package/dist/services/verification-matrix.js +57 -8
- package/dist/services/vnext-code-review.js +22 -8
- package/dist/services/vnext-merge.js +20 -2
- package/dist/services/vnext-plan-review.js +87 -19
- package/dist/services/work-registry.js +11 -2
- package/package.json +1 -1
|
@@ -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";
|
|
@@ -164,13 +165,15 @@ export class AcpBridge {
|
|
|
164
165
|
const identity = this.modelSessions.get(adapterId); if (!identity) return;
|
|
165
166
|
this.modelProfiles.set(identity.sessionId, observed);
|
|
166
167
|
await observeModel({ journal: this.options.journal, harness: this.options.clientInfo?.name === "dd-grok" ? "grok-acp" : "zcode-acp",
|
|
167
|
-
sessionId: identity.sessionId, parentSessionId: identity.parentSessionId, requested: identity.requested, observed, source });
|
|
168
|
+
sessionId: identity.sessionId, parentSessionId: identity.parentSessionId, requested: identity.requested, observed, source: typeof source === "string" ? { channel: source, scope: "configured", raw_model: observed.raw_model ?? observed.model, raw_provider: observed.provider } : source });
|
|
168
169
|
}
|
|
169
170
|
async observeModelUpdate(message) {
|
|
170
171
|
const update = message.params?.update;
|
|
171
172
|
if (update?.sessionUpdate !== "config_option_update" || !Array.isArray(update.configOptions)) return;
|
|
172
173
|
const value = category => update.configOptions.find(option => option.type === "select" && (option.category === category || option.id === category))?.currentValue ?? null;
|
|
173
|
-
const
|
|
174
|
+
const raw = value("model");
|
|
175
|
+
const model = this.options.clientInfo?.name === "dd-grok" ? { model: raw } : zcodeModelSelector(raw);
|
|
176
|
+
const observed = { ...model, reasoning: value("thought_level"), mode: value("mode") };
|
|
174
177
|
const identity = this.modelSessions.get(message.params.sessionId); if (!identity?.armed) return;
|
|
175
178
|
checkObservedProfile(identity.requested, observed);
|
|
176
179
|
await this.recordModel(message.params.sessionId, observed, "acp.config_option_update");
|
|
@@ -421,13 +424,10 @@ export class AcpBridge {
|
|
|
421
424
|
async close() {
|
|
422
425
|
// Productive notification failures belong to flush()/the original Turn.
|
|
423
426
|
// Draining them must not relabel a successful physical shutdown as failed.
|
|
424
|
-
await
|
|
425
|
-
|
|
426
|
-
this.
|
|
427
|
-
|
|
428
|
-
await stopProcessGroup(this.child, 2_000);
|
|
429
|
-
}
|
|
430
|
-
await this.journal.flush();
|
|
427
|
+
return await closeBridgeProcess(this, async () => {
|
|
428
|
+
await Promise.allSettled([...this.lifecycleNotifications, this.notifications]);
|
|
429
|
+
await this.journal.flush();
|
|
430
|
+
});
|
|
431
431
|
}
|
|
432
432
|
}
|
|
433
433
|
|
|
@@ -443,11 +443,20 @@ function isProductiveSessionNotification(message) {
|
|
|
443
443
|
return true;
|
|
444
444
|
}
|
|
445
445
|
|
|
446
|
+
// The qualified bridge encodes exactly providerId\\modelId; no other suffix or
|
|
447
|
+
// slash normalization is authorized. Bare IDs do not prove account routing.
|
|
448
|
+
export function zcodeModelSelector(raw, provider = null) {
|
|
449
|
+
if (typeof raw !== "string") return { model: null, provider, raw_model: raw };
|
|
450
|
+
const parts = raw.split("\\");
|
|
451
|
+
if (parts.length === 1) return { model: raw || null, provider, raw_model: raw };
|
|
452
|
+
if (parts.length !== 2 || !parts.every(Boolean) || provider && provider !== parts[0]) return { model: null, provider, raw_model: raw };
|
|
453
|
+
return { model: parts[1], provider: parts[0], raw_model: raw };
|
|
454
|
+
}
|
|
455
|
+
|
|
446
456
|
export function observedProfile(read) {
|
|
447
457
|
const model = read?.settings?.model?.current ?? {};
|
|
448
458
|
return {
|
|
449
|
-
|
|
450
|
-
model: typeof model.modelId === "string" ? model.modelId : null,
|
|
459
|
+
...zcodeModelSelector(model.modelId, typeof model.providerId === "string" ? model.providerId : null),
|
|
451
460
|
reasoning: read?.settings?.thoughtLevel?.current ?? null,
|
|
452
461
|
mode: read?.settings?.mode?.current ?? read?.projection?.mode ?? null,
|
|
453
462
|
};
|
|
@@ -710,9 +719,10 @@ function requireJournal(options) { absolute(options.journal, "--journal"); }
|
|
|
710
719
|
function receipt(options, value) { return options.daemonId ? { daemon_id: options.daemonId, ...value } : value; }
|
|
711
720
|
|
|
712
721
|
async function withBridge(options, operation) {
|
|
713
|
-
const bridge = new AcpBridge(options);
|
|
722
|
+
const bridge = new AcpBridge(options); let primary;
|
|
714
723
|
try { const initialized = await bridge.start(); const result = await operation(bridge, initialized); await bridge.flush(); return result; }
|
|
715
|
-
|
|
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 }; } }
|
|
716
726
|
}
|
|
717
727
|
|
|
718
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,42 +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; } };
|
|
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
|
+
};
|
|
296
351
|
if (!alive()) return;
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
if (!
|
|
308
|
-
if (child.exitCode != null || child.signalCode != null) return stopProcessGroup(child, graceMs);
|
|
309
|
-
process.kill(target, "SIGKILL");
|
|
310
|
-
// Grace controls escalation, not the OS acknowledgement budget. A caller
|
|
311
|
-
// testing immediate escalation must still allow the kernel to reap children.
|
|
312
|
-
const deadline = Date.now() + Math.max(1_000, graceMs);
|
|
313
|
-
while (alive() && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 25));
|
|
314
|
-
if (alive()) throw Object.assign(new Error(`process group ${child.pid} did not stop`), { code: "process_group_stop_incomplete" });
|
|
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") });
|
|
315
363
|
}
|
|
316
364
|
|
|
317
365
|
/** A daemon acknowledgement is not a completed stop until its endpoint closes. */
|
|
318
|
-
export async function confirmDaemonStopped(request, socket, timeoutMs = 30_000) {
|
|
319
|
-
const
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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));
|
|
325
395
|
}
|
|
326
|
-
throw Object.assign(new Error("daemon acknowledged stop but its socket remained live"), { code: "daemon_stop_incomplete", details: { socket } });
|
|
327
396
|
}
|
|
@@ -5,6 +5,7 @@ import { withRunnerLock } from "./runner-lock.mjs";
|
|
|
5
5
|
|
|
6
6
|
const text = value => typeof value === "string" && value.trim() ? value.trim() : null;
|
|
7
7
|
const fields = ["provider", "model", "reasoning", "mode", "permission_mode"];
|
|
8
|
+
const observationScope = source => JSON.stringify([source?.channel ?? null, source?.scope ?? null, source?.attempt_id ?? null, source?.turn_id ?? null, source?.non_asserting === true]);
|
|
8
9
|
export const observedFields = value => Object.fromEntries(fields.map(key => [key, text(value?.[key])]));
|
|
9
10
|
export const modelObservationFile = journal => path.join(path.dirname(journal), "model-observations.jsonl");
|
|
10
11
|
|
|
@@ -31,20 +32,23 @@ export async function readModelObservations(journal) {
|
|
|
31
32
|
export async function observeModel({ journal, harness, sessionId, parentSessionId = null, requested = {}, observed = {}, source, evidence = "configured", reason = null, nativeTimestamp = null, previousModel = null }) {
|
|
32
33
|
if (!journal || !sessionId) return null;
|
|
33
34
|
if (!source || !["configured", "request", "response", "unavailable"].includes(evidence)) throw new Error("model observation requires a native source and evidence level");
|
|
35
|
+
if (typeof source === "string") source = { channel: source, ...(["configured", "response"].includes(evidence) ? { scope: evidence } : {}) };
|
|
34
36
|
const file = modelObservationFile(journal), profile = observedFields(observed);
|
|
35
37
|
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
36
38
|
return withRunnerLock(file, async () => {
|
|
37
39
|
const events = await readModelObservations(journal);
|
|
38
|
-
const replay = source?.event_sha256 ? events.find(item => item.harness === harness && item.session_id === sessionId && item.source?.event_sha256 === source.event_sha256) : null;
|
|
40
|
+
const replay = source?.event_sha256 ? events.find(item => item.harness === harness && item.session_id === sessionId && observationScope(item.source) === observationScope(source) && item.source?.event_sha256 === source.event_sha256) : null;
|
|
39
41
|
if (replay) return replay;
|
|
40
|
-
const
|
|
41
|
-
const previous = events.findLast(item => item.harness === harness && item.session_id === sessionId && (item.source?.channel ?? item.evidence) === channel
|
|
42
|
-
&& (item.source?.attempt_id ?? null) === (source?.attempt_id ?? null));
|
|
42
|
+
const previous = events.findLast(item => item.harness === harness && item.session_id === sessionId && observationScope(item.source) === observationScope(source));
|
|
43
43
|
const normalizedReason = text(reason)?.slice(0, 200) ?? null;
|
|
44
|
-
if (previous && JSON.stringify(previous.observed) === JSON.stringify(profile) && previous.reason === normalizedReason && previous.evidence === evidence
|
|
45
|
-
|
|
44
|
+
if (previous && JSON.stringify(previous.observed) === JSON.stringify(profile) && previous.reason === normalizedReason && previous.evidence === evidence
|
|
45
|
+
&& previous.source?.raw_model === source?.raw_model && previous.source?.raw_provider === source?.raw_provider) return previous;
|
|
46
|
+
const previousKnown = events.findLast(item => item.harness === harness && item.session_id === sessionId && item.evidence === evidence && item.observed?.model
|
|
47
|
+
&& (item.source?.scope ?? "session") === (source?.scope ?? "session") && (item.source?.attempt_id ?? null) === (source?.attempt_id ?? null));
|
|
46
48
|
const fromModel = text(previousModel) ?? previousKnown?.observed.model ?? null;
|
|
47
|
-
const
|
|
49
|
+
const previousRouting = events.findLast(item => item.harness === harness && item.session_id === sessionId && item.evidence === evidence && item.observed?.provider
|
|
50
|
+
&& (item.source?.scope ?? "session") === (source?.scope ?? "session") && (item.source?.attempt_id ?? null) === (source?.attempt_id ?? null));
|
|
51
|
+
const changed = Boolean(profile.model && fromModel && (profile.model !== fromModel || profile.provider && previousRouting && profile.provider !== previousRouting.observed.provider));
|
|
48
52
|
const value = { schema_id: "dd-flow/model-observation@1", sequence: events.length + 1,
|
|
49
53
|
harness, session_id: sessionId, parent_session_id: parentSessionId,
|
|
50
54
|
kind: changed ? "model_changed" : !profile.model ? "model_observation_unavailable" : "model_observed",
|
|
@@ -76,19 +80,22 @@ export function modelAttribution(events, expectedSessions = []) {
|
|
|
76
80
|
const missingSessions = expectedSessions.filter(session => typeof session === "string" ? !observedIds.has(session)
|
|
77
81
|
: !sessions.has(`${session.harness}:${session.session_id}`));
|
|
78
82
|
const models = [...new Set(events.map(event => event.observed?.model).filter(Boolean))];
|
|
83
|
+
const providers = [...new Set(events.map(event => event.observed?.provider).filter(Boolean))];
|
|
79
84
|
const incomplete = !events.length || missingSessions.length > 0 || [...sessions.values()].some(session => {
|
|
80
85
|
const known = session.observations.filter(event => event.observed?.model);
|
|
81
86
|
if (!known.length) return true;
|
|
82
87
|
const knownScopes = new Set(known.map(event => `${event.evidence}:${event.source?.scope ?? "session"}:${event.source?.attempt_id ?? ""}:${event.source?.turn_id ?? ""}`));
|
|
83
88
|
return session.observations.some(event => {
|
|
84
|
-
if (event.evidence !== "unavailable") return false;
|
|
85
89
|
if (event.source?.non_asserting) return false;
|
|
90
|
+
if (["configured", "response"].includes(event.evidence) && !event.observed?.model) return true;
|
|
91
|
+
if (event.evidence !== "unavailable") return false;
|
|
86
92
|
const scope = event.source?.scope;
|
|
87
93
|
return !scope || !knownScopes.has(`configured:${scope}:${event.source?.attempt_id ?? ""}:${event.source?.turn_id ?? ""}`)
|
|
88
94
|
&& !knownScopes.has(`response:${scope}:${event.source?.attempt_id ?? ""}:${event.source?.turn_id ?? ""}`);
|
|
89
95
|
});
|
|
90
96
|
});
|
|
91
|
-
return { models, mixed: models.length > 1 || events.some(event => event.observed?.model && event.requested?.model && event.observed.model !== event.requested.model
|
|
97
|
+
return { models, providers, mixed: models.length > 1 || providers.length > 1 || events.some(event => event.observed?.model && event.requested?.model && event.observed.model !== event.requested.model
|
|
98
|
+
|| event.observed?.provider && event.requested?.provider && event.observed.provider !== event.requested.provider),
|
|
92
99
|
observation_completeness: incomplete ? "incomplete" : "available_native_sources", transitions: events.filter(event => event.kind === "model_changed"),
|
|
93
100
|
sessions: [...sessions.values()], missing_sessions: missingSessions, usage_attribution: "session_totals_unattributed_to_model", note: "Native settings/events do not prove undisclosed server routing or per-model token costs." };
|
|
94
101
|
}
|
|
@@ -10,6 +10,7 @@ export class ToolObservations {
|
|
|
10
10
|
calls: Map<string, ToolEvent>;
|
|
11
11
|
turns: Map<string, { session: { harness_id: string; session_id: string }; turn_id: string; started: boolean; completed: boolean }>;
|
|
12
12
|
gaps: Set<string>;
|
|
13
|
+
sources: Map<string, ToolEvent>;
|
|
13
14
|
add(event: ToolEvent | null): void;
|
|
14
15
|
observeTurn(event: { session: { harness_id: string; session_id: string }; turn_id: string; started?: boolean; completed?: boolean }): void;
|
|
15
16
|
summary(sessionId?: string, cursor?: Set<string>): { total: number; failures: number; pending: number; by_tool: Record<string, number>; completeness: string; outcome_completeness: string; sessions: Array<{ session: { harness_id: string; session_id: string }; total: number; failures: number; pending: number; by_tool: Record<string, number> }>; reasons: string[] };
|
|
@@ -3,7 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
/** Canonical physical-session tool accounting. Native decoding lives below;
|
|
4
4
|
* consumers never have to inspect provider envelopes or aggregate tokens. */
|
|
5
5
|
export class ToolObservations {
|
|
6
|
-
constructor() { this.calls = new Map(); this.turns = new Map(); this.gaps = new Set(); }
|
|
6
|
+
constructor() { this.calls = new Map(); this.turns = new Map(); this.gaps = new Set(); this.transcriptSessions = new Set(); this.sources = new Map(); }
|
|
7
7
|
observeTurn(event) {
|
|
8
8
|
if (!event?.session?.harness_id || !event.session.session_id || !event.turn_id) { this.gaps.add("turn_identity_missing"); return; }
|
|
9
9
|
const key = JSON.stringify([event.session, event.turn_id]);
|
|
@@ -13,10 +13,19 @@ export class ToolObservations {
|
|
|
13
13
|
}
|
|
14
14
|
add(event) {
|
|
15
15
|
if (!event) return;
|
|
16
|
-
if (event.reason)
|
|
16
|
+
if (event.reason) this.gaps.add(event.reason);
|
|
17
|
+
if (event.source_observed && event.session?.harness_id && event.session?.session_id) { this.sources.set(JSON.stringify(event.session), event); return; }
|
|
18
|
+
if (event.reason && !event.call_id) return;
|
|
17
19
|
const { harness_id, session_id } = event.session ?? {};
|
|
18
20
|
if (!harness_id || !session_id || !event.call_id) { this.gaps.add("tool_identity_missing"); return; }
|
|
19
21
|
const key = JSON.stringify([harness_id, session_id, event.epoch ?? "", event.call_id]);
|
|
22
|
+
const physical = JSON.stringify([harness_id, session_id]);
|
|
23
|
+
if (event.source?.channel === "agy.native_transcript") {
|
|
24
|
+
this.transcriptSessions.add(physical);
|
|
25
|
+
for (const [oldKey, call] of this.calls) if (JSON.stringify([call.session.harness_id, call.session.session_id]) === physical && call.source?.channel !== "agy.native_transcript") {
|
|
26
|
+
this.calls.delete(oldKey); this.gaps.add("agy_live_transcript_correlation_unproven");
|
|
27
|
+
}
|
|
28
|
+
} else if (this.transcriptSessions.has(physical)) { this.gaps.add("agy_live_transcript_correlation_unproven"); return; }
|
|
20
29
|
const old = this.calls.get(key);
|
|
21
30
|
// Replay and late starts cannot undo a terminal outcome. Conflicting
|
|
22
31
|
// terminal facts remain visible rather than silently choosing success.
|
|
@@ -28,12 +37,12 @@ export class ToolObservations {
|
|
|
28
37
|
: old?.status === "unknown" || event.status === "unknown" ? "unknown" : "running" });
|
|
29
38
|
}
|
|
30
39
|
summary(sessionId, cursor) {
|
|
31
|
-
const by_tool =
|
|
40
|
+
const by_tool = Object.create(null), sessions = new Map(), reasons = new Set(this.gaps); let total = 0, failures = 0, pending = 0;
|
|
32
41
|
for (const [key, value] of this.calls) {
|
|
33
42
|
if (sessionId !== undefined && value.session.session_id !== sessionId || cursor?.has(key)) continue;
|
|
34
43
|
const id = JSON.stringify(value.session);
|
|
35
44
|
if (value.name === "unknown") reasons.add("tool_start_missing");
|
|
36
|
-
const session = sessions.get(id) ?? { session: value.session, parent_session: value.parent_session ?? null, total: 0, failures: 0, pending: 0, by_tool:
|
|
45
|
+
const session = sessions.get(id) ?? { session: value.session, parent_session: value.parent_session ?? null, total: 0, failures: 0, pending: 0, by_tool: Object.create(null) };
|
|
37
46
|
total++; session.total++; by_tool[value.name] = (by_tool[value.name] ?? 0) + 1; session.by_tool[value.name] = (session.by_tool[value.name] ?? 0) + 1;
|
|
38
47
|
if (value.status === "failed") { failures++; session.failures++; }
|
|
39
48
|
if (!["completed", "failed"].includes(value.status)) { pending++; session.pending++; }
|
|
@@ -45,6 +54,10 @@ export class ToolObservations {
|
|
|
45
54
|
sessions.set(id, sessions.get(id) ?? { session: turn.session, parent_session: null, total: 0, failures: 0, pending: 0, by_tool: {} });
|
|
46
55
|
if (!turn.started || !turn.completed) reasons.add("turn_lifecycle_incomplete");
|
|
47
56
|
}
|
|
57
|
+
if (!cursor) for (const [id, event] of this.sources) {
|
|
58
|
+
if (sessionId !== undefined && event.session.session_id !== sessionId) continue;
|
|
59
|
+
sessions.set(id, sessions.get(id) ?? { session: event.session, parent_session: event.parent_session ?? null, total: 0, failures: 0, pending: 0, by_tool: {} });
|
|
60
|
+
}
|
|
48
61
|
return { schema_id: "dd-flow/tool-summary@1", scope: "physical_sessions", measurement: "observed_invocations", origin: "native_tool_events", aggregation: cursor ? "delta" : "cumulative", total, failures, pending, by_tool,
|
|
49
62
|
sessions: [...sessions.values()], completeness: reasons.size ? "partial" : "complete", outcome_completeness: pending ? "partial" : "complete", reasons: [...reasons] };
|
|
50
63
|
}
|
|
@@ -56,7 +69,7 @@ export class ToolObservations {
|
|
|
56
69
|
export function journalToolObservations(file, events, previous) {
|
|
57
70
|
const batch = new ToolObservations();
|
|
58
71
|
for (const event of events) batch.add(event);
|
|
59
|
-
const records = [...batch.calls.values(), ...[...batch.gaps].map(reason => ({ reason }))];
|
|
72
|
+
const records = [...batch.calls.values(), ...batch.sources.values(), ...[...batch.gaps].map(reason => ({ reason }))];
|
|
60
73
|
for (const event of records) {
|
|
61
74
|
const key = JSON.stringify([event.session, event.epoch, event.call_id, event.reason]);
|
|
62
75
|
const encoded = JSON.stringify(event);
|
|
@@ -67,7 +80,9 @@ export function journalToolObservations(file, events, previous) {
|
|
|
67
80
|
}
|
|
68
81
|
|
|
69
82
|
const identity = (harness_id, session_id) => ({ harness_id, session_id });
|
|
70
|
-
const status = value => ["error", "failed", "ERROR"].includes(value) ? "failed" : ["completed", "success", "DONE"].includes(value) ? "completed"
|
|
83
|
+
const status = value => ["error", "failed", "ERROR"].includes(value) ? "failed" : ["completed", "success", "DONE"].includes(value) ? "completed"
|
|
84
|
+
: value === undefined || value === null || ["running", "pending", "in_progress", "inProgress", "RUNNING", "PENDING", "IN_PROGRESS"].includes(value) ? "running" : "unknown";
|
|
85
|
+
const nativeStatus = value => ({ status: status(value), ...(status(value) === "unknown" ? { reason: "tool_status_unsupported" } : {}) });
|
|
71
86
|
export function zcodePhysicalSession(update, rootSessionId) {
|
|
72
87
|
const native = update?._meta?.zcodeRuntime ?? {};
|
|
73
88
|
if (native.source === "subagent" && !native.childSessionId) return null;
|
|
@@ -84,7 +99,7 @@ export function acpToolObservation(message, { harness = "zcode-acp", rootSession
|
|
|
84
99
|
const session = harness === "zcode-acp" ? zcodePhysicalSession(update, root) : harness === "grok-acp" ? transport : root;
|
|
85
100
|
if (!session || !update.toolCallId) return { reason: "tool_identity_missing" };
|
|
86
101
|
return { session: identity(harness, session), parent_session: session !== root ? identity(harness, root) : null,
|
|
87
|
-
transport_session_id: transport, call_id: update.toolCallId, name: update._meta?.["x.ai/tool"]?.name ?? update._meta?.claudeCode?.toolName ?? update.title?.split(":", 1)[0],
|
|
102
|
+
transport_session_id: transport, call_id: update.toolCallId, name: update._meta?.["x.ai/tool"]?.name ?? update._meta?.claudeCode?.toolName ?? update.title?.split(":", 1)[0], ...nativeStatus(update.status) };
|
|
88
103
|
}
|
|
89
104
|
|
|
90
105
|
export function droidToolObservations(record, sessionId) {
|
|
@@ -130,15 +145,41 @@ export function codexAppServerObservation(message) {
|
|
|
130
145
|
export function openCodeToolObservation(part, sessionId, messageId) {
|
|
131
146
|
if (part.type !== "tool") return null;
|
|
132
147
|
return { session: identity("opencode-server", part.sessionID ?? sessionId), call_id: part.callID ?? part.id,
|
|
133
|
-
epoch: messageId, name: part.tool,
|
|
148
|
+
epoch: messageId, name: part.tool, ...nativeStatus(part.state?.status) };
|
|
134
149
|
}
|
|
135
150
|
|
|
136
151
|
export function agyToolObservation(step, rootSessionId) {
|
|
137
|
-
if (step.step_type !== "tool") return null;
|
|
152
|
+
if (step.step_type !== "tool" && !(step.step_type === "subagent" && step.tool_name === "invoke_subagent")) return null;
|
|
138
153
|
const sessionId = step.conversation_id ?? rootSessionId;
|
|
139
154
|
return { session: identity("antigravity-cli", sessionId), parent_session: sessionId !== rootSessionId ? identity("antigravity-cli", rootSessionId) : null,
|
|
140
155
|
call_id: step.step_id ?? step.stepId ?? step.id ?? step.tool_call_id ?? step.toolCallId ?? (Number.isInteger(step.step_index) ? String(step.step_index) : null),
|
|
141
|
-
name: step.tool_name ?? step.tool_info?.name,
|
|
156
|
+
name: step.tool_name ?? step.tool_info?.name, ...(step.tool_info?.error ? { status: "failed" } : nativeStatus(step.state)) };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Native AGY JSONL records have planner tool invocations, not matching live
|
|
160
|
+
* step IDs. GENERIC result text is deliberately not guessed into success. */
|
|
161
|
+
export function agyTranscriptObservations(text, sessionId, parentSessionId, source = {}) {
|
|
162
|
+
const events = [], lines = text.split("\n"); let recognized = false;
|
|
163
|
+
const session = identity("antigravity-cli", sessionId);
|
|
164
|
+
for (const [line, bytes] of lines.entries()) {
|
|
165
|
+
if (!bytes.trim()) continue;
|
|
166
|
+
let record; try { record = JSON.parse(bytes); } catch { events.push({ session, reason: line === lines.length - 1 ? "agy_transcript_partial_tail" : "agy_transcript_invalid_record" }); continue; }
|
|
167
|
+
if (!record || typeof record !== "object" || Array.isArray(record)) { events.push({ session, reason: "agy_transcript_shape_unsupported" }); continue; }
|
|
168
|
+
if (record.conversation_id && record.conversation_id !== sessionId) { events.push({ session, reason: "agy_transcript_session_mismatch" }); continue; }
|
|
169
|
+
if (!["SYSTEM_MESSAGE", "USER_INPUT", "PLANNER_RESPONSE", "GENERIC", "TOOL_RESPONSE", "CHECKPOINT"].includes(record.type)) { events.push({ session, reason: "agy_transcript_shape_unsupported" }); continue; }
|
|
170
|
+
recognized = true;
|
|
171
|
+
if (record.tool_calls !== undefined && !Array.isArray(record.tool_calls)) events.push({ session, reason: "agy_transcript_shape_unsupported" });
|
|
172
|
+
if (record.truncated || record.truncation || typeof record.content === "string" && /\[.*truncat|<.*truncat/i.test(record.content)) events.push({ session, reason: "agy_transcript_truncated" });
|
|
173
|
+
for (const [index, call] of (Array.isArray(record.tool_calls) ? record.tool_calls : []).entries()) {
|
|
174
|
+
if (!Number.isInteger(record.step_index) || !call || typeof call.name !== "string" || !call.name) { events.push({ session, reason: "agy_transcript_tool_identity_missing" }); continue; }
|
|
175
|
+
const id = call.id ?? call.tool_call_id;
|
|
176
|
+
events.push({ session, parent_session: identity("antigravity-cli", parentSessionId), epoch: "native_transcript", call_id: id ?? `${record.step_index}:${index}`, name: call.name,
|
|
177
|
+
status: "unknown", reason: "agy_transcript_tool_outcome_unproven", source: { ...source, channel: "agy.native_transcript", line: line + 1, step_index: record.step_index } });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (!recognized) events.push({ session, reason: "agy_transcript_shape_unsupported" });
|
|
181
|
+
else events.push({ session, parent_session: identity("antigravity-cli", parentSessionId), source_observed: true, source: { ...source, channel: "agy.native_transcript" } });
|
|
182
|
+
return events;
|
|
142
183
|
}
|
|
143
184
|
|
|
144
185
|
/** Read-only replay, shared by accounting and offline diagnosis. A journal
|
|
@@ -150,7 +191,7 @@ export function replayToolJournal(file, options, reducer = new ToolObservations(
|
|
|
150
191
|
for (const [index, line] of lines.entries()) {
|
|
151
192
|
if (!line.trim()) continue;
|
|
152
193
|
let e; try { e = JSON.parse(line); } catch { reducer.gaps.add(index === lines.length - 1 ? "journal_partial_tail" : "journal_invalid_record"); continue; }
|
|
153
|
-
const add = event => { if (event) reducer.add({ ...event, source: { journal: file,
|
|
194
|
+
const add = event => { if (event) reducer.add({ ...event, source: { ...event.source, journal: file, journal_line: index + 1 }, observed_at: e.observed_at ?? e.timestamp ?? e.at ?? e.ts ?? null }); };
|
|
154
195
|
if (e.kind === "tool_observation" && e.schema_id === "dd-flow/tool-observation@1") { add(e.event); continue; }
|
|
155
196
|
if (e.kind === "notification_failure") reducer.gaps.add("notification_failure");
|
|
156
197
|
const m = e.payload ?? e;
|
|
@@ -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 */ } }
|