@deksden-com/dd-flow-cli 0.9.0-beta.149 → 0.9.0-beta.150

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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.9.0-beta.150
4
+
5
+ ### Patch Changes
6
+
7
+ - Retain bound test-runtime failure diagnostics, reconcile AGY native task
8
+ completion and cancellation without model calls, keep cleanup retry available
9
+ until owned resources settle, and disable auto-update for managed AGY launches.
10
+
3
11
  ## 0.9.0-beta.149
4
12
 
5
13
  ### Patch Changes
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.149",
4
- "cli_commit": "7d17167f768483ffdb5d37f64cc385035c7d4658",
5
- "built_at": "2026-10-24T10:44:58.080Z",
3
+ "cli_version": "0.9.0-beta.150",
4
+ "cli_commit": "ad05c70447949f295e588546fe27f6abc4640c3f",
5
+ "built_at": "2026-10-24T17:47:48.856Z",
6
6
  "built_with_canon": {
7
7
  "version": "4.1.2",
8
8
  "commit": "925ef10ecc0b683f7feba6f74913e15560842500",
@@ -65,7 +65,7 @@ function ownedSnapshot(snapshot, rootPid) {
65
65
  const mergeProcesses = (...snapshots) => [...new Map(snapshots.flat().map(item => [`${item.pid}:${item.started}`, item])).values()];
66
66
 
67
67
  /** The same incarnation may retry remaining cleanup, never a native Turn. */
68
- export async function completeDaemonShutdown(root, state, persist, prepare, phases) {
68
+ export async function completeDaemonShutdown(root, state, persist, prepare, phases, reconcileResources) {
69
69
  const daemonId = state.daemon_id ?? state.config?.daemonId;
70
70
  let shutdown = state.shutdown;
71
71
  if (shutdown && shutdown.schema_id !== NATIVE_SHUTDOWN_CONTRACT) throw Object.assign(new Error("shutdown contract mismatch; retain historical owner evidence"), { code: "native_contract_mismatch" });
@@ -84,10 +84,23 @@ export async function completeDaemonShutdown(root, state, persist, prepare, phas
84
84
  // Compute ancestry within each snapshot, not across recycled parent PIDs.
85
85
  const resources = mergeProcesses(shutdown.owned_processes, ownedSnapshot(await processSnapshot(), state.pid));
86
86
  shutdown = { ...shutdown, phases: { execution: true }, owned_processes: resources,
87
- inventory_complete: state.pid === process.pid, tree_observed_at: new Date().toISOString(), result: result ?? {} };
87
+ inventory_complete: state.pid === process.pid, tree_observed_at: new Date().toISOString(), preparation_clean: result?.clean !== false, result: result ?? {} };
88
88
  await persist({ shutdown, shutdown_state: "stopping" });
89
89
  }
90
90
  commitDaemonShutdown(root);
91
+ if (reconcileResources && !shutdown.phases.owned_resources) {
92
+ const revision = state.native_execution_revision, generation = dispatchGenerations.get(root) ?? shutdown.generation;
93
+ const resources = await reconcileResources();
94
+ const prior = shutdown.result?.native_resources;
95
+ if ((state.daemon_id ?? state.config?.daemonId) !== daemonId || state.native_execution_revision !== revision
96
+ || (dispatchGenerations.get(root) ?? shutdown.generation) !== generation || state.active_tree !== false || state.unclaimed_activity
97
+ || !resources || resources.schema_id !== 'native-resources@1' || resources.owner_daemon_id !== daemonId
98
+ || resources.generation !== prior?.generation || resources.revision !== prior?.revision || !Array.isArray(resources.entries)
99
+ || resources.entries.some(item => item.owner_daemon_id !== daemonId || item.generation !== resources.generation))
100
+ throw Object.assign(new Error('native resource reconciliation lost its owner/execution fence'), { code: 'native_resources_unproven' });
101
+ shutdown = { ...shutdown, result: { ...shutdown.result, native_resources: resources } };
102
+ await persist({ shutdown });
103
+ }
91
104
  for (const [phase, action] of phases) {
92
105
  if (phase === "daemon_resource" && !shutdown.phases.owned_resources) {
93
106
  if (!shutdown.inventory_complete) throw Object.assign(new Error("owned resource coverage is unknown"), { code: "native_resources_unproven" });
@@ -99,12 +112,16 @@ export async function completeDaemonShutdown(root, state, persist, prepare, phas
99
112
  const remaining = await processSnapshot();
100
113
  if ((shutdown.owned_processes ?? []).some(retained => remaining.some(item => item.pid === retained.pid && item.started === retained.started && !item.zombie))) throw Object.assign(new Error("owned resources remain live"), { code: "native_resources_unproven" });
101
114
  const nativeResources = shutdown.result?.native_resources;
102
- const resourceGap = nativeResources?.entries?.some(resource => resource.state !== "stopped"
115
+ const resourceGap = nativeResources?.entries?.some(resource => resource.observation_conflict === true || resource.state !== "stopped"
103
116
  && !(resource.classification === "required_work" && shutdown.result?.native_settled === true && shutdown.result?.settled === true)
104
117
  && !(shutdown.owned_processes ?? []).some(item => resource.pid === item.pid && resource.started === item.started));
105
118
  shutdown = { ...shutdown, phases: { ...shutdown.phases, owned_resources: !resourceGap },
106
119
  resource_retirement: { confirmed: !resourceGap, reason: resourceGap ? "native_resource_identity_unproven" : "owned_processes_retired" } };
107
120
  await persist({ shutdown });
121
+ // Keep the technical cleanup owner/endpoint available for a later
122
+ // read-only reconciliation. Retiring its lease here would make the
123
+ // actual daemon exit before the retained resources can converge.
124
+ if (resourceGap) throw Object.assign(new Error("native resource retirement remains unproven"), { code: "native_resources_unproven" });
108
125
  }
109
126
  if (shutdown.phases[phase]) continue;
110
127
  await action();
@@ -120,7 +137,7 @@ export async function completeDaemonShutdown(root, state, persist, prepare, phas
120
137
  shutdown = { ...shutdown, generation: dispatchGenerations.get(root) ?? shutdown.generation };
121
138
  const { result: ignored, ...proof } = shutdown; void ignored;
122
139
  let result = { ...shutdown.result, daemon_id: daemonId, stopped: true, physical_stopped: physicalStopped,
123
- settled: nativeSettled, native_settled: nativeSettled, clean: shutdown.result?.clean !== false && nativeSettled && physicalStopped,
140
+ settled: nativeSettled, native_settled: nativeSettled, clean: (shutdown.preparation_clean ?? shutdown.result?.clean !== false) && nativeSettled && physicalStopped,
124
141
  resource_retirement: shutdown.resource_retirement,
125
142
  shutdown_contract: NATIVE_SHUTDOWN_CONTRACT, shutdown_proof: proof, _shutdown: true };
126
143
  shutdown = { ...shutdown, result };
@@ -22,7 +22,7 @@ import { agyWorkspaceHooks } from "./agy-hooks.mjs";
22
22
  import { durableDaemonDispatch, completeDaemonShutdown, assertDaemonDispatch, assertDaemonRestartable, trackDaemonConnections, retireDaemonEndpoint, retireFailedDaemon } from "./daemon-operations.mjs";
23
23
  import path from "node:path";
24
24
  import { observeModel } from "./model-observations.mjs";
25
- import { AGY_VERSION_TIMEOUT_MS, AgyError, agyTerminalFailure, agyModelArgs, assertProfile, executable, observedProfile, runAgy, usageSnapshot } from "./dd-agy.mjs";
25
+ import { AGY_VERSION_TIMEOUT_MS, AgyError, agyTerminalFailure, agyModelArgs, assertProfile, executable, observedProfile, runAgy, usageSnapshot, managedAgyEnv } from "./dd-agy.mjs";
26
26
  import { recoveredAgyErrorBoundary } from "./agy-error-boundary.mjs";
27
27
  import { settledNativeTree, nativeExecutionReceipt } from "./native-children.mjs";
28
28
  import { retainStartupFailure, prepareRuntimeOwner, cleanupFailedStart, cleanupFailedDaemonStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, stopProcessGroup } from "./managed-daemon.mjs";
@@ -34,16 +34,43 @@ class DaemonError extends AgyError {}
34
34
  // classify services. This transcript format has no tool-call IDs: parallel
35
35
  // calls therefore retain unknown resources, never FIFO-derived authority.
36
36
  export function agyTranscriptFacts(text, source, sessionId) {
37
- const children = [], resources = [], calls = [];
38
- if (!source?.locator || !source?.sha256 || !sessionId) return { children, resources };
39
- for (const line of text.trim().split('\n')) {
37
+ const children = [], resources = new Map(), calls = [];
38
+ const result = () => ({ children, resources: [...resources.values()] });
39
+ if (!source?.locator || !source?.sha256 || !sessionId) return result();
40
+ const lines = text.split('\n');
41
+ for (let index = 0; index < lines.length; index++) {
42
+ const line = lines[index];
43
+ if (!line && index === lines.length - 1) continue;
40
44
  let event;
41
- try { event = JSON.parse(line); } catch { return { children: [], resources: [] }; }
45
+ try { event = JSON.parse(line); } catch {
46
+ // A writer may have appended only part of the last record. Retain the
47
+ // valid prefix; a malformed newline-terminated record is a real gap.
48
+ return index === lines.length - 1 ? result() : { ...result(), gap: 'agy_transcript_invalid_record' };
49
+ }
50
+ if (!event || typeof event !== 'object' || Array.isArray(event)) return { ...result(), gap: 'agy_transcript_invalid_record' };
51
+ const content = typeof event.content === 'string' ? event.content : '';
52
+ const native_source = { ...source, step_index: event.step_index };
53
+ if (event.source === 'SYSTEM' && event.type === 'SYSTEM_MESSAGE' && event.status === 'DONE') {
54
+ const terminal = content.match(/(?:^|\n)\[Message\] timestamp=[^\n]+ sender=([\w-]+\/task-\d+) priority=MESSAGE_PRIORITY_(?:HIGH|LOW) content=Task id "([\w-]+\/task-\d+)" (finished with result:\nCommand exited with code (-?\d+)\.|was canceled with result:\nTool execution was canceled)(?:\n|$)/);
55
+ if (terminal && terminal[1] === terminal[2]) {
56
+ const prior = resources.get(terminal[1]);
57
+ if (prior) resources.set(prior.resource_id, mergeAgyResource(prior, { ...prior, state: 'stopped', native_outcome: terminal[4] === undefined ? 'cancelled' : Number(terminal[4]) === 0 ? 'completed' : 'failed', terminal_source: native_source }));
58
+ }
59
+ continue;
60
+ }
42
61
  if (event.source !== 'MODEL') continue;
43
62
  if (event.type === 'PLANNER_RESPONSE') { calls.length = 0; if (Array.isArray(event.tool_calls) && event.tool_calls.length === 1) calls.push(event.tool_calls[0]); continue; }
44
63
  if (event.type !== 'GENERIC') continue;
45
- const call = calls.shift(), content = typeof event.content === 'string' ? event.content : '';
46
- const native_source = { ...source, step_index: event.step_index };
64
+ const call = calls.shift();
65
+ if (call?.name === 'manage_task' && event.status === 'DONE') {
66
+ const decode = value => { try { return JSON.parse(value); } catch { return value; } };
67
+ const id = decode(call.args?.TaskId), action = decode(call.args?.Action);
68
+ const prior = resources.get(id);
69
+ if (prior && action === 'kill' && content.match(/^Created At: [^\n]+\nCompleted At: [^\n]+\nTask "([\w-]+\/task-\d+)" cancelled\.(?:\n|$)/)?.[1] === id)
70
+ resources.set(id, mergeAgyResource(prior, { ...prior, state: 'stopped', native_outcome: 'cancelled', terminal_source: native_source }));
71
+ const status = action === 'status' && content.match(/^Created At: [^\n]+\nCompleted At: [^\n]+\nTask: ([\w-]+\/task-\d+)\nStatus: (COMPLETED|FAILED|CANCELLED)\n/);
72
+ if (prior && status?.[1] === id) resources.set(id, mergeAgyResource(prior, { ...prior, state: 'stopped', native_outcome: status[2].toLowerCase(), terminal_source: native_source }));
73
+ }
47
74
  if (call?.name === 'invoke_subagent' && event.status === 'DONE') {
48
75
  const match = content.match(/^Created At: [^\n]+\nCompleted At: [^\n]+\nCreated the following subagent:\n(\{[\s\S]*?\})\nThe subagent will send you a message/);
49
76
  if (!match) continue;
@@ -57,10 +84,28 @@ export function agyTranscriptFacts(text, source, sessionId) {
57
84
  if (!task || !task[1].startsWith(`${sessionId}/`)) continue;
58
85
  let daemon = call?.name === 'run_command' ? call.args?.IsDaemon : null;
59
86
  if (typeof daemon === 'string') { try { daemon = JSON.parse(daemon); } catch { daemon = null; } }
60
- resources.push({ resource_id: task[1], provider_session_id: sessionId, classification: daemon === true ? 'support_service' : daemon === false ? 'required_work' : 'unknown', state: 'unknown', native_source,
61
- ...(typeof daemon === 'boolean' ? { native_classification: { tool: 'run_command', IsDaemon: daemon } } : {}) });
87
+ const resource = { resource_id: task[1], provider_session_id: sessionId, classification: daemon === true ? 'support_service' : daemon === false ? 'required_work' : 'unknown', state: 'unknown', native_source,
88
+ ...(typeof daemon === 'boolean' ? { native_classification: { tool: 'run_command', IsDaemon: daemon } } : {}) };
89
+ resources.set(resource.resource_id, mergeAgyResource(resources.get(resource.resource_id), resource));
62
90
  }
63
- return { children, resources };
91
+ return result();
92
+ }
93
+ export function mergeAgyResource(prior, next) {
94
+ if (!prior) return next;
95
+ if (prior.provider_session_id !== next.provider_session_id || prior.native_source?.locator !== next.native_source?.locator)
96
+ return { ...prior, state: 'unknown', observation_conflict: true };
97
+ if (prior.native_source?.step_index !== next.native_source?.step_index)
98
+ return { ...prior, state: 'unknown', observation_conflict: true };
99
+ if (prior.observation_conflict || next.observation_conflict || prior.native_outcome && next.native_outcome && prior.native_outcome !== next.native_outcome)
100
+ return { ...prior, state: 'unknown', observation_conflict: true };
101
+ if (next.native_outcome && (!Number.isSafeInteger(next.native_source?.step_index) || !Number.isSafeInteger(next.terminal_source?.step_index) || next.terminal_source.step_index <= next.native_source.step_index))
102
+ return { ...prior, state: 'unknown', observation_conflict: true };
103
+ if (prior.state === 'stopped' && next.state !== 'stopped') {
104
+ if (next.native_source?.step_index > (prior.terminal_source?.step_index ?? prior.native_source?.step_index))
105
+ return { ...prior, state: 'unknown', observation_conflict: true };
106
+ return prior;
107
+ }
108
+ return { ...prior, ...next };
64
109
  }
65
110
  export const agyTranscriptResources = (text, source, sessionId) => agyTranscriptFacts(text, source, sessionId).resources;
66
111
  export async function captureAgyTranscript(runtimeRoot, child, rootSessionId, ownedSessionIds = [rootSessionId], inspect = null) {
@@ -99,7 +144,7 @@ async function writeJson(file, value) { const temporary = `${file}.${process.pid
99
144
  async function readState(dir) { try { return JSON.parse(await readFile(locations(dir).state, "utf8")); } catch (error) { if (error.code === "ENOENT") return null; throw error; } }
100
145
  async function removeSocket(paths) { try { if (!(await lstat(paths.socket)).isSocket()) throw new DaemonError("unsafe_socket", "refusing to replace a non-socket daemon path"); await unlink(paths.socket); } catch (error) { if (error.code !== "ENOENT") throw error; } }
101
146
  const shell = value => `'${String(value).replaceAll("'", `'"'"'`)}'`;
102
- export function sanitizedEnv(config) { const env = { ...process.env, TMPDIR: config.temporary, DD_AGY_HOOK_NODE: process.execPath, DD_AGY_HOOK_ENTRY: config.entryPath, DD_AGY_STATE_DIR: config.stateDir, DD_AGY_DAEMON_ID: config.daemonId }; for (const key of ["GEMINI_API_KEY","GOOGLE_API_KEY","ANTHROPIC_API_KEY","OPENAI_API_KEY"]) env[key] = undefined; return { ...env, ...managedFlowEnv(config) }; }
147
+ export function sanitizedEnv(config) { const env = { ...process.env, TMPDIR: config.temporary, DD_AGY_HOOK_NODE: process.execPath, DD_AGY_HOOK_ENTRY: config.entryPath, DD_AGY_STATE_DIR: config.stateDir, DD_AGY_DAEMON_ID: config.daemonId }; for (const key of ["GEMINI_API_KEY","GOOGLE_API_KEY","ANTHROPIC_API_KEY","OPENAI_API_KEY"]) env[key] = undefined; return managedAgyEnv({ ...env, ...managedFlowEnv(config) }); }
103
148
  const errorPayload = errorRecord;
104
149
  async function forwardUsage(config, sessionId, result, tools, descendants) {
105
150
  if (!config.ddFlowBin || !config.projectRoot) return;
@@ -439,7 +484,8 @@ export class Runtime {
439
484
  const events = parent === root && root
440
485
  ? await captureAgyTranscript(this.paths.runtime, child, root, [root, ...ancestors], (text, source) => {
441
486
  const facts = agyTranscriptFacts(text, source, child.provider_session_id);
442
- for (const resource of facts.resources) this.nativeResources.set(resource.resource_id, resource);
487
+ for (const resource of facts.resources) this.nativeResources.set(resource.resource_id, mergeAgyResource(this.nativeResources.get(resource.resource_id), resource));
488
+ if (facts.gap) { this.state.native_topology_error = facts.gap; this.invalidateSettlement(root); }
443
489
  for (const discovered of facts.children) {
444
490
  const expected = path.join(this.paths.runtime, 'brain', discovered.provider_session_id, '.system_generated', 'logs', 'transcript.jsonl');
445
491
  const sourceBrain = path.resolve(path.dirname(fileURLToPath(source.locator)), '../../..');
@@ -1040,7 +1086,10 @@ async function dispatch(runtime, request) {
1040
1086
  }
1041
1087
  catch (error) { await runtime.rejectHook(error, params.event, params.payload ?? {}, current); throw error; }
1042
1088
  }
1043
- if (request.operation === "daemon.stop") return await completeDaemonShutdown(runtime.paths.dir, runtime.state, patch => runtime.persist(patch), () => runtime.prepareStop(params.cancelTree === true), [["provider", () => runtime.close(false)], ["daemon_resource", () => runtime.finishResource()]]);
1089
+ if (request.operation === "daemon.stop") return await completeDaemonShutdown(runtime.paths.dir, runtime.state, patch => runtime.persist(patch), () => runtime.prepareStop(params.cancelTree === true), [["provider", () => runtime.close(false)], ["daemon_resource", () => runtime.finishResource()]], async () => {
1090
+ await runtime.captureChildTools();
1091
+ return runtime.receipt().native_resources;
1092
+ });
1044
1093
  throw new DaemonError("unknown_operation", `unknown dd-agy daemon operation: ${request.operation}`);
1045
1094
  }
1046
1095
  import { authorizeRetainedDaemonResume } from "./driver-recovery.mjs";
@@ -8,6 +8,8 @@ import { classifyProviderError, safeProviderError, streamRequestKey } from "./pr
8
8
 
9
9
  const DEFAULT_MODEL = "gemini-3.1-pro-high";
10
10
  export const AGY_VERSION_TIMEOUT_MS = 30_000;
11
+ // Managed binaries are immutable qualification inputs, including doctor probes.
12
+ export const managedAgyEnv = (env = {}) => ({ ...process.env, ...env, AGY_CLI_DISABLE_AUTO_UPDATE: "true" });
11
13
 
12
14
  export class AgyError extends Error {
13
15
  constructor(code, message, retryable = false, details) { super(message); this.code = code; this.retryable = retryable; this.details = details; }
@@ -44,7 +46,7 @@ export function executable(bin, args = []) {
44
46
  }
45
47
 
46
48
  export async function runAgy(bin, args, options = {}) {
47
- const stdout = await commandText(bin, args, { cwd: options.cwd, env: options.env, input: options.stdin ?? "", timeoutMs: options.timeoutMs, startupMs: options.startupMs ?? options.timeoutMs ?? SETUP_STARTUP_MS, phase: "native-doctor" });
49
+ const stdout = await commandText(bin, args, { cwd: options.cwd, env: managedAgyEnv(options.env), input: options.stdin ?? "", timeoutMs: options.timeoutMs, startupMs: options.startupMs ?? options.timeoutMs ?? SETUP_STARTUP_MS, phase: "native-doctor" });
48
50
  return { stdout, stderr: "", code: 0 };
49
51
  }
50
52
 
@@ -40,7 +40,7 @@ export function runnerFailureValid(value) {
40
40
  if (value.repair_evidence !== undefined) {
41
41
  const proof = value.repair_evidence;
42
42
  if (object(proof) && proof.rule === "candidate_test_runtime_error")
43
- return ["TypeError", "ReferenceError"].includes(String(proof.exception_type))
43
+ return ["TypeError", "ReferenceError", "Error"].includes(String(proof.exception_type)) && (proof.exception_type !== "Error" || proof.phase === "test")
44
44
  && absoluteFile(proof.file) && hash(proof.file_sha256) && ["test", "setup", "collection"].includes(String(proof.phase))
45
45
  && [proof.line, proof.column].every(value => Number.isSafeInteger(value) && Number(value) > 0) && proofArtifact(proof.native_artifact);
46
46
  if (object(proof) && proof.rule === "candidate_migration_sql")
@@ -332,8 +332,33 @@ export function runnerContractsValid(value) {
332
332
  return alias.startsWith("@check/") && ["dd-flow/check-runner-evidence@1", "dd-flow/check-runner-evidence@2"].includes(String(contract.schema_id)) && typeof contract.path === "string" && contract.path.length > 0 && contract.path.split("/")[0] !== "engine-outcome.json" && !contract.path.includes("\\") && !path.isAbsolute(contract.path) && !contract.path.split("/").some(part => part === ".." || part === "" || part === ".");
333
333
  });
334
334
  }
335
+ function diagnostic(context, value) {
336
+ // Diagnostics are locators, never another copy of untrusted report content.
337
+ if (context.diagnostics.length >= 64 || context.bytes + Buffer.byteLength(JSON.stringify(value)) > 32 * 1024 - 64) {
338
+ context.truncated = true;
339
+ return;
340
+ }
341
+ context.diagnostics.push(value);
342
+ context.bytes += Buffer.byteLength(JSON.stringify(value));
343
+ }
344
+ export function readRunnerEvidenceDetailed(root, contract, binding) {
345
+ const context = { diagnostics: [], bytes: 0, truncated: false };
346
+ const evidence = readRunnerEvidenceInternal(root, contract, binding, 0, { reports: 0 }, context, "");
347
+ return { evidence, diagnostics: context.diagnostics, ...(context.truncated ? { diagnostics_truncated: true } : {}) };
348
+ }
335
349
  export function readRunnerEvidence(root, contract, binding, depth = 0, budget = { reports: 0 }) {
350
+ return readRunnerEvidenceInternal(root, contract, binding, depth, budget, { diagnostics: [], bytes: 0, truncated: false }, "");
351
+ }
352
+ function readRunnerEvidenceInternal(root, contract, binding, depth, budget, context, prefix) {
353
+ const report = relativeProofPath(contract.path) ? path.posix.join(prefix, contract.path) : "<invalid-report-path>";
354
+ const count = context.diagnostics.length;
355
+ let accepted = false;
356
+ let failureReason = "invalid_shape";
357
+ const invalid = () => { if (context.diagnostics.length === count)
358
+ diagnostic(context, { reason: failureReason, report }); return null; };
336
359
  try {
360
+ if (!relativeProofPath(contract.path))
361
+ return invalid();
337
362
  if (depth > 4 || ++budget.reports > 64)
338
363
  return null;
339
364
  let current = root;
@@ -344,15 +369,27 @@ export function readRunnerEvidence(root, contract, binding, depth = 0, budget =
344
369
  if (fs.lstatSync(current).isSymbolicLink())
345
370
  return null;
346
371
  }
347
- const evidence = JSON.parse(readBoundedProofFile(current, 1024 * 1024).toString("utf8"));
372
+ const bytes = readBoundedProofFile(current, 1024 * 1024);
373
+ failureReason = "invalid_json";
374
+ const evidence = JSON.parse(bytes.toString("utf8"));
375
+ failureReason = "invalid_shape";
376
+ if (!object(evidence))
377
+ return invalid();
348
378
  if (!["dd-flow/check-runner-evidence@1", "dd-flow/check-runner-evidence@2"].includes(contract.schema_id))
349
379
  return null;
350
380
  const partial = evidence.failure_projection;
351
381
  if (partial !== undefined && (evidence.schema_id !== "dd-flow/check-runner-evidence@2" || !object(partial) || partial.state !== "partial" || !["count_bound", "byte_bound", "interrupt"].includes(partial.reason)
352
382
  || ![partial.represented, partial.omitted].every(value => Number.isSafeInteger(value) && value >= 0)))
353
383
  return null;
354
- if (evidence.schema_id !== contract.schema_id || !evidence.binding || Object.entries(binding).some(([key, value]) => evidence.binding[key] !== value)
355
- || typeof evidence.runner?.name !== "string" || !evidence.runner.name || typeof evidence.runner.version !== "string" || !evidence.runner.version || typeof evidence.completed !== "boolean" || !evidence.counts
384
+ if (evidence.schema_id !== contract.schema_id) {
385
+ diagnostic(context, { reason: "invalid_shape", report, expected: contract.schema_id, observed: ["dd-flow/check-runner-evidence@1", "dd-flow/check-runner-evidence@2"].includes(evidence.schema_id) ? evidence.schema_id : "unsupported" });
386
+ return null;
387
+ }
388
+ if (!evidence.binding || Object.entries(binding).some(([key, value]) => evidence.binding[key] !== value)) {
389
+ diagnostic(context, { reason: "binding_mismatch", report });
390
+ return null;
391
+ }
392
+ if (typeof evidence.runner?.name !== "string" || !evidence.runner.name || typeof evidence.runner.version !== "string" || !evidence.runner.version || typeof evidence.completed !== "boolean" || !evidence.counts
356
393
  || ![evidence.counts.passed, evidence.counts.failed, evidence.counts.skipped].every(n => Number.isSafeInteger(n) && n >= 0)
357
394
  || !Array.isArray(evidence.failures) || evidence.failures.length > 64 || evidence.failures.some(f => !runnerFailureValid(f))
358
395
  || !partial && evidence.counts.failed > evidence.failures.length || evidence.observation_unknown !== undefined && typeof evidence.observation_unknown !== "boolean")
@@ -384,8 +421,10 @@ export function readRunnerEvidence(root, contract, binding, depth = 0, budget =
384
421
  if (crypto.createHash("sha256").update(step.binding.command).digest("hex") !== step.command_sha256)
385
422
  return null;
386
423
  invocations.add(step.binding.invocation_id);
424
+ failureReason = "artifact_mismatch";
387
425
  readProofArtifact(root, step.report, 1024 * 1024);
388
- const child = readRunnerEvidence(path.join(root, path.dirname(step.report.relative_path)), { schema_id: contract.schema_id, path: path.basename(step.report.relative_path) }, step.binding, depth + 1, budget);
426
+ failureReason = "invalid_shape";
427
+ const child = readRunnerEvidenceInternal(path.join(root, path.dirname(step.report.relative_path)), { schema_id: contract.schema_id, path: path.basename(step.report.relative_path) }, step.binding, depth + 1, budget, context, path.posix.join(prefix, path.posix.dirname(step.report.relative_path)));
389
428
  if (!child || step.state === "passed" && (child.counts.failed !== 0 || child.failures.length !== 0) || step.state === "failed" && !child.failures.length && child.completed && !child.observation_unknown)
390
429
  return null;
391
430
  if (!child.completed || child.observation_unknown)
@@ -397,6 +436,7 @@ export function readRunnerEvidence(root, contract, binding, depth = 0, budget =
397
436
  return null;
398
437
  }
399
438
  for (const failure of evidence.failures) {
439
+ failureReason = "artifact_mismatch";
400
440
  if (failure.child_evidence) {
401
441
  const ref = failure.child_evidence, step = evidence.steps?.find(item => item.id === ref.step_id), child = children.get(ref.step_id);
402
442
  if (!step || !child || !isDeepStrictEqual(ref.binding, step.binding) || !isDeepStrictEqual(ref.report, step.report))
@@ -423,6 +463,7 @@ export function readRunnerEvidence(root, contract, binding, depth = 0, budget =
423
463
  if (!isDeepStrictEqual(native.binding, binding) || Object.entries(facts).some(([key, value]) => !isDeepStrictEqual(native[key], value)))
424
464
  return null;
425
465
  if (proof.rule === "candidate_test_runtime_error") {
466
+ failureReason = "attribution_unproven";
426
467
  if (evidence.schema_id !== "dd-flow/check-runner-evidence@2" || native.executing_file !== proof.file || native.exception_name !== proof.exception_type
427
468
  || !isDeepStrictEqual(native.top_frame, { file: proof.file, line: proof.line, column: proof.column }) || !boundedString(native.stack, 16384) || !String(native.stack).startsWith(`${proof.exception_type}:`))
428
469
  return null;
@@ -447,6 +488,7 @@ export function readRunnerEvidence(root, contract, binding, depth = 0, budget =
447
488
  validatedBrowserProofs.add(proof);
448
489
  }
449
490
  }
491
+ failureReason = "invalid_shape";
450
492
  const represented = [...consumed.values()].reduce((total, used) => total + used.size, 0), childFailures = [...children.values()].reduce((total, child) => total + child.failures.length, 0);
451
493
  if (partial) {
452
494
  if (children.size ? partial.represented !== represented || partial.omitted !== childFailures - represented : partial.represented !== evidence.failures.filter(failure => failure.code !== "report_truncated").length)
@@ -466,10 +508,26 @@ export function readRunnerEvidence(root, contract, binding, depth = 0, budget =
466
508
  evidence.observation_unknown = true;
467
509
  }
468
510
  validatedReports.set(evidence, (evidence.steps ?? []).flatMap(step => step.report && children.has(step.id) ? [{ report: step.report, evidence: children.get(step.id) }] : []));
511
+ if (unsupportedObservationContracts.has(evidence))
512
+ diagnostic(context, { reason: "unsupported_observation_contract", report, expected: "dd-tasks/browser-observation@2", observed: "legacy" });
513
+ else if (!evidence.completed || evidence.observation_unknown)
514
+ diagnostic(context, { reason: "observation_incomplete", report });
515
+ for (const failure of evidence.failures)
516
+ if (failure.origin === "unknown" || failure.category === "unclassified" && !failure.repair_evidence) {
517
+ diagnostic(context, { reason: "attribution_unproven", report });
518
+ break;
519
+ }
520
+ accepted = true;
469
521
  return evidence;
470
522
  }
471
- catch {
472
- return null;
523
+ catch (error) {
524
+ if (error.code === "ENOENT")
525
+ failureReason = "missing";
526
+ return invalid();
527
+ }
528
+ finally {
529
+ if (!accepted && context.diagnostics.length === count)
530
+ diagnostic(context, { reason: failureReason, report });
473
531
  }
474
532
  }
475
533
  export function runnerEvidencePass(evidence) { return Boolean(evidence?.completed && !evidence.failure_projection && !evidence.observation_unknown && evidence.counts.failed === 0 && evidence.failures.length === 0); }
@@ -1,5 +1,5 @@
1
1
  import crypto from "node:crypto";
2
- import { candidateAbsencesValid, candidateFailureAbsences, candidateFailureFiles, checkRepairDisposition, readRunnerEvidence, readRunnerProgress, runnerContractsValid, runnerEvidencePass, runnerObservationDurations, runnerProofArtifacts } from "./check-runner-evidence.js";
2
+ import { candidateAbsencesValid, candidateFailureAbsences, candidateFailureFiles, checkRepairDisposition, readRunnerEvidence, readRunnerEvidenceDetailed, readRunnerProgress, runnerContractsValid, runnerEvidencePass, runnerObservationDurations, runnerProofArtifacts } from "./check-runner-evidence.js";
3
3
  export { assertSourceRepairEligible, checkRepairDisposition } from "./check-runner-evidence.js";
4
4
  import { isGitlessWorkspace, ownedWorkspaceHookPath, projectServicePathFilter, workspaceGitFiles } from "./workspace-files.js";
5
5
  import { spawn, spawnSync } from "node:child_process";
@@ -656,7 +656,8 @@ async function runCodeChecksOwned(context, input, workspaceGateId, generation, s
656
656
  if (mutationPaths.length)
657
657
  fs.appendFileSync(stderrPath, `\ncheck inputs changed while running: ${mutationPaths.join(", ")}\n`);
658
658
  }
659
- const runnerEvidence = group.runnerEvidence ? readRunnerEvidence(evidenceDir, group.runnerEvidence, { invocation_id: id, check_id: id, command: group.command, input_hash: inputHash, verification_epoch: epoch }) : undefined;
659
+ const runnerRead = group.runnerEvidence ? readRunnerEvidenceDetailed(evidenceDir, group.runnerEvidence, { invocation_id: id, check_id: id, command: group.command, input_hash: inputHash, verification_epoch: epoch }) : undefined;
660
+ const runnerEvidence = runnerRead?.evidence;
660
661
  const physicalFailures = executionFailures({ fingerprintScope: "check_inputs", exitCode: result.exitCode, launcherError: result.error ?? undefined, cleanupUnconfirmed: result.cleanupUnconfirmed, missing: artifacts.missing, mutations: mutationPaths, invalidRunner: Boolean(group.runnerEvidence && !runnerEvidence) });
661
662
  if (result.failure)
662
663
  physicalFailures.push(result.failure);
@@ -680,6 +681,9 @@ async function runCodeChecksOwned(context, input, workspaceGateId, generation, s
680
681
  if (group.runnerEvidence) {
681
682
  receipt.runner_evidence_required = group.runnerEvidence;
682
683
  receipt.runner_evidence = runnerEvidence ?? null;
684
+ receipt.runner_evidence_diagnostics = runnerRead.diagnostics;
685
+ if (runnerRead?.diagnostics_truncated)
686
+ receipt.runner_evidence_diagnostics_truncated = true;
683
687
  }
684
688
  context.db.run("UPDATE check_receipts SET status = ?, exit_code = ?, after_fingerprint = ?, mutation_paths_json = ?, artifacts_json = ?, finished_at = ? WHERE id = ? AND project_id = ? AND status = 'running'", [receipt.status, receipt.exit_code, receipt.after_fingerprint, JSON.stringify(receipt.mutation_paths), JSON.stringify(receipt.artifacts), receipt.finished_at, id, input.projectId]);
685
689
  writeReceipt(receiptPath, receipt);
@@ -752,7 +756,8 @@ export function reconcileUnfinishedChecks(context, input) {
752
756
  const mutationPaths = workspaceUnchanged ? [] : scoped && completionSnapshot ? ["<declared check inputs differed at completion; exact delta unavailable>"]
753
757
  : scoped && beforeFiles ? changedPaths(beforeFiles, selectedInputFiles(input.workspaceRoot, after.files, receipt.inputs ?? [], input.runHome ? [input.runHome] : []))
754
758
  : [scoped ? "<declared check inputs changed; exact manifest unavailable>" : "<workspace changed while detached check ran>"];
755
- const runnerEvidence = receipt?.runner_evidence_required ? readRunnerEvidence(path.join(path.dirname(pendingReceipt.receipt_path), "artifacts"), receipt.runner_evidence_required, { invocation_id: receipt.id, check_id: receipt.id, command: receipt.command, input_hash: receipt.input_hash, verification_epoch: receipt.verification_epoch }) : undefined;
759
+ const runnerRead = receipt?.runner_evidence_required ? readRunnerEvidenceDetailed(path.join(path.dirname(pendingReceipt.receipt_path), "artifacts"), receipt.runner_evidence_required, { invocation_id: receipt.id, check_id: receipt.id, command: receipt.command, input_hash: receipt.input_hash, verification_epoch: receipt.verification_epoch }) : undefined;
760
+ const runnerEvidence = runnerRead?.evidence;
756
761
  const exitCode = completion?.exit_code ?? null;
757
762
  const finishedAt = completion?.finished_at ?? context.now();
758
763
  const physicalFailures = executionFailures({ fingerprintScope: scoped ? "check_inputs" : undefined, exitCode, missing: artifacts.missing.filter(file => file !== "engine-outcome.json"), mutations: mutationPaths, invalidRunner: Boolean(receipt?.runner_evidence_required && !runnerEvidence) });
@@ -786,7 +791,7 @@ export function reconcileUnfinishedChecks(context, input) {
786
791
  if (process)
787
792
  finishManagedProcess(context, { id: process.id, leaseToken: process.lease_token, state: status === "passed" ? "stopped" : "failed", reason: status === "passed" ? "reconciled_completion" : completion === null ? "completion_marker_missing" : "reconciled_check_failed" });
788
793
  if (receipt) {
789
- const repaired = { ...receipt, ...(receipt.runner_evidence_required ? { runner_evidence: runnerEvidence ?? null } : {}), completion_path: receipt.completion_path || path.join(path.dirname(pendingReceipt.receipt_path), "completion.json"), required_artifacts: receipt.required_artifacts ?? [], ...(status === "aborted" ? { abort_reason: "completion_marker_missing" } : {}), status, exit_code: exitCode, after_fingerprint: afterFingerprint, mutation_paths: mutationPaths, artifacts: artifacts.items, finished_at: finishedAt };
794
+ const repaired = { ...receipt, ...(receipt.runner_evidence_required ? { runner_evidence: runnerEvidence ?? null, runner_evidence_diagnostics: runnerRead.diagnostics, ...(runnerRead?.diagnostics_truncated ? { runner_evidence_diagnostics_truncated: true } : {}) } : {}), completion_path: receipt.completion_path || path.join(path.dirname(pendingReceipt.receipt_path), "completion.json"), required_artifacts: receipt.required_artifacts ?? [], ...(status === "aborted" ? { abort_reason: "completion_marker_missing" } : {}), status, exit_code: exitCode, after_fingerprint: afterFingerprint, mutation_paths: mutationPaths, artifacts: artifacts.items, finished_at: finishedAt };
790
795
  repaired.candidate_root = input.workspaceRoot;
791
796
  repaired.candidate_files = candidateFiles;
792
797
  repaired.candidate_absences = candidateAbsences;
@@ -976,11 +981,19 @@ function resolvedReceiptArtifacts(receiptPath, artifacts, binding) {
976
981
  return !path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`) ? path.join(currentRoot, relative) : file;
977
982
  };
978
983
  let evidence = null;
984
+ let diagnostics = [];
985
+ let diagnosticsTruncated = false;
979
986
  if (original.runner_evidence_required && original.id && original.command && original.input_hash && original.verification_epoch) {
980
987
  const root = path.join(currentRoot, "artifacts");
981
988
  const retained = collectCheckArtifacts(root, [original.runner_evidence_required.path]);
982
- if (retained.complete && retained.items.every(item => artifacts.some(artifact => relocate(artifact.path) === item.path && artifact.sha256 === item.sha256)))
983
- evidence = readRunnerEvidence(root, original.runner_evidence_required, { invocation_id: binding.id, check_id: binding.id, command: binding.command, input_hash: binding.input_hash, verification_epoch: binding.verification_epoch });
989
+ if (retained.complete && retained.items.every(item => artifacts.some(artifact => relocate(artifact.path) === item.path && artifact.sha256 === item.sha256))) {
990
+ const read = readRunnerEvidenceDetailed(root, original.runner_evidence_required, { invocation_id: binding.id, check_id: binding.id, command: binding.command, input_hash: binding.input_hash, verification_epoch: binding.verification_epoch });
991
+ evidence = read.evidence;
992
+ diagnostics = read.diagnostics;
993
+ diagnosticsTruncated = Boolean(read.diagnostics_truncated);
994
+ }
995
+ else
996
+ diagnostics = [{ reason: retained.complete ? "artifact_mismatch" : "missing", report: original.runner_evidence_required.path }];
984
997
  }
985
998
  let outcome = {};
986
999
  const outcomePath = path.join(currentRoot, "artifacts", "engine-outcome.json");
@@ -993,7 +1006,7 @@ function resolvedReceiptArtifacts(receiptPath, artifacts, binding) {
993
1006
  }
994
1007
  catch { /* Missing physical proof cannot grant attribution. */ }
995
1008
  }
996
- return { completion_path: path.join(currentRoot, "completion.json"), required_artifacts: original.required_artifacts ?? [], inputs: original.inputs ?? [], artifacts: artifacts.map((artifact) => ({ ...artifact, path: relocate(artifact.path) })), ...(original.runner_evidence_required ? { runner_evidence_required: original.runner_evidence_required, runner_evidence: evidence } : {}), execution_failures: outcome.execution_failures ?? [], ...(outcome.candidate_root ? { candidate_root: outcome.candidate_root } : {}), ...(outcome.candidate_files ? { candidate_files: outcome.candidate_files } : {}), ...(outcome.candidate_absences ? { candidate_absences: outcome.candidate_absences } : {}) };
1009
+ return { completion_path: path.join(currentRoot, "completion.json"), required_artifacts: original.required_artifacts ?? [], inputs: original.inputs ?? [], artifacts: artifacts.map((artifact) => ({ ...artifact, path: relocate(artifact.path) })), ...(original.runner_evidence_required ? { runner_evidence_required: original.runner_evidence_required, runner_evidence: evidence, runner_evidence_diagnostics: diagnostics, ...(diagnosticsTruncated ? { runner_evidence_diagnostics_truncated: true } : {}) } : {}), execution_failures: outcome.execution_failures ?? [], ...(outcome.candidate_root ? { candidate_root: outcome.candidate_root } : {}), ...(outcome.candidate_files ? { candidate_files: outcome.candidate_files } : {}), ...(outcome.candidate_absences ? { candidate_absences: outcome.candidate_absences } : {}) };
997
1010
  }
998
1011
  function executionFailures(input) {
999
1012
  const failures = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.149",
3
+ "version": "0.9.0-beta.150",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.23.0",