@deksden-com/dd-flow-cli 0.9.0-beta.123 → 0.9.0-beta.127
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 +26 -0
- package/README.md +33 -1
- package/dist/build-info.json +7 -7
- package/dist/cli/argument-values.js +38 -0
- package/dist/cli/command-inputs.js +4 -3
- package/dist/cli/help.js +1 -0
- package/dist/cli/hook-ingress.js +1 -1
- package/dist/cli/run-cli.js +126 -193
- package/dist/cli/runtime-maintenance-diagnostics.js +46 -0
- package/dist/cli/runtime-maintenance-ingress.js +51 -0
- package/dist/cli/runtime-process.js +128 -0
- package/dist/cli.js +8 -5
- package/dist/domain/stage-catalog.js +1 -1
- package/dist/harness-runtime/bin/dd-agy.mjs +3 -2
- package/dist/harness-runtime/bin/dd-codex.mjs +3 -2
- package/dist/harness-runtime/bin/dd-droid.mjs +3 -2
- package/dist/harness-runtime/bin/dd-grok.mjs +16 -6
- package/dist/harness-runtime/bin/dd-opencode.mjs +3 -2
- package/dist/harness-runtime/bin/dd-zcode.mjs +20 -16
- package/dist/harness-runtime/lib/adapter-timeouts.d.mts +5 -0
- package/dist/harness-runtime/lib/adapter-timeouts.mjs +34 -0
- package/dist/harness-runtime/lib/codex-capacity-policy.d.mts +10 -0
- package/dist/harness-runtime/lib/codex-capacity-policy.mjs +89 -0
- package/dist/harness-runtime/lib/daemon-observation.d.mts +2 -0
- package/dist/harness-runtime/lib/daemon-observation.mjs +163 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +159 -15
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +215 -86
- package/dist/harness-runtime/lib/dd-agy.mjs +11 -13
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +47 -23
- package/dist/harness-runtime/lib/dd-codex.mjs +257 -99
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +32 -30
- package/dist/harness-runtime/lib/dd-droid.mjs +117 -31
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +96 -38
- package/dist/harness-runtime/lib/dd-grok.mjs +15 -13
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +54 -20
- package/dist/harness-runtime/lib/dd-opencode.mjs +44 -14
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +81 -76
- package/dist/harness-runtime/lib/dd-zcode.mjs +204 -116
- package/dist/harness-runtime/lib/driver-recovery.mjs +12 -2
- package/dist/harness-runtime/lib/lease-renewal.d.mts +8 -0
- package/dist/harness-runtime/lib/lease-renewal.mjs +23 -0
- package/dist/harness-runtime/lib/lifecycle-command.d.mts +3 -3
- package/dist/harness-runtime/lib/lifecycle-command.mjs +74 -20
- package/dist/harness-runtime/lib/managed-daemon.d.mts +11 -0
- package/dist/harness-runtime/lib/managed-daemon.mjs +321 -65
- package/dist/harness-runtime/lib/native-children.d.mts +13 -0
- package/dist/harness-runtime/lib/native-children.mjs +78 -0
- package/dist/harness-runtime/lib/native-hook-command.d.mts +4 -0
- package/dist/harness-runtime/lib/native-hook-command.mjs +115 -11
- package/dist/harness-runtime/lib/native-streams.mjs +45 -0
- package/dist/harness-runtime/lib/observation-clock.d.mts +18 -0
- package/dist/harness-runtime/lib/observation-clock.mjs +73 -17
- package/dist/harness-runtime/lib/operation-errors.d.mts +14 -0
- package/dist/harness-runtime/lib/operation-errors.mjs +105 -8
- package/dist/harness-runtime/lib/operation-progress.d.mts +7 -0
- package/dist/harness-runtime/lib/operation-progress.mjs +112 -0
- package/dist/harness-runtime/lib/process-json.mjs +136 -38
- package/dist/harness-runtime/lib/tool-observations.d.mts +1 -0
- package/dist/harness-runtime/lib/tool-observations.mjs +5 -2
- package/dist/harness-runtime/lib/zcode-usage.mjs +3 -2
- package/dist/runtime/context.js +8 -4
- package/dist/schemas/flow-run.schema.json +1 -0
- package/dist/schemas/plan-review-decision.schema.json +3 -3
- package/dist/schemas/run-control-receipt.schema.json +19 -0
- package/dist/services/code-checks.js +93 -27
- package/dist/services/codex-capacity.js +251 -74
- package/dist/services/codex-hook-delivery.js +2 -2
- package/dist/services/compatibility-preflight.js +4 -1
- package/dist/services/controller-fanout.js +9 -75
- package/dist/services/engines.js +70 -20
- package/dist/services/eval-snapshots.js +179 -40
- package/dist/services/external-work-launch.js +21 -9
- package/dist/services/harness-adapter.js +195 -25
- package/dist/services/hooks.js +65 -21
- package/dist/services/lifecycle-invocations.js +404 -32
- package/dist/services/managed-lease-monitor.js +128 -0
- package/dist/services/managed-processes.js +117 -53
- package/dist/services/merge-server.js +66 -23
- package/dist/services/recovery-observation-budget.js +67 -7
- package/dist/services/recovery-snapshot-database.js +7 -4
- package/dist/services/review-deferral.js +23 -0
- package/dist/services/run-control-receipt.js +1 -1
- package/dist/services/run-control-worker.js +177 -118
- package/dist/services/run-control.js +99 -8
- package/dist/services/run-controller-adapter.js +177 -51
- package/dist/services/run-controller-capture.js +14 -2
- package/dist/services/run-controller-process.js +11 -4
- package/dist/services/run-controller-recovery.js +17 -0
- package/dist/services/run-controller-state.js +68 -7
- package/dist/services/run-controller.js +207 -107
- package/dist/services/run-fork.js +8 -3
- package/dist/services/run-recovery-guard.js +75 -0
- package/dist/services/run-recovery-runtime.js +21 -30
- package/dist/services/run-recovery.js +17 -13
- package/dist/services/runs.js +40 -7
- package/dist/services/runtime-budget.js +32 -15
- package/dist/services/runtime-scope-control.js +8 -0
- package/dist/services/runtime-scope-resume.js +30 -8
- package/dist/services/runtime-scope-worker.js +118 -73
- package/dist/services/runtime-service.js +6 -7
- package/dist/services/scope-writer-cleanup.js +3 -2
- package/dist/services/stage-context.js +135 -0
- package/dist/services/stage-lifecycle.js +3 -15
- package/dist/services/stage-pause.js +3 -2
- package/dist/services/usage.js +14 -3
- package/dist/services/verification-matrix.js +30 -1
- package/dist/services/vnext-code-review.js +26 -13
- package/dist/services/vnext-code.js +10 -8
- package/dist/services/vnext-contracts.js +10 -1
- package/dist/services/vnext-execution-profile.js +75 -2
- package/dist/services/vnext-fanout.js +5 -0
- package/dist/services/vnext-merge.js +30 -11
- package/dist/services/vnext-plan-review.js +102 -35
- package/dist/services/vnext-plan.js +9 -7
- package/dist/services/vnext-protocolize.js +15 -9
- package/dist/services/vnext-specify.js +6 -6
- package/dist/services/work-registry.js +43 -6
- package/dist/services/workspace-bootstrap.js +14 -3
- package/dist/shared/errors.js +11 -6
- package/dist/storage/agent-output.js +26 -0
- package/dist/storage/database.js +80 -21
- package/package.json +7 -4
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import { AppError } from "../shared/errors.js";
|
|
3
|
+
import { validateObservationDuration } from "../harness-runtime/lib/observation-clock.mjs";
|
|
3
4
|
export const recoveryObservationBudgetMs = 120_000;
|
|
4
5
|
const activeObservation = new AsyncLocalStorage();
|
|
5
|
-
export const withRecoveryObservation = (budget, action) => activeObservation.run(budget, action);
|
|
6
|
+
export const withRecoveryObservation = (budget, action, retainActive = false) => activeObservation.run(retainActive ? activeObservation.getStore() ?? budget : budget, action);
|
|
6
7
|
export const recoveryRpcTimeout = (maximum) => {
|
|
8
|
+
if (maximum != null)
|
|
9
|
+
validateObservationDuration(maximum);
|
|
7
10
|
const observation = activeObservation.getStore();
|
|
8
11
|
// An unbounded productive transport wait is still bounded while recovery is
|
|
9
12
|
// actively reconciling. Outside recovery, its owner policy decides when to
|
|
@@ -23,19 +26,32 @@ export function renewRecoveryObservation(snapshot, requestId) {
|
|
|
23
26
|
throw new AppError("recovery_observation_invalid", "Retained reconciliation identities are malformed", 1);
|
|
24
27
|
if (ids.includes(requestId))
|
|
25
28
|
return snapshot;
|
|
26
|
-
return { ...snapshot, recovery_observation: { budget_ms: recoveryObservationBudgetMs, remaining_ms: recoveryObservationBudgetMs, reconciliation_ids: [...ids, requestId], poll_index: 0 } };
|
|
29
|
+
return { ...snapshot, recovery_observation: { policy_id: "settlement-inactivity@1", observer_started: false, budget_ms: recoveryObservationBudgetMs, remaining_ms: recoveryObservationBudgetMs, progress_markers: [], observation_gaps: 0, reconciliation_ids: [...ids, requestId], poll_index: 0 } };
|
|
27
30
|
}
|
|
28
31
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
32
|
+
* New episodes measure silence between confirmed retirement facts; legacy
|
|
33
|
+
* receipts keep cumulative active-observer accounting. A gap never proves
|
|
34
|
+
* native death, and repeated gaps exhaust observation rather than grant time.
|
|
35
|
+
* Remaining time and seen proofs survive owner replacement.
|
|
33
36
|
*/
|
|
34
37
|
export function recoveryObservationBudget(saved, monotonic = () => performance.now()) {
|
|
35
38
|
const prior = saved && typeof saved === "object" ? saved.recovery_observation : null;
|
|
39
|
+
if (prior?.policy_id !== undefined && prior.policy_id !== "settlement-inactivity@1")
|
|
40
|
+
throw new AppError("recovery_observation_invalid", "Unknown retained observation policy", 1);
|
|
41
|
+
const sliding = !prior || prior.policy_id === "settlement-inactivity@1";
|
|
42
|
+
if (prior?.progress_markers !== undefined && (!Array.isArray(prior.progress_markers) || prior.progress_markers.some(marker => typeof marker !== "string"))
|
|
43
|
+
|| prior?.observation_gaps !== undefined && (!Number.isSafeInteger(prior.observation_gaps) || prior.observation_gaps < 0)
|
|
44
|
+
|| prior?.observer_started !== undefined && typeof prior.observer_started !== "boolean")
|
|
45
|
+
throw new AppError("recovery_observation_invalid", "Retained observation progress is malformed", 1);
|
|
46
|
+
const markers = new Set(prior?.progress_markers ?? []);
|
|
47
|
+
let gaps = prior?.observation_gaps ?? 0;
|
|
36
48
|
let remaining = typeof prior?.remaining_ms === "number" && Number.isFinite(prior.remaining_ms)
|
|
37
49
|
? Math.max(0, Math.min(recoveryObservationBudgetMs, prior.remaining_ms))
|
|
38
50
|
: prior == null ? recoveryObservationBudgetMs : 0;
|
|
51
|
+
// A restored observer cannot know what happened while it was absent. Retain
|
|
52
|
+
// one bounded uncertainty episode; repeated reconstruction never grants time.
|
|
53
|
+
if (sliding && prior && prior.observer_started !== false && ++gaps > 1)
|
|
54
|
+
remaining = 0;
|
|
39
55
|
let last = monotonic();
|
|
40
56
|
let pollIndex = Number.isSafeInteger(prior?.poll_index) && prior.poll_index >= 0 ? prior.poll_index : 0;
|
|
41
57
|
const observation = {
|
|
@@ -47,10 +63,25 @@ export function recoveryObservationBudget(saved, monotonic = () => performance.n
|
|
|
47
63
|
// or death and therefore does not consume the reconciliation budget.
|
|
48
64
|
if (elapsed >= 0 && elapsed <= 60_000)
|
|
49
65
|
remaining = Math.max(0, remaining - elapsed);
|
|
66
|
+
else if (sliding && ++gaps > 1)
|
|
67
|
+
remaining = 0;
|
|
50
68
|
return remaining === 0;
|
|
51
69
|
},
|
|
52
|
-
|
|
70
|
+
observe: snapshot => {
|
|
71
|
+
observation.exhausted();
|
|
72
|
+
if (!sliding || remaining === 0)
|
|
73
|
+
return;
|
|
74
|
+
const proof = settlementProgressMarkers(snapshot);
|
|
75
|
+
const advanced = proof.some(marker => !markers.has(marker));
|
|
76
|
+
proof.forEach(marker => markers.add(marker));
|
|
77
|
+
if (advanced) {
|
|
78
|
+
remaining = recoveryObservationBudgetMs;
|
|
79
|
+
gaps = 0;
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
state: () => ({ ...(sliding ? { policy_id: "settlement-inactivity@1", observer_started: true, progress_markers: [...markers].sort(), observation_gaps: gaps } : {}), budget_ms: recoveryObservationBudgetMs, remaining_ms: Math.ceil(remaining), reconciliation_ids: prior?.reconciliation_ids ?? [], poll_index: pollIndex }),
|
|
53
83
|
timeout: (maximum = 30_000) => {
|
|
84
|
+
validateObservationDuration(maximum);
|
|
54
85
|
if (observation.exhausted())
|
|
55
86
|
throw new AppError("recovery_observation_budget_exhausted", "Recovery observation budget exhausted; explicit reconciliation is required", 1);
|
|
56
87
|
return Math.max(1, Math.min(maximum, Math.ceil(remaining)));
|
|
@@ -59,3 +90,32 @@ export function recoveryObservationBudget(saved, monotonic = () => performance.n
|
|
|
59
90
|
};
|
|
60
91
|
return observation;
|
|
61
92
|
}
|
|
93
|
+
/** Only confirmed settlement facts advance recovery. Polls, errors, timestamps,
|
|
94
|
+
* and native model activity cannot replenish physical retirement observation. */
|
|
95
|
+
export function settlementProgressMarkers(snapshot) {
|
|
96
|
+
const result = [];
|
|
97
|
+
const visit = (value, prefix, depth) => {
|
|
98
|
+
if (!value || typeof value !== "object" || depth > 12)
|
|
99
|
+
return;
|
|
100
|
+
if (Array.isArray(value)) {
|
|
101
|
+
for (const item of value)
|
|
102
|
+
visit(item, prefix, depth + 1);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const object = value;
|
|
106
|
+
const identity = ["operation_id", "process_id", "session_id", "run_id", "control_id", "scope_id", "id"].map(key => typeof object[key] === "string" ? `${key}:${object[key]}` : "").filter(Boolean).join("|");
|
|
107
|
+
const location = `${prefix}/${identity}`;
|
|
108
|
+
for (const key of ["physical_settled", "native_settled", "settled", "retired", "published", "committed"])
|
|
109
|
+
if (object[key] === true)
|
|
110
|
+
result.push(`${location}/${key}`);
|
|
111
|
+
if (["completed", "released", "sealed", "verified", "retired"].includes(String(object.status)))
|
|
112
|
+
result.push(`${location}/status:${String(object.status)}`);
|
|
113
|
+
if (object.state === "settled")
|
|
114
|
+
result.push(`${location}/state:settled`);
|
|
115
|
+
for (const [key, item] of Object.entries(object))
|
|
116
|
+
if (key !== "recovery_observation" && key !== "error" && key !== "metadata" && key !== "capture_error")
|
|
117
|
+
visit(item, `${location}/${key}`, depth + 1);
|
|
118
|
+
};
|
|
119
|
+
visit(snapshot, "", 0);
|
|
120
|
+
return [...new Set(result)].sort();
|
|
121
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { DatabaseSync } from "node:sqlite";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { AppError } from "../shared/errors.js";
|
|
4
|
+
import { AppError, errorRecord } from "../shared/errors.js";
|
|
5
5
|
import { registerStoreWriter } from "../storage/writer-contract.js";
|
|
6
6
|
import { managedProcessRuntimeHome } from "./managed-processes.js";
|
|
7
7
|
import { cleanupScopeWriters } from "./scope-writer-cleanup.js";
|
|
@@ -56,6 +56,9 @@ export function captureRecoveryDatabase(source, output, projectId, runId, protoc
|
|
|
56
56
|
rules[table] = ownRun;
|
|
57
57
|
for (const table of ["plan_bindings", "plan_progress", "worktree_records", "audit_events"])
|
|
58
58
|
rules[table] = `${ownProject} AND protocol_id IN (${protocols})`;
|
|
59
|
+
// Relocation proofs are RUN-owned provenance needed to map unchanged context
|
|
60
|
+
// bytes through subsequent recovery generations, not portable live authority.
|
|
61
|
+
rules.audit_events = `${ownProject} AND (protocol_id IN (${protocols}) OR (event_type = 'flow_run.publication_relocation' AND json_extract(payload_json, '$.run_id') IN (SELECT run_id FROM capture_scope)))`;
|
|
59
62
|
for (const table of ["run_controller_events", "run_controller_operations"])
|
|
60
63
|
rules[table] = `controller_id IN (SELECT controller_id FROM run_controllers WHERE ${ownRun})`;
|
|
61
64
|
for (const table of ["run_control_admissions", "run_control_stage_admissions", "run_control_operations", "run_control_workers"])
|
|
@@ -89,7 +92,7 @@ export function captureRecoveryDatabase(source, output, projectId, runId, protoc
|
|
|
89
92
|
fs.rmSync(output, { force: true });
|
|
90
93
|
}
|
|
91
94
|
catch (error) {
|
|
92
|
-
cleanup.push({ target: output, phase: "remove_incomplete_output", message: String(error) });
|
|
95
|
+
cleanup.push({ target: output, phase: "remove_incomplete_output", message: String(error), error: errorRecord(error) });
|
|
93
96
|
}
|
|
94
97
|
}
|
|
95
98
|
if (failed || cleanup.length) {
|
|
@@ -98,7 +101,7 @@ export function captureRecoveryDatabase(source, output, projectId, runId, protoc
|
|
|
98
101
|
primary.details.snapshot_cleanup_errors = cleanup;
|
|
99
102
|
throw primary;
|
|
100
103
|
}
|
|
101
|
-
throw new AppError("snapshot_database_failed", primary instanceof Error ? primary.message : "Snapshot database cleanup failed", 1, { ...(failed ? { cause:
|
|
104
|
+
throw new AppError("snapshot_database_failed", primary instanceof Error ? primary.message : "Snapshot database cleanup failed", 1, { ...(failed ? { cause: errorRecord(primary) } : {}), snapshot_cleanup_errors: cleanup });
|
|
102
105
|
}
|
|
103
106
|
}
|
|
104
107
|
/** Physical leases are evidence, not authority to resurrect on another host. */
|
|
@@ -136,7 +139,7 @@ export function readRecoveryResourceEvidence(resourceHome, projectId, runId, aut
|
|
|
136
139
|
primary.details.snapshot_cleanup_errors = cleanup;
|
|
137
140
|
throw primary;
|
|
138
141
|
}
|
|
139
|
-
throw new AppError("snapshot_resource_evidence_failed", primary instanceof Error ? primary.message : "Resource evidence cleanup failed", 1, { ...(failed ? { cause:
|
|
142
|
+
throw new AppError("snapshot_resource_evidence_failed", primary instanceof Error ? primary.message : "Resource evidence cleanup failed", 1, { ...(failed ? { cause: errorRecord(primary) } : {}), snapshot_cleanup_errors: cleanup });
|
|
140
143
|
}
|
|
141
144
|
return result;
|
|
142
145
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { AppError } from "../shared/errors.js";
|
|
5
|
+
import { assertPortableArtifactRef } from "./portable-refs.js";
|
|
6
|
+
/** A deferral names one project-local file, never an arbitrary path. */
|
|
7
|
+
export function reviewDefEvidence(workspaceRoot, defId) {
|
|
8
|
+
try {
|
|
9
|
+
if (!defId || !/^DEF-[A-Za-z0-9][A-Za-z0-9_-]*$/.test(defId))
|
|
10
|
+
throw new Error("invalid DEF identifier");
|
|
11
|
+
const relative = `.memory-bank/defs/${defId}.md`;
|
|
12
|
+
const file = assertPortableArtifactRef(relative, { workspaceRoot, runHome: workspaceRoot, runId: "" });
|
|
13
|
+
const directory = fs.realpathSync(path.join(workspaceRoot, ".memory-bank", "defs"));
|
|
14
|
+
const root = fs.realpathSync(workspaceRoot);
|
|
15
|
+
const directoryRelative = path.relative(root, directory);
|
|
16
|
+
if (directoryRelative === ".." || directoryRelative.startsWith(`..${path.sep}`) || path.isAbsolute(directoryRelative) || path.dirname(file) !== directory || !fs.statSync(file).isFile())
|
|
17
|
+
throw new Error("DEF must be a regular file in the project defs directory");
|
|
18
|
+
return { def_id: defId, path: relative, sha256: crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex") };
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
throw new AppError("deferral_invalid", "A deferral requires a readable project-local named DEF", 2, { def_id: defId ?? null, cause: error instanceof Error ? error.message : String(error) });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -50,7 +50,7 @@ export function controlStatusReceipt(observation, request = {}) {
|
|
|
50
50
|
requested_mode: request.action ?? (activeResume ? "resume" : text(control.requested_mode)),
|
|
51
51
|
applied_mode: settled ? control.requested_mode : state === "running" ? "resume" : null,
|
|
52
52
|
state, settled, nodes, unsettled_reasons: reasons,
|
|
53
|
-
resumability: settled || state === "recovery_required" ? "recovery_required" : ["running", "not_requested", "terminal_noop", "superseded"].includes(state) ? "not_applicable" : "pending",
|
|
53
|
+
resumability: ["done", "failed", "cancelled"].includes(String(observation.run_status)) ? "not_applicable" : settled || state === "recovery_required" ? "recovery_required" : ["running", "not_requested", "terminal_noop", "superseded"].includes(state) ? "not_applicable" : "pending",
|
|
54
54
|
recovery_id: text(control.recovery_id)
|
|
55
55
|
};
|
|
56
56
|
}
|
|
@@ -5,11 +5,12 @@ import os from "node:os";
|
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { setTimeout as delay } from "node:timers/promises";
|
|
8
|
-
import { AppError } from "../shared/errors.js";
|
|
8
|
+
import { AppError, errorRecord } from "../shared/errors.js";
|
|
9
9
|
import { resolveProjectRoot } from "../storage/paths.js";
|
|
10
10
|
import { requireProjectByRoot } from "./projects.js";
|
|
11
11
|
import { getResourceDatabase } from "../storage/database.js";
|
|
12
|
-
import { confirmManagedProcess, finishManagedProcess,
|
|
12
|
+
import { assertManagedProcessLease, confirmManagedProcess, finishManagedProcess, renewManagedProcess, managedProcessRuntimeHome, processIsAlive, processStartedAt, processTreeIsAlive, registerManagedProcess, resourceHome } from "./managed-processes.js";
|
|
13
|
+
import { managedLeaseMonitor } from "./managed-lease-monitor.js";
|
|
13
14
|
import { interruptControlledSessions, observeControlledSessions, recordRunControlIntent, runControlStatus } from "./run-control.js";
|
|
14
15
|
import { recoveryGuard, recordRecoveryCapture, sealRunRecovery } from "./run-recovery.js";
|
|
15
16
|
import { createEvalRunSnapshot, verifyRecoverySnapshot } from "./eval-snapshots.js";
|
|
@@ -106,13 +107,13 @@ export async function waitForControlWorkerAdmission(context, controlId, ownerTok
|
|
|
106
107
|
if (!worker || worker.owner_token !== ownerToken)
|
|
107
108
|
return "admitted";
|
|
108
109
|
// A running worker without a resource lease is still in the registration
|
|
109
|
-
// gap;
|
|
110
|
+
// gap; a known child not proven dead can remain pending, not admitted.
|
|
110
111
|
if (worker.process_id || ["failed", "completed", "superseded"].includes(worker.status))
|
|
111
112
|
return "admitted";
|
|
112
113
|
if (Date.now() >= deadline) {
|
|
113
|
-
if (launched
|
|
114
|
+
if (launched && processIsAlive(launched))
|
|
114
115
|
return "pending";
|
|
115
|
-
throw new AppError("run_control_worker_start_timeout", "Control worker did not publish its managed-process lease and is not alive", 1, { control_id: controlId, timeout_ms: timeoutMs });
|
|
116
|
+
throw new AppError("run_control_worker_start_timeout", "Control worker did not publish its managed-process lease and is not alive", 1, { control_id: controlId, timeout_ms: timeoutMs, launched });
|
|
116
117
|
}
|
|
117
118
|
await delay(25);
|
|
118
119
|
}
|
|
@@ -128,8 +129,8 @@ function reclaimControlWorker(context, input, projectId, token, home) {
|
|
|
128
129
|
return false;
|
|
129
130
|
const saved = JSON.parse(previous.snapshot_json ?? "{}");
|
|
130
131
|
const requested = input.reconcileId ? renewRecoveryObservation(saved, input.reconcileId) : saved;
|
|
131
|
-
|
|
132
|
-
|
|
132
|
+
const observation = recoveryObservationBudget(requested);
|
|
133
|
+
const exhausted = observation.state().remaining_ms === 0;
|
|
133
134
|
const registry = fs.existsSync(path.join(home, "runtime.sqlite")) ? getResourceDatabase(home) : null;
|
|
134
135
|
let record = previous.process_id ? registry?.get("SELECT * FROM managed_processes WHERE id = ?", [previous.process_id]) : null;
|
|
135
136
|
const operationId = launchOperationId(input.controlId, previous.owner_token);
|
|
@@ -180,17 +181,30 @@ function reclaimControlWorker(context, input, projectId, token, home) {
|
|
|
180
181
|
}
|
|
181
182
|
// The resource store and RUN store are separate: retirement is idempotent,
|
|
182
183
|
// and a crash before the RUN CAS leaves the same dead owner reconcilable.
|
|
184
|
+
if (exhausted && previous.status === "failed" && saved.status === "recovery_blocked"
|
|
185
|
+
&& saved.recovery_observation?.remaining_ms === 0)
|
|
186
|
+
return false;
|
|
183
187
|
if (record && !finishManagedProcess(context, { id: record.id, leaseToken: record.lease_token, state: "stopped", reason: "control_owner_crash_reconciled" }))
|
|
184
188
|
return false;
|
|
185
189
|
const launcherBirth = processStartedAt(process.pid);
|
|
186
190
|
return context.db.writeTransaction(() => {
|
|
187
|
-
|
|
191
|
+
// An exhausted episode still needs an operator-visible receipt, but only
|
|
192
|
+
// after the same exact dead-owner proof and generation CAS as replacement.
|
|
193
|
+
const blockedError = { code: "recovery_observation_budget_exhausted", message: "Control settlement has no confirmed result within the active observation budget", ...(previous.error_json ? { cause: JSON.parse(previous.error_json) } : {}) };
|
|
194
|
+
const blocked = { ...saved, status: "recovery_blocked", settled: false,
|
|
195
|
+
pending_reasons: [...new Set([...(Array.isArray(saved.pending_reasons) ? saved.pending_reasons : []), "recovery_observation_budget_exhausted"])],
|
|
196
|
+
error: blockedError, recovery_observation: observation.state() };
|
|
197
|
+
const fields = exhausted ? "status = 'failed', snapshot_json = ?, error_json = ?, updated_at = ?"
|
|
198
|
+
: "owner_token = ?, owner_pid = NULL, status = 'starting', process_id = NULL, process_lease_token = NULL, updated_at = ?";
|
|
199
|
+
const values = exhausted ? [JSON.stringify(blocked), previous.error_json ?? JSON.stringify(blockedError), context.now()] : [token, context.now()];
|
|
200
|
+
const changed = context.db.run(`UPDATE run_control_workers SET ${fields}
|
|
188
201
|
WHERE control_id = ? AND owner_token = ? AND process_id IS ? AND owner_pid IS ? AND status = ?
|
|
202
|
+
AND snapshot_json IS ? AND error_json IS ?
|
|
189
203
|
AND (SELECT receipt_json FROM run_control_operations WHERE operation_id = ?) IS ?
|
|
190
204
|
AND EXISTS (SELECT 1 FROM run_controls c JOIN run_recovery_guards g ON g.recovery_id = c.recovery_id
|
|
191
205
|
WHERE c.control_id = run_control_workers.control_id AND c.status = 'requested' AND g.status IN ('draining','sealed')
|
|
192
|
-
AND g.generation = (SELECT MAX(generation) FROM run_recovery_guards WHERE project_id = g.project_id AND run_id = g.run_id))`, [
|
|
193
|
-
if (changed.changes !== 1)
|
|
206
|
+
AND g.generation = (SELECT MAX(generation) FROM run_recovery_guards WHERE project_id = g.project_id AND run_id = g.run_id))`, [...values, input.controlId, previous.owner_token, previous.process_id, previous.owner_pid, previous.status, previous.snapshot_json ?? null, previous.error_json, operationId, launch?.receipt_json ?? null]);
|
|
207
|
+
if (changed.changes !== 1 || exhausted)
|
|
194
208
|
return false;
|
|
195
209
|
if (input.reconcileId) {
|
|
196
210
|
const snapshot = renewRecoveryObservation(JSON.parse(previous.snapshot_json ?? "{}"), input.reconcileId);
|
|
@@ -223,129 +237,174 @@ export async function serveRunControl(context, input) {
|
|
|
223
237
|
context.db.run("INSERT INTO run_control_operations (operation_id, control_id, target_json, status, receipt_json, created_at, updated_at) VALUES (?, ?, ?, 'completed', ?, ?, ?)", [`${launchOperationId(input.controlId, worker.owner_token)}:worker-claim`, input.controlId, JSON.stringify({ kind: "control_worker_self_claim" }), JSON.stringify({ pid: process.pid, pid_started_at: workerBirth }), now, now]);
|
|
224
238
|
});
|
|
225
239
|
const resourceContext = { ...context, env: { ...context.env, DD_FLOW_RESOURCE_HOME: worker.resource_home } };
|
|
226
|
-
|
|
240
|
+
const lease = managedLeaseMonitor(attemptMs => renewManagedProcess({ ...resourceContext, resourceDatabaseMode: "prepared_existing", databaseDeadlineAt: Date.now() + attemptMs }, { id: worker.process_id, leaseToken: worker.process_lease_token }));
|
|
241
|
+
let timerFailure;
|
|
242
|
+
let primaryFailure;
|
|
243
|
+
const assertControlCurrent = () => {
|
|
244
|
+
const latest = recoveryGuard(context, project.id, input.runId);
|
|
245
|
+
if (latest?.recovery_id !== intent.recovery_id || !["draining", "sealed"].includes(latest.status))
|
|
246
|
+
throw new AppError("run_recovery_generation_stale", "Control intent was superseded during renewal", 1);
|
|
247
|
+
};
|
|
248
|
+
const ownedContext = { ...context, assertControlOwner: () => {
|
|
249
|
+
lease.assertNoRenewal();
|
|
250
|
+
assertManagedProcessLease(resourceContext, { id: worker.process_id, leaseToken: worker.process_lease_token, pid: process.pid });
|
|
251
|
+
assertControlCurrent();
|
|
252
|
+
} };
|
|
227
253
|
const heartbeat = setInterval(() => {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
}
|
|
232
|
-
catch {
|
|
233
|
-
leaseLost = true;
|
|
234
|
-
}
|
|
254
|
+
if (!worker.process_id)
|
|
255
|
+
return;
|
|
256
|
+
lease.background(assertControlCurrent, error => { timerFailure = error; });
|
|
235
257
|
}, 10_000);
|
|
236
258
|
heartbeat.unref();
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
const log = path.join(worker.state_dir, "control.log");
|
|
240
|
-
const record = registerManagedProcess(resourceContext, { kind: "run-control", ownerId: input.controlId, projectId: project.id, runId: input.runId, operationId: `control:${input.controlId}`, uniqueActiveOperation: true, ownerPid: process.pid, stdoutPath: log, stderrPath: log, metadata: { control_id: input.controlId } });
|
|
241
|
-
worker.process_id = record.id;
|
|
242
|
-
worker.process_lease_token = record.lease_token;
|
|
243
|
-
context.db.run("UPDATE run_control_workers SET process_id = ?, process_lease_token = ?, updated_at = ? WHERE control_id = ? AND owner_token = ? AND owner_pid = ? AND status = 'running'", [record.id, record.lease_token, context.now(), input.controlId, worker.owner_token, process.pid]);
|
|
244
|
-
}
|
|
245
|
-
if (!worker.process_lease_token)
|
|
246
|
-
throw new AppError("run_control_owner_mismatch", "Control owner has no resource lease", 1);
|
|
247
|
-
confirmManagedProcess(resourceContext, { id: worker.process_id, leaseToken: worker.process_lease_token, pid: process.pid, processGroupId: process.pid, ownerPid: process.pid });
|
|
248
|
-
let priorObservation = null;
|
|
259
|
+
let cleanupFailure;
|
|
260
|
+
const result = await (async () => {
|
|
249
261
|
try {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
for (;;) {
|
|
257
|
-
if (leaseLost)
|
|
258
|
-
throw new AppError("run_control_lease_lost", "Control worker lost its physical ownership lease", 1);
|
|
259
|
-
const current = context.db.get("SELECT * FROM run_controls WHERE control_id = ?", [input.controlId]);
|
|
260
|
-
const guard = recoveryGuard(context, project.id, input.runId);
|
|
261
|
-
if (current.status !== "requested" || guard?.recovery_id !== intent.recovery_id || !["draining", "sealed"].includes(guard.status)) {
|
|
262
|
-
const status = current.status === "settled" ? "completed" : "superseded";
|
|
263
|
-
context.db.run("UPDATE run_control_workers SET status = ?, updated_at = ? WHERE control_id = ? AND owner_token = ?", [status, context.now(), input.controlId, worker.owner_token]);
|
|
264
|
-
return { ok: true, control_id: input.controlId, status };
|
|
262
|
+
if (!worker.process_id) {
|
|
263
|
+
const log = path.join(worker.state_dir, "control.log");
|
|
264
|
+
const record = registerManagedProcess(resourceContext, { kind: "run-control", ownerId: input.controlId, projectId: project.id, runId: input.runId, operationId: `control:${input.controlId}`, uniqueActiveOperation: true, ownerPid: process.pid, stdoutPath: log, stderrPath: log, metadata: { control_id: input.controlId } });
|
|
265
|
+
worker.process_id = record.id;
|
|
266
|
+
worker.process_lease_token = record.lease_token;
|
|
267
|
+
context.db.run("UPDATE run_control_workers SET process_id = ?, process_lease_token = ?, updated_at = ? WHERE control_id = ? AND owner_token = ? AND owner_pid = ? AND status = 'running'", [record.id, record.lease_token, context.now(), input.controlId, worker.owner_token, process.pid]);
|
|
265
268
|
}
|
|
266
|
-
if (
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
catch { /* Retain a blocked status even when the old projection is malformed. */ }
|
|
273
|
-
const snapshot = { ...retained, status: "recovery_blocked", settled: false, pending_reasons: ["recovery_observation_budget_exhausted"],
|
|
274
|
-
error: { code: "recovery_observation_budget_exhausted", message: "Control settlement has no confirmed result within the active observation budget" }, recovery_observation: observation.state() };
|
|
275
|
-
context.db.run("UPDATE run_control_workers SET status = 'failed', snapshot_json = ?, error_json = ?, updated_at = ? WHERE control_id = ? AND owner_token = ? AND status = 'running'", [JSON.stringify(snapshot), JSON.stringify(snapshot.error), context.now(), input.controlId, worker.owner_token]);
|
|
276
|
-
return { ok: true, control_id: input.controlId, ...snapshot };
|
|
269
|
+
if (!worker.process_lease_token)
|
|
270
|
+
throw new AppError("run_control_owner_mismatch", "Control owner has no resource lease", 1);
|
|
271
|
+
confirmManagedProcess(resourceContext, { id: worker.process_id, leaseToken: worker.process_lease_token, pid: process.pid, processGroupId: process.pid, ownerPid: process.pid });
|
|
272
|
+
let priorObservation = null;
|
|
273
|
+
try {
|
|
274
|
+
priorObservation = worker.snapshot_json ? JSON.parse(worker.snapshot_json) : null;
|
|
277
275
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
276
|
+
catch { /* A malformed prior snapshot cannot grant a new budget. */
|
|
277
|
+
priorObservation = { recovery_observation: { remaining_ms: 0 } };
|
|
278
|
+
}
|
|
279
|
+
const observation = recoveryObservationBudget(priorObservation);
|
|
280
|
+
context.db.run("UPDATE run_control_workers SET snapshot_json = json_set(COALESCE(snapshot_json, '{}'), '$.recovery_observation', json(?)), updated_at = ? WHERE control_id = ? AND owner_token = ? AND status = 'running'", [JSON.stringify(observation.state()), context.now(), input.controlId, worker.owner_token]);
|
|
281
|
+
for (;;) {
|
|
282
|
+
if (timerFailure)
|
|
283
|
+
throw timerFailure;
|
|
284
|
+
await lease.ensureConfirmed(assertControlCurrent);
|
|
285
|
+
const current = context.db.get("SELECT * FROM run_controls WHERE control_id = ?", [input.controlId]);
|
|
286
|
+
const guard = recoveryGuard(context, project.id, input.runId);
|
|
287
|
+
if (current.status !== "requested" || guard?.recovery_id !== intent.recovery_id || !["draining", "sealed"].includes(guard.status)) {
|
|
288
|
+
const status = current.status === "settled" ? "completed" : "superseded";
|
|
289
|
+
context.db.run("UPDATE run_control_workers SET status = ?, updated_at = ? WHERE control_id = ? AND owner_token = ?", [status, context.now(), input.controlId, worker.owner_token]);
|
|
290
|
+
return { ok: true, control_id: input.controlId, status };
|
|
291
|
+
}
|
|
292
|
+
observation.observe({ control_id: input.controlId, status: guard.status, ...(guard.capture_path ? { published: true } : {}) });
|
|
293
|
+
if (observation.exhausted()) {
|
|
294
|
+
const final = context.db.get("SELECT * FROM run_controls WHERE control_id = ?", [input.controlId]);
|
|
295
|
+
const finalGuard = recoveryGuard(context, project.id, input.runId);
|
|
296
|
+
if (final.status !== "requested" || finalGuard?.recovery_id !== intent.recovery_id || !["draining", "sealed"].includes(finalGuard.status)) {
|
|
297
|
+
const status = final.status === "settled" ? "completed" : "superseded";
|
|
298
|
+
context.db.run("UPDATE run_control_workers SET status = ?, updated_at = ? WHERE control_id = ? AND owner_token = ?", [status, context.now(), input.controlId, worker.owner_token]);
|
|
299
|
+
return { ok: true, control_id: input.controlId, status };
|
|
300
|
+
}
|
|
301
|
+
const latest = context.db.get("SELECT snapshot_json FROM run_control_workers WHERE control_id = ?", [input.controlId]);
|
|
302
|
+
let retained = {};
|
|
303
|
+
try {
|
|
304
|
+
retained = JSON.parse(latest?.snapshot_json ?? "{}") ?? {};
|
|
295
305
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
if (applied.changes !== 1)
|
|
301
|
-
throw new AppError("run_control_state_changed", "Control changed before its capture could be acknowledged", 1);
|
|
302
|
-
const snapshot = { ...settlement, status: "settled", capture_path: capture, recovery_observation: observation.state() };
|
|
303
|
-
context.db.run("UPDATE run_control_workers SET status = 'completed', snapshot_json = ?, error_json = NULL, updated_at = ? WHERE control_id = ? AND owner_token = ?", [JSON.stringify(snapshot), context.now(), input.controlId, worker.owner_token]);
|
|
306
|
+
catch { /* Retain a blocked status even when the old projection is malformed. */ }
|
|
307
|
+
const snapshot = { ...retained, status: "recovery_blocked", settled: false, pending_reasons: ["recovery_observation_budget_exhausted"],
|
|
308
|
+
error: { code: "recovery_observation_budget_exhausted", message: "Control settlement has no confirmed result within the active observation budget" }, recovery_observation: observation.state() };
|
|
309
|
+
context.db.run("UPDATE run_control_workers SET status = 'failed', snapshot_json = ?, error_json = ?, updated_at = ? WHERE control_id = ? AND owner_token = ? AND status = 'running'", [JSON.stringify(snapshot), JSON.stringify(snapshot.error), context.now(), input.controlId, worker.owner_token]);
|
|
304
310
|
return { ok: true, control_id: input.controlId, ...snapshot };
|
|
305
311
|
}
|
|
312
|
+
if (guard.status === "sealed") {
|
|
313
|
+
// Capture retries are local evidence work. Never dispatch another
|
|
314
|
+
// native interrupt/inspect merely because publication previously failed.
|
|
315
|
+
const settlement = JSON.parse(guard.settlement_json ?? "{}");
|
|
316
|
+
try {
|
|
317
|
+
if (settlement.settled !== true)
|
|
318
|
+
throw new AppError("run_control_settlement_missing", "Sealed control has no settled tree receipt", 1);
|
|
319
|
+
const root = path.resolve(context.env.DD_FLOW_RECOVERY_HOME ?? path.join(os.homedir(), ".dd-flow-recovery"));
|
|
320
|
+
const capture = guard.capture_path ?? path.join(root, crypto.createHash("sha256").update(context.ddFlowHome).digest("hex"), project.id, input.runId, guard.recovery_id);
|
|
321
|
+
const scope = { projectRoot: project.root, runId: input.runId, recoveryId: guard.recovery_id };
|
|
322
|
+
if (!fs.existsSync(capture))
|
|
323
|
+
createEvalRunSnapshot(context, { ...scope, output: capture });
|
|
324
|
+
await lease.ensureConfirmed(assertControlCurrent);
|
|
325
|
+
if (!guard.capture_path)
|
|
326
|
+
recordRecoveryCapture(context, { ...scope, capturePath: capture });
|
|
327
|
+
else {
|
|
328
|
+
const run = context.db.get("SELECT workspace_root FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
|
|
329
|
+
verifyRecoverySnapshot(capture, { ...scope, workspaceRoot: run.workspace_root });
|
|
330
|
+
}
|
|
331
|
+
await lease.ensureConfirmed(assertControlCurrent);
|
|
332
|
+
const applied = context.db.run(`UPDATE run_controls SET status = 'settled', updated_at = ?
|
|
333
|
+
WHERE control_id = ? AND status = 'requested' AND recovery_id = ?
|
|
334
|
+
AND EXISTS (SELECT 1 FROM run_control_workers w WHERE w.control_id = run_controls.control_id AND w.owner_token = ? AND w.status = 'running')
|
|
335
|
+
AND EXISTS (SELECT 1 FROM run_recovery_guards g WHERE g.recovery_id = run_controls.recovery_id AND g.status = 'sealed' AND g.capture_path = ? AND g.generation = (SELECT MAX(generation) FROM run_recovery_guards WHERE project_id = g.project_id AND run_id = g.run_id))`, [context.now(), input.controlId, guard.recovery_id, worker.owner_token, capture]);
|
|
336
|
+
if (applied.changes !== 1)
|
|
337
|
+
throw new AppError("run_control_state_changed", "Control changed before its capture could be acknowledged", 1);
|
|
338
|
+
const snapshot = { ...settlement, status: "settled", capture_path: capture, recovery_observation: observation.state() };
|
|
339
|
+
context.db.run("UPDATE run_control_workers SET status = 'completed', snapshot_json = ?, error_json = NULL, updated_at = ? WHERE control_id = ? AND owner_token = ?", [JSON.stringify(snapshot), context.now(), input.controlId, worker.owner_token]);
|
|
340
|
+
return { ok: true, control_id: input.controlId, ...snapshot };
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
observation.exhausted();
|
|
344
|
+
const snapshot = { ...settlement, status: "requested", capture: { status: "pending", error: failure(error) }, pending_reasons: ["recovery_capture_required"], recovery_observation: observation.state() };
|
|
345
|
+
context.db.run("UPDATE run_control_workers SET snapshot_json = ?, error_json = ?, updated_at = ? WHERE control_id = ? AND owner_token = ? AND status = 'running'", [JSON.stringify(snapshot), JSON.stringify(failure(error)), context.now(), input.controlId, worker.owner_token]);
|
|
346
|
+
await delay(observation.nextDelay());
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const request = JSON.parse(current.request_json);
|
|
351
|
+
const interrupt = current.action === "stop" || request.escalate === "interrupt" && Date.now() >= Date.parse(current.created_at) + (request.grace_ms ?? 30_000);
|
|
352
|
+
let snapshot;
|
|
353
|
+
try {
|
|
354
|
+
snapshot = await withRecoveryObservation(observation, async () => {
|
|
355
|
+
const cancellation = interrupt ? await interruptControlledSessions(ownedContext, input) : null;
|
|
356
|
+
await lease.ensureConfirmed(assertControlCurrent);
|
|
357
|
+
return { ...await observeControlledSessions(ownedContext, input), ...(cancellation ? { cancellation } : {}) };
|
|
358
|
+
});
|
|
359
|
+
}
|
|
306
360
|
catch (error) {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
361
|
+
snapshot = { ok: true, control_id: input.controlId, status: "requested", settled: false, pending_reasons: ["control_observation_failed"], error: failure(error) };
|
|
362
|
+
}
|
|
363
|
+
await lease.ensureConfirmed(assertControlCurrent);
|
|
364
|
+
observation.observe(snapshot);
|
|
365
|
+
snapshot = { ...snapshot, recovery_observation: observation.state() };
|
|
366
|
+
context.db.run("UPDATE run_control_workers SET snapshot_json = ?, updated_at = ? WHERE control_id = ? AND owner_token = ? AND status = 'running'", [JSON.stringify(snapshot), context.now(), input.controlId, worker.owner_token]);
|
|
367
|
+
if (snapshot.settled === true) {
|
|
368
|
+
try {
|
|
369
|
+
sealRunRecovery(context, { projectRoot: project.root, runId: input.runId, recoveryId: guard.recovery_id, settlement: JSON.stringify(snapshot) });
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
context.db.run("UPDATE run_control_workers SET snapshot_json = ?, error_json = ?, updated_at = ? WHERE control_id = ? AND owner_token = ? AND status = 'running'", [JSON.stringify({ ...snapshot, pending_reasons: ["recovery_seal_required"], error: failure(error) }), JSON.stringify(failure(error)), context.now(), input.controlId, worker.owner_token]);
|
|
374
|
+
}
|
|
312
375
|
}
|
|
376
|
+
await delay(observation.nextDelay());
|
|
313
377
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
primaryFailure = error;
|
|
317
381
|
try {
|
|
318
|
-
|
|
319
|
-
const cancellation = interrupt ? await interruptControlledSessions(context, input) : null;
|
|
320
|
-
return { ...await observeControlledSessions(context, input), ...(cancellation ? { cancellation } : {}) };
|
|
321
|
-
});
|
|
382
|
+
context.db.run("UPDATE run_control_workers SET status = 'failed', error_json = ?, updated_at = ? WHERE control_id = ? AND owner_token = ?", [JSON.stringify(failure(error)), context.now(), input.controlId, worker.owner_token]);
|
|
322
383
|
}
|
|
323
|
-
catch (
|
|
324
|
-
|
|
384
|
+
catch (secondary) {
|
|
385
|
+
process.stderr.write(`${JSON.stringify({ primary: failure(error), phase: "persist_control_failure", secondary: failure(secondary) })}\n`);
|
|
325
386
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
387
|
+
throw error;
|
|
388
|
+
}
|
|
389
|
+
finally {
|
|
390
|
+
lease.close();
|
|
391
|
+
clearInterval(heartbeat);
|
|
392
|
+
try {
|
|
393
|
+
if (worker.process_id && worker.process_lease_token)
|
|
394
|
+
finishManagedProcess(resourceContext, { id: worker.process_id, leaseToken: worker.process_lease_token, state: "stopped", reason: "control_owner_exited" });
|
|
395
|
+
}
|
|
396
|
+
catch (secondary) {
|
|
397
|
+
cleanupFailure = secondary;
|
|
398
|
+
if (primaryFailure)
|
|
399
|
+
process.stderr.write(`${JSON.stringify({ primary: failure(primaryFailure), phase: "finish_control_owner", secondary: failure(secondary) })}\n`);
|
|
337
400
|
}
|
|
338
|
-
await delay(observation.nextDelay());
|
|
339
401
|
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
finishManagedProcess(resourceContext, { id: worker.process_id, leaseToken: worker.process_lease_token, state: "stopped", reason: "control_owner_exited" });
|
|
349
|
-
}
|
|
402
|
+
})();
|
|
403
|
+
if (cleanupFailure !== undefined)
|
|
404
|
+
throw cleanupFailure;
|
|
405
|
+
return result;
|
|
406
|
+
}
|
|
407
|
+
function failure(error) {
|
|
408
|
+
const record = errorRecord(error);
|
|
409
|
+
return { ...record, code: record.code === "unexpected" ? "run_control_failed" : record.code };
|
|
350
410
|
}
|
|
351
|
-
function failure(error) { return { code: error instanceof AppError ? error.code : "run_control_failed", message: error instanceof Error ? error.message : String(error) }; }
|