@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 +14 -0
- package/README.md +2 -0
- package/dist/build-info.json +7 -7
- package/dist/cli/command-inputs.js +1 -0
- package/dist/cli/run-cli.js +5 -0
- package/dist/harness-runtime/DD-ZCODE.md +13 -0
- package/dist/harness-runtime/bin/dd-opencode.mjs +3 -3
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +14 -1
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +36 -1
- package/dist/harness-runtime/lib/dd-zcode.mjs +7 -3
- package/dist/harness-runtime/lib/opencode-plugin.mjs +3 -1
- package/dist/services/cli-operation-classifier.js +4 -0
- package/dist/services/controller-child-observer.js +99 -0
- package/dist/services/controller-fanout.js +9 -2
- package/dist/services/engines.js +1 -1
- package/dist/services/managed-processes.js +30 -0
- package/dist/services/run-controller.js +84 -8
- package/dist/services/run-health.js +149 -0
- package/dist/services/runs.js +61 -13
- package/dist/services/stage-lifecycle.js +2 -1
- package/dist/services/stage-report-renderer.js +13 -0
- package/dist/services/vnext-fanout.js +66 -1
- package/dist/services/vnext-protocolize.js +60 -2
- package/dist/storage/database.js +18 -3
- package/dist/storage/writer-contract.js +8 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
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
|
-
|
|
702
|
-
|
|
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
|
+
}
|
package/dist/services/runs.js
CHANGED
|
@@ -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 !== "
|
|
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
|
-
|
|
1072
|
-
|
|
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 ??
|
|
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 };
|