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

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 (40) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +17 -22
  3. package/dist/build-info.json +6 -6
  4. package/dist/harness-runtime/lib/agy-error-boundary.mjs +26 -5
  5. package/dist/harness-runtime/lib/agy-terminal-envelope.mjs +67 -0
  6. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +10 -14
  7. package/dist/harness-runtime/lib/dd-agy.mjs +3 -3
  8. package/dist/harness-runtime/lib/dd-droid.mjs +2 -2
  9. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +62 -10
  10. package/dist/harness-runtime/lib/dd-opencode.mjs +32 -14
  11. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +1 -1
  12. package/dist/harness-runtime/lib/dd-zcode.mjs +37 -12
  13. package/dist/harness-runtime/lib/delegation-instructions.d.mts +1 -1
  14. package/dist/harness-runtime/lib/delegation-instructions.mjs +13 -4
  15. package/dist/harness-runtime/lib/model-observations.mjs +19 -7
  16. package/dist/harness-runtime/lib/native-children.d.mts +12 -1
  17. package/dist/harness-runtime/lib/native-children.mjs +47 -1
  18. package/dist/harness-runtime/lib/provider-overload-policy.d.mts +9 -1
  19. package/dist/harness-runtime/lib/provider-overload-policy.mjs +97 -6
  20. package/dist/services/check-runner-evidence.js +54 -309
  21. package/dist/services/check-runner-proof.js +373 -0
  22. package/dist/services/code-checks.js +31 -12
  23. package/dist/services/controller-child-capacity.js +58 -0
  24. package/dist/services/controller-child-observer.js +9 -3
  25. package/dist/services/memory-init-native-capacity.js +1 -1
  26. package/dist/services/provider-capacity.js +15 -7
  27. package/dist/services/repair-intents.js +4 -1
  28. package/dist/services/run-controller-adapter.js +1 -1
  29. package/dist/services/run-controller.js +87 -18
  30. package/dist/services/run-recovery-guard.js +1 -1
  31. package/dist/services/run-recovery-runtime.js +1 -1
  32. package/dist/services/stage-context.js +56 -8
  33. package/dist/services/vnext-code-review.js +1 -1
  34. package/dist/services/vnext-code.js +3 -2
  35. package/dist/services/vnext-fanout.js +1 -1
  36. package/dist/services/vnext-merge.js +1 -1
  37. package/dist/services/work-registry.js +1 -1
  38. package/dist/verification-components.json +299 -0
  39. package/package.json +7 -6
  40. package/tools/verification-jobs.mjs +317 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.9.0-beta.151
4
+
5
+ ### Patch Changes
6
+
7
+ - Preserve causal AGY completion and model/role evidence, share bounded recursive
8
+ runner proof, and continue eligible provider failures with effect-fenced native
9
+ child admission. Admit the reviewed CP253 ZCode bridge and bind browser facts
10
+ to stage context without turning an unavailable probe into a baseline gate.
11
+ - Replace blanket release checks with scoped contract receipts and package
12
+ delivery verification; keep provider, Memory Bank and CLI ownership separate
13
+ so harness fixes do not launch unrelated acceptance suites.
14
+
3
15
  ## 0.9.0-beta.150
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -510,28 +510,23 @@ remain invalid and receive a standalone retry.
510
510
 
511
511
  ## Verification
512
512
 
513
- For a code merge, start with the cheap common contracts, then select affected
514
- storage/process/flow checks explicitly and record the selection reason:
515
-
516
- ```bash
517
- pnpm gate:merge
518
- ```
519
-
520
- For release acceptance, freeze source/canon, build once, then use
521
- `DD_FLOW_GATE_REPORT_ROOT=/absolute/external/report pnpm gate:release`.
522
- Node family selection lives in `package.json`, Vitest membership in
523
- `vitest.config.ts`, and required release receipts in `scripts/release-candidate.mjs`.
524
- The local and CI profiles cover the same required guarantees; qualification-only
525
- cases report their prerequisites/skips separately. Tests invoking compiled CLI
526
- must use the current frozen build, even for a partial merge check.
527
- For development use `pnpm test:fast`, `pnpm test:core` or `pnpm test:flow` plus
528
- the affected native contracts; these subsets are not full acceptance.
529
- New files default to serial core execution. A forced serial fallback is
530
- `pnpm test:integration --no-file-parallelism --maxWorkers=1`.
531
- The [test-suite verification runbook](https://github.com/deksden-com/dd-eval/blob/main/runbooks/flow-test-suite-verification.md)
532
- owns group qualification, complete reports and cross-repository commands.
533
-
534
- Manual smoke checks should use a temporary `DD_FLOW_HOME` when they do not intentionally inspect the operator's real local state.
513
+ Checks follow changed contracts, not a merge/release/EVAL event. Inspect
514
+ `pnpm verify:plan`, then run `pnpm test` (or `pnpm gate:merge`) for only missing
515
+ affected contracts. `scripts/verification-policy.mjs` owns impact selection;
516
+ existing Node/Vitest runners retain complete selected-file/case evidence.
517
+ Unknown impact requires an explicit policy mapping, never a full-suite fallback.
518
+
519
+ There is no mandatory full release gate. Freeze source/canon, build and pack the
520
+ exact artifact, then accept with `DD_FLOW_VERIFICATION_RECEIPT` pointing to the
521
+ scoped verification receipt. Preserve receipts outside the checkout. Unchanged
522
+ contract inputs reuse intact reports; commit/version/build time alone do not
523
+ invalidate behavior evidence. Failed/unknown attempts require investigation,
524
+ not automatic retries. Package identity and registry readback remain mandatory.
525
+ GitHub CI is disabled. Publication does not repeat the accepted package's smoke
526
+ after identical registry tar readback. EVAL preparation is resource readiness,
527
+ not another engine or product test suite.
528
+ The [verification runbook](https://github.com/deksden-com/dd-eval/blob/main/runbooks/flow-test-suite-verification.md)
529
+ owns the local procedure. A scoped PASS is not a claim that every test ran.
535
530
 
536
531
  ### RUN status during recovery
537
532
 
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.150",
4
- "cli_commit": "ad05c70447949f295e588546fe27f6abc4640c3f",
5
- "built_at": "2026-10-24T17:47:48.856Z",
3
+ "cli_version": "0.9.0-beta.152",
4
+ "cli_commit": "70ed6da341845d1b7f262b74f6731ac4787aff73",
5
+ "built_at": "2026-10-26T03:31:35.940Z",
6
6
  "built_with_canon": {
7
7
  "version": "4.1.2",
8
8
  "commit": "925ef10ecc0b683f7feba6f74913e15560842500",
9
9
  "flow_contract": "dd-flow-canonical-2026-08",
10
- "repo_root": "/Users/deksden/Documents/_Projects/_worktrees/dd-canon-release146",
11
- "memorybank_root": "/Users/deksden/Documents/_Projects/_worktrees/dd-canon-release146/.memory-bank",
12
- "flow_root": "/Users/deksden/Documents/_Projects/_worktrees/dd-canon-release146/.memory-bank/dd-flow",
10
+ "repo_root": "/Users/deksden/Documents/_Projects/_worktrees/dd-memorybank-cp253",
11
+ "memorybank_root": "/Users/deksden/Documents/_Projects/_worktrees/dd-memorybank-cp253/.memory-bank",
12
+ "flow_root": "/Users/deksden/Documents/_Projects/_worktrees/dd-memorybank-cp253/.memory-bank/dd-flow",
13
13
  "layout": "dot_memory_bank"
14
14
  }
15
15
  }
@@ -1,19 +1,22 @@
1
1
  import { parseLifecycleCommand, commandOption, commandPosition } from "./lifecycle-command.mjs";
2
2
  import { entityReferenceVariants } from "./entity-references.mjs";
3
+ import { agyTerminalEnvelope, ownedAgyOutput } from "./agy-terminal-envelope.mjs";
3
4
 
4
5
  /** Ordered native transcript evidence, never assistant text as a success signal. */
5
- export function recoveredAgyErrorBoundary(text, error, floor = -1) {
6
+ export function recoveredAgyErrorBoundary(text, error, floor = -1, source = null, sessionId = null) {
6
7
  let rows;
7
8
  try { rows = text.trim().split("\n").filter(Boolean).map(JSON.parse); } catch { return null; }
8
- let previous = -1, lastError = null, boundary = null, invocation = null;
9
+ let previous = -1, lastError = null, boundary = null, invocation = null, call = null;
10
+ const launches = new Map();
9
11
  for (const row of rows) {
10
12
  if (!Number.isInteger(row.step_index) || row.step_index <= previous) return null;
11
13
  previous = row.step_index;
12
14
  if (row.step_index <= (floor ?? -1)) continue;
13
- if (row.type === "USER_INPUT") { lastError = null; boundary = null; invocation = null; continue; }
15
+ if (row.type === "USER_INPUT") { lastError = null; boundary = null; invocation = null; call = null; launches.clear(); continue; }
14
16
  if (row.type === "ERROR_MESSAGE") { lastError = row; boundary = null; invocation = null; continue; }
15
17
  if (Array.isArray(row.tool_calls)) {
16
18
  invocation = null;
19
+ call = row.tool_calls.length === 1 ? row.tool_calls[0] : null;
17
20
  if (row.tool_calls.length === 1 && row.tool_calls[0]?.name === "run_command") {
18
21
  let command = row.tool_calls[0]?.args?.CommandLine;
19
22
  if (typeof command === "string") {
@@ -23,8 +26,22 @@ export function recoveredAgyErrorBoundary(text, error, floor = -1) {
23
26
  }
24
27
  }
25
28
  }
26
- if (!lastError || row.type !== "GENERIC" || row.status !== "DONE" || typeof row.content !== "string" || !row.content.includes("Command exited with code 0.")) continue;
27
- const output = row.content.split("Command output:\n")[1]?.replaceAll("\r", "");
29
+ if (row.type === 'GENERIC' && row.status === 'RUNNING') {
30
+ const task = row.content?.match(/^Created At: [^\n]+\nTool is running as a background task with task id: ([\w-]+\/task-\d+)\n/);
31
+ if (task && invocation && (!sessionId || task[1].startsWith(`${sessionId}/`))) launches.set(task[1], launches.has(task[1]) ? null : { invocation, step: row.step_index });
32
+ call = null; invocation = null; continue;
33
+ }
34
+ const envelope = agyTerminalEnvelope(row, call);
35
+ let launchStep = null;
36
+ if (envelope?.task_id) {
37
+ const launch = launches.get(envelope.task_id);
38
+ invocation = launch && launch.step < row.step_index ? launch.invocation : null;
39
+ launchStep = launch?.step ?? null;
40
+ }
41
+ if (!lastError || envelope?.exit_code !== 0) continue;
42
+ const retained = envelope.output_reference && source && sessionId ? ownedAgyOutput(envelope.output_reference, source, sessionId, envelope.task_id) : null;
43
+ const full = retained ? agyTerminalEnvelope({ type: 'GENERIC', status: 'DONE', content: retained.text }) : null;
44
+ const output = (retained ? full?.exit_code === 0 ? full.output : null : envelope.output)?.replaceAll("\r", "");
28
45
  if (!output) continue;
29
46
  for (const match of output.matchAll(/^\s*\{/gm)) {
30
47
  let receipt;
@@ -51,6 +68,10 @@ export function recoveredAgyErrorBoundary(text, error, floor = -1) {
51
68
  && [receipt.run_id, receipt.stage, receipt.work_id].every(value => typeof value === "string" && value)
52
69
  && (receipt.outcome !== "paused" || typeof receipt.pause?.id === "string")) boundary = { receipt: { ok: true, outcome: receipt.outcome, run_id: receipt.run_id, stage: receipt.stage, work_id: receipt.work_id,
53
70
  lifecycle_operation: "stage_pause", pause: { id: receipt.pause.id } }, error_step: lastError.step_index, boundary_step: row.step_index };
71
+ if (boundary?.boundary_step === row.step_index) {
72
+ if (launchStep !== null) boundary.invocation_step = launchStep;
73
+ if (retained) boundary.output_source = { locator: retained.locator, sha256: retained.sha256 };
74
+ }
54
75
  }
55
76
  }
56
77
  return boundary && lastError?.error === error ? boundary : null;
@@ -0,0 +1,67 @@
1
+ import { constants, openSync, closeSync, fstatSync, lstatSync, readSync, realpathSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import path from 'node:path';
4
+ import { createHash } from 'node:crypto';
5
+
6
+ const taskId = '[\\w-]+/task-\\d+';
7
+
8
+ export function ownedAgyOutput(reference, source, sessionId, ownerTask = null) {
9
+ let fd;
10
+ try {
11
+ const transcript = fileURLToPath(source.locator), file = fileURLToPath(reference);
12
+ const system = path.dirname(path.dirname(transcript));
13
+ if (path.basename(path.dirname(system)) !== sessionId || path.basename(system) !== '.system_generated') return null;
14
+ const relative = path.relative(system, file);
15
+ if (!/^(?:steps\/\d+\/output\.txt|tasks\/task-\d+\.log)$/.test(relative) || realpathSync(file) !== file || realpathSync(system) !== system) return null;
16
+ if (ownerTask) {
17
+ const number = ownerTask.match(new RegExp(`^${sessionId}/task-(\\d+)$`))?.[1];
18
+ if (!number || ![`steps/${number}/output.txt`, `tasks/task-${number}.log`].includes(relative)) return null;
19
+ }
20
+ const before = lstatSync(file);
21
+ if (!before.isFile() || before.size > 2 * 1024 * 1024) return null;
22
+ fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
23
+ const stat = fstatSync(fd);
24
+ if (!stat.isFile() || stat.ino !== before.ino || stat.dev !== before.dev) return null;
25
+ const bytes = Buffer.alloc(stat.size);
26
+ let offset = 0;
27
+ while (offset < bytes.length) { const count = readSync(fd, bytes, offset, bytes.length - offset, offset); if (!count) return null; offset += count; }
28
+ const after = fstatSync(fd);
29
+ if (stat.size !== after.size || stat.mtimeMs !== after.mtimeMs) return null;
30
+ return { text: bytes.toString('utf8'), sha256: createHash('sha256').update(bytes).digest('hex'), locator: reference };
31
+ } catch { return null; }
32
+ finally { if (fd !== undefined) closeSync(fd); }
33
+ }
34
+
35
+ /** Only a native envelope is decoded here; stdout cannot introduce terminal authority. */
36
+ export function agyTerminalEnvelope(row, call = null) {
37
+ if (row?.status !== 'DONE' || typeof row.content !== 'string') return null;
38
+ let body = row.content.replaceAll('\r\n', '\n'), id = null, cancelled = false;
39
+ if (row.source === 'SYSTEM' && row.type === 'SYSTEM_MESSAGE') {
40
+ const match = body.match(new RegExp(`(?:^|\\n)\\[Message\\] timestamp=[^\\n]+ sender=(${taskId}) priority=MESSAGE_PRIORITY_(?:HIGH|LOW) content=Task id "(${taskId})" (finished with result:|was canceled with result:)\\n`));
41
+ if (!match || match[1] !== match[2]) return null;
42
+ id = match[1]; cancelled = match[3].startsWith('was canceled');
43
+ body = body.slice(match.index + match[0].length).replace(/\n<\/SYSTEM_MESSAGE>\s*$/, '');
44
+ } else if (row.type !== 'GENERIC' || row.source && row.source !== 'MODEL') return null;
45
+ else {
46
+ body = body.replace(/^Created At: [^\n]+\nCompleted At: [^\n]+\n/, '');
47
+ if (call?.name === 'manage_task') {
48
+ const decode = value => { try { return JSON.parse(value); } catch { return value; } };
49
+ id = decode(call.args?.TaskId);
50
+ const action = decode(call.args?.Action);
51
+ const status = body.match(new RegExp(`^Task: (${taskId})\\nStatus: (DONE|COMPLETED|FAILED|CANCELLED)(?:\\n|$)`));
52
+ if (action === 'status' && status?.[1] === id) return { task_id: id, task_terminal: true, exit_code: null, command_outcome: status[2] === 'DONE' ? null : status[2].toLowerCase(), output: null };
53
+ if (action === 'kill' && body.match(new RegExp(`^Task "(${taskId})" cancelled\\.(?:\\n|$)`))?.[1] === id) return { task_id: id, task_terminal: true, exit_code: null, command_outcome: 'cancelled', output: null };
54
+ return null;
55
+ }
56
+ }
57
+ // This exact wrapper preserves the beginning of the native result, including its exit header.
58
+ const wrapper = body.match(/^The tool's output was truncated due to being too long\. You can find the full output in a file at (file:\/\/[^\n]+)\.\n\nHere is a preview with the middle of the output truncated:\n/);
59
+ if (wrapper) body = body.slice(wrapper[0].length);
60
+ if (cancelled && /^Tool execution was canceled(?:\n|$)/.test(body)) return { task_id: id, task_terminal: true, exit_code: null, command_outcome: 'cancelled', output: null };
61
+ const header = body.match(/^Command exited with code (-?\d+)\.(?:\n|$)/);
62
+ if (!header) return null;
63
+ const exit = Number(header[1]);
64
+ if (!Number.isSafeInteger(exit)) return null;
65
+ return { task_id: id, task_terminal: true, exit_code: exit, command_outcome: exit === 0 ? 'completed' : 'failed', output: wrapper ? null : body.split('Command output:\n')[1] ?? null,
66
+ ...(wrapper ? { output_reference: wrapper[1] } : {}) };
67
+ }
@@ -24,6 +24,7 @@ import path from "node:path";
24
24
  import { observeModel } from "./model-observations.mjs";
25
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
+ import { agyTerminalEnvelope } from "./agy-terminal-envelope.mjs";
27
28
  import { settledNativeTree, nativeExecutionReceipt } from "./native-children.mjs";
28
29
  import { retainStartupFailure, prepareRuntimeOwner, cleanupFailedStart, cleanupFailedDaemonStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, stopProcessGroup } from "./managed-daemon.mjs";
29
30
  import { readNativeLines, streamNativeDiagnostics, nativeObservationError, admitNativeRequestBuffer } from "./native-streams.mjs";
@@ -51,10 +52,10 @@ export function agyTranscriptFacts(text, source, sessionId) {
51
52
  const content = typeof event.content === 'string' ? event.content : '';
52
53
  const native_source = { ...source, step_index: event.step_index };
53
54
  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 }));
55
+ const terminal = agyTerminalEnvelope(event);
56
+ if (terminal) {
57
+ const prior = resources.get(terminal.task_id);
58
+ if (prior) resources.set(prior.resource_id, mergeAgyResource(prior, { ...prior, state: 'stopped', ...(terminal.command_outcome ? { native_outcome: terminal.command_outcome } : {}), terminal_source: native_source }));
58
59
  }
59
60
  continue;
60
61
  }
@@ -63,13 +64,8 @@ export function agyTranscriptFacts(text, source, sessionId) {
63
64
  if (event.type !== 'GENERIC') continue;
64
65
  const call = calls.shift();
65
66
  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 }));
67
+ const terminal = agyTerminalEnvelope(event, call), prior = resources.get(terminal?.task_id);
68
+ if (prior) resources.set(prior.resource_id, mergeAgyResource(prior, { ...prior, state: 'stopped', ...(terminal.command_outcome ? { native_outcome: terminal.command_outcome } : {}), terminal_source: native_source }));
73
69
  }
74
70
  if (call?.name === 'invoke_subagent' && event.status === 'DONE') {
75
71
  const match = content.match(/^Created At: [^\n]+\nCompleted At: [^\n]+\nCreated the following subagent:\n(\{[\s\S]*?\})\nThe subagent will send you a message/);
@@ -98,7 +94,7 @@ export function mergeAgyResource(prior, next) {
98
94
  return { ...prior, state: 'unknown', observation_conflict: true };
99
95
  if (prior.observation_conflict || next.observation_conflict || prior.native_outcome && next.native_outcome && prior.native_outcome !== next.native_outcome)
100
96
  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))
97
+ if (next.terminal_source && (!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
98
  return { ...prior, state: 'unknown', observation_conflict: true };
103
99
  if (prior.state === 'stopped' && next.state !== 'stopped') {
104
100
  if (next.native_source?.step_index > (prior.terminal_source?.step_index ?? prior.native_source?.step_index))
@@ -578,8 +574,8 @@ export class Runtime {
578
574
  if (result.status === "ERROR" && settled && rootStop?.fullyIdle === true && !this.state.last_hook_rejection && current.operationId && this.paths.runtime) {
579
575
  const root = this.init.conversation_id, transcript = path.join(this.paths.runtime, "brain", root, ".system_generated", "logs", "transcript.jsonl");
580
576
  await captureAgyTranscript(this.paths.runtime, { provider_session_id: root, parent_provider_session_id: root, log_uri: new URL(`file://${transcript}`).href }, root, [root], (text, source) => {
581
- const boundary = recoveredAgyErrorBoundary(text, result.error, current.stepFloor);
582
- const hook = current.nativeHookEvents?.get(boundary?.boundary_step);
577
+ const boundary = recoveredAgyErrorBoundary(text, result.error, current.stepFloor, source, root);
578
+ const hook = current.nativeHookEvents?.get(boundary?.invocation_step ?? boundary?.boundary_step);
583
579
  if (boundary && hook) terminalMetadata.recovered_error_boundary = { ...boundary, receipt: { ...boundary.receipt, native_hook_event_key: hook, daemon_instance: this.config.daemonId }, source };
584
580
  });
585
581
  }
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import { SETUP_STARTUP_MS } from './setup-progress.mjs';
5
5
 
6
6
  import { checkObservedProfile } from "./model-observations.mjs";
7
- import { classifyProviderError, safeProviderError, streamRequestKey } from "./provider-overload-policy.mjs";
7
+ import { classifyRecoveryError as classifyProviderError, safeProviderError, streamRequestKey } from "./provider-overload-policy.mjs";
8
8
 
9
9
  const DEFAULT_MODEL = "gemini-3.1-pro-high";
10
10
  export const AGY_VERSION_TIMEOUT_MS = 30_000;
@@ -31,9 +31,9 @@ export function agyTerminalFailure(result, metadata = {}) {
31
31
  const identity = result.turn_id ?? result.request_id;
32
32
  const turn = typeof identity === "string" && identity ? identity : null;
33
33
  const stream = streamRequestKey(metadata.request_proof);
34
- const proofMissing = result.status === "ERROR" && ["provider_capacity_transient", "provider_rate_limited"].includes(category) && !turn && !stream;
34
+ const proofMissing = result.status === "ERROR" && ["provider_capacity_transient", "provider_rate_limited", "provider_network_transient"].includes(category) && !turn && !stream;
35
35
  if (proofMissing) code = "provider_overload_evidence_unavailable";
36
- return new AgyError(code, String(native.message ?? "Antigravity returned ERROR"), code === "provider_capacity_transient" || code === "provider_rate_limited", {
36
+ return new AgyError(code, String(native.message ?? "Antigravity returned ERROR"), ["provider_capacity_transient", "provider_rate_limited", "provider_network_transient"].includes(code), {
37
37
  provider_result: result, ...metadata, provider_error: safeProviderError(native), native_source: "agy.result", terminal_status: result.status === "ERROR" ? "failed" : "cancelled",
38
38
  ...(turn ? { turn_id: turn, native_turn_id: turn } : {}),
39
39
  ...(proofMissing ? { provider_category: category, reason: "native_turn_identity_unavailable" } : {}),
@@ -21,7 +21,7 @@ const metadataReaders = new Map();
21
21
  function metadataReader(factory) { if (!metadataReaders.has(factory)) metadataReaders.set(factory, new DroidMetadataReader(factory)); return metadataReaders.get(factory); }
22
22
  import { confirmDaemonProcess, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, stopProcessGroup } from "./managed-daemon.mjs";
23
23
  import { renderAdapterPolicy, lifecycleRetryInstruction } from "./delegation-instructions.mjs";
24
- import { classifyProviderError, safeProviderError } from "./provider-overload-policy.mjs";
24
+ import { classifyRecoveryError as classifyProviderError, safeProviderError } from "./provider-overload-policy.mjs";
25
25
  import { settledNativeTree, nativeExecutionReceipt } from "./native-children.mjs";
26
26
 
27
27
  export const DROID_CONTRACT = "dd-droid-harness@1";
@@ -108,7 +108,7 @@ export function droidTerminalFailure(outcome, sessionId, settled) {
108
108
  terminal_status: "failed", native_source: "droid.agent_turn_outcome", observed_at: new Date().toISOString(),
109
109
  provider_error: safeProviderError(outcome.error), will_retry: outcome.error.willRetry === true, native_retry_active: outcome.error.willRetry === true,
110
110
  native_turn_items: { observed: settled === true, possible_effects: true, pending: settled !== true }
111
- }), { retryable: code === "provider_capacity_transient" || code === "provider_rate_limited" });
111
+ }), { retryable: ["provider_capacity_transient", "provider_rate_limited", "provider_network_transient"].includes(code) });
112
112
  }
113
113
 
114
114
 
@@ -14,7 +14,7 @@ import { durableDaemonDispatch, completeDaemonShutdown, commitDaemonShutdown, tr
14
14
  import { waitForSettlement } from "./session-settlement.mjs";
15
15
  import { nativeExecutionReceipt } from "./native-children.mjs";
16
16
  import { errorRecord, isProvenShellCorrection, reportedError } from "./operation-errors.mjs";
17
- import { checkObservedProfile, observeModel } from "./model-observations.mjs";
17
+ import { checkObservedProfile, observeModel, observedFields, readModelObservations } from "./model-observations.mjs";
18
18
  import os from "node:os";
19
19
  import path from "node:path";
20
20
  import { parse as parseJsonc } from "jsonc-parser";
@@ -91,9 +91,12 @@ async function prepare(paths, config, authSource) {
91
91
  }
92
92
  }
93
93
  catch (error) { if (error.code !== "ENOENT") throw new DaemonError("opencode_config_invalid", "Portable OpenCode provider config must be valid JSON"); }
94
- await writeJson(path.join(paths.config, "opencode", "opencode.json"), { ...portable, ...agentRestrictions, permissions,
95
- agents: { ...agentRestrictions.agents, "dd-flow-worker": { mode: "subagent", system: "Execute the assigned dd-flow Work. Follow the issued lifecycle instructions. Do not spawn descendants.", model: `${config.provider}/${config.model}${config.variant !== "default" ? `#${config.variant}` : ""}` } },
96
- experimental: { subagent_depth: 1, ...(policies ? { policies } : {}) } });
94
+ const generatedConfig = { ...portable, ...agentRestrictions, permissions,
95
+ agents: { ...agentRestrictions.agents, "dd-flow-worker": { ...agentRestrictions.agent?.[config.agent], ...agentRestrictions.agents?.[config.agent], mode: "subagent", system: "Execute the assigned dd-flow Work. Follow the issued lifecycle instructions. Do not spawn descendants.", model: `${config.provider}/${config.model}${config.variant !== "default" ? `#${config.variant}` : ""}` } },
96
+ experimental: { subagent_depth: 1, ...(policies ? { policies } : {}) } };
97
+ const configPath = path.join(paths.config, "opencode", "opencode.json");
98
+ await writeJson(configPath, generatedConfig);
99
+ config.workerLaunchConfig = { locator: configPath, sha256: createHash('sha256').update(await readFile(configPath)).digest('hex'), primary_agent: config.agent, subagent: 'dd-flow-worker', max_depth: 1 };
97
100
  config.pluginSha256 = await writePlugin(paths, config);
98
101
  }
99
102
  async function bootstrapCredentials(config, client) {
@@ -199,6 +202,15 @@ export class Runtime {
199
202
  const record = { provider_session_id: id, adapter_session_id: id, parent_provider_session_id: parent, root_provider_session_id: root, cwd: info.directory };
200
203
  this.sessions.set(id, record); return record;
201
204
  }
205
+ registerObservedChild(info, root) {
206
+ const owner = this.sessions.get(root);
207
+ if (!owner || owner.parent_provider_session_id || owner.root_provider_session_id !== root
208
+ || info.id === root || info.parentID !== root || info.fork)
209
+ throw new DaemonError("session_identity_mismatch", "OpenCode child is not a direct member of the owned root");
210
+ if (typeof info.directory !== "string" || !path.isAbsolute(info.directory))
211
+ throw new OpenCodeError("opencode_unavailable", "OpenCode child directory binding is not yet observable");
212
+ return this.retainSession(info, root);
213
+ }
202
214
  async journal(operation, result) { const line = `${JSON.stringify({ schema_id: "dd-opencode/daemon-event@1", event_id: randomUUID(), observed_at: new Date().toISOString(), operation, active_tree: this.state.active_tree, session_id: result?.provider_session_id ?? null })}\n`; for (const journal of new Set([this.paths.journal, this.state.config.journal].filter(Boolean))) { await mkdir(path.dirname(journal), { recursive: true, mode: 0o700 }); await appendFile(journal, line, { mode: 0o600 }); } }
203
215
  async childTree(id, seen = new Set()) {
204
216
  if (seen.has(id)) throw new DaemonError("session_identity_mismatch", "OpenCode native topology contains a cycle");
@@ -296,15 +308,52 @@ export class Runtime {
296
308
  return nativeExecutionReceipt(receipt, { daemonId: this.state.daemon_id, revision: before.get(id) ?? 0 });
297
309
  }
298
310
  async observeModels(id, messages, parent = null) {
299
- this.modelMessages ??= new Set();
311
+ const journal = this.state.config.journal ?? this.paths.journal;
312
+ if (!this.modelMessages) {
313
+ this.modelMessages = new Map();
314
+ for (const event of journal ? await readModelObservations(journal) : []) {
315
+ if (event.harness !== 'opencode-server' || event.source?.method !== 'session/message' || !event.source.message_id) continue;
316
+ const key = `${event.session_id}:${event.source.message_id}`;
317
+ if (event.evidence_conflict) { const prior = this.modelMessages.get(key); if (prior) prior.conflicted = true; continue; }
318
+ if (!this.modelMessages.has(key)) this.modelMessages.set(key, { source: event.source,
319
+ known: { parent: event.parent_session_id, root: event.native_agent_role?.launch?.root_session_id,
320
+ role: event.native_agent_role ?? null, requested: observedFields(event.requested), observed: observedFields(event.observed) } });
321
+ }
322
+ }
300
323
  for (const message of messages ?? []) {
301
324
  const info = message.info;
302
- if (info?.role !== "assistant" || !info.id || this.modelMessages.has(info.id)) continue;
325
+ if (info?.role !== "assistant" || !info.id) continue;
303
326
  if (info.sessionID && info.sessionID !== id) throw new DaemonError("session_identity_mismatch", "native message belongs to another Session");
304
- await observeModel({ journal: this.state.config.journal ?? this.paths.journal, harness: "opencode-server", sessionId: id, parentSessionId: parent,
305
- requested: { ...this.state.config, reasoning: this.state.config.variant, mode: parent ? "dd-flow-worker" : this.state.config.agent },
306
- observed: { provider: info.providerID, model: info.modelID, reasoning: info.variant, mode: info.mode }, source: { method: "session/message", message_id: info.id, event_sha256: createHash("sha256").update(`${id}:${info.id}:${info.modelID}`).digest("hex") }, evidence: "response" });
307
- this.modelMessages.add(info.id);
327
+ const record = this.sessions.get(id);
328
+ if (record && (record.parent_provider_session_id ?? null) !== parent)
329
+ throw new DaemonError("session_identity_mismatch", "OpenCode observed message parent changed");
330
+ if (!record?.root_provider_session_id || !info.mode) {
331
+ await observeModel({ journal, harness: "opencode-server", sessionId: id, parentSessionId: parent,
332
+ source: { channel: "native.message_observer", scope: "response", non_asserting: true }, evidence: "unavailable", reason: "native_role_binding_unavailable" });
333
+ continue;
334
+ }
335
+ const requested = { ...this.state.config, reasoning: this.state.config.variant, mode: parent ? "dd-flow-worker" : this.state.config.agent };
336
+ const nativeAgentRole = this.state.config.workerLaunchConfig ? { name: info.mode, kind: parent ? 'subagent' : 'primary', parent_session_id: parent,
337
+ launch: { ...this.state.config.workerLaunchConfig, root_session_id: record.root_provider_session_id } } : null;
338
+ const observed = observedFields({ provider: info.providerID, model: info.modelID, reasoning: info.variant, mode: info.mode });
339
+ const tuple = { parent, root: record.root_provider_session_id, role: nativeAgentRole, requested: observedFields(requested), observed };
340
+ const key = `${id}:${info.id}`, previous = this.modelMessages.get(key);
341
+ if (previous?.conflicted) throw new DaemonError("model_observation_identity_conflict", "OpenCode native message has a retained identity/profile conflict", false, { session_id: id, message_id: info.id });
342
+ const conflicts = (before, after) => before != null && after != null && (typeof before === "object" && typeof after === "object"
343
+ ? [...new Set([...Object.keys(before), ...Object.keys(after)])].some(field => conflicts(before[field], after[field])) : before !== after);
344
+ if (previous && !conflicts(previous.known, tuple)) {
345
+ // Optional disclosure can arrive late. Do not rewrite the old native fact;
346
+ // remember known values only to detect a later positive contradiction.
347
+ for (const [field, value] of Object.entries(observed)) if (value != null) previous.known.observed[field] = value;
348
+ continue;
349
+ }
350
+ const source = previous?.source ?? { method: "session/message", message_id: info.id, event_sha256: createHash("sha256").update(`${id}:${info.id}`).digest("hex") };
351
+ const published = await observeModel({ journal, harness: "opencode-server", sessionId: id, parentSessionId: parent, requested, nativeAgentRole, observed, source, evidence: "response" });
352
+ if (previous || published?.evidence_conflict) {
353
+ if (previous) previous.conflicted = true;
354
+ throw new DaemonError("model_observation_identity_conflict", "OpenCode native message changed its published identity/profile", false, { session_id: id, message_id: info.id });
355
+ }
356
+ this.modelMessages.set(key, { known: structuredClone(tuple), source });
308
357
  }
309
358
  }
310
359
  async requireSettled(id) {
@@ -457,6 +506,9 @@ export class Runtime {
457
506
  assertDispatch, checkHookFailures, livenessTimeoutMs: params.livenessTimeoutMs ?? this.client.timeoutMs,
458
507
  onDispatched: () => this.journal("native_dispatch", { operation_id: operationId, provider_session_id: params.sessionId }),
459
508
  onModelMessages: (id, messages, parent) => this.observeModels(id, messages, parent),
509
+ onNativeSession: (info, root) => this.registerObservedChild(info, root),
510
+ rootSessionId: this.sessions.get(params.sessionId)?.root_provider_session_id ?? params.sessionId,
511
+ parentSessionId: this.sessions.get(params.sessionId)?.parent_provider_session_id ?? null,
460
512
  onObservationError: error => observeModel({ journal: this.state.config.journal ?? this.paths.journal, harness: "opencode-server", sessionId: params.sessionId, requested: this.state.config, source: "native.message_observer", evidence: "unavailable", reason: error.code ?? "observation_unavailable" })
461
513
  }); await checkHookFailures(); }
462
514
  finally { current.requests.clear(); if (this.nativeHookOperation === current) this.nativeHookOperation = null; }
@@ -4,7 +4,7 @@ import { ToolObservations, openCodeToolObservation } from "./tool-observations.m
4
4
  import { ObservationClock, observedTimeout, validateObservationDuration } from "./observation-clock.mjs";
5
5
  import { assertNativeLine } from "./native-streams.mjs";
6
6
  import { operationProgressReporter } from "./operation-progress.mjs";
7
- import { classifyProviderError, safeProviderError } from "./provider-overload-policy.mjs";
7
+ import { classifyRecoveryError as classifyProviderError, safeProviderError } from "./provider-overload-policy.mjs";
8
8
 
9
9
  export const OPENCODE_PROTOCOL = 2;
10
10
  export class OpenCodeError extends Error {
@@ -59,7 +59,7 @@ export function providerFailure(native, sessionId, turnId) {
59
59
  const category = classifyProviderError(native), code = category === "provider_failed" ? "opencode_provider_failed" : category;
60
60
  // Never forward response.body or provider credentials into controller/Judge.
61
61
  return new OpenCodeError(code, `OpenCode native execution failed (${type}${status ? `, HTTP ${status}` : ""})`,
62
- ["provider_capacity_transient", "provider_rate_limited"].includes(code), {
62
+ ["provider_capacity_transient", "provider_rate_limited", "provider_network_transient"].includes(code), {
63
63
  provider_session_id: sessionId, session_id: sessionId, turn_id: turnId, native_error_type: type,
64
64
  native_turn_id: turnId, terminal_status: "failed",
65
65
  provider_error: safeProviderError(native),
@@ -169,13 +169,32 @@ export class OpenCodeClient {
169
169
  import(data) { return this.call("session.import", data, { productive: true }); }
170
170
 
171
171
  async prompt(id, { prompt, messageId = `msg_${randomUUID().replaceAll("-", "")}`, retained,
172
- save, onModelMessages, onObservationError, assertDispatch, onDispatched, checkHookFailures, livenessTimeoutMs = this.timeoutMs }) {
172
+ save, onModelMessages, onNativeSession, rootSessionId = id, parentSessionId = null,
173
+ onObservationError, assertDispatch, onDispatched, checkHookFailures, livenessTimeoutMs = this.timeoutMs }) {
173
174
  validateObservationDuration(livenessTimeoutMs);
174
175
  const request = { sessionID: id, id: messageId, text: prompt, delivery: "queue", resume: true }, digest = requestDigest(request);
175
176
  if (retained && (retained.request_sha256 !== digest || retained.message_id !== messageId)) throw new OpenCodeError("operation_request_mismatch", "OpenCode operation already binds another prompt", false, { effect: "no_effect" });
176
177
  let state = retained ?? { message_id: messageId, request_sha256: digest, phase: "prepared", cursor: 0 };
177
- const previous = await this.messages(id), baseline = new Map([[id, new Set(previous.map(item => item.info.id))]]), parents = new Map();
178
- const capture = async session => { for (const child of await this.children(session)) { parents.set(child.id, child.parentID); baseline.set(child.id, new Set((await this.messages(child.id)).map(item => item.info.id))); await capture(child.id); } };
178
+ const previous = await this.messages(id), baseline = new Map([[id, new Set(previous.map(item => item.info.id))]]), parents = new Map([[id, parentSessionId]]);
179
+ // The same native lineage guard applies before dispatch and while polling.
180
+ // Registration precedes message publication; a list ID alone is not ownership.
181
+ const childrenOf = async (session, visited) => {
182
+ const children = await this.children(session);
183
+ for (const child of children) {
184
+ if (!child?.id || child.parentID !== session || child.fork || session !== rootSessionId || visited.has(child.id)
185
+ || parents.has(child.id) && parents.get(child.id) !== session || visited.size >= 1024)
186
+ throw new OpenCodeError("session_identity_mismatch", "OpenCode native child lineage is invalid", false, { session_id: child?.id, parent_session_id: session });
187
+ visited.add(child.id); parents.set(child.id, session);
188
+ await onNativeSession?.(child, rootSessionId);
189
+ }
190
+ return children;
191
+ };
192
+ const capture = async (session, visited = new Set([id])) => {
193
+ for (const child of await childrenOf(session, visited)) {
194
+ baseline.set(child.id, new Set((await this.messages(child.id)).map(item => item.info.id)));
195
+ await capture(child.id, visited);
196
+ }
197
+ };
179
198
  await capture(id);
180
199
  if (retained) {
181
200
  const index = previous.findIndex(item => item.info.id === messageId);
@@ -258,8 +277,8 @@ export class OpenCodeClient {
258
277
  }
259
278
  if (state.cursor !== watermark) { state = { ...state, cursor: watermark }; await save?.(state); }
260
279
  let runningTools = false;
261
- const observe = async (session, visited = new Set()) => {
262
- if (visited.has(session)) throw lost("OpenCode topology contains a cycle"); visited.add(session); known.add(session);
280
+ const observe = async (session, visited = new Set([id])) => {
281
+ known.add(session);
263
282
  const messages = await this.messages(session);
264
283
  await onModelMessages?.(session, messages, parents.get(session) ?? null);
265
284
  const floor = baseline.get(session) ?? new Set(), current = messages.filter(item => item.info.role === "assistant" && !floor.has(item.info.id));
@@ -291,10 +310,7 @@ export class OpenCodeClient {
291
310
  }
292
311
  }
293
312
  }
294
- for (const child of await this.children(session)) {
295
- if (child.parentID !== session) throw lost("OpenCode child has a foreign parent");
296
- parents.set(child.id, session); await observe(child.id, visited);
297
- }
313
+ for (const child of await childrenOf(session, visited)) await observe(child.id, visited);
298
314
  return current;
299
315
  };
300
316
  const current = await observe(id), rootMessages = await this.messages(id);
@@ -309,8 +325,9 @@ export class OpenCodeClient {
309
325
  // Native messages and topology are separate reads: a child can
310
326
  // finish between them. Reconcile its ancestry before rejecting
311
327
  // the notification, never trust the notification alone.
312
- const child = (await this.children(id)).find(child => child.id === item.info.metadata.childID && child.parentID === id);
313
- if (child) { parents.set(child.id, id); await observe(child.id); }
328
+ const visited = new Set([id]);
329
+ const child = (await childrenOf(id, visited)).find(child => child.id === item.info.metadata.childID);
330
+ if (child) await observe(child.id, visited);
314
331
  }
315
332
  if (item.info.role === "user" || item.info.role === "synthetic" && !(
316
333
  item.info.metadata?.source === "subagent" && parents.get(item.info.metadata.childID) === id
@@ -353,7 +370,8 @@ export class OpenCodeClient {
353
370
  }
354
371
  } catch (error) {
355
372
  await checkHookFailures?.();
356
- if (error instanceof OpenCodeError && !["opencode_unavailable", "operation_observation_lost"].includes(error.code)) throw error;
373
+ if (error instanceof OpenCodeError && !["opencode_unavailable", "operation_observation_lost"].includes(error.code)
374
+ || ["session_identity_mismatch", "model_observation_identity_conflict"].includes(error.code)) throw error;
357
375
  await onObservationError?.(error);
358
376
  }
359
377
  if (clock.sample(marker)) throw lost("OpenCode native progress stopped or observation could not recover", { session_id: id, message_id: messageId, observation: clock.state(), ...(observationErrors.count ? { observation_errors: observationErrors } : {}) });
@@ -459,7 +459,7 @@ export class DaemonRuntime {
459
459
  if (active && active.session_id !== params.sessionId) throw new DaemonError("session_identity_mismatch", "Retained observation has a foreign active root");
460
460
  const cacheScope = `${params.sessionId}:${generation}`;
461
461
  if (this.childObservationScope !== cacheScope) { this.childObservationScope = cacheScope; this.childTerminalCache = new Map(); this.childObservationKeys = new Set(); }
462
- const subagents = await readZcodeSubagents(this.bridge, root.adapter_session_id, 30_000, true, this.childTerminalCache);
462
+ const subagents = await readZcodeSubagents(this.bridge, root.adapter_session_id, 30_000, true, this.childTerminalCache, root.provider_session_id);
463
463
  if (generation !== (this.controlGeneration ?? 0)) throw new DaemonError("operation_cancelled", "Observation generation was retired");
464
464
  if (activeIdentity(this.state.active_request) !== capturedIdentity) throw new DaemonError("operation_cancelled", "Observation belongs to a retired active Turn");
465
465
  assertDaemonDispatch(this.paths.dir);