@deksden-com/dd-flow-cli 0.9.0-beta.136 → 0.9.0-beta.137

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 (59) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/build-info.json +3 -3
  3. package/dist/harness-runtime/DD-ZCODE.md +13 -0
  4. package/dist/harness-runtime/bin/dd-opencode.mjs +3 -3
  5. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +14 -1
  6. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +36 -1
  7. package/dist/harness-runtime/lib/dd-zcode.mjs +7 -3
  8. package/dist/harness-runtime/lib/opencode-plugin.mjs +3 -1
  9. package/dist/services/controller-child-observer.js +99 -0
  10. package/dist/services/controller-fanout.js +9 -2
  11. package/dist/services/run-controller.js +83 -8
  12. package/dist/services/stage-lifecycle.js +2 -1
  13. package/dist/services/stage-report-renderer.js +13 -0
  14. package/dist/services/vnext-fanout.js +66 -1
  15. package/dist/services/vnext-protocolize.js +60 -2
  16. package/package.json +1 -1
  17. package/dist/cli/command-metadata.js +0 -144
  18. package/dist/cli/command-option-descriptions.js +0 -186
  19. package/dist/cli/completion-cli.js +0 -70
  20. package/dist/cli/completion.js +0 -57
  21. package/dist/cli/error-output.js +0 -52
  22. package/dist/cli/executor-onboarding.js +0 -71
  23. package/dist/cli/harnesses.js +0 -84
  24. package/dist/cli/models.js +0 -39
  25. package/dist/cli/operator-profiles.js +0 -61
  26. package/dist/cli/output-options.js +0 -35
  27. package/dist/cli/response-file.js +0 -69
  28. package/dist/cli/setup.js +0 -147
  29. package/dist/services/cli-upgrade-allowlist.js +0 -27
  30. package/dist/services/discovery/agy-discovery.js +0 -2
  31. package/dist/services/discovery/codex-discovery.js +0 -2
  32. package/dist/services/discovery/droid-discovery.js +0 -2
  33. package/dist/services/discovery/grok-discovery.js +0 -2
  34. package/dist/services/discovery/opencode-discovery.js +0 -2
  35. package/dist/services/discovery/static.js +0 -31
  36. package/dist/services/discovery/zcode-discovery.js +0 -2
  37. package/dist/services/executor-onboarding.js +0 -56
  38. package/dist/services/harness-discovery.js +0 -29
  39. package/dist/services/harness-doctor.js +0 -75
  40. package/dist/services/harness-probe-effects.js +0 -51
  41. package/dist/services/harness-version-probe.js +0 -100
  42. package/dist/services/hybrid-contracts.js +0 -455
  43. package/dist/services/installation-lock.js +0 -92
  44. package/dist/services/model-catalog.js +0 -109
  45. package/dist/services/model-discovery.js +0 -46
  46. package/dist/services/operator-profiles.js +0 -236
  47. package/dist/services/release-archive-producer.js +0 -52
  48. package/dist/services/release-archive.js +0 -143
  49. package/dist/services/release-cache.js +0 -142
  50. package/dist/services/release-compatibility.js +0 -18
  51. package/dist/services/release-download.js +0 -67
  52. package/dist/services/release-envelope.js +0 -104
  53. package/dist/services/release-manifest.js +0 -45
  54. package/dist/services/release-payload.js +0 -114
  55. package/dist/services/release-producer.js +0 -63
  56. package/dist/services/release-provenance.js +0 -160
  57. package/dist/services/release-registry.js +0 -66
  58. package/dist/services/release-source.js +0 -194
  59. package/dist/services/setup.js +0 -377
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.9.0-beta.137
4
+
5
+ - Preserve shared reasoning/mode options in OpenCode v2 and support non-generative doctor before daemon startup.
6
+
7
+ ### Patch Changes
8
+
9
+ - e65a615: Retain scoped native child failures through read-only ZCode observation and durable dispatch fences. Bind failure provenance to current Work/Session/controller identities. Publish materialized PROTOCOLIZE evidence separately from authored input, and replay completed stages without revalidating an archived worktree or regressing later stages.
10
+
3
11
  ## 0.9.0-beta.136
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.136",
4
- "cli_commit": "2a01d3ee7f3ae52b3fc8c43ad88b5e6bcd5104aa",
5
- "built_at": "2026-10-13T21:36:08.226Z",
3
+ "cli_version": "0.9.0-beta.137",
4
+ "cli_commit": "3a1e4cb4a0a24e388228ebb85591350fa4f5886b",
5
+ "built_at": "2026-10-14T05:52:24.329Z",
6
6
  "built_with_canon": {
7
7
  "version": "4.1.2",
8
8
  "commit": "1ddedf9d3f086e046f8e7b194c739ec386e7c8b9",
@@ -18,6 +18,19 @@ closed transport is not proof that its native descendants stopped. The adapter
18
18
  must retain causal errors, missing observations and usage completeness rather
19
19
  than turn them into successful/zero-valued results.
20
20
 
21
+ ## Retained child observations
22
+
23
+ Daemon capability `retained_child_observation` enables `session observe-children`
24
+ for its registered root. It reads retained topology and exact terminal metadata,
25
+ never loads/resumes a child or performs semantic work. During fanout the controller
26
+ records failed child facts before dispatching anything else; healthy in-flight
27
+ siblings may settle normally. Observation polling is not productive model activity.
28
+ Ordinary `session inspect` is not a substitute for this read-only operation.
29
+
30
+ Native attribution is a reported fact, not proof that an HTTP request reached a
31
+ provider. If the native store discarded the causal exception, retain unknown;
32
+ do not scrape ambient logs or infer overload/retry authority from an error message.
33
+
21
34
  The bridge marks heuristic ACP completion with
22
35
  `_meta.zcodeCompletionEvidence: "inferred"` and exhausted failed turns with
23
36
  `"failed"`. dd-zcode rejects these before any post-turn materializing read:
@@ -2,19 +2,19 @@
2
2
  import { adapterTimeoutOptions } from "../lib/adapter-timeouts.mjs";
3
3
  import { durableDaemonStart, inspectDaemonOperation, interruptDaemonOperation, settleDaemonOperation } from "../lib/daemon-operations.mjs";
4
4
  import { readFile } from "node:fs/promises";
5
- import { callDaemon, serveDaemon, startDaemon, stopDaemon } from "../lib/dd-opencode-daemon.mjs";
5
+ import { callDaemon, doctor, openCodeProfileOptions, serveDaemon, startDaemon, stopDaemon } from "../lib/dd-opencode-daemon.mjs";
6
6
 
7
7
  function parse(argv) { const positional = [], options = {}; for (let i = 0; i < argv.length; i += 1) { const token = argv[i]; if (!token.startsWith("--")) { positional.push(token); continue; } const key = token.slice(2); if (["json","cancel-tree","no-flow"].includes(key)) options[key] = true; else { if (argv[i + 1] === undefined) throw new Error(`--${key} requires a value`); options[key] = argv[++i]; } } return { positional, options }; }
8
8
  const prompt = async options => options["prompt-file"] ? await readFile(options["prompt-file"], "utf8") : options.prompt;
9
9
  try {
10
- const { positional, options } = parse(process.argv.slice(2)), [family, command, modifier] = positional; const common = { journal: options.journal, stateDir: options["state-dir"], sessionId: options["session-id"], cwd: options.cwd, bin: options["opencode-bin"], provider: options.provider, model: options.model, variant: options.variant, agent: options.agent, authPath: options["auth-path"], configPath: options["config-path"], projectRoot: options["project-root"], ddFlowBin: options["dd-flow-bin"], ddFlowHome: options["dd-flow-home"], noFlow: options["no-flow"] === true, sessionArchive: options["session-archive"], ...adapterTimeoutOptions(options, family === "session" ? command : null) }; let result;
10
+ const { positional, options } = parse(process.argv.slice(2)), [family, command, modifier] = positional; const common = { journal: options.journal, stateDir: options["state-dir"], sessionId: options["session-id"], cwd: options.cwd, bin: options["opencode-bin"], provider: options.provider, model: options.model, ...openCodeProfileOptions(options), authPath: options["auth-path"], configPath: options["config-path"], projectRoot: options["project-root"], ddFlowBin: options["dd-flow-bin"], ddFlowHome: options["dd-flow-home"], noFlow: options["no-flow"] === true, sessionArchive: options["session-archive"], ...adapterTimeoutOptions(options, family === "session" ? command : null) }; let result;
11
11
  if (family === "daemon" && command === "start") result = await durableDaemonStart({ ...common, entryPath: process.argv[1] }, startDaemon);
12
12
  else if (family === "daemon" && command === "serve") { await serveDaemon(common.stateDir); process.exit(0); }
13
13
  else if (family === "daemon" && command === "status") result = await callDaemon(common.stateDir, "daemon.status");
14
14
  else if (family === "daemon" && command === "operation" && modifier === "interrupt") result = await interruptDaemonOperation(common.stateDir, options["operation-id"], { control_id: options["control-id"], stop_operation_id: options["stop-operation-id"], daemon_id: options["daemon-id"], session_id: options["session-id"], evidence: options.evidence ? JSON.parse(options.evidence) : null });
15
15
  else if (family === "daemon" && command === "operation" && modifier === "settle") result = await settleDaemonOperation(common.stateDir, options["operation-id"]);
16
16
  else if (family === "daemon" && command === "operation") result = await inspectDaemonOperation(common.stateDir, options["operation-id"]);
17
- else if (family === "doctor") result = await callDaemon(common.stateDir, "daemon.doctor");
17
+ else if (family === "doctor") result = await doctor(common);
18
18
  else if (family === "daemon" && command === "stop") result = await stopDaemon({ stateDir: common.stateDir, cancelTree: options["cancel-tree"], timeoutMs: common.timeoutMs });
19
19
  else if (family === "session") { const operation = command === "status" ? "inspect" : command; const params = { livenessTimeoutMs: common.livenessTimeoutMs, sessionId: options["session-id"], title: options.title, messageId: options["message-id"], archiveFile: options["archive-file"], ...(command === "prompt" ? { prompt: await prompt(options) } : {}) }; result = await callDaemon(common.stateDir, `session.${operation}`, params, common.timeoutMs ?? 1_800_000); }
20
20
  else throw new Error("usage: dd-opencode daemon start|status|stop | session create|prompt|inspect|fork|cancel|archive [options] --json");
@@ -22,6 +22,19 @@ import { prepareRuntimeOwner, cleanupFailedStart, confirmDaemonProcess, confirmD
22
22
  const REQUEST_SCHEMA = "dd-opencode/daemon-request@1", RESPONSE_SCHEMA = "dd-opencode/daemon-response@1", STATE_SCHEMA = "dd-opencode/daemon-state@2", ARCHIVE_SCHEMA = "dd-opencode/session-archive@2";
23
23
  class DaemonError extends Error { constructor(code, message, retryable = false, details) { super(message); this.code = code; this.retryable = retryable; this.details = details; } }
24
24
  const absolute = (value, label) => { if (!value || !path.isAbsolute(value)) throw new DaemonError("invalid_path", `${label} must be an absolute path`); return path.resolve(value); };
25
+ export function openCodeProfileOptions(options) {
26
+ for (const [native, shared] of [["variant", "reasoning"], ["agent", "mode"]]) {
27
+ if (options[native] !== undefined && options[shared] !== undefined && options[native] !== options[shared]) throw new DaemonError("profile_conflict", `--${native} and --${shared} disagree`);
28
+ }
29
+ return { variant: options.variant ?? options.reasoning, agent: options.agent ?? options.mode };
30
+ }
31
+ export async function doctor(options) {
32
+ if (options.stateDir) return await callDaemon(options.stateDir, "daemon.doctor");
33
+ const output = (await run(options.bin ?? process.env.DD_OPENCODE_BIN ?? "opencode", ["--version"], { cwd: options.cwd ?? process.cwd() })).trim();
34
+ const version = output.match(/(?:^|\s)v?(2\.\d+\.\d+(?:[-+][\w.-]+)?)(?:\s|$)/)?.[1];
35
+ if (!version) throw new DaemonError("unsupported_version", "dd-opencode supports OpenCode v2 only");
36
+ return { compatible: true, native_protocol: 2, observed_runtime: { opencode: version }, productive_hooks: false, profile_observation: "pending_native_session" };
37
+ }
25
38
  function locations(stateDir) { const dir = absolute(stateDir, "--state-dir"), local = path.join(dir, "daemon.sock"); return { dir, socket: Buffer.byteLength(local) < 100 ? local : `/tmp/dd-opencode-${createHash("sha256").update(dir).digest("hex").slice(0, 20)}.sock`, state: path.join(dir, "daemon.json"), journal: path.join(dir, "events.jsonl"), log: path.join(dir, "daemon.log"), data: path.join(dir, "xdg-data"), config: path.join(dir, "xdg-config"), cache: path.join(dir, "xdg-cache"), archive: path.join(dir, "import.json"), db: path.join(dir, "native.db"), xdgState: path.join(dir, "xdg-state") }; }
26
39
  const writeJson = writeJsonAtomic;
27
40
  async function readState(dir) { try { return JSON.parse(await readFile(locations(dir).state, "utf8")); } catch (error) { if (error.code === "ENOENT") return null; throw error; } }
@@ -272,7 +285,7 @@ export class Runtime {
272
285
  if (info.pid !== this.child?.pid || info.version !== this.state.versions.opencode) throw new DaemonError("foreign_server", "OpenCode endpoint is not the owned process");
273
286
  const pluginID = this.state.config.noFlow ? "dd-flow-security" : "dd-flow";
274
287
  if (!plugins.data.some(plugin => plugin.id === pluginID && plugin.state?.status === "active")) throw new DaemonError("opencode_plugin_missing", "Owned v2 plugin is not active");
275
- return { compatible: true, native_protocol: 2, productive_hooks: !this.state.config.noFlow, versions: this.state.versions, plugin_sha256: this.state.config.pluginSha256, auth_status: this.state.auth_status };
288
+ return { compatible: true, native_protocol: 2, observed_runtime: this.state.versions, productive_hooks: !this.state.config.noFlow, versions: this.state.versions, plugin_sha256: this.state.config.pluginSha256, auth_status: this.state.auth_status };
276
289
  }
277
290
  if (operation === "session.create") return this.productive(operation, async () => {
278
291
  const info = await this.client.create(params.title, this.state.config);
@@ -24,6 +24,7 @@ import {
24
24
  promptSessionWithBridge,
25
25
  zcodeChildSessionIds,
26
26
  observeClosedZcodeTree,
27
+ readZcodeSubagents,
27
28
  } from "./dd-zcode.mjs";
28
29
 
29
30
  const REQUEST_SCHEMA = "dd-zcode/daemon-request@1";
@@ -203,6 +204,7 @@ export async function stopDaemon(options) {
203
204
  export class DaemonRuntime {
204
205
  constructor(paths, state, bridge, initialized) {
205
206
  this.paths = paths; this.state = state; this.bridge = bridge; this.initialized = initialized;
207
+ this.state.capabilities = { ...(this.state.capabilities ?? {}), retained_child_observation: true };
206
208
  this.sessions = new Map((state.sessions ?? []).map((item) => [item.provider_session_id, item]));
207
209
  this.liveAdapters = new Set();
208
210
  this.activeProductive = null; this.server = null; this.persisting = Promise.resolve();
@@ -439,13 +441,46 @@ export class DaemonRuntime {
439
441
 
440
442
  async dispatch(operation, params, operationId = null) {
441
443
  if (["session.create", "session.prompt", "session.start", "session.fork", "session.resume"].includes(operation)) assertDaemonDispatch(this.paths.dir);
444
+ if (operation === "session.observe-children") {
445
+ assertDaemonDispatch(this.paths.dir);
446
+ const root = this.sessions.get(params.sessionId);
447
+ if (!root || root.close_receipt) throw new DaemonError("session_identity_mismatch", "Retained observation requires a registered open root Session");
448
+ const generation = this.controlGeneration ?? 0;
449
+ const active = this.state.active_request;
450
+ const activeIdentity = value => JSON.stringify([value?.operation_id ?? null, value?.session_id ?? null, value?.adapter_session_id ?? null, value?.generation ?? null]);
451
+ const capturedIdentity = activeIdentity(active);
452
+ if (active && active.session_id !== params.sessionId) throw new DaemonError("session_identity_mismatch", "Retained observation has a foreign active root");
453
+ const cacheScope = `${params.sessionId}:${generation}`;
454
+ if (this.childObservationScope !== cacheScope) { this.childObservationScope = cacheScope; this.childTerminalCache = new Map(); this.childObservationKeys = new Set(); }
455
+ const subagents = await readZcodeSubagents(this.bridge, root.adapter_session_id, 30_000, true, this.childTerminalCache);
456
+ if (generation !== (this.controlGeneration ?? 0)) throw new DaemonError("operation_cancelled", "Observation generation was retired");
457
+ if (activeIdentity(this.state.active_request) !== capturedIdentity) throw new DaemonError("operation_cancelled", "Observation belongs to a retired active Turn");
458
+ assertDaemonDispatch(this.paths.dir);
459
+ const receipt = { schema_id: "dd-zcode/child-observation@1", daemon_id: this.state.daemon_id, provider_session_id: params.sessionId,
460
+ generation, active_operation_id: active?.operation_id ?? null, subagents };
461
+ const failed = (subagents.ended?.items ?? subagents.completed ?? []).filter(child => child.status === "failed" && child.terminal_turn_id);
462
+ const fresh = failed.filter(child => !this.childObservationKeys.has(`${child.childSessionId}:${child.terminal_turn_id}`));
463
+ if (fresh.length) {
464
+ await this.bridge.journal.write("native_child_observation", { ...receipt, subagents: { running: [], ended: { total: fresh.length, items: fresh } } });
465
+ await this.bridge.journal.flush();
466
+ if (this.state.config?.journal) {
467
+ const journal = await open(this.state.config.journal, "a");
468
+ try { await journal.sync(); } finally { await journal.close(); }
469
+ }
470
+ for (const child of fresh) this.childObservationKeys.add(`${child.childSessionId}:${child.terminal_turn_id}`);
471
+ }
472
+ if (generation !== (this.controlGeneration ?? 0)) throw new DaemonError("operation_cancelled", "Observation generation was retired before ACK");
473
+ if (activeIdentity(this.state.active_request) !== capturedIdentity) throw new DaemonError("operation_cancelled", "Observation Turn changed before ACK");
474
+ assertDaemonDispatch(this.paths.dir);
475
+ return receipt;
476
+ }
442
477
  if (operation === "daemon.status") return {
443
478
  daemon_id: this.state.daemon_id, pid: process.pid, socket: this.paths.socket,
444
479
  cwd: this.state.cwd, journal: this.state.journal, versions: this.state.versions,
445
480
  started_at: this.state.started_at, updated_at: this.state.updated_at,
446
481
  shutdown_state: this.state.shutdown_state, recovery_status: this.state.recovery_status,
447
482
  active_tree: this.state.active_tree, active_operation: this.activeProductive, active_request: this.state.active_request ?? null,
448
- sessions: [...this.sessions.values()], config: this.state.config,
483
+ sessions: [...this.sessions.values()], config: this.state.config, capabilities: { retained_child_observation: true },
449
484
  };
450
485
  if (operation === "hook.resolve") {
451
486
  const sessionId = String(params.sessionId ?? "");
@@ -544,7 +544,7 @@ async function resume(bridge, sessionId, cwd) {
544
544
 
545
545
  /** Ended children are paginated by the native API (20 by default). A partial
546
546
  * directory is never settlement evidence, including during teardown. */
547
- export async function readZcodeSubagents(bridge, sessionId, timeoutMs = 30_000, retained = false) {
547
+ export async function readZcodeSubagents(bridge, sessionId, timeoutMs = 30_000, retained = false, terminalCache = new Map()) {
548
548
  const clock = new ObservationClock({ timeoutMs });
549
549
  const cursors = new Set(), ended = new Map();
550
550
  let first, cursor;
@@ -575,6 +575,8 @@ export async function readZcodeSubagents(bridge, sessionId, timeoutMs = 30_000,
575
575
  if (child.status !== "failed" || !Number.isSafeInteger(child.startedAt) || child.startedAt <= 0
576
576
  || !Number.isSafeInteger(child.endedAt) || child.endedAt < child.startedAt || child.endedAt > 8.64e15) { items.push(child); continue; }
577
577
  // Closed children must never be loaded merely to explain their failure.
578
+ const cacheKey = JSON.stringify([child.childSessionId, child.startedAt, child.endedAt]);
579
+ if (terminalCache.has(cacheKey)) { items.push(terminalCache.get(cacheKey)); continue; }
578
580
  let terminal;
579
581
  try { terminal = await bridge.request("zcode/session/retainedTerminal", { sessionId: child.childSessionId, startedAt: child.startedAt, endedAt: child.endedAt }, timeoutMs); }
580
582
  catch (error) { items.push({ ...child, terminal_metadata_unavailable: error.code ?? "terminal_metadata_unavailable" }); continue; }
@@ -584,8 +586,10 @@ export async function readZcodeSubagents(bridge, sessionId, timeoutMs = 30_000,
584
586
  || terminal.startedAt !== child.startedAt || terminal.endedAt !== child.endedAt
585
587
  || !Number.isFinite(observedAt) || observedAt < child.startedAt || observedAt > child.endedAt) fail("ZCode terminal failure metadata has foreign identity", "native_child_turn_conflict");
586
588
  const providerError = safeNativeFailure(terminal.provider_error);
587
- items.push({ ...child, terminal_turn_id: terminal.terminal_turn_id, terminal_observed_at: terminal.terminal_observed_at,
588
- ...(providerError ? { provider_error: providerError } : {}) });
589
+ const item = { ...child, terminal_turn_id: terminal.terminal_turn_id, terminal_observed_at: terminal.terminal_observed_at,
590
+ ...(providerError ? { provider_error: providerError } : {}) };
591
+ terminalCache.set(cacheKey, item);
592
+ items.push(item);
589
593
  clock.sample(child.childSessionId);
590
594
  }
591
595
  return { ...first, ended: { total: first.ended.total, items } };
@@ -29,7 +29,9 @@ export function makeOpenCodePlugin(binding, invoke = invokeNativeHook) {
29
29
  delete event.env.OPENCODE_PASSWORD; delete event.env.OPENCODE_SERVER_PASSWORD;
30
30
  });
31
31
  await ctx.tool.hook("execute.before", async event => {
32
- if (event.tool !== "shell" || !binding.ddFlowBin) return;
32
+ if (event.tool !== "shell") return;
33
+ if (event.input.timeout === undefined) event.input.timeout = 0;
34
+ if (!binding.ddFlowBin) return;
33
35
  const budget = hookBudget(), payload = await envelope("before", event, budget);
34
36
  event.input = { ...event.input, workdir: payload.session.directory };
35
37
  const result = await deliver({ ...payload, input: event.input }, budget);
@@ -0,0 +1,99 @@
1
+ import fs from "node:fs";
2
+ import { normalizeNativeChildren, safeNativeFailure } from "../harness-runtime/lib/native-children.mjs";
3
+ /** A durable observation is a dispatch fence, not authority to complete Work. */
4
+ export function retainedChildObserver(input) {
5
+ let active = true, pending;
6
+ let gap = null;
7
+ const retained = new Map();
8
+ const sameScope = (scope) => scope?.controller_id === input.scope.controller_id && scope.generation === input.scope.generation && scope.stage === input.scope.stage && scope.root_session_id === input.scope.root_session_id;
9
+ const normalized = (child) => {
10
+ if (!child || child.parent_session_id !== input.scope.root_session_id || child.status !== "failed" || typeof child.terminal_turn_id !== "string" || ![child.session_id, child.terminal_turn_id, child.source].every(value => typeof value === "string" && value.length > 0 && value.length <= 256))
11
+ throw new Error("native_child_observation_invalid");
12
+ const provider_error = safeNativeFailure(child.provider_error);
13
+ return { session_id: child.session_id, parent_session_id: child.parent_session_id, status: "failed", source: child.source, terminal_turn_id: child.terminal_turn_id,
14
+ ...(typeof child.terminal_observed_at === "string" && Number.isFinite(Date.parse(child.terminal_observed_at)) ? { terminal_observed_at: child.terminal_observed_at } : {}),
15
+ ...(provider_error ? { provider_error } : {}) };
16
+ };
17
+ const accept = (children) => {
18
+ for (const raw of children) {
19
+ const child = normalized(raw);
20
+ retained.set(`${child.session_id}:${child.terminal_turn_id}`, child);
21
+ }
22
+ };
23
+ if (fs.existsSync(input.file))
24
+ for (const line of fs.readFileSync(input.file, "utf8").split("\n").filter(Boolean)) {
25
+ const observation = JSON.parse(line);
26
+ if (sameScope(observation.scope)) {
27
+ if (Array.isArray(observation.children))
28
+ accept(observation.children);
29
+ if (Object.hasOwn(observation, "gap"))
30
+ gap = observation.gap ?? null;
31
+ }
32
+ }
33
+ const complete = (children) => {
34
+ if (!active)
35
+ return;
36
+ input.assertOwner();
37
+ const failed = children.filter(child => child.parent_session_id === input.scope.root_session_id && child.status === "failed" && child.terminal_turn_id && !retained.has(`${child.session_id}:${child.terminal_turn_id}`)).map(normalized);
38
+ if (!failed.length && !gap)
39
+ return;
40
+ const fd = fs.openSync(input.file, "a", 0o600);
41
+ try {
42
+ fs.writeSync(fd, `${JSON.stringify({ scope: input.scope, children: failed, gap: null })}\n`);
43
+ fs.fsyncSync(fd);
44
+ }
45
+ finally {
46
+ fs.closeSync(fd);
47
+ }
48
+ accept(failed);
49
+ gap = null;
50
+ };
51
+ return {
52
+ failed: () => [...retained.values()],
53
+ gap: () => gap,
54
+ drain: () => pending ?? Promise.resolve(),
55
+ completePostTurn(receipt) {
56
+ const evidence = receipt.evidence;
57
+ const tree = evidence?.subagents;
58
+ if (receipt.provider_session_id !== input.scope.root_session_id || receipt.observation_error || evidence?.observation_error || !evidence?.read?.projection || !evidence.read.session || !tree || tree.observation_error || !Array.isArray(tree.running))
59
+ return false;
60
+ if (tree.ended ? !Array.isArray(tree.ended.items) || tree.ended.total !== tree.ended.items.length || tree.ended.nextCursor : !Array.isArray(tree.completed))
61
+ return false;
62
+ const children = normalizeNativeChildren(receipt, input.scope.root_session_id);
63
+ if (children.some(child => child.status === "failed" && !child.terminal_turn_id))
64
+ return false;
65
+ complete(children);
66
+ return true;
67
+ },
68
+ tick() {
69
+ if (!active || pending)
70
+ return;
71
+ pending = (async () => {
72
+ const children = await input.read();
73
+ if (!active || children === null)
74
+ return;
75
+ complete(children);
76
+ })().catch(error => {
77
+ if (!active)
78
+ return;
79
+ try {
80
+ input.assertOwner();
81
+ const code = String(error?.code ?? "observation_unavailable");
82
+ gap = /^[\w-]{1,128}$/.test(code) ? code : "observation_unavailable";
83
+ const fd = fs.openSync(input.file, "a", 0o600);
84
+ try {
85
+ fs.writeSync(fd, `${JSON.stringify({ scope: input.scope, gap })}\n`);
86
+ fs.fsyncSync(fd);
87
+ }
88
+ finally {
89
+ fs.closeSync(fd);
90
+ }
91
+ }
92
+ catch { /* Lost owner cannot publish; the gap remains an explicit caller error. */ }
93
+ input.gap(error);
94
+ }).finally(() => { pending = undefined; });
95
+ },
96
+ // The caller aborts its request; a late reply cannot publish after closure.
97
+ close() { active = false; return pending ?? Promise.resolve(); },
98
+ };
99
+ }
@@ -1,6 +1,6 @@
1
1
  import { AppError, errorRecord } from "../shared/errors.js";
2
2
  import { normalizeNativeChildren, safeNativeFailure } from "../harness-runtime/lib/native-children.mjs";
3
- import { getVnextFanoutStatus, observeVnextNativeChildren, reconcileVnextFanout, subagentCapacityKey } from "./vnext-fanout.js";
3
+ import { getVnextFanoutStatus, observeVnextNativeChildren, reconcileVnextFanout, subagentCapacityKey, verifiedNativeWorkFailure } from "./vnext-fanout.js";
4
4
  import { getFlowRunVariables } from "./runs.js";
5
5
  import { dispatchVnextPlanReview } from "./vnext-plan-review.js";
6
6
  import { resolveExecutionPolicy } from "./execution-policy.js";
@@ -54,8 +54,11 @@ export async function nextControllerFanout(context, input) {
54
54
  throw new AppError("fanout_reconciliation_required", "Native children have unresolved lifecycle ownership", 1, { issues });
55
55
  }
56
56
  let fanout = getVnextFanoutStatus(context, input).orchestration;
57
- if (!fanout)
57
+ if (!fanout) {
58
+ if (input.dispatchFence)
59
+ throw new AppError("fanout_reconciliation_required", "Observed native failure has no current graph; dispatch stays fenced", 1, { stage: input.stage, run_id: input.runId });
58
60
  return { kind: "none" };
61
+ }
59
62
  if (fanout.state === "compatibility_required")
60
63
  throw new AppError("stage_graph_compatibility_required", "The retained graph cannot be dispatched by this controller; use the RUN's pinned engine", 1, { stage: input.stage, run_id: input.runId, graph: fanout });
61
64
  if (children.length) {
@@ -67,6 +70,8 @@ export async function nextControllerFanout(context, input) {
67
70
  return { kind: "wait", reason: "native_children_unsettled" };
68
71
  fanout = getVnextFanoutStatus(context, input).orchestration;
69
72
  }
73
+ if (input.dispatchFence && !fanout.works.failed)
74
+ throw new AppError("fanout_reconciliation_required", "Observed native failure has no proven failed Work; dispatch stays fenced", 1, { stage: input.stage, run_id: input.runId });
70
75
  if (fanout.state === "dispatch_required") {
71
76
  dispatchVnextPlanReview(context, input);
72
77
  fanout = getVnextFanoutStatus(context, input).orchestration;
@@ -88,7 +93,9 @@ export async function nextControllerFanout(context, input) {
88
93
  }
89
94
  catch { /* Missing legacy metadata is unknown. */ }
90
95
  const providerError = safeNativeFailure(receipt.provider_error);
96
+ const nativeBinding = verifiedNativeWorkFailure(context, { projectRoot: input.projectRoot, runId: input.runId, stage: input.stage, parentSessionId: input.sessionId, parentWorkId: fanout.parent_work_id, workId: work.work_id, receipt });
91
97
  return { work_id: work.work_id, status: work.status, ...(providerError ? { provider_error: providerError } : {}),
98
+ ...(nativeBinding ? { native_binding: nativeBinding } : {}),
92
99
  ...Object.fromEntries(["terminal_turn_id", "terminal_observed_at"].filter(key => typeof receipt[key] === "string" && receipt[key].length <= 256).map(key => [key, receipt[key]])) };
93
100
  });
94
101
  throw new AppError("fanout_child_failed", "A child Work failed or was cancelled; no retry authority has been issued", 1, { stage: input.stage, run_id: input.runId, parent_work_id: fanout.parent_work_id, failed: fanout.works.failed, cancelled: fanout.works.cancelled, failures, effect: "unknown" });
@@ -31,6 +31,7 @@ import { requireRun, requireController, controllerReceipt, appendEvent, saveStat
31
31
  import { assertNoCorrelatedRejection } from "./continuation-outcome.js";
32
32
  import { spawnController, recordControllerLauncher, claimControllerProcess, markControllerDispatchReady, assertControllerRecoveryAllowed } from "./run-controller-process.js";
33
33
  import { controllerAdapter, controllerEnvironment, sessionArguments } from "./run-controller-adapter.js";
34
+ import { retainedChildObserver } from "./controller-child-observer.js";
34
35
  import { capacityDelay, continueCodexCapacity, eligibleCodexChildOverload } from "./codex-capacity.js";
35
36
  import { resumeControllerSession, failControllerRecovery } from "./run-controller-recovery.js";
36
37
  import { captureControllerBoundary } from "./run-controller-capture.js";
@@ -460,6 +461,52 @@ async function executeController(context, row, manifest, state, assertOwnership,
460
461
  let fatal;
461
462
  let stopping;
462
463
  const cancellation = new AbortController();
464
+ const observationCancellation = new AbortController();
465
+ const childObservers = new Map();
466
+ let promptPending = false;
467
+ let observedPromptOperation = null;
468
+ const childObserver = (session) => {
469
+ if (runtimeHarness(session.profile) !== "zcode-acp" || !state.stage || !["plan-review", "code", "code-review"].includes(state.stage))
470
+ return undefined;
471
+ if (readNativeDaemonState(session.state_dir).capabilities?.retained_child_observation !== true)
472
+ return undefined;
473
+ const scope = { controller_id: row.controller_id, generation: row.generation, stage: state.stage, root_session_id: session.id };
474
+ const key = JSON.stringify(scope);
475
+ let retained = childObservers.get(key);
476
+ if (!retained) {
477
+ retained = retainedChildObserver({ file: path.join(row.state_dir, "native-child-observations.jsonl"), scope, assertOwner: assertOwnership,
478
+ gap: error => { process.stderr.write(`${JSON.stringify({ event: "native_child_observation_gap", scope, error_code: String(error?.code ?? "observation_unavailable") })}\n`); },
479
+ read: async () => {
480
+ assertOwnership();
481
+ const operation = observedPromptOperation;
482
+ const binding = requireManagedDaemonBinding(executionContext, { stateDir: session.state_dir, projectId: row.project_id, runId: row.run_id });
483
+ let receipt;
484
+ try {
485
+ receipt = await runHarnessAdapter({ executable: manifest.adapters["zcode-acp"].executable,
486
+ args: ["session", "observe-children", "--session-id", session.id, ...sessionArguments(context, manifest, session)],
487
+ env: controllerEnvironment(context, manifest, session), operationId: crypto.randomUUID(), harness: "zcode", timeoutMs: 30_000,
488
+ signal: AbortSignal.any([cancellation.signal, observationCancellation.signal]) });
489
+ }
490
+ catch (error) {
491
+ if (!promptPending || observedPromptOperation !== operation)
492
+ return null;
493
+ throw error;
494
+ }
495
+ if (!promptPending || observedPromptOperation !== operation || receipt.active_operation_id === null)
496
+ return null;
497
+ if (receipt.schema_id !== "dd-zcode/child-observation@1" || receipt.provider_session_id !== session.id || receipt.daemon_id !== binding.state.daemon_id)
498
+ throw new AppError("native_child_observation_conflict", "Retained child observation has foreign daemon/root identity", 1);
499
+ if (receipt.active_operation_id !== operation)
500
+ throw new AppError("native_child_observation_conflict", "Retained observation has a foreign productive operation", 1);
501
+ const children = controllerNativeChildren(receipt, session.id).filter(child => !(state.native_baseline ?? []).includes(child.session_id));
502
+ if (children.some(child => child.status === "failed" && !child.terminal_turn_id))
503
+ throw new AppError("operation_observation_lost", "Retained child terminal identity is unavailable", 1);
504
+ return children;
505
+ } });
506
+ childObservers.set(key, retained);
507
+ }
508
+ return retained;
509
+ };
463
510
  const capacityUncertain = (error) => error instanceof AppError && ["capacity_continuation_blocked", "operation_observation_lost"].includes(error.code) && error.details.capacity_uncertainty === true || Boolean(state.pending_capacity) && (String(error?.code) === "capacity_continuation_blocked" || isObservationLoss(error));
464
511
  const stopAfterFailure = (error) => {
465
512
  // A retained successor may already be running. Fencing/recovery records
@@ -492,10 +539,15 @@ async function executeController(context, row, manifest, state, assertOwnership,
492
539
  }).finally(() => cancellation.abort());
493
540
  return stopping;
494
541
  };
495
- const setStatus = (status) => {
496
- const changed = context.db.run("UPDATE run_controllers SET status = ?, updated_at = ? WHERE controller_id = ? AND owner_token = ? AND status <> ?", [status, context.now(), row.controller_id, row.owner_token, status]);
497
- if (changed.changes)
498
- appendEvent(context, row.controller_id, "status_changed", { status, stage: state.stage });
542
+ const setStatus = (status, terminalData) => {
543
+ context.db.writeTransaction(() => {
544
+ const changed = context.db.run("UPDATE run_controllers SET status = ?, updated_at = ? WHERE controller_id = ? AND owner_token = ? AND status <> ?", [status, context.now(), row.controller_id, row.owner_token, status]);
545
+ if (changed.changes) {
546
+ appendEvent(context, row.controller_id, "status_changed", { status, stage: state.stage });
547
+ if (terminalData)
548
+ appendEvent(context, row.controller_id, status, terminalData);
549
+ }
550
+ });
499
551
  };
500
552
  const call = (session, args, productive = true) => controllerAdapter(executionContext, row, manifest, session, args, productive ? assertOwner : undefined, undefined, productive ? cancellation.signal : undefined, productive ? renewLease : undefined);
501
553
  const confirmStopped = async (stateDir, receipt) => {
@@ -575,7 +627,17 @@ async function executeController(context, row, manifest, state, assertOwnership,
575
627
  throw new AppError("capacity_continuation_conflict", "Retained capacity root has no failure receipt", 1);
576
628
  throw JSON.parse(root.error_json);
577
629
  }
578
- receipt = await send(text, firstOperationId);
630
+ if (childObserver(session)?.failed().length)
631
+ throw new AppError("fanout_dispatch_fenced", "A retained child failure forbids another coordinator dispatch", 1);
632
+ promptPending = true;
633
+ observedPromptOperation = firstOperationId;
634
+ try {
635
+ receipt = await send(text, firstOperationId);
636
+ }
637
+ finally {
638
+ promptPending = false;
639
+ observedPromptOperation = null;
640
+ }
579
641
  }
580
642
  catch (error) {
581
643
  if (!["codex-desktop", "opencode-server"].includes(runtimeHarness(session.profile)))
@@ -640,6 +702,11 @@ async function executeController(context, row, manifest, state, assertOwnership,
640
702
  receipt = resumed;
641
703
  }
642
704
  checkOutcomes();
705
+ await childObserver(session)?.drain();
706
+ childObserver(session)?.completePostTurn(receipt);
707
+ const observationGap = childObserver(session)?.gap();
708
+ if (observationGap)
709
+ throw new AppError("operation_observation_lost", "In-flight child observation remained unavailable at the safe Turn boundary", 1, { observation_error_code: observationGap, provider_session_id: session.id, stage: state.stage });
643
710
  if (fatal)
644
711
  throw fatal;
645
712
  }
@@ -661,6 +728,8 @@ async function executeController(context, row, manifest, state, assertOwnership,
661
728
  return;
662
729
  try {
663
730
  checkOutcomes();
731
+ if (promptPending && state.current_session !== null)
732
+ childObserver(state.sessions[state.current_session])?.tick();
664
733
  }
665
734
  catch (error) {
666
735
  void stopAfterFailure(error);
@@ -692,15 +761,16 @@ async function executeController(context, row, manifest, state, assertOwnership,
692
761
  const next = view.continuation;
693
762
  terminalFinalization = next.kind === "terminal";
694
763
  const completed = state.stage && view.index.stage_runs.find(stage => stage.stage === state.stage && ["done", "skipped"].includes(stage.status));
764
+ if (state.current_session !== null && (completed || next.stage !== state.stage) && childObserver(state.sessions[state.current_session])?.failed().length)
765
+ throw new AppError("fanout_dispatch_fenced", "A retained child failure forbids completing or advancing its Stage", 1);
695
766
  if (completed && manifest.request.stop_after === state.stage) {
696
767
  // A bounded run is final evidence, not an entry fixture for its successor.
697
768
  // Stop owned observers before capture; keep the controller lease/fence.
698
769
  await settle();
699
770
  if (manifest.request.capture_root)
700
771
  await captureControllerBoundary(executionContext, row, manifest, completed, null, assertPhysicalOwner);
701
- setStatus("stop_target_reached");
702
772
  setFlowRunRecoveryPaused(context, row.project_id, row.run_id, true);
703
- appendEvent(context, row.controller_id, "stop_target_reached", { stage: state.stage, attempt: completed.attempt ?? null });
773
+ setStatus("stop_target_reached", { stage: state.stage, attempt: completed.attempt ?? null });
704
774
  return;
705
775
  }
706
776
  if (completed && manifest.request.capture_root && ["start_stage", "terminal"].includes(next.kind)) {
@@ -839,6 +909,8 @@ async function executeController(context, row, manifest, state, assertOwnership,
839
909
  const session = state.sessions[state.current_session];
840
910
  const baseline = new Set(state.native_baseline ?? []);
841
911
  let children = controllerNativeChildren(state.last_receipt ?? {}, session.id).filter(child => !baseline.has(child.session_id));
912
+ const retainedFailures = childObserver(session)?.failed() ?? [];
913
+ children = controllerNativeChildren({ descendants: [...children, ...retainedFailures] }, session.id);
842
914
  if (runtimeHarness(session.profile) === "codex-desktop" && children.some(child => child.status === "failed")) {
843
915
  const inspected = await call(session, ["session", "inspect", "--session-id", session.id, ...sessionArguments(context, manifest, session)], false);
844
916
  state.last_receipt = inspected;
@@ -941,7 +1013,7 @@ async function executeController(context, row, manifest, state, assertOwnership,
941
1013
  continue;
942
1014
  }
943
1015
  }
944
- const fanout = await nextControllerFanout(executionContext, { projectRoot: run.project_root, runId: run.id, stage: next.stage, harness: runtimeHarness(session.profile), sessionId: session.id, children, policy, profiles });
1016
+ const fanout = await nextControllerFanout(executionContext, { projectRoot: run.project_root, runId: run.id, stage: next.stage, harness: runtimeHarness(session.profile), sessionId: session.id, children, policy, profiles, dispatchFence: retainedFailures.length > 0 });
945
1017
  assertOwner();
946
1018
  if (fanout.kind === "none") {
947
1019
  assertNoCorrelatedRejection(context, { projectId: row.project_id, runId: row.run_id, generation: row.generation, stage: next.stage, turn: state.turns });
@@ -1013,6 +1085,9 @@ async function executeController(context, row, manifest, state, assertOwnership,
1013
1085
  }
1014
1086
  finally {
1015
1087
  clearInterval(monitor);
1088
+ const drainedObservations = [...childObservers.values()].map(retained => retained.close());
1089
+ observationCancellation.abort();
1090
+ await Promise.allSettled(drainedObservations);
1016
1091
  if (stopping)
1017
1092
  await stopping;
1018
1093
  observer.db.close?.();
@@ -741,7 +741,8 @@ function buildStageReport(context, view, stage, status, semantic, stageRoot, lin
741
741
  next_action: stringValue(semantic.next_action) ?? "Resolve the stage result before continuing.",
742
742
  ...(Array.isArray(semantic.questions) ? { questions: semantic.questions } : {}),
743
743
  ...(Array.isArray(semantic.reviewer_findings) ? { reviewer_findings: semantic.reviewer_findings } : {}),
744
- ...(Array.isArray(semantic.def_outcomes) ? { def_outcomes: semantic.def_outcomes } : {})
744
+ ...(Array.isArray(semantic.def_outcomes) ? { def_outcomes: semantic.def_outcomes } : {}),
745
+ ...(semantic.materialization !== undefined ? { materialization: semantic.materialization } : {})
745
746
  },
746
747
  mechanical: {
747
748
  started_at: startedAt,
@@ -164,6 +164,19 @@ export function reconcileRunStageReports(context, projectId, runId, exceptStage)
164
164
  }
165
165
  function normalizeStageReport(report) {
166
166
  const semantic = object(report.semantic);
167
+ if (semantic.materialization !== undefined) {
168
+ const value = object(semantic.materialization);
169
+ const authored = object(value.authored_result);
170
+ const relative = (item) => typeof item === "string" && item.length > 0 && !path.isAbsolute(item) && !item.split(/[\\/]/).includes("..");
171
+ if (value.schema_id !== "dd-flow/protocolize-materialization@1" || value.run_id !== report.run_id || report.stage !== "protocolize" || value.stage !== "protocolize"
172
+ || !["create", "link", "update", "not_applicable"].includes(String(value.action)) || value.status !== "passed"
173
+ || authored.role !== "authored_input" || !relative(authored.run_relative_path) || !/^[a-f0-9]{64}$/.test(String(authored.sha256))
174
+ || !(value.feature_path === null || relative(value.feature_path)) || !Array.isArray(value.protocol_ids) || value.protocol_ids.some(id => typeof id !== "string")
175
+ || !Array.isArray(value.documents) || value.documents.some(item => { const ref = object(item); return !relative(ref.path) || !/^[a-f0-9]{64}$/.test(String(ref.sha256)); })
176
+ || !Array.isArray(value.owned_links) || value.owned_links.some(item => { const edge = object(item); return !relative(edge.feature_path) || !relative(edge.protocol_path) || !value.protocol_ids.includes(edge.protocol_id); })) {
177
+ throw new AppError("stage_report_materialization_invalid", "PROTOCOLIZE materialization receipt is malformed or out of scope", 1);
178
+ }
179
+ }
167
180
  const sourceArtifacts = object(report.artifacts);
168
181
  return {
169
182
  ...report,