@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.
@@ -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,14 +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
- appendEvent(context, row.controller_id, "stop_target_reached", { stage: state.stage, attempt: completed.attempt ?? null });
772
+ setFlowRunRecoveryPaused(context, row.project_id, row.run_id, true);
773
+ setStatus("stop_target_reached", { stage: state.stage, attempt: completed.attempt ?? null });
703
774
  return;
704
775
  }
705
776
  if (completed && manifest.request.capture_root && ["start_stage", "terminal"].includes(next.kind)) {
@@ -838,6 +909,8 @@ async function executeController(context, row, manifest, state, assertOwnership,
838
909
  const session = state.sessions[state.current_session];
839
910
  const baseline = new Set(state.native_baseline ?? []);
840
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);
841
914
  if (runtimeHarness(session.profile) === "codex-desktop" && children.some(child => child.status === "failed")) {
842
915
  const inspected = await call(session, ["session", "inspect", "--session-id", session.id, ...sessionArguments(context, manifest, session)], false);
843
916
  state.last_receipt = inspected;
@@ -940,7 +1013,7 @@ async function executeController(context, row, manifest, state, assertOwnership,
940
1013
  continue;
941
1014
  }
942
1015
  }
943
- 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 });
944
1017
  assertOwner();
945
1018
  if (fanout.kind === "none") {
946
1019
  assertNoCorrelatedRejection(context, { projectId: row.project_id, runId: row.run_id, generation: row.generation, stage: next.stage, turn: state.turns });
@@ -1012,6 +1085,9 @@ async function executeController(context, row, manifest, state, assertOwnership,
1012
1085
  }
1013
1086
  finally {
1014
1087
  clearInterval(monitor);
1088
+ const drainedObservations = [...childObservers.values()].map(retained => retained.close());
1089
+ observationCancellation.abort();
1090
+ await Promise.allSettled(drainedObservations);
1015
1091
  if (stopping)
1016
1092
  await stopping;
1017
1093
  observer.db.close?.();
@@ -0,0 +1,149 @@
1
+ import { AppError } from "../shared/errors.js";
2
+ import { managedProcessBinding, managedProcessRuntimeHome, managedProcessStatus, managedProcessObservation, processTreeIsAlive } from "./managed-processes.js";
3
+ import { reconcileStoredFlowRun } from "./runs.js";
4
+ import { recoveryGuard } from "./run-recovery.js";
5
+ import { spawnSync } from "node:child_process";
6
+ function physicalInventory() {
7
+ const reply = spawnSync('ps', ['-axo', 'pid=,pgid=,stat='], { encoding: 'utf8', timeout: 5000 });
8
+ if (reply.status !== 0)
9
+ return null;
10
+ return reply.stdout.trim().split('\n').map(line => line.trim().split(/\s+/)).filter(parts => parts.length === 3).map(parts => ({ pid: Number(parts[0]), group: Number(parts[1]), zombie: parts[2].startsWith('Z') }));
11
+ }
12
+ const terminalRun = (status) => ["done", "failed", "cancelled", "discarded"].includes(status);
13
+ const waiting = new Set(["waiting_for_user", "waiting_for_context", "waiting_for_capacity", "waiting_for_children", "waiting_for_merge", "stop_target_reached", "paused", "control_requested"]);
14
+ const table = (context, name) => Boolean(context.db.get("SELECT 1 FROM sqlite_schema WHERE name = ?", [name]));
15
+ const latest = (values) => values.filter((value) => typeof value === "string" && Number.isFinite(Date.parse(value))).sort((a, b) => Date.parse(b) - Date.parse(a))[0] ?? null;
16
+ /** Evidence only: lease expiry and quiet providers never prove an exit. */
17
+ export function assessRunHealth(input) {
18
+ const phase = input.phase ?? null;
19
+ let state = "unknown", reason = "owner_identity_missing";
20
+ if (terminalRun(input.status)) {
21
+ state = "settled";
22
+ reason = "saved_terminal_result";
23
+ }
24
+ else if (input.childrenAlive && input.owner === "absent") {
25
+ state = "owner_lost";
26
+ reason = "native_children_still_active";
27
+ }
28
+ else if (phase === 'recovery_required' || phase === 'capture_failed') {
29
+ state = input.owner === 'absent' ? 'owner_lost' : 'unknown';
30
+ reason = phase;
31
+ }
32
+ else if (phase && waiting.has(phase) || ["waiting_for_user", "blocked"].includes(input.status)) {
33
+ state = "waiting";
34
+ reason = phase ?? input.status;
35
+ }
36
+ else if (input.owner === "absent") {
37
+ state = "owner_lost";
38
+ reason = "physical_owner_retired";
39
+ }
40
+ else if (input.owner === "alive") {
41
+ const age = input.lastProgress ? Date.parse(input.now) - Date.parse(input.lastProgress) : NaN;
42
+ state = Number.isFinite(age) && age > input.noProgressMs ? "no_progress" : "active";
43
+ reason = state === "no_progress" ? "progress_overdue" : "physical_owner_observed";
44
+ }
45
+ else if (input.status === "paused") {
46
+ state = "waiting";
47
+ reason = "saved_pause";
48
+ }
49
+ return { schema_id: "dd-flow/run-health@1", state, reason, phase, owner_state: input.owner, last_progress_at: input.lastProgress ?? null, last_heartbeat_at: input.heartbeat ?? null, observed_at: input.now, controller_id: input.controllerId ?? null, generation: input.generation ?? null, no_progress_ms: input.noProgressMs };
50
+ }
51
+ /** No dispatch, cancellation, process signals or provider calls. All changes
52
+ * are fenced by the retained RUN revision, controller and process binding. */
53
+ export function reconcileFlowRunHealth(context, input = {}) {
54
+ const noProgressMs = input.noProgressMs ?? 30 * 60_000;
55
+ if (!Number.isSafeInteger(noProgressMs) || noProgressMs < 1_000)
56
+ throw new AppError("usage", "no-progress-ms must be an integer of at least 1000", 2);
57
+ const runs = context.db.all(`SELECT * FROM runs${input.runId ? " WHERE (id = ? OR short_id = ?)" : ""}`, input.runId ? [input.runId, input.runId] : []).filter(run => !input.projectRoot || run.project_root === input.projectRoot);
58
+ if (input.runId && runs.length !== 1)
59
+ throw new AppError("not_found", "RUN scope is missing or ambiguous", 1);
60
+ const processes = physicalInventory();
61
+ const alive = (record) => {
62
+ if (!record.pid)
63
+ return !['stopped', 'failed'].includes(record.state);
64
+ const physical = processes?.find(item => item.pid === record.pid);
65
+ if (processes && (!physical || physical.zombie)) {
66
+ const metadata = JSON.parse(record.metadata_json);
67
+ // A retired group leader may still own physical children. Merely ending
68
+ // the controller does not settle those children or remote provider work.
69
+ return Boolean(metadata.process_group_id && processes.some(item => item.group === metadata.process_group_id && !item.zombie));
70
+ }
71
+ return !record.pid_started_at || processTreeIsAlive(record);
72
+ };
73
+ return { ok: true, results: runs.map(run => {
74
+ const index = JSON.parse(run.index_json);
75
+ const controllers = table(context, "run_controllers") ? context.db.all("SELECT * FROM run_controllers WHERE project_id = ? AND run_id = ? ORDER BY generation DESC, created_at DESC", [run.project_id, run.id]) : [];
76
+ const controller = controllers[0];
77
+ const manifest = controller ? JSON.parse(controller.manifest_json) : null;
78
+ const resourceContext = manifest?.resource_home ? { ...context, env: { ...context.env, DD_FLOW_RESOURCE_HOME: manifest.resource_home } } : context;
79
+ let records = [], inventoryComplete = false;
80
+ try {
81
+ records = managedProcessStatus(resourceContext).filter(record => record.project_id === run.project_id && record.run_id === run.id && managedProcessRuntimeHome(record, context) === context.ddFlowHome);
82
+ inventoryComplete = true;
83
+ }
84
+ catch { /* Missing registry is uncertainty, never retirement proof. */ }
85
+ const physical = controller && records.find(record => record.id === controller.process_id && record.lease_token === controller.process_lease_token && record.owner_id === controller.controller_id && record.kind === "run-controller");
86
+ const owner = physical?.pid && physical.pid_started_at ? processes && !processes.some(item => item.pid === physical.pid && !item.zombie) && !alive(physical) ? 'absent' : managedProcessObservation(physical) : "unknown";
87
+ const retireController = Boolean(controller && owner === 'absent' && !['completed', 'cancelled', 'superseded', 'stop_target_reached', 'recovery_required', 'capture_failed', 'waiting_for_user', 'waiting_for_context', 'control_requested', 'paused'].includes(controller.status));
88
+ const phase = retireController ? 'recovery_required' : controller?.status ?? null;
89
+ const childrenAlive = records.some(record => record.id !== physical?.id && alive(record)) || Boolean(physical && owner === 'absent' && alive(physical));
90
+ const progressEvent = controller && table(context, "run_controller_events") ? context.db.get("SELECT MAX(created_at) AS at FROM run_controller_events WHERE controller_id = ? AND event_type NOT IN ('health_reconciled','heartbeat')", [controller.controller_id])?.at : null;
91
+ const workProgress = table(context, "works") ? context.db.get("SELECT MAX(at) AS at FROM (SELECT created_at AS at FROM works WHERE project_id=? AND run_id=? UNION ALL SELECT started_at FROM works WHERE project_id=? AND run_id=? UNION ALL SELECT completed_at FROM works WHERE project_id=? AND run_id=?)", [run.project_id, run.id, run.project_id, run.id, run.project_id, run.id])?.at : null;
92
+ let hookProgress = null;
93
+ if (table(context, 'hook_events') && table(context, 'sessions'))
94
+ try {
95
+ hookProgress = context.db.get("SELECT MAX(h.created_at) AS at FROM hook_events h JOIN sessions s ON s.session_id=h.session_id AND s.project_id=h.project_id WHERE s.project_id=? AND s.run_id=? AND h.event_name IN ('PostToolUse','UserPromptSubmit','SubagentStop','Stop','SessionStart')", [run.project_id, run.id])?.at ?? null;
96
+ }
97
+ catch { /* Legacy hooks have no certified Run membership. */ }
98
+ const lastProgress = latest([run.created_at, progressEvent, workProgress, hookProgress, ...index.stage_runs.flatMap(stage => [stage.started_at, stage.completed_at])]);
99
+ const policy = input.noProgressMs ?? (phase === 'starting' ? 120_000 : phase === 'capturing' ? 300_000 : 30 * 60_000);
100
+ const health = assessRunHealth({ status: run.status, phase, owner, childrenAlive, lastProgress, heartbeat: physical?.updated_at ?? null, now: context.now(), noProgressMs: policy, controllerId: controller?.controller_id ?? null, generation: controller?.generation ?? null });
101
+ if (terminalRun(run.status) && (childrenAlive || owner === "alive")) {
102
+ health.state = "unknown";
103
+ health.reason = "terminal_result_has_live_runtime";
104
+ }
105
+ const sessionState = controller ? JSON.parse(controller.state_json) : null;
106
+ const nativeSettled = Boolean(sessionState?.sessions.length && sessionState.sessions.every(session => session.stopped === true));
107
+ const outcomeUnknown = controller && table(context, "run_controller_operations") && context.db.get("SELECT 1 FROM run_controller_operations WHERE controller_id = ? AND generation = ? AND status IN ('accepted','outcome_unknown') LIMIT 1", [controller.controller_id, controller.generation]);
108
+ let transition;
109
+ const guard = table(context, 'run_recovery_guards') ? recoveryGuard(context, run.project_id, run.id) : null;
110
+ const currentGeneration = !guard || !controller || controller.generation === guard.generation;
111
+ if (!terminalRun(run.status) && currentGeneration) {
112
+ if (controller && ["recovery_required", "capture_failed"].includes(controller.status))
113
+ transition = "paused";
114
+ else if (controller?.status === "stop_target_reached" && owner !== "alive" && nativeSettled && inventoryComplete && !childrenAlive)
115
+ transition = "paused";
116
+ else if (health.state === "owner_lost" && owner === "absent") {
117
+ transition = controller?.last_error_json && nativeSettled && inventoryComplete && !childrenAlive && !outcomeUnknown && !guard ? "failed" : "paused";
118
+ }
119
+ }
120
+ const nextStatus = transition ?? run.status;
121
+ if (transition === 'failed') {
122
+ health.state = 'settled';
123
+ health.reason = 'saved_terminal_result';
124
+ }
125
+ const unchanged = index.health && JSON.stringify({ ...index.health, observed_at: null }) === JSON.stringify({ ...health, observed_at: null }) && nextStatus === run.status;
126
+ let changed = false;
127
+ if (input.apply && !unchanged) {
128
+ // Verify the same physical inventory immediately before publishing. A
129
+ // replacement/renewal invalidates this observation, even with the same PID.
130
+ const binding = (items) => JSON.stringify(items.map(item => ({ ...managedProcessBinding(item, manifest?.resource_home ?? context.ddFlowHome), state: item.state, updated_at: item.updated_at, lease_expires_at: item.lease_expires_at })));
131
+ if (inventoryComplete) {
132
+ const fresh = managedProcessStatus(resourceContext).filter(record => record.project_id === run.project_id && record.run_id === run.id && managedProcessRuntimeHome(record, context) === context.ddFlowHome);
133
+ if (binding(fresh) !== binding(records) || physical && owner === "absent" && managedProcessObservation(physical) !== 'absent')
134
+ throw new AppError("run_health_changed", "Runtime ownership changed during reconciliation", 1);
135
+ }
136
+ context.db.writeTransaction(() => {
137
+ const current = controller && context.db.get("SELECT * FROM run_controllers WHERE controller_id = ?", [controller.controller_id]);
138
+ if (controller && JSON.stringify(current) !== JSON.stringify(controller))
139
+ throw new AppError("run_health_changed", "Controller changed during reconciliation", 1);
140
+ reconcileStoredFlowRun(context, run, health, transition, inventoryComplete && owner !== "alive" && !childrenAlive && nativeSettled);
141
+ if (controller && retireController) {
142
+ context.db.run("UPDATE run_controllers SET status = 'recovery_required', updated_at = ? WHERE controller_id = ? AND owner_token = ? AND generation = ?", [context.now(), controller.controller_id, controller.owner_token, controller.generation]);
143
+ }
144
+ changed = true;
145
+ });
146
+ }
147
+ return { project_id: run.project_id, run_id: run.id, previous_status: run.status, status: nextStatus, health, changed, would_change: !unchanged };
148
+ }) };
149
+ }
@@ -911,6 +911,46 @@ export function setFlowRunRecoveryPaused(context, projectId, runId, paused) {
911
911
  const project = requireProjectByRoot(context, run.project_root);
912
912
  persistRunState(context, project, run, index);
913
913
  }
914
+ /** Reconciliation repairs projections under the same revision CAS as normal
915
+ * lifecycle writes. Unknown physical ownership never closes child records. */
916
+ export function reconcileStoredFlowRun(context, expected, health, transition, childrenSettled = false) {
917
+ const run = resolveRun(context, expected.project_id, expected.id);
918
+ if (run.index_json !== expected.index_json || run.status !== expected.status)
919
+ throw new AppError("run_revision_changed", "RUN changed during health observation", 1);
920
+ const index = authoritativeIndex(run), now = context.now();
921
+ index.health = health;
922
+ if (transition && !["done", "failed", "cancelled", "discarded"].includes(run.status)) {
923
+ index.status = transition;
924
+ if (transition === "failed") {
925
+ index.verdict = "failed";
926
+ // Observation time is not the historical exit time. Without a retained
927
+ // finish receipt, keep timing unknown rather than manufacturing days of work.
928
+ delete index.completed_at;
929
+ delete index.finished_at;
930
+ index.duration_ms = null;
931
+ index.timing_status = 'legacy_incomplete';
932
+ closeOpenStagesForOverride(index, "failed", now, false);
933
+ closeOpenWorksForOverride(context, run.project_id, run.id, "failed", now, false);
934
+ }
935
+ else if (childrenSettled && health.state === "owner_lost") {
936
+ for (const stage of index.stage_runs)
937
+ if (stage.status === "running") {
938
+ stage.status = "paused";
939
+ stage.updated_at = now;
940
+ }
941
+ }
942
+ }
943
+ if (childrenSettled && ["failed", "cancelled", "discarded"].includes(index.status)) {
944
+ closeOpenStagesForOverride(index, index.status === "failed" ? "failed" : "cancelled", now, false);
945
+ closeOpenWorksForOverride(context, run.project_id, run.id, index.status === "failed" ? "failed" : "cancelled", now, false);
946
+ context.db.run("UPDATE sessions SET status = 'stopped', stop_reason = ?, updated_at = ? WHERE project_id = ? AND run_id = ? AND status IN ('pending','active','waiting_user','blocked','stopping')", [`run_${index.status}`, now, run.project_id, run.id]);
947
+ context.db.run("UPDATE check_receipts SET status = 'aborted' WHERE project_id = ? AND run_id = ? AND status = 'running'", [run.project_id, run.id]);
948
+ }
949
+ // Health observation is not semantic progress or a new completion timestamp.
950
+ const project = requireProjectByRoot(context, run.project_root);
951
+ persistRunState(context, project, run, index, { healthOnly: true });
952
+ appendAudit(context, { projectId: project.id, eventType: "flow_run.health_reconciled", payload: { run_id: run.id, previous_status: run.status, status: index.status, health } });
953
+ }
914
954
  export function prepareFlowRunCompletion(context, input) {
915
955
  if (input.manualOverrideReason !== undefined) {
916
956
  requiredPlain(input.manualOverrideReason, "override reason");
@@ -1061,15 +1101,21 @@ export function prepareVnextMergeSourceRepairAttempt(context, input) {
1061
1101
  appendRunTimeline(context, project.id, run.id, { at: now, type: "merge_source_repair_prepared", run_id: run.id, merge_request_id: input.mergeRequestId, cycle });
1062
1102
  return { run_id: run.id, cycle };
1063
1103
  }
1064
- function closeOpenStagesForOverride(index, status, now) {
1104
+ function closeOpenStagesForOverride(index, status, now, timingKnown = true) {
1065
1105
  const stageStatus = status === "cancelled" ? "skipped" : "failed";
1066
1106
  for (const stage of index.stage_runs) {
1067
- if (stage.status !== "running" && stage.status !== "pending" && stage.status !== "paused" && stage.status !== "blocked")
1107
+ if (stage.status !== "running" && stage.status !== "paused" && stage.status !== "waiting_for_user" && stage.status !== "blocked")
1068
1108
  continue;
1069
1109
  stage.status = stageStatus;
1070
1110
  stage.updated_at = now;
1071
- stage.completed_at = now;
1072
- stage.duration_ms = durationMs(stage.started_at ?? null, now);
1111
+ if (timingKnown) {
1112
+ stage.completed_at = now;
1113
+ stage.duration_ms = durationMs(stage.started_at ?? null, now);
1114
+ }
1115
+ else {
1116
+ delete stage.completed_at;
1117
+ stage.duration_ms = null;
1118
+ }
1073
1119
  }
1074
1120
  index.attempts = index.stage_runs.map((stage) => ({
1075
1121
  stage: stage.stage,
@@ -1077,20 +1123,20 @@ function closeOpenStagesForOverride(index, status, now) {
1077
1123
  root: stage.dir,
1078
1124
  archive: null,
1079
1125
  status: stage.status,
1080
- started_at: stage.started_at ?? now,
1126
+ started_at: stage.started_at ?? null,
1081
1127
  finished_at: stage.completed_at ?? null
1082
1128
  }));
1083
1129
  }
1084
- function closeOpenWorksForOverride(context, projectId, runId, status, now) {
1130
+ function closeOpenWorksForOverride(context, projectId, runId, status, now, timingKnown = true) {
1085
1131
  const workStatus = status === "cancelled" ? "cancelled" : "failed";
1086
1132
  context.db.run(`UPDATE works
1087
- SET status = ?, updated_at = ?, completed_at = ?
1133
+ SET status = ?, updated_at = ?, completed_at = CASE WHEN ? = 1 THEN ? ELSE completed_at END
1088
1134
  WHERE project_id = ? AND run_id = ?
1089
- AND status IN ('created', 'running', 'paused')`, [workStatus, now, now, projectId, runId]);
1135
+ AND status IN ('created', 'running', 'paused')`, [workStatus, now, timingKnown ? 1 : 0, now, projectId, runId]);
1090
1136
  context.db.run(`UPDATE work_sessions
1091
- SET status = ?, updated_at = ?, completed_at = ?
1137
+ SET status = ?, updated_at = ?, completed_at = CASE WHEN ? = 1 THEN ? ELSE completed_at END
1092
1138
  WHERE work_id IN (SELECT work_id FROM works WHERE project_id = ? AND run_id = ?)
1093
- AND status <> 'completed'`, [workStatus, now, now, projectId, runId]);
1139
+ AND status <> 'completed'`, [workStatus, now, timingKnown ? 1 : 0, now, projectId, runId]);
1094
1140
  }
1095
1141
  function closeRunOrchestrators(context, projectId, run, status, now) {
1096
1142
  if (!["done", "blocked", "cancelled", "failed"].includes(status))
@@ -1327,8 +1373,10 @@ function vnextSpecifyGuidance(run, index) {
1327
1373
  function persistRunState(context, project, run, index, options = {}) {
1328
1374
  // Fenced productive admission is a logical pause, even while physical work
1329
1375
  // drains. Late admitted Stage results must not revive a stopped RUN.
1330
- if (index.status === "running" && ["draining", "sealed", "resuming"].includes(recoveryGuard(context, project.id, run.id)?.status ?? ""))
1376
+ if (index.status === "running" && (!options.healthOnly || context.db.get("SELECT 1 FROM sqlite_schema WHERE name = 'run_recovery_guards'")) && ["draining", "sealed", "resuming"].includes(recoveryGuard(context, project.id, run.id)?.status ?? ""))
1331
1377
  index.status = "paused";
1378
+ if (!options.healthOnly)
1379
+ delete index.health;
1332
1380
  const runtimePath = run.runtime_path || projectRunJsonPath(context.ddFlowHome, project.id, run.id);
1333
1381
  context.db.writeTransaction(() => {
1334
1382
  const stored = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [project.id, run.id]);
@@ -1344,7 +1392,7 @@ function persistRunState(context, project, run, index, options = {}) {
1344
1392
  if (options.flagRevision)
1345
1393
  history.push(options.flagRevision);
1346
1394
  index.flag_revision_history = history.slice(-32);
1347
- const updated = context.db.run(`UPDATE runs SET status = ?, verdict = ?, next_action = ?, index_json = ?, runtime_path = ?, run_index_path = ?, updated_at = ?, completed_at = ?
1395
+ const updated = options.healthOnly ? context.db.run("UPDATE runs SET status = ?, verdict = ?, next_action = ?, index_json = ?, updated_at = ?, completed_at = ? WHERE project_id = ? AND id = ? AND index_json = ?", [index.status, index.verdict, index.next_action, JSON.stringify(persistedVnextIndex(index)), index.updated_at, index.completed_at ?? null, project.id, run.id, stored.index_json]) : context.db.run(`UPDATE runs SET status = ?, verdict = ?, next_action = ?, index_json = ?, runtime_path = ?, run_index_path = ?, updated_at = ?, completed_at = ?
1348
1396
  WHERE project_id = ? AND id = ? AND index_json = ?`, [index.status, index.verdict, index.next_action, JSON.stringify(persistedVnextIndex(index)), runtimePath, runtimePath, index.updated_at, index.completed_at ?? null, project.id, run.id, stored.index_json]);
1349
1397
  if (updated.changes !== 1)
1350
1398
  throw new AppError("run_revision_changed", "RUN changed during preparation", 1, { run_id: run.id });
@@ -1431,7 +1479,7 @@ export function publishRunProjections(context, projectId, runId) {
1431
1479
  if (!latest)
1432
1480
  throw new AppError("not_found", "RUN is not registered", 1, { run_id: runId });
1433
1481
  const index = authoritativeIndex(latest);
1434
- const mutations = context.db.all("SELECT resulting_revision, result_json, created_at, idempotency_key FROM flow_run_flag_mutations WHERE project_id = ? AND run_id = ? ORDER BY resulting_revision DESC LIMIT 32", [projectId, runId]);
1482
+ const mutations = context.db.get("SELECT 1 FROM sqlite_schema WHERE name = 'flow_run_flag_mutations'") ? context.db.all("SELECT resulting_revision, result_json, created_at, idempotency_key FROM flow_run_flag_mutations WHERE project_id = ? AND run_id = ? ORDER BY resulting_revision DESC LIMIT 32", [projectId, runId]) : [];
1435
1483
  const history = mutations.reverse().map((row) => ({
1436
1484
  revision: row.resulting_revision,
1437
1485
  checksum: String(JSON.parse(row.result_json).snapshot_checksum ?? ""),
@@ -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,
@@ -32,6 +32,62 @@ export function getStageFanoutCapacity(context, input) {
32
32
  const available = variables.variables[subagentCapacityKey];
33
33
  return { run_key: subagentCapacityKey, available_slots: typeof available === "number" && Number.isInteger(available) && available >= 0 ? available : null, source: "harness_qualification" };
34
34
  }
35
+ function nativeFailureController(context, projectId, runId, stage, parent) {
36
+ if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type='table' AND name='run_controllers'"))
37
+ return null;
38
+ const rows = context.db.all("SELECT controller_id,generation,state_json FROM run_controllers WHERE project_id=? AND run_id=? AND status NOT IN ('completed','cancelled','superseded')", [projectId, runId]);
39
+ const matching = rows.filter(row => {
40
+ try {
41
+ const state = JSON.parse(row.state_json);
42
+ return state.stage === stage && state.sessions?.[state.current_session]?.id === parent;
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ });
48
+ return matching.length === 1 ? matching[0] : null;
49
+ }
50
+ /** Failure metadata authored by a Work is not native evidence. Require the
51
+ * independently recorded reconciliation and its exact current registry links. */
52
+ export function verifiedNativeWorkFailure(context, input) {
53
+ const b = input.receipt.native_binding;
54
+ if (!b || b.schema_id !== "dd-flow/native-work-failure@1")
55
+ return;
56
+ const project = requireProjectByRoot(context, input.projectRoot);
57
+ const scope = stageGraphScope(context, project.id, input.runId, input.stage);
58
+ const controller = nativeFailureController(context, project.id, input.runId, input.stage, input.parentSessionId);
59
+ if (!controller || b.controller_id !== controller.controller_id || b.generation !== controller.generation
60
+ || b.project_id !== project.id || b.run_id !== input.runId || b.stage !== input.stage
61
+ || b.stage_attempt_id !== scope.attempt || b.stage_cycle !== scope.cycle
62
+ || b.work_id !== input.workId || b.parent_work_id !== input.parentWorkId
63
+ || b.parent_provider_session_id !== input.parentSessionId || b.terminal_turn_id !== input.receipt.terminal_turn_id
64
+ || input.receipt.source !== "native_child_observation" || input.receipt.outcome !== "failed")
65
+ return;
66
+ const link = context.db.get(`SELECT ws.id FROM work_sessions ws JOIN works w ON w.work_id=ws.work_id JOIN sessions s ON s.project_id=w.project_id AND s.session_id=ws.session_id
67
+ WHERE ws.id=? AND w.project_id=? AND w.run_id=? AND w.work_id=? AND w.parent_work_id=? AND ws.status='failed' AND w.status='failed' AND s.parent_session_id=? AND s.provider_session_id=? AND s.harness=?`, [b.work_session_id, project.id, input.runId, input.workId, input.parentWorkId, storageSessionId({ harness_id: b.harness_id, session_id: input.parentSessionId }), b.provider_session_id, b.harness_id]);
68
+ if (!link)
69
+ return;
70
+ const run = context.db.get("SELECT run_root FROM runs WHERE project_id=? AND id=?", [project.id, input.runId]);
71
+ try {
72
+ const events = fs.readFileSync(path.join(run.run_root, "timeline.jsonl"), "utf8").trim().split("\n");
73
+ const proof = events.some(line => {
74
+ const event = JSON.parse(line);
75
+ if (event.type !== "native_child_reconciliation" || event.stage !== input.stage)
76
+ return false;
77
+ const terminal = event.observations?.children?.find((child) => child.session_id === b.provider_session_id && child.terminal_turn_id === b.terminal_turn_id);
78
+ if (event.observations?.parent_session_id !== b.parent_provider_session_id || event.observations?.harness_id !== b.harness_id
79
+ || terminal?.status !== "failed" || terminal.terminal_observed_at !== input.receipt.terminal_observed_at
80
+ || JSON.stringify(safeNativeFailure(terminal.provider_error)) !== JSON.stringify(safeNativeFailure(input.receipt.provider_error)))
81
+ return false;
82
+ return event.reconciled?.some((entry) => entry.outcome === "failed"
83
+ && entry.native_binding && Object.keys(b).every(key => b[key] === entry.native_binding[key]));
84
+ });
85
+ return proof ? b : undefined;
86
+ }
87
+ catch {
88
+ return;
89
+ }
90
+ }
35
91
  export function fanoutState(input) {
36
92
  if (!input.hasWork)
37
93
  return input.dispatch === "none" ? "coordinator_required" : "dispatch_required";
@@ -177,12 +233,21 @@ export async function reconcileVnextFanout(context, input) {
177
233
  continue;
178
234
  }
179
235
  const providerError = safeNativeFailure(child.provider_error);
236
+ const controller = nativeFailureController(context, project.id, input.runId, input.stage, value.parent_session_id);
237
+ const scope = stageGraphScope(context, project.id, input.runId, input.stage);
238
+ const terminalTime = typeof child.terminal_observed_at === "string" ? Date.parse(child.terminal_observed_at) : NaN;
239
+ const nativeBinding = child.status === "failed" && controller && typeof child.terminal_turn_id === "string" && child.terminal_turn_id.length > 0 && child.terminal_turn_id.length <= 256 && Number.isFinite(terminalTime)
240
+ ? { schema_id: "dd-flow/native-work-failure@1", project_id: project.id, run_id: input.runId, stage: input.stage, stage_attempt_id: scope.attempt, stage_cycle: scope.cycle,
241
+ parent_work_id: status.orchestration.parent_work_id, work_id: link.work_id, work_session_id: link.id, harness_id: value.harness_id,
242
+ parent_provider_session_id: value.parent_session_id, provider_session_id: String(child.session_id), terminal_turn_id: child.terminal_turn_id,
243
+ controller_id: controller.controller_id, generation: controller.generation } : undefined;
180
244
  const reason = JSON.stringify({ source: "native_child_observation", harness_id: value.harness_id, session_id: child.session_id, work_session_id: link.id, outcome: child.status,
245
+ ...(nativeBinding ? { native_binding: nativeBinding } : {}),
181
246
  ...(typeof child.terminal_turn_id === "string" ? { terminal_turn_id: child.terminal_turn_id } : {}),
182
247
  ...(typeof child.terminal_observed_at === "string" ? { terminal_observed_at: child.terminal_observed_at } : {}),
183
248
  ...(providerError ? { provider_error: providerError } : {}) });
184
249
  const result = await (child.status === "failed" ? failWork : cancelWork)(context, link.work_id, reason);
185
- reconciled.push({ work_id: link.work_id, work_session_id: link.id, outcome: child.status, result });
250
+ reconciled.push({ work_id: link.work_id, work_session_id: link.id, outcome: child.status, ...(nativeBinding ? { native_binding: nativeBinding } : {}), result });
186
251
  }
187
252
  appendFlowRunTimelineEvent(context, project.id, input.runId, { type: "native_child_reconciliation", stage: input.stage, observations: value, issues, reconciled });
188
253
  return { ok: true, issues, reconciled };