@deksden-com/dd-flow-cli 0.9.0-beta.135 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
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
+
11
+ ## 0.9.0-beta.136
12
+
13
+ ### Patch Changes
14
+
15
+ - 8c75ca6: Reconcile retained RUN health using physical ownership and controller settlement without dispatch or lease takeover. Persist target-stage pauses, preserve unknown historical timing, and leave future pending stages untouched after a failure.
16
+
3
17
  ## 0.9.0-beta.135
4
18
 
5
19
  ### Patch Changes
package/README.md CHANGED
@@ -540,3 +540,5 @@ It does not claim physical settlement or alter its receipts. `--cancel-abandoned
540
540
  explicitly cancels an abandoned fixture only when the project directory is absent
541
541
  and it has no Work, Session, controller or recovery history. Historical experiment
542
542
  engine snapshots remain pinned; rebuild/release the engine for future executions.
543
+
544
+ `dd-flow run reconcile [RUN] --json` проверяет сохранённые статусы без dispatch; `--apply` сохраняет согласованную health-проекцию. Возраст записи даёт предупреждение, а не доказательство ошибки. Ожидания и потеря владельца различаются; обслуживание не сигналит процессам и не перехватывает leases.
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.135",
4
- "cli_commit": "2bf31592781b08329fed05b207d73e2485bdb868",
5
- "built_at": "2026-10-13T16:18:04.114Z",
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
- "commit": "fb848eee0c7340f8a776f269b86b9ac8412dec24",
8
+ "commit": "1ddedf9d3f086e046f8e7b194c739ec386e7c8b9",
9
9
  "flow_contract": "dd-flow-canonical-2026-08",
10
- "repo_root": "/Users/deksden/Documents/_Projects/_worktrees/dd-memorybank-opencode-v2-release",
11
- "memorybank_root": "/Users/deksden/Documents/_Projects/_worktrees/dd-memorybank-opencode-v2-release/.memory-bank",
12
- "flow_root": "/Users/deksden/Documents/_Projects/_worktrees/dd-memorybank-opencode-v2-release/.memory-bank/dd-flow",
10
+ "repo_root": "/Users/deksden/Documents/_Projects/dd-memorybank",
11
+ "memorybank_root": "/Users/deksden/Documents/_Projects/dd-memorybank/.memory-bank",
12
+ "flow_root": "/Users/deksden/Documents/_Projects/dd-memorybank/.memory-bank/dd-flow",
13
13
  "layout": "dot_memory_bank"
14
14
  }
15
15
  }
@@ -80,6 +80,7 @@ export const commandInputs = {
80
80
  "run start": route(0, `${project} flow-kind subject-type subject-id slug`, "workspace-root next-action preset task-profile-file execution-routing-file protocol-override-file flag", "", "flag"),
81
81
  "run prepare-vnext-specify": route(0, `${project} slug`, "execution-routing-file"),
82
82
  "run status": route(1, project),
83
+ "run reconcile": route([0, 1], "", "project-root no-progress-ms", "apply"),
83
84
  "run list": route(0, project),
84
85
  "run timeline": route(1, project, "hide", "", "hide"),
85
86
  "run config status": route(1, project),
@@ -70,6 +70,7 @@ import { vnextStageDirectory } from "../domain/stage-catalog.js";
70
70
  import { prepareCommandRoute } from "./command-inputs.js";
71
71
  import { parseLifecycleCommand } from "../services/lifecycle-command.js";
72
72
  import { dispatchRuntimeProcess, prepareRuntimeProcess } from "./runtime-process.js";
73
+ import { reconcileFlowRunHealth } from "../services/run-health.js";
73
74
  import { fenceRuntimeBudget, runtimeBudgetStatus, prepareRuntimeScopeControl, prepareRuntimeScopeFence } from "../services/runtime-budget.js";
74
75
  import { stopRuntimeScope } from "../services/runtime-scope-stop.js";
75
76
  import { launchRuntimeScopeControl, launchRuntimeScopeResume, prepareRuntimeScopeReconciliation, prepareRuntimeScopeServing, reconcileRuntimeScopeControl, serveRuntimeScopeControl } from "../services/runtime-scope-worker.js";
@@ -505,6 +506,8 @@ function resolveProjectRootBeforeRouting(args, env, deadlineAt) {
505
506
  function databaseModeForCommand(args, classification) {
506
507
  if (isNativeHookIngress(args))
507
508
  return "hook";
509
+ if (args[0] === "run" && args[1] === "reconcile")
510
+ return args.includes("--apply") ? "reconcile" : "read_existing";
508
511
  if (isRuntimeMaintenance(args))
509
512
  return "read_existing";
510
513
  const [family, command] = args;
@@ -2474,6 +2477,8 @@ function recoveryResumeInput(parsed) {
2474
2477
  return { projectRoot: requiredOption(parsed, "project-root"), runId: requiredPosition(parsed, 1, "run-id"), recoveryId: requiredOption(parsed, "recovery-id"), daemonId: requiredOption(parsed, "daemon-id"), harness: requiredOption(parsed, "harness"), nativeSessionId: requiredOption(parsed, "native-session-id") };
2475
2478
  }
2476
2479
  function dispatchRun(context, command, parsed, prepared = {}) {
2480
+ if (command === "reconcile")
2481
+ return reconcileFlowRunHealth(context, { runId: parsed.positional[0], projectRoot: optionalOption(parsed, "project-root"), apply: hasOption(parsed, "apply"), noProgressMs: optionalNumber(parsed, "no-progress-ms") });
2477
2482
  if (command === "recovery") {
2478
2483
  const action = requiredPosition(parsed, 0, "recovery action");
2479
2484
  const projectRoot = requiredOption(parsed, "project-root");
@@ -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);
@@ -6,6 +6,8 @@ export function classifyCliOperation(args, env = process.env) {
6
6
  if (isMbUpgradeMode(args, env)) {
7
7
  return { mode: "mb_upgrade", operation, family, command, action };
8
8
  }
9
+ if (family === "run" && command === "reconcile" && !args.includes("--apply"))
10
+ return { mode: "read_only_diagnostics", operation, family, command, action };
9
11
  if (isRouterNative(args)) {
10
12
  return { mode: "router_native", operation, family, command, action };
11
13
  }
@@ -85,6 +87,8 @@ function isRouterNative(args) {
85
87
  return true;
86
88
  if (["engine", "version", "schema"].includes(first))
87
89
  return true;
90
+ if (first === "run" && args[1] === "reconcile")
91
+ return true;
88
92
  if (first === "storage" && args[1] === "migrate-writer")
89
93
  return true;
90
94
  return (first === "codex" && args[1] === "hook" && args[2] === "handle")
@@ -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" });
@@ -30,7 +30,7 @@ export function isRouterNativeCommand(args) {
30
30
  return true;
31
31
  if (args.includes("--help") || args.includes("-h"))
32
32
  return true;
33
- return routerNativeFamilies.has(first) || first === "codex" && args[1] === "hook" && args[2] === "handle";
33
+ return first === "run" && args[1] === "reconcile" || routerNativeFamilies.has(first) || first === "codex" && args[1] === "hook" && args[2] === "handle";
34
34
  }
35
35
  export function installCurrentEngine(context, input = {}) {
36
36
  const installation = prepareInstallation(context, input);
@@ -289,6 +289,36 @@ export function processIsAlive(record) {
289
289
  const started = processStartedAt(record.pid);
290
290
  return !record.pid_started_at || !started || record.pid_started_at === started;
291
291
  }
292
+ /** Unlike an admission guard, observability must distinguish unknown from
293
+ * alive. Unknown identity still cannot authorize termination or finalization. */
294
+ export function managedProcessObservation(record) {
295
+ if (!record.pid || !record.pid_started_at)
296
+ return 'unknown';
297
+ try {
298
+ process.kill(record.pid, 0);
299
+ }
300
+ catch (error) {
301
+ return error.code === 'ESRCH' ? 'absent' : 'unknown';
302
+ }
303
+ const reply = spawnSync('ps', ['-o', 'lstart=,stat=', '-p', String(record.pid)], { encoding: 'utf8', timeout: 1000, env: { ...process.env, TZ: 'UTC', LC_ALL: 'C' } });
304
+ if (reply.status !== 0 || !reply.stdout.trim())
305
+ return 'unknown';
306
+ const parts = reply.stdout.trim().split(/\s+/), state = parts.pop();
307
+ if (state?.startsWith('Z'))
308
+ return 'absent';
309
+ const metadata = parseMetadata(record.metadata_json);
310
+ if (Object.hasOwn(metadata, 'process_birth')) {
311
+ const expected = parseBirth(metadata.process_birth);
312
+ if (!expected)
313
+ return 'unknown';
314
+ const observed = `ps-lstart-utc-c@1:${parts.join(' ')}`;
315
+ return expected.replace(/\s+/g, ' ') === observed ? 'alive' : 'absent';
316
+ }
317
+ // Legacy lstart lacks a recorded timezone. A mismatch might be timezone
318
+ // drift, so only equal identity or a missing PID establishes an outcome.
319
+ const legacy = processStartedAt(record.pid);
320
+ return legacy && legacy === record.pid_started_at ? 'alive' : 'unknown';
321
+ }
292
322
  export function processTreeIsAlive(record) {
293
323
  if (processIsAlive(record))
294
324
  return true;