@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +23 -0
  3. package/dist/build-info.json +4 -4
  4. package/dist/cli/run-cli.js +3 -0
  5. package/dist/domain/stage-catalog.js +1 -1
  6. package/dist/harness-runtime/lib/codex-capacity-policy.d.mts +10 -0
  7. package/dist/harness-runtime/lib/codex-capacity-policy.mjs +89 -0
  8. package/dist/harness-runtime/lib/daemon-operations.mjs +100 -3
  9. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +117 -21
  10. package/dist/harness-runtime/lib/dd-agy.mjs +13 -3
  11. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +58 -13
  12. package/dist/harness-runtime/lib/dd-codex.mjs +16 -26
  13. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +19 -12
  14. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +42 -10
  15. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +57 -14
  16. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +25 -23
  17. package/dist/harness-runtime/lib/dd-zcode.mjs +24 -14
  18. package/dist/harness-runtime/lib/driver-recovery.mjs +12 -2
  19. package/dist/harness-runtime/lib/managed-daemon.d.mts +2 -0
  20. package/dist/harness-runtime/lib/managed-daemon.mjs +100 -31
  21. package/dist/harness-runtime/lib/model-observations.mjs +16 -9
  22. package/dist/harness-runtime/lib/tool-observations.d.mts +1 -0
  23. package/dist/harness-runtime/lib/tool-observations.mjs +52 -11
  24. package/dist/schemas/flow-run.schema.json +1 -0
  25. package/dist/schemas/plan-review-decision.schema.json +3 -3
  26. package/dist/services/code-checks.js +14 -18
  27. package/dist/services/codex-capacity.js +251 -31
  28. package/dist/services/controller-fanout.js +1 -0
  29. package/dist/services/eval-snapshots.js +13 -8
  30. package/dist/services/external-work-launch.js +36 -11
  31. package/dist/services/harness-adapter.js +21 -15
  32. package/dist/services/managed-processes.js +14 -6
  33. package/dist/services/merge-server.js +35 -12
  34. package/dist/services/review-copy.js +21 -0
  35. package/dist/services/review-deferral.js +23 -0
  36. package/dist/services/run-controller-adapter.js +33 -8
  37. package/dist/services/run-controller-recovery.js +26 -2
  38. package/dist/services/run-controller.js +91 -24
  39. package/dist/services/run-observations.js +7 -2
  40. package/dist/services/run-recovery-runtime.js +49 -3
  41. package/dist/services/runs.js +1 -1
  42. package/dist/services/stage-report-renderer.js +25 -2
  43. package/dist/services/verification-matrix.js +57 -8
  44. package/dist/services/vnext-code-review.js +22 -8
  45. package/dist/services/vnext-merge.js +20 -2
  46. package/dist/services/vnext-plan-review.js +87 -19
  47. package/dist/services/work-registry.js +11 -2
  48. 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 { stopProcessGroup } from "./managed-daemon.mjs";
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 observed = { model: value("model"), reasoning: value("thought_level"), mode: value("mode") };
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 Promise.allSettled([...this.lifecycleNotifications, this.notifications]);
425
- if (this.child) {
426
- this.child.stdin.end();
427
- await Promise.race([new Promise((resolve) => this.child.once("exit", resolve)), new Promise((resolve) => setTimeout(resolve, 2000))]);
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
- provider: typeof model.providerId === "string" ? model.providerId : null,
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
- finally { await bridge.close(); }
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.shutdown_state !== "clean" || previous.active_tree === true || 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"), { code: "daemon_state_terminal" });
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.shutdown_state !== "clean" || state.active_tree === true) throw Object.assign(new Error("provider tree has no clean shutdown receipt"), { code: "recovery_settlement_unconfirmed" });
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) {
@@ -0,0 +1,2 @@
1
+ import type { ChildProcess } from "node:child_process";
2
+ export function stopProcessGroup(child: ChildProcess | null | undefined, graceMs?: number, options?: { detached?: boolean }): Promise<void>;
@@ -286,42 +286,111 @@ export async function cleanupFailedDaemonStart(config, daemonRecord, daemonChild
286
286
  }
287
287
  }
288
288
 
289
- export async function stopProcessGroup(child, graceMs = 1_000) {
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
- // Harness bridges are detached on Unix, so their own process group is the
292
- // smallest reliable unit of cleanup. Windows has no equivalent negative-PID
293
- // signal operation; there the direct child is the safe best effort.
294
- const target = process.platform === "win32" ? child.pid : -child.pid;
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
- if ((child.exitCode !== undefined && child.exitCode !== null) || (child.signalCode !== undefined && child.signalCode !== null)) {
298
- // The leader can exit a few ticks before its helpers. Observe their normal
299
- // shutdown before failing ownership; never signal a leaderless group.
300
- const deadline = Date.now() + Math.max(1_000, graceMs);
301
- while (alive() && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 25));
302
- if (!alive()) return;
303
- 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: { pid: child.pid } });
304
- }
305
- process.kill(target, "SIGTERM");
306
- await new Promise((resolve) => setTimeout(resolve, graceMs));
307
- if (!alive()) return;
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 result = await request();
320
- const deadline = Date.now() + timeoutMs;
321
- while (Date.now() < deadline) {
322
- try { await lstat(socket); }
323
- catch (error) { if (error.code === "ENOENT") return result; throw error; }
324
- await new Promise((resolve) => setTimeout(resolve, 25));
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 channel = source?.channel ?? evidence;
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) return previous;
45
- const previousKnown = events.findLast(item => item.harness === harness && item.session_id === sessionId && item.evidence === evidence && item.observed?.model);
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 changed = Boolean(profile.model && fromModel && profile.model !== fromModel);
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) { this.gaps.add(event.reason); return; }
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 = {}, sessions = new Map(), reasons = new Set(this.gaps); let total = 0, failures = 0, pending = 0;
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" : "running";
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], status: status(update.status) };
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, status: status(part.state?.status) };
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, status: step.tool_info?.error ? "failed" : status(step.state) };
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, line: index + 1 }, observed_at: e.observed_at ?? e.timestamp ?? e.at ?? e.ts ?? null }); };
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",
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@3"},
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
- finishManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, state: result.aborted || result.exitCode !== 0 || Boolean(result.error) ? "failed" : "stopped", reason: result.error });
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 escalation;
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
- if (child.pid) {
839
- terminateProcessGroup(child.pid, "SIGTERM");
840
- escalation = setTimeout(() => terminateProcessGroup(child.pid, "SIGKILL"), 5_000);
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
- if (escalation)
851
- clearTimeout(escalation);
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 */ } }