@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
|
@@ -5,8 +5,13 @@ import { commandJson } from "./process-json.mjs";
|
|
|
5
5
|
import { errorRecord } from "./operation-errors.mjs";
|
|
6
6
|
import { writeJsonAtomic } from "./runner-events.mjs";
|
|
7
7
|
import { runBoundedCommand } from "./native-hook-command.mjs";
|
|
8
|
+
import { createRenewalState, maintenanceRetryable, renewalConfirmed, renewalDelay, renewalFailure, renewalRemaining, RENEWAL_POLICY } from "./lease-renewal.mjs";
|
|
8
9
|
|
|
9
10
|
const leases = new Map();
|
|
11
|
+
// A rejected Promise is not physical exit. Do not start another writer for
|
|
12
|
+
// this exact binding after an unconfirmed maintenance subtree cleanup.
|
|
13
|
+
const maintenancePoison = new Map();
|
|
14
|
+
const maintenanceFlights = new Map();
|
|
10
15
|
|
|
11
16
|
/** Freeze resource ownership independently of whether Subject hooks are enabled. */
|
|
12
17
|
export function bindRuntimeOwner(config, env = process.env) {
|
|
@@ -38,6 +43,7 @@ export function bindRuntimeOwner(config, env = process.env) {
|
|
|
38
43
|
|
|
39
44
|
/** Reject rebinding before provider setup, including the already-running path. */
|
|
40
45
|
export async function prepareRuntimeOwner(config, stateDir, env = process.env) {
|
|
46
|
+
config.stateDir = path.resolve(stateDir);
|
|
41
47
|
const owner = bindRuntimeOwner(config, env);
|
|
42
48
|
if (owner && path.resolve(owner.state_dir) !== path.resolve(stateDir)) {
|
|
43
49
|
throw Object.assign(new Error("runtime owner belongs to another state directory"), { code: "runtime_owner_mismatch" });
|
|
@@ -52,27 +58,44 @@ export async function prepareRuntimeOwner(config, stateDir, env = process.env) {
|
|
|
52
58
|
return owner;
|
|
53
59
|
}
|
|
54
60
|
|
|
55
|
-
export async function assertDaemonOwnership(stateDir, dispatch = {}, assertCurrent = () => {}) {
|
|
56
|
-
|
|
61
|
+
export async function assertDaemonOwnership(stateDir, dispatch = {}, assertCurrent = () => {}, progress = async () => {}, waiting = async () => {}) {
|
|
62
|
+
const gate = async () => {
|
|
63
|
+
assertCurrent();
|
|
64
|
+
for (const lease of leases.values()) {
|
|
65
|
+
if (lease.stateDir !== path.resolve(stateDir)) continue;
|
|
66
|
+
await reconfirmLease(lease, assertCurrent);
|
|
67
|
+
assertCurrent();
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
await gate();
|
|
71
|
+
await progress('ownership_confirmed');
|
|
57
72
|
const state = JSON.parse(await readFile(path.join(stateDir, "daemon.json"), "utf8"));
|
|
58
73
|
const owner = bindRuntimeOwner({ runtime_owner: state.config?.runtime_owner ?? null }, {});
|
|
59
74
|
if (!owner) return;
|
|
60
75
|
const record = state.resource_process;
|
|
61
76
|
if (owner.state_dir !== stateDir || !record?.id || !record.lease_token) throw Object.assign(new Error("owned daemon has no confirmed resource binding"), { code: "process_ownership_unknown" });
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (Date.now() >= lease.expiresAt) throw Object.assign(new Error("managed process renewal is overdue"), { code: "process_ownership_unknown", details: { process_id: lease.record.id, cause: lease.error ? errorRecord(lease.error) : null } });
|
|
65
|
-
// A failed renewal is not a lost lease. Reconfirm before productive work;
|
|
66
|
-
// never dispatch on the old lease merely because it has time remaining.
|
|
67
|
-
if (lease.error && !lease.lost) await heartbeatDaemonProcess(lease.config, lease.record);
|
|
68
|
-
if (lease.error) throw lease.error;
|
|
69
|
-
}
|
|
77
|
+
scheduleDaemonProcessHeartbeat(state.config, record);
|
|
78
|
+
const admissionRenewal = createRenewalState();
|
|
70
79
|
for (;;) {
|
|
80
|
+
await gate();
|
|
81
|
+
admissionRenewal.uncertainSince ??= performance.now();
|
|
82
|
+
let result;
|
|
83
|
+
try { result = await runtimeProcess(state.config, "admission", { id: record.id, "lease-token": record.lease_token, owner: owner.owner_id, "state-dir": stateDir, "daemon-id": state.daemon_id ?? state.config?.daemonId, ...dispatch }, { timeoutMs: renewalRemaining(admissionRenewal) }); }
|
|
84
|
+
catch (error) {
|
|
85
|
+
if (!maintenanceRetryable(error) || !renewalFailure(admissionRenewal)) throw error;
|
|
86
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(renewalDelay(admissionRenewal), renewalRemaining(admissionRenewal))));
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
71
89
|
assertCurrent();
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
90
|
+
if (result?.ok !== true || result.process_id !== record.id || result.admitted !== true && result.reason !== "provider_capacity") throw Object.assign(new Error("productive runtime admission was not confirmed"), { code: "process_ownership_unknown" });
|
|
91
|
+
requireTimelyMaintenance(admissionRenewal, "admission", result, record);
|
|
92
|
+
// This bound ACK ends admission uncertainty. The post-ACK lease checks
|
|
93
|
+
// have their own episodes; successful renewal must not exhaust an already
|
|
94
|
+
// confirmed admission while the provider/daemon still retain ownership.
|
|
95
|
+
if (!renewalConfirmed(admissionRenewal)) requireTimelyMaintenance(admissionRenewal, "admission", result, record);
|
|
96
|
+
await gate();
|
|
97
|
+
await waiting({ ...result, lease_expires_at: result.lease_expires_at ?? record.lease_expires_at });
|
|
98
|
+
if (result.admitted === true) { await progress('admission_confirmed'); return; }
|
|
76
99
|
const predecessor = result.blocking_turns?.find(turn => turn?.process_id === record.id
|
|
77
100
|
&& turn?.session_id === dispatch["native-session-id"]
|
|
78
101
|
&& turn?.operation_id !== dispatch["operation-id"]);
|
|
@@ -153,20 +176,92 @@ async function budgetState(stateDir) {
|
|
|
153
176
|
|
|
154
177
|
async function releaseBudget(state, options) {
|
|
155
178
|
const record = state.resource_process, owner = state.config.runtime_owner;
|
|
156
|
-
const receipt = await runtimeProcess(state.config, "release-turn", { id: record.id, "lease-token": record.lease_token, owner: owner.owner_id, "daemon-id": state.daemon_id ?? state.config?.daemonId, ...options });
|
|
179
|
+
const receipt = await retryBookkeeping(timeoutMs => runtimeProcess(state.config, "release-turn", { id: record.id, "lease-token": record.lease_token, owner: owner.owner_id, "daemon-id": state.daemon_id ?? state.config?.daemonId, ...options }, { timeoutMs }));
|
|
157
180
|
if (receipt?.ok !== true) throw Object.assign(new Error("runtime budget release was not confirmed"), { code: "runtime_budget_release_unconfirmed" });
|
|
158
181
|
}
|
|
159
182
|
|
|
160
|
-
export async function runtimeProcess(config, action, options = {}) {
|
|
183
|
+
export async function runtimeProcess(config, action, options = {}, { timeoutMs = RENEWAL_POLICY.budgetMs, signal } = {}) {
|
|
184
|
+
const resourceHome = config.runtime_owner?.resource_home ?? config.resourceHome;
|
|
185
|
+
if (action === "status" || !options.id || !resourceHome) return executeRuntimeProcess(config, action, options, { timeoutMs, signal });
|
|
186
|
+
const key = JSON.stringify([path.resolve(resourceHome), options.id, options["lease-token"] ?? null]);
|
|
187
|
+
const validBudget = Number.isFinite(timeoutMs) && timeoutMs > 0;
|
|
188
|
+
const deadline = performance.now() + (validBudget ? timeoutMs : 0);
|
|
189
|
+
const prior = maintenanceFlights.get(key);
|
|
190
|
+
let started = false;
|
|
191
|
+
let settle;
|
|
192
|
+
const flight = new Promise(resolve => { settle = resolve; });
|
|
193
|
+
maintenanceFlights.set(key, flight);
|
|
194
|
+
try {
|
|
195
|
+
if (!validBudget) throw Object.assign(new Error("Managed maintenance budget is invalid or exhausted before dispatch"), { code: "process_maintenance_timeout", details: { effect: "no_effect", native_dispatch_started: false } });
|
|
196
|
+
if (prior) {
|
|
197
|
+
let timer;
|
|
198
|
+
try {
|
|
199
|
+
await Promise.race([prior, new Promise((_, reject) => {
|
|
200
|
+
const expire = () => {
|
|
201
|
+
const remaining = deadline - performance.now();
|
|
202
|
+
// A timer wakeup is not proof that its monotonic deadline passed.
|
|
203
|
+
if (remaining > 0) { timer = setTimeout(expire, Math.min(2_147_483_647, Math.ceil(remaining))); return; }
|
|
204
|
+
reject(Object.assign(new Error("managed maintenance queue deadline expired"), { code: "process_maintenance_timeout", details: { effect: "no_effect" } }));
|
|
205
|
+
};
|
|
206
|
+
expire();
|
|
207
|
+
})]);
|
|
208
|
+
} finally { clearTimeout(timer); }
|
|
209
|
+
}
|
|
210
|
+
const remaining = deadline - performance.now();
|
|
211
|
+
if (remaining <= 0) throw Object.assign(new Error("managed maintenance queue deadline expired"), { code: "process_maintenance_timeout", details: { effect: "no_effect" } });
|
|
212
|
+
started = true;
|
|
213
|
+
return await executeRuntimeProcess(config, action, options, { timeoutMs: remaining, signal });
|
|
214
|
+
} catch (error) {
|
|
215
|
+
const lease = leases.get(key);
|
|
216
|
+
const text = value => typeof value === "string" ? value.slice(0, 128) : undefined;
|
|
217
|
+
const request = { phase: started ? "request" : "queue", action, process_id: text(options.id), operation_id: text(options["operation-id"] ?? config.runtime_owner?.operation_id), role: text(lease?.record.kind ?? config.runtime_owner?.role), attempt_kind: prior ? "join" : "fresh", attempt: (lease?.renewal.attempt ?? 0) + 1, remaining_ms: Math.max(0, Math.round(deadline - performance.now())), native_dispatch_started: false, maintenance_effect: started ? "unknown" : "no_effect" };
|
|
218
|
+
// Parent queue/import failures precede any child trace. Keep only bounded,
|
|
219
|
+
// credential-free context and the existing child phase allowlist.
|
|
220
|
+
const childPhases = Array.isArray(error.details?.hook_diagnostics) ? error.details.hook_diagnostics : [];
|
|
221
|
+
error.details = { ...error.details, hook_diagnostics: [request, ...childPhases.slice(-15)] };
|
|
222
|
+
throw error;
|
|
223
|
+
} finally {
|
|
224
|
+
// Even a waiter whose deadline expires must retain its predecessor's
|
|
225
|
+
// physical fence until that predecessor settles.
|
|
226
|
+
const finish = () => {
|
|
227
|
+
settle();
|
|
228
|
+
if (maintenanceFlights.get(key) === flight) maintenanceFlights.delete(key);
|
|
229
|
+
};
|
|
230
|
+
if (prior) void prior.then(finish); else finish();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function executeRuntimeProcess(config, action, options = {}, { timeoutMs, signal }) {
|
|
161
235
|
const owner = config.runtime_owner;
|
|
162
236
|
const resourceHome = owner?.resource_home ?? config.resourceHome;
|
|
163
237
|
const ddFlowHome = owner?.dd_flow_home ?? config.ddFlowHome;
|
|
164
238
|
const ddFlowBin = owner?.dd_flow_bin ?? config.ddFlowBin;
|
|
165
239
|
if (!resourceHome || !ddFlowHome || !ddFlowBin) return null;
|
|
240
|
+
const poisonKey = token => JSON.stringify([path.resolve(resourceHome), options.id, token ?? null]);
|
|
241
|
+
if (action !== "status" && options.id) {
|
|
242
|
+
const poison = maintenancePoison.get(poisonKey(options["lease-token"])) ?? maintenancePoison.get(poisonKey(null));
|
|
243
|
+
if (poison) throw poison;
|
|
244
|
+
}
|
|
166
245
|
const args = ["runtime", "process", action];
|
|
167
246
|
for (const [key, value] of Object.entries(options)) if (value !== null && value !== undefined) args.push(`--${key}`, String(value));
|
|
168
|
-
if (
|
|
169
|
-
|
|
247
|
+
if (["register", "confirm", "heartbeat", "admission", "check-admission", "finish", "release-turn", "status"].includes(action)) {
|
|
248
|
+
const maintenanceStarted = performance.now();
|
|
249
|
+
process.stderr.write(`${JSON.stringify({ kind: "process_maintenance_phase", phase: "request", observed_at: new Date().toISOString(), operation_id: options['operation-id'] ?? config.runtime_owner?.operation_id ?? null, action, process_id: options.id ?? null, remaining_ms: Math.round(timeoutMs), native_dispatch_started: false })}\n`);
|
|
250
|
+
let output;
|
|
251
|
+
try { output = await runBoundedCommand({ bin: ddFlowBin, args: [...args, "--json"], cwd: config.cwd,
|
|
252
|
+
env: { ...(config.env ?? {}), DD_FLOW_HOME: ddFlowHome, DD_FLOW_RESOURCE_HOME: resourceHome }, timeoutMs,
|
|
253
|
+
timeoutCode: action === "heartbeat" ? "process_heartbeat_timeout" : "process_maintenance_timeout", inheritDeadline: false, cleanupWithinBudget: true,
|
|
254
|
+
maintenanceProgress: { action, processId: options.id ?? null, inactivityMs: RENEWAL_POLICY.attemptMs }, signal }); }
|
|
255
|
+
catch (error) {
|
|
256
|
+
if (action !== "status" && options.id && error.details?.cleanup_unconfirmed) maintenancePoison.set(poisonKey(options["lease-token"]), error);
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
259
|
+
let result;
|
|
260
|
+
try { result = JSON.parse(output); } catch { throw Object.assign(new Error("Invalid managed process maintenance response"), { code: "process_maintenance_receipt_invalid", details: { action } }); }
|
|
261
|
+
if (result?.ok === false && result.error?.code) throw Object.assign(new Error(result.error.message ?? "Managed process maintenance failed"), { code: result.error.code, details: result.error.details });
|
|
262
|
+
process.stderr.write(`${JSON.stringify({ kind: "process_maintenance_phase", phase: "ack", observed_at: new Date().toISOString(), duration_ms: Math.round(performance.now() - maintenanceStarted), operation_id: options['operation-id'] ?? config.runtime_owner?.operation_id ?? null, action, process_id: options.id ?? null, lease_expires_at: result.lease_expires_at ?? result.process?.lease_expires_at ?? null, native_dispatch_started: false })}\n`);
|
|
263
|
+
return result;
|
|
264
|
+
}
|
|
170
265
|
return await commandJson(ddFlowBin, args, {
|
|
171
266
|
cwd: config.cwd,
|
|
172
267
|
env: { ...(config.env ?? {}), DD_FLOW_HOME: ddFlowHome, DD_FLOW_RESOURCE_HOME: resourceHome }
|
|
@@ -177,8 +272,38 @@ export async function registerDaemonProcess(config, input) {
|
|
|
177
272
|
const owner = bindRuntimeOwner(config);
|
|
178
273
|
const harness = ({ "codex-daemon": "codex-desktop", "codex-provider": "codex-desktop", "zcode-daemon": "zcode-acp", "zcode-provider": "zcode-acp", "grok-daemon": "grok-acp", "grok-provider": "grok-acp", "agy-daemon": "antigravity-cli", "opencode-daemon": "opencode-server", "droid-daemon": "droid-cli" })[input.kind];
|
|
179
274
|
const scope = owner ? { owner: owner.owner_id, "project-id": owner.project_id, run: owner.run_id, work: owner.work_id, role: owner.role, "state-dir": owner.state_dir, "adapter-executable": owner.adapter_executable, ...(owner.budget ? { "budget-json": JSON.stringify(owner.budget), harness } : {}), ...(owner.project_id && owner.run_id || owner.budget ? { "dispatch-barrier": "run-generation@1" } : {}), operation: `${owner.operation_id}:${input.operation ?? input.kind}` } : {};
|
|
180
|
-
const
|
|
181
|
-
|
|
275
|
+
const options = { ...input, ...scope, id: `PROC-${input.kind}-${randomUUID()}`, "owner-pid": process.pid, "lease-ms": 300_000 };
|
|
276
|
+
const renewal = createRenewalState();
|
|
277
|
+
renewal.uncertainSince = performance.now();
|
|
278
|
+
let result;
|
|
279
|
+
for (;;) {
|
|
280
|
+
try { result = await runtimeProcess(config, "register", options, { timeoutMs: renewalRemaining(renewal) }); break; }
|
|
281
|
+
catch (error) {
|
|
282
|
+
if (!maintenanceRetryable(error) || !renewalFailure(renewal)) throw error;
|
|
283
|
+
// The child has exited before a timeout is retryable. Reconcile the exact
|
|
284
|
+
// startup identity, not an arbitrary PID or another operation's owner.
|
|
285
|
+
let status;
|
|
286
|
+
try { status = await retryBookkeeping(timeoutMs => runtimeProcess(config, "status", {}, { timeoutMs }), renewal); }
|
|
287
|
+
catch (reconciliationError) {
|
|
288
|
+
// Retain the failed write's phases without changing the reconciliation
|
|
289
|
+
// failure's classification or authorizing another startup attempt.
|
|
290
|
+
reconciliationError.details = { ...reconciliationError.details, registration_error: errorRecord(error) };
|
|
291
|
+
throw reconciliationError;
|
|
292
|
+
}
|
|
293
|
+
const retained = status?.processes?.find(record => record.id === options.id);
|
|
294
|
+
if (retained) {
|
|
295
|
+
let metadata; try { metadata = JSON.parse(retained.metadata_json); } catch { /* Invalid binding below. */ }
|
|
296
|
+
if (retained.kind !== options.kind || retained.owner_id !== options.owner || retained.operation_id !== options.operation || retained.state !== "starting" || metadata?.owner_pid !== process.pid) throw Object.assign(new Error("Startup reconciliation changed managed process binding"), { code: "process_maintenance_receipt_invalid" });
|
|
297
|
+
result = { process: retained }; break;
|
|
298
|
+
}
|
|
299
|
+
if (!Array.isArray(status?.processes)) throw Object.assign(new Error("Startup reconciliation has no registry proof"), { code: "process_maintenance_receipt_invalid" });
|
|
300
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(renewalDelay(renewal), renewalRemaining(renewal))));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (!result) return null;
|
|
304
|
+
if (result.process?.id !== options.id || !result.process.lease_token) throw Object.assign(new Error("Managed startup receipt belongs to another registration"), { code: "process_maintenance_receipt_invalid" });
|
|
305
|
+
requireTimelyMaintenance(renewal, "register", result, result.process);
|
|
306
|
+
return { id: result.process.id, lease_token: result.process.lease_token, kind: input.kind, lease_expires_at: result.process.lease_expires_at };
|
|
182
307
|
}
|
|
183
308
|
|
|
184
309
|
export async function confirmDaemonProcess(config, record, child) {
|
|
@@ -187,7 +312,11 @@ export async function confirmDaemonProcess(config, record, child) {
|
|
|
187
312
|
await finishDaemonProcess(config, record, "failed", "daemon_spawn_missing_pid");
|
|
188
313
|
return;
|
|
189
314
|
}
|
|
190
|
-
|
|
315
|
+
const renewal = createRenewalState();
|
|
316
|
+
const result = await retryBookkeeping(timeoutMs => runtimeProcess(config, "confirm", { id: record.id, "lease-token": record.lease_token, pid: child.pid, "process-group-id": child.pid, "owner-pid": record.kind?.endsWith("-daemon") ? child.pid : process.pid, "lease-ms": 300_000 }, { timeoutMs }), renewal);
|
|
317
|
+
if (!result?.process || result.process.id !== record.id || result.process.lease_token !== record.lease_token) throw Object.assign(new Error("Managed process confirmation binding is invalid"), { code: "process_maintenance_receipt_invalid" });
|
|
318
|
+
retainReceipt(record, result.process);
|
|
319
|
+
requireTimelyMaintenance(renewal, "confirm", result, record);
|
|
191
320
|
}
|
|
192
321
|
|
|
193
322
|
/** Provider bridges use a different process group from their daemon. Publish
|
|
@@ -217,29 +346,40 @@ export async function closeManagedBridge(config, state, bridge) {
|
|
|
217
346
|
|
|
218
347
|
export function scheduleDaemonProcessHeartbeat(config, record) {
|
|
219
348
|
if (!record?.id || !record.lease_token) return;
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
349
|
+
const key = leaseKey(config, record);
|
|
350
|
+
if (leases.has(key)) return;
|
|
351
|
+
const lease = { pending: null, error: null, timer: null, lost: false, closing: false, expiresAt: Date.parse(record.lease_expires_at ?? ""), renewal: createRenewalState(), stateDir: path.resolve(config.runtime_owner?.state_dir ?? config.stateDir ?? config.cwd), config, record };
|
|
352
|
+
leases.set(key, lease);
|
|
353
|
+
// Retry inside the same uncertainty episode while native work is active.
|
|
354
|
+
// Waiting for the next 60s tick would exhaust the 30s budget after one BUSY.
|
|
355
|
+
lease.timer = setInterval(() => reconfirmLease(lease, () => {}).catch(() => {}), 60_000);
|
|
224
356
|
lease.timer.unref();
|
|
225
357
|
}
|
|
226
358
|
|
|
227
359
|
export async function heartbeatDaemonProcess(config, record) {
|
|
228
360
|
if (!record?.id || !record.lease_token) return;
|
|
229
361
|
scheduleDaemonProcessHeartbeat(config, record);
|
|
230
|
-
const lease = leases.get(record
|
|
362
|
+
const lease = leases.get(leaseKey(config, record));
|
|
363
|
+
if (lease.closing) throw Object.assign(new Error("Managed process is closing"), { code: "process_closing" });
|
|
231
364
|
if (lease.lost) throw lease.error;
|
|
365
|
+
if (lease.error && !maintenanceRetryable(lease.error)) throw lease.error;
|
|
366
|
+
if (!renewalRemaining(lease.renewal)) throw lease.error;
|
|
232
367
|
if (lease.pending) return await lease.pending;
|
|
233
368
|
lease.pending = (async () => {
|
|
234
369
|
try {
|
|
235
|
-
|
|
236
|
-
|
|
370
|
+
lease.renewal.uncertainSince ??= performance.now();
|
|
371
|
+
const result = await runtimeProcess(config, "heartbeat", { id: record.id, "lease-token": record.lease_token, "lease-ms": 300_000 }, { timeoutMs: renewalRemaining(lease.renewal) });
|
|
372
|
+
if (result?.ok === false) {
|
|
237
373
|
lease.lost = true; clearInterval(lease.timer);
|
|
238
374
|
throw Object.assign(new Error("managed process lease is no longer owned; new productive operations are blocked"), { code: "process_lease_lost", details: { process_id: record.id } });
|
|
239
375
|
}
|
|
240
|
-
|
|
241
|
-
lease.
|
|
376
|
+
if (result?.ok !== true || result.process_id !== record.id) throw Object.assign(new Error("Managed renewal response belongs to another process"), { code: "process_maintenance_receipt_invalid", details: { process_id: record.id } });
|
|
377
|
+
if (!lease.closing) {
|
|
378
|
+
if (!renewalConfirmed(lease.renewal)) requireTimelyMaintenance(lease.renewal, "heartbeat", result, record);
|
|
379
|
+
retainReceipt(record, result); lease.error = null; lease.expiresAt = Date.parse(record.lease_expires_at);
|
|
380
|
+
}
|
|
242
381
|
} catch (cause) {
|
|
382
|
+
if (!lease.closing) renewalFailure(lease.renewal);
|
|
243
383
|
lease.error = cause.code === "process_lease_lost" ? cause : Object.assign(new Error("cannot confirm managed process ownership"), { code: "process_ownership_unknown", cause, details: { process_id: record.id, cause: errorRecord(cause) } });
|
|
244
384
|
throw lease.error;
|
|
245
385
|
}
|
|
@@ -249,13 +389,59 @@ export async function heartbeatDaemonProcess(config, record) {
|
|
|
249
389
|
|
|
250
390
|
export async function finishDaemonProcess(config, process, state = "stopped", reason = "daemon_stopped") {
|
|
251
391
|
if (process?.id && process.lease_token) {
|
|
252
|
-
const
|
|
253
|
-
const
|
|
392
|
+
const settlement = createRenewalState(); settlement.uncertainSince = performance.now();
|
|
393
|
+
const key = leaseKey(config, process), lease = leases.get(key);
|
|
394
|
+
if (lease) { lease.closing = true; clearInterval(lease.timer); await lease.pending?.catch(() => {}); if (lease.error?.cause?.details?.cleanup_unconfirmed) throw lease.error; }
|
|
395
|
+
const result = await retryBookkeeping(timeoutMs => runtimeProcess(config, "finish", { id: process.id, "lease-token": process.lease_token, state, reason }, { timeoutMs }), settlement);
|
|
254
396
|
if (result?.ok !== true) throw Object.assign(new Error("managed process finish was not accepted"), { code: "process_lease_lost", details: { process_id: process.id } });
|
|
255
|
-
leases.delete(
|
|
397
|
+
leases.delete(key);
|
|
256
398
|
}
|
|
257
399
|
}
|
|
258
400
|
|
|
401
|
+
function leaseKey(config, record) { return JSON.stringify([path.resolve(config.runtime_owner?.resource_home ?? config.resourceHome), record.id, record.lease_token]); }
|
|
402
|
+
function retainReceipt(record, receipt) {
|
|
403
|
+
const expiresAt = Date.parse(receipt.lease_expires_at ?? "");
|
|
404
|
+
if (!Number.isFinite(expiresAt) || !/^[a-f0-9]{64}$/.test(receipt.registration_sha256 ?? "") || record.registration_sha256 && record.registration_sha256 !== receipt.registration_sha256) throw Object.assign(new Error("Managed lease receipt has no authoritative expiry/binding"), { code: "process_maintenance_receipt_invalid", details: { process_id: record.id } });
|
|
405
|
+
record.lease_expires_at = receipt.lease_expires_at;
|
|
406
|
+
record.registration_sha256 = receipt.registration_sha256;
|
|
407
|
+
}
|
|
408
|
+
async function reconfirmLease(lease, assertCurrent) {
|
|
409
|
+
for (;;) {
|
|
410
|
+
assertCurrent();
|
|
411
|
+
if (lease.closing || lease.lost) throw lease.error ?? Object.assign(new Error("Managed process is closing"), { code: "process_closing" });
|
|
412
|
+
try { await heartbeatDaemonProcess(lease.config, lease.record); }
|
|
413
|
+
catch (error) {
|
|
414
|
+
if (!maintenanceRetryable(error) || !renewalRemaining(lease.renewal)) throw error;
|
|
415
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(renewalDelay(lease.renewal), renewalRemaining(lease.renewal))));
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
assertCurrent();
|
|
419
|
+
if (lease.closing || lease.error || !Number.isFinite(lease.expiresAt) || Date.now() >= lease.expiresAt) throw lease.error ?? Object.assign(new Error("Committed lease receipt expired before admission"), { code: "process_ownership_unknown" });
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
async function retryBookkeeping(action, renewal = createRenewalState()) {
|
|
424
|
+
renewal.uncertainSince ??= performance.now();
|
|
425
|
+
for (;;) {
|
|
426
|
+
try { return await action(renewalRemaining(renewal)); }
|
|
427
|
+
catch (error) { if (!maintenanceRetryable(error) || !renewalFailure(renewal)) throw error; await new Promise(resolve => setTimeout(resolve, Math.min(renewalDelay(renewal), renewalRemaining(renewal)))); }
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// A committed ACK is evidence, but cannot revive an expired authority episode.
|
|
432
|
+
// Cleanup-only finish/release receipts remain facts, not productive grants.
|
|
433
|
+
function requireTimelyMaintenance(renewal, action, receipt, record) {
|
|
434
|
+
if (renewalRemaining(renewal)) return;
|
|
435
|
+
const safeText = value => typeof value === "string" ? value.slice(0, 128) : undefined;
|
|
436
|
+
const hash = receipt?.process?.registration_sha256 ?? receipt?.registration_sha256 ?? record?.registration_sha256;
|
|
437
|
+
throw Object.assign(new Error("Managed ownership confirmation arrived after its uncertainty budget"), {
|
|
438
|
+
code: "process_ownership_unknown", details: { action, process_id: safeText(record?.id ?? receipt?.process_id),
|
|
439
|
+
registration_sha256: typeof hash === "string" && /^[a-f0-9]{64}$/.test(hash) ? hash : undefined,
|
|
440
|
+
lease_expires_at: safeText(receipt?.process?.lease_expires_at ?? receipt?.lease_expires_at ?? record?.lease_expires_at),
|
|
441
|
+
effect: action === "admission" && receipt?.admitted !== true ? "no_effect" : "committed", native_dispatch_started: false }
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
259
445
|
/** Preserve the startup error and retain the lease/resources if cleanup cannot
|
|
260
446
|
* prove termination. A failed cleanup is not a terminal managed process. */
|
|
261
447
|
export async function cleanupFailedStart(config, record, child, original) {
|
|
@@ -286,43 +472,113 @@ export async function cleanupFailedDaemonStart(config, daemonRecord, daemonChild
|
|
|
286
472
|
}
|
|
287
473
|
}
|
|
288
474
|
|
|
289
|
-
|
|
475
|
+
const processStops = new WeakMap();
|
|
476
|
+
export async function closeBridgeProcess(bridge, drain, stopOptions = {}) {
|
|
477
|
+
if (bridge.closePending) return await bridge.closePending;
|
|
478
|
+
const pending = (async () => {
|
|
479
|
+
let primary;
|
|
480
|
+
try {
|
|
481
|
+
if (!bridge.physicallyClosed && bridge.child) {
|
|
482
|
+
const child = bridge.child;
|
|
483
|
+
if (!bridge.stdinEnded && !child.stdin?.destroyed) { bridge.stdinEnded = true; child.stdin?.end(); }
|
|
484
|
+
if (child.exitCode == null && child.signalCode == null) {
|
|
485
|
+
await new Promise(resolve => {
|
|
486
|
+
const done = () => { clearTimeout(timer); child.removeListener("exit", done); resolve(); };
|
|
487
|
+
const timer = setTimeout(done, 2_000); child.once("exit", done);
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
await stopProcessGroup(child, 2_000, stopOptions);
|
|
491
|
+
bridge.physicallyClosed = true;
|
|
492
|
+
}
|
|
493
|
+
} catch (error) { primary = error; }
|
|
494
|
+
let drainTimer;
|
|
495
|
+
try { await Promise.race([drain(), new Promise((_, reject) => { drainTimer = setTimeout(() => reject(Object.assign(new Error("bridge drain did not finish"), { code: "bridge_drain_timeout" })), 10_000); })]); }
|
|
496
|
+
catch (error) { if (primary) { primary.cleanup_error = errorRecord(error); primary.details = { ...primary.details, cleanup_error: primary.cleanup_error }; } else primary = error; }
|
|
497
|
+
finally { clearTimeout(drainTimer); }
|
|
498
|
+
if (primary) throw primary;
|
|
499
|
+
})();
|
|
500
|
+
bridge.closePending = pending;
|
|
501
|
+
try { return await pending; }
|
|
502
|
+
finally { bridge.closePending = null; }
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export async function stopProcessGroup(child, graceMs = 1_000, { detached = true, settlementMs = 10_000 } = {}) {
|
|
290
506
|
if (!child?.pid) return;
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
507
|
+
if (processStops.has(child)) return await processStops.get(child);
|
|
508
|
+
const pending = stopOwnedChild(child, graceMs, detached, settlementMs);
|
|
509
|
+
processStops.set(child, pending);
|
|
510
|
+
try { return await pending; } finally { processStops.delete(child); }
|
|
511
|
+
}
|
|
512
|
+
async function stopOwnedChild(child, graceMs, detached, settlementMs) {
|
|
513
|
+
const target = process.platform === "win32" || !detached ? child.pid : -child.pid;
|
|
514
|
+
const details = signal => ({ pid: child.pid, pgid: target < 0 ? child.pid : null, signal, platform: process.platform, exit_code: child.exitCode ?? null, signal_code: child.signalCode ?? null, observed_at: new Date().toISOString() });
|
|
295
515
|
const alive = () => { try { process.kill(target, 0); return true; } catch (error) { if (error.code === "ESRCH") return false; if (error.code === "EPERM") return true; throw error; } };
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
516
|
+
const gone = async ms => {
|
|
517
|
+
const deadline = performance.now() + ms;
|
|
518
|
+
while (alive() && performance.now() < deadline) await new Promise(resolve => setTimeout(resolve, 25));
|
|
519
|
+
return !alive();
|
|
520
|
+
};
|
|
521
|
+
const signal = async name => {
|
|
522
|
+
try { process.kill(target, name); return true; }
|
|
523
|
+
catch (error) {
|
|
524
|
+
if (error.code === "ESRCH") return false;
|
|
525
|
+
error.details = { ...error.details, ...details(name), syscall: error.syscall ?? "kill" };
|
|
526
|
+
// Permission denial is not permission to escalate. Observe only the retained target.
|
|
527
|
+
let absent = false;
|
|
528
|
+
try { absent = await gone(Math.min(250, Math.max(25, graceMs))); }
|
|
529
|
+
catch (observation) { error.details.observation_error = errorRecord(observation); }
|
|
530
|
+
if (absent) {
|
|
531
|
+
process.stderr.write(`${JSON.stringify({ cleanup_reobserved: errorRecord(error), physical_exit: "ESRCH" })}\n`);
|
|
532
|
+
return false;
|
|
533
|
+
}
|
|
534
|
+
throw error;
|
|
535
|
+
}
|
|
536
|
+
};
|
|
308
537
|
if (!alive()) return;
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
538
|
+
const leaderExited = () => child.exitCode != null || child.signalCode != null;
|
|
539
|
+
const observeLeaderless = async () => {
|
|
540
|
+
// Signal grace is not a drain deadline. Helpers can retire naturally after
|
|
541
|
+
// their leader exits; observing them does not authorize another signal.
|
|
542
|
+
if (await gone(Math.max(1_000, graceMs, settlementMs))) return;
|
|
543
|
+
throw Object.assign(new Error("process group remains after its leader exited; ownership cannot be proven from this child handle"), { code: "process_group_ownership_unknown", details: { ...details(null), settlement_ms: Math.max(1_000, graceMs, settlementMs) } });
|
|
544
|
+
};
|
|
545
|
+
if (leaderExited()) return await observeLeaderless();
|
|
546
|
+
if (!await signal("SIGTERM")) return;
|
|
547
|
+
if (await gone(graceMs)) return;
|
|
548
|
+
if (leaderExited()) return await observeLeaderless();
|
|
549
|
+
if (!await signal("SIGKILL")) return;
|
|
550
|
+
if (!await gone(Math.max(1_000, graceMs))) throw Object.assign(new Error(`process group ${child.pid} did not stop`), { code: "process_group_stop_incomplete", details: details("SIGKILL") });
|
|
316
551
|
}
|
|
317
552
|
|
|
318
553
|
/** A daemon acknowledgement is not a completed stop until its endpoint closes. */
|
|
319
|
-
export async function confirmDaemonStopped(request, socket, timeoutMs = 30_000) {
|
|
320
|
-
const
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
554
|
+
export async function confirmDaemonStopped(request, socket, timeoutMs = 30_000, { readState } = {}) {
|
|
555
|
+
const deadline = performance.now() + timeoutMs;
|
|
556
|
+
const expected = await readState?.();
|
|
557
|
+
const identity = state => state?.daemon_id ?? state?.config?.daemonId;
|
|
558
|
+
if (readState && (!identity(expected) || !Number.isSafeInteger(expected?.pid) || expected.pid < 1)) throw Object.assign(new Error('daemon stop has no retained incarnation/PID proof'), { code: 'daemon_cleanup_identity_mismatch' });
|
|
559
|
+
const failure = async observer => {
|
|
560
|
+
const state = await readState?.();
|
|
561
|
+
if (expected && identity(state) !== identity(expected)) throw Object.assign(new Error("daemon incarnation changed during stop"), { code: "daemon_identity_mismatch" });
|
|
562
|
+
const primary = state?.shutdown_state === "cleanup_failed" ? state.cleanup_error : null;
|
|
563
|
+
if (primary) throw Object.assign(new Error(primary.message), primary, { details: { ...primary.details, socket, phase: "confirm_daemon_stop", observer_error: errorRecord(observer) } });
|
|
564
|
+
throw observer;
|
|
565
|
+
};
|
|
566
|
+
let result;
|
|
567
|
+
try { result = await request(); }
|
|
568
|
+
catch (error) { return await failure(error); }
|
|
569
|
+
if (result?.stopped !== true) return await failure(Object.assign(new Error("stop acknowledgement does not prove shutdown"), { code: "daemon_stop_incomplete" }));
|
|
570
|
+
for (;;) {
|
|
571
|
+
const state = await readState?.();
|
|
572
|
+
if (expected && identity(state) !== identity(expected)) return await failure(Object.assign(new Error("daemon incarnation changed"), { code: "daemon_identity_mismatch" }));
|
|
573
|
+
let endpointPresent = true;
|
|
574
|
+
try { await lstat(socket); } catch (error) { if (error.code === "ENOENT") endpointPresent = false; else throw error; }
|
|
575
|
+
let daemonPresent = false;
|
|
576
|
+
if (expected?.pid) { try { process.kill(expected.pid, 0); daemonPresent = true; } catch (error) { if (error.code !== "ESRCH") daemonPresent = true; } }
|
|
577
|
+
if (!endpointPresent && !daemonPresent) {
|
|
578
|
+
if (readState && result.clean === true && (state?.shutdown_state !== 'clean' || state.active_tree !== false)) return await failure(Object.assign(new Error('daemon clean ACK conflicts with its retained state'), { code: 'daemon_stop_incomplete' }));
|
|
579
|
+
return result;
|
|
580
|
+
}
|
|
581
|
+
if (performance.now() >= deadline) return await failure(Object.assign(new Error("daemon stop could not confirm endpoint retirement and owned PID exit"), { code: "daemon_stop_incomplete", details: { socket, daemon_pid: expected?.pid ?? null, endpoint_present: endpointPresent, daemon_present: daemonPresent } }));
|
|
582
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
326
583
|
}
|
|
327
|
-
throw Object.assign(new Error("daemon acknowledged stop but its socket remained live"), { code: "daemon_stop_incomplete", details: { socket } });
|
|
328
584
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const NATIVE_CHILD_CONTRACT_VERSION: "native-children@1";
|
|
2
|
+
export interface NativeChild {
|
|
3
|
+
session_id: string;
|
|
4
|
+
parent_session_id: string;
|
|
5
|
+
status: "running" | "unknown" | "completed" | "failed" | "cancelled" | "settled_by_root";
|
|
6
|
+
source: string;
|
|
7
|
+
provenance?: "root_child" | "parent_mismatch";
|
|
8
|
+
terminal_turn_id?: string;
|
|
9
|
+
terminal_observed_at?: string;
|
|
10
|
+
provider_error?: unknown;
|
|
11
|
+
native_turn_items?: { observed?: boolean; possible_effects?: boolean; pending?: boolean };
|
|
12
|
+
}
|
|
13
|
+
export function normalizeNativeChildren(receipt: Record<string, unknown>, rootSessionId: string): NativeChild[];
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export const NATIVE_CHILD_CONTRACT_VERSION = "native-children@1";
|
|
2
|
+
import { errorRecord } from "./operation-errors.mjs";
|
|
3
|
+
|
|
4
|
+
function nativeFailure(code, message, exitCode, details) { return Object.assign(new Error(message), { code, exitCode, details }); }
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
/** Normalize provider-observed topology only; never parse assistant prose. */
|
|
8
|
+
export function normalizeNativeChildren(receipt, rootSessionId) {
|
|
9
|
+
const candidates = [];
|
|
10
|
+
const object = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
11
|
+
receipt = object(receipt);
|
|
12
|
+
const list = (value) => Array.isArray(value) ? value : [];
|
|
13
|
+
const text = (value, keys) => { for (const key of keys) if (typeof value[key] === "string" && value[key]) return value[key]; return null; };
|
|
14
|
+
const settledTree = new Set(
|
|
15
|
+
list(receipt.tree_observations)
|
|
16
|
+
.filter(value => object(value).settled === true)
|
|
17
|
+
.map(value => text(object(value), ["provider_session_id", "session_id"]))
|
|
18
|
+
.filter(id => id !== null)
|
|
19
|
+
);
|
|
20
|
+
const add = (raw, source, scopedParent = null, fallback = "unknown") => {
|
|
21
|
+
const value = object(raw);
|
|
22
|
+
const id = text(value, ["provider_session_id", "session_id", "sessionId", "childSessionId", "conversation_id", "conversationId", "id", "subagentId"]);
|
|
23
|
+
const parent = text(value, ["parent_provider_session_id", "parent_session_id", "parentSessionId", "parentID", "parentId"]) ?? scopedParent;
|
|
24
|
+
if (!id) return;
|
|
25
|
+
const outcomeError = object(value.outcome_error);
|
|
26
|
+
if (typeof outcomeError.code === "string") {
|
|
27
|
+
const record = errorRecord(outcomeError);
|
|
28
|
+
throw Object.assign(nativeFailure(record.code, record.message, 1, record.details), record, { details: { ...object(record.details), session_id: id, source } });
|
|
29
|
+
}
|
|
30
|
+
const state = String(value.status ?? value.state ?? fallback).toLowerCase();
|
|
31
|
+
if (state === "lost") throw nativeFailure("native_child_outcome_lost", "Native child ended without a recoverable outcome", 1, { session_id: id, parent_session_id: parent, source, native_status: state });
|
|
32
|
+
// Provider idle means no current activity, not a Work result. Only an
|
|
33
|
+
// explicit terminal result or a separately proven root settlement may be
|
|
34
|
+
// terminal here; unknown states stay unknown for reconciliation.
|
|
35
|
+
const status = ["completed", "complete", "done", "success", "succeeded"].includes(state) ? "completed" : state === "settled_by_root" ? "settled_by_root" : ["failed", "error"].includes(state) ? "failed" : ["cancelled", "canceled", "interrupted"].includes(state) ? "cancelled" : ["running", "active", "pending"].includes(state) ? "running" : fallback;
|
|
36
|
+
const rootChild = parent === rootSessionId;
|
|
37
|
+
const settled = rootChild && status === "unknown" && settledTree.has(id) ? "settled_by_root" : status;
|
|
38
|
+
candidates.push({ session_id: id, parent_session_id: parent ?? "", status: settled, source, ...(rootChild ? {} : { provenance: "parent_mismatch" }),
|
|
39
|
+
...(text(value, ["terminal_turn_id"]) ? { terminal_turn_id: text(value, ["terminal_turn_id"]) } : {}),
|
|
40
|
+
...(text(value, ["terminal_observed_at"]) ? { terminal_observed_at: text(value, ["terminal_observed_at"]) } : {}),
|
|
41
|
+
...(value.provider_error !== undefined ? { provider_error: value.provider_error } : {}),
|
|
42
|
+
...(value.native_turn_items && typeof value.native_turn_items === "object" ? { native_turn_items: value.native_turn_items } : {}) });
|
|
43
|
+
};
|
|
44
|
+
for (const value of list(receipt.descendants)) add(value, "adapter.descendants");
|
|
45
|
+
for (const value of list(receipt.children)) add(value, "opencode.session.children");
|
|
46
|
+
const subagents = object(object(receipt.evidence).subagents ?? receipt.subagents);
|
|
47
|
+
for (const value of list(subagents.running)) add(value, "zcode/session/subagents", rootSessionId, "running");
|
|
48
|
+
for (const value of list(subagents.completed)) add(value, "zcode/session/subagents", rootSessionId, "completed");
|
|
49
|
+
for (const value of list(object(subagents.ended).items)) {
|
|
50
|
+
const status = String(object(value).status ?? "unknown");
|
|
51
|
+
if (!["success", "failed", "cancelled", "lost"].includes(status)) throw nativeFailure("native_child_outcome_unavailable", "ZCode ended child has no recognized native outcome", 1, { child: value });
|
|
52
|
+
add(value, "zcode/session/subagents", rootSessionId);
|
|
53
|
+
}
|
|
54
|
+
const unique = new Map();
|
|
55
|
+
const rank = { unknown: 0, running: 1, settled_by_root: 2, completed: 3, failed: 3, cancelled: 3 };
|
|
56
|
+
for (const child of candidates) {
|
|
57
|
+
const previous = unique.get(child.session_id);
|
|
58
|
+
if (previous && previous.parent_session_id !== child.parent_session_id) {
|
|
59
|
+
// Keep the contradictory parent visible to the existing provenance gate,
|
|
60
|
+
// independently of input order or which source happened to say success.
|
|
61
|
+
const foreign = [previous, child].filter(item => item.parent_session_id !== rootSessionId).sort((a, b) => a.parent_session_id.localeCompare(b.parent_session_id))[0];
|
|
62
|
+
unique.set(child.session_id, { ...foreign, provenance: "parent_mismatch" });
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (previous && rank[previous.status] === 3 && rank[child.status] === 3 && previous.status !== child.status) throw nativeFailure("native_child_outcome_conflict", "Native child has contradictory terminal outcomes", 1, { session_id: child.session_id, observations: [previous, child] });
|
|
66
|
+
if (previous && previous.status === child.status && previous.terminal_turn_id && child.terminal_turn_id && previous.terminal_turn_id !== child.terminal_turn_id)
|
|
67
|
+
throw nativeFailure("native_child_turn_conflict", "Native child has contradictory terminal Turn identities", 1, { session_id: child.session_id, observations: [previous, child] });
|
|
68
|
+
if (previous && previous.status === child.status) {
|
|
69
|
+
unique.set(child.session_id, { ...previous, ...child,
|
|
70
|
+
...(child.terminal_turn_id ?? previous.terminal_turn_id ? { terminal_turn_id: child.terminal_turn_id ?? previous.terminal_turn_id } : {}),
|
|
71
|
+
...(child.provider_error ?? previous.provider_error ? { provider_error: child.provider_error ?? previous.provider_error } : {}),
|
|
72
|
+
...(child.native_turn_items ?? previous.native_turn_items ? { native_turn_items: child.native_turn_items ?? previous.native_turn_items } : {}) });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (!previous || rank[child.status] > rank[previous.status]) unique.set(child.session_id, child);
|
|
76
|
+
}
|
|
77
|
+
return [...unique.values()].sort((left, right) => left.session_id.localeCompare(right.session_id));
|
|
78
|
+
}
|
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
export function invokeNativeHook(input: { bin: string; args: string[]; payload: unknown; home?: string | undefined; cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number }): Promise<Record<string, unknown> & { timing?: Record<string, number> }>;
|
|
2
2
|
export function hookBudget(timeoutMs?: number, deadlineAt?: number | null): { deadlineAt: number; remaining(phase: string, maximum?: number): number; run<T>(action: () => T | Promise<T>, phase: string): Promise<T> };
|
|
3
3
|
export function readNativeHookInput(stream: NodeJS.ReadStream, budget: { remaining(phase: string): number }, maxBytes?: number): Promise<string>;
|
|
4
|
+
export function acceptNativeHookRequest(stateDir: string, binding: Record<string, unknown>, current?: { requests: Map<string, { binding: Record<string, unknown> }>; accepting?: Map<string, Promise<Record<string, unknown>>> } | null): Promise<Record<string, unknown>>;
|
|
5
|
+
export function writeNativeHookFailure(stateDir: string, binding: Record<string, unknown> | undefined, error: unknown, budget: ReturnType<typeof hookBudget>): Promise<void>;
|
|
6
|
+
export function readNativeHookFailures(stateDir: string, requests: Map<string, { current: unknown; binding: Record<string, unknown> }>, current: unknown, reject: (error: Error & { code?: string; details?: Record<string, unknown> }) => void): Promise<void>;
|
|
7
|
+
export function runBoundedCommand(input: { bin: string; args: string[]; input?: string | null; cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number; timeoutCode?: string; inheritDeadline?: boolean; cleanupWithinBudget?: boolean; maintenanceProgress?: { action: string; processId: string | null; inactivityMs: number } | null; signal?: AbortSignal }): Promise<string>;
|