@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
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export function recoverDaemonReply(root: string, id: string, options?: { timeoutMs?: number | null; signal?: AbortSignal; binding?: Record<string, unknown>; onProgress?: (value: Record<string, unknown>) => void; onOutcome?: (value: Record<string, unknown>) => void; onPendingSettlement?: (value: Record<string, unknown>) => Promise<void>; observationClock?: import("./observation-clock.mjs").ObservationClock }): Promise<Record<string, unknown>>;
|
|
2
|
+
export function retainedDaemonReply(root: string, id: string, binding?: Record<string, unknown>): Promise<{ ready: boolean; outcome: unknown; value?: unknown; settlement?: Record<string, unknown> | null; reason?: string } | null>;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { hashJson, writeJsonAtomic } from './runner-events.mjs';
|
|
5
|
+
import { ObservationClock } from './observation-clock.mjs';
|
|
6
|
+
import { canonicalNativeOperation, nativeOperationWait, validateDaemonWait } from './adapter-timeouts.mjs';
|
|
7
|
+
import { assertNativeLine, retainNativeReplyBuffer } from './native-streams.mjs';
|
|
8
|
+
import { operationDirectory, readOperationRecord, readOperationProgress, readOperationWaiting, suspendOperationClock } from './operation-progress.mjs';
|
|
9
|
+
|
|
10
|
+
const productive = new Set(['session.create', 'session.prompt', 'session.start', 'session.fork', 'session.resume']);
|
|
11
|
+
const fault = (code, message, details) => Object.assign(new Error(message), { code, retryable: false, details });
|
|
12
|
+
export async function retainedDaemonReply(root, id, binding = {}) {
|
|
13
|
+
const requested = await readOperationRecord(root, id, 'requested.json');
|
|
14
|
+
if (!requested) return null;
|
|
15
|
+
if (requested.operation_id !== id || Object.entries(binding).some(([key, value]) => (requested[key] ?? null) !== (value ?? null))) throw fault('operation_conflict', 'retained operation differs from dispatched request', { operation_id: id });
|
|
16
|
+
const terminal = await readOperationRecord(root, id, 'result.json');
|
|
17
|
+
if (!terminal) return null;
|
|
18
|
+
if (['operation_id','daemon_id','operation','params_sha256','owner_id','generation'].some(field=>Object.hasOwn(terminal,field)&&(terminal[field] ?? null)!==(requested[field] ?? null))) throw fault('operation_conflict','native outcome binding differs from original operation',{operation_id:id});
|
|
19
|
+
if (terminal.state !== 'completed') throw Object.assign(new Error(terminal.error?.message ?? 'native operation failed'), terminal.error);
|
|
20
|
+
if (Number.isSafeInteger(requested.generation)) try {
|
|
21
|
+
const fence = JSON.parse(await readFile(path.join(root,'operation-fence.json'),'utf8'));
|
|
22
|
+
if (fence.daemon_id === requested.daemon_id && fence.generation > requested.generation) throw fault('operation_cancelled','continuation is fenced by newer control',{operation_id:id,native_result:terminal.result});
|
|
23
|
+
} catch(error) { if (error.code !== 'ENOENT') throw error; }
|
|
24
|
+
const settlement = await readOperationRecord(root, id, 'settlement.json');
|
|
25
|
+
try {
|
|
26
|
+
const daemon = JSON.parse(await readFile(path.join(root,'daemon.json'),'utf8'));
|
|
27
|
+
if ((daemon.daemon_id ?? daemon.config?.daemonId ?? null)!==requested.daemon_id || (daemon.config?.runtime_owner?.owner_id ?? null)!==(requested.owner_id ?? null) || ['stopping','clean','unclean','cleanup_failed','failed'].includes(daemon.shutdown_state)) return {ready:false,outcome:terminal.result,settlement,reason:'daemon_authority_changed'};
|
|
28
|
+
if (requested.owner_id) try { process.kill(daemon.pid,0); } catch { return {ready:false,outcome:terminal.result,settlement,reason:'daemon_owner_dead'}; }
|
|
29
|
+
} catch(error) { if(error.code!=='ENOENT') throw error; if(requested.daemon_id || requested.owner_id) return {ready:false,outcome:terminal.result,settlement,reason:'daemon_absent'}; }
|
|
30
|
+
if (!['settled', 'not_required'].includes(settlement?.state)) return { ready: false, outcome: terminal.result, settlement };
|
|
31
|
+
return { ready: true, outcome: terminal.result, settlement, value: settlement.state === 'not_required' ? terminal.result : { ...terminal.result, settlement } };
|
|
32
|
+
}
|
|
33
|
+
export async function recoverDaemonReply(root, id, { timeoutMs = 30_000, signal, binding = {}, onProgress = () => {}, onOutcome = () => {}, onPendingSettlement, observationClock } = {}) {
|
|
34
|
+
const saved = observationClock ? null : await readOperationRecord(root, id, 'observer-recovery.json') ?? await readOperationRecord(root, id, 'observer-transport.json');
|
|
35
|
+
signal?.throwIfAborted();
|
|
36
|
+
const clock = observationClock ?? new ObservationClock({ timeoutMs: saved?.timeout_ms ?? timeoutMs, saved });
|
|
37
|
+
let lastSequence = clock.progress ?? 0, lastSaved = 0, latestProgress, settlementAttempted = false, settlementError;
|
|
38
|
+
const exhaustedCursor = clock.progress;
|
|
39
|
+
let exhausted = clock.observationLost || clock.elapsed >= clock.timeoutMs;
|
|
40
|
+
for (;;) {
|
|
41
|
+
signal?.throwIfAborted();
|
|
42
|
+
const retained = await retainedDaemonReply(root, id, binding);
|
|
43
|
+
signal?.throwIfAborted();
|
|
44
|
+
if (retained) {
|
|
45
|
+
onOutcome(retained.outcome);
|
|
46
|
+
signal?.throwIfAborted();
|
|
47
|
+
if (retained.ready) return retained.value;
|
|
48
|
+
}
|
|
49
|
+
const progress = await readOperationProgress(root, id, binding);
|
|
50
|
+
signal?.throwIfAborted();
|
|
51
|
+
if (progress && progress.sequence > lastSequence) {
|
|
52
|
+
lastSequence = progress.sequence;
|
|
53
|
+
const observedAt = Date.parse(progress.observed_at);
|
|
54
|
+
// The outer cursor counts diagnostics, not durable sequences. A handoff
|
|
55
|
+
// may recognize a higher sequence with the same already-observed event.
|
|
56
|
+
if (clock.progressAt === null || observedAt > clock.progressAt) {
|
|
57
|
+
latestProgress={cursor:progress.sequence,observed_at:observedAt}; onProgress(progress);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const waiting = await readOperationWaiting(root,id,binding);
|
|
61
|
+
signal?.throwIfAborted();
|
|
62
|
+
if (waiting) suspendOperationClock(clock);
|
|
63
|
+
const sampled = waiting ? false : clock.sample(latestProgress);
|
|
64
|
+
if (clock.progress !== exhaustedCursor && clock.elapsed < clock.timeoutMs && !clock.observationLost) exhausted = false;
|
|
65
|
+
const expired = !waiting && (exhausted || sampled);
|
|
66
|
+
if ((expired || Date.now() - lastSaved >= 1_000) && await readOperationRecord(root,id,'requested.json')) { await writeJsonAtomic(path.join(operationDirectory(root,id),'observer-recovery.json'),clock.state()); lastSaved = Date.now(); }
|
|
67
|
+
if (expired) {
|
|
68
|
+
const final = await retainedDaemonReply(root, id, binding);
|
|
69
|
+
signal?.throwIfAborted();
|
|
70
|
+
if (final?.ready) return final.value;
|
|
71
|
+
throw fault(final ? 'settlement_reconciliation_required' : 'operation_observation_lost', 'original operation has no dispatch-ready reply', { operation_id: id, native_outcome: final ? 'completed' : 'unknown', native_result: final?.outcome, settlement: final?.settlement, reason:final?.reason, ...(settlementError ? { settlement_error:settlementError } : {}), state_dir: root });
|
|
72
|
+
}
|
|
73
|
+
if (retained && onPendingSettlement && !settlementAttempted && !retained.reason) {
|
|
74
|
+
settlementAttempted = true;
|
|
75
|
+
try { await onPendingSettlement(retained); } catch (error) { settlementError = {code:error.code,message:error.message}; }
|
|
76
|
+
signal?.throwIfAborted();
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(100, timeoutMs / 4)));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export async function callDaemonObserved({ stateDir, socket, request, responseSchema, harness, timeoutMs }) {
|
|
83
|
+
request = { ...request, operation: canonicalNativeOperation(harness, request.operation) };
|
|
84
|
+
validateDaemonWait(harness, request.operation, timeoutMs);
|
|
85
|
+
assertNativeLine(JSON.stringify(request), 'request');
|
|
86
|
+
const durable = productive.has(request.operation);
|
|
87
|
+
const binding = { operation: request.operation, params_sha256: hashJson({ operation: request.operation, params: request.params ?? {} }) };
|
|
88
|
+
let initialDaemon;
|
|
89
|
+
if (durable) try {
|
|
90
|
+
const state = JSON.parse(await readFile(path.join(stateDir,'daemon.json'),'utf8'));
|
|
91
|
+
initialDaemon = state.daemon_id ?? state.config?.daemonId ?? null;
|
|
92
|
+
binding.owner_id = state.config?.runtime_owner?.owner_id ?? null;
|
|
93
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
94
|
+
// Reject a reused ID with different inputs before opening a transport.
|
|
95
|
+
// Otherwise its retained progress can win the race with the daemon's
|
|
96
|
+
// conflict reply and misclassify a valid old receipt as corrupt progress.
|
|
97
|
+
if (durable) await retainedDaemonReply(stateDir, request.id, {
|
|
98
|
+
...binding, ...(initialDaemon === undefined ? {} : { daemon_id: initialDaemon })
|
|
99
|
+
});
|
|
100
|
+
const savedClock = durable ? await readOperationRecord(stateDir,request.id,'observer-transport.json') : null;
|
|
101
|
+
return await new Promise((resolve, reject) => {
|
|
102
|
+
const client = net.createConnection(socket);
|
|
103
|
+
let buffer = '', done = false, polling = false, pollFlight, lastSequence = savedClock?.cursor ?? 0, frozenDaemon = initialDaemon, disconnected = false, latestProgress, lastSaved = 0;
|
|
104
|
+
const bound = () => ({ ...binding, ...(frozenDaemon === undefined ? {} : { daemon_id: frozenDaemon }) });
|
|
105
|
+
const nativeWork = nativeOperationWait(harness, request.operation) === 'native-work';
|
|
106
|
+
let clock = nativeWork ? null : new ObservationClock({ timeoutMs, saved: savedClock });
|
|
107
|
+
const finish = (error, result) => { if (done) return; done = true; clearInterval(timer); client.destroy(); void Promise.resolve(pollFlight).then(() => error ? reject(error) : resolve(result)); };
|
|
108
|
+
const reconcile = async error => {
|
|
109
|
+
if (done) return;
|
|
110
|
+
try {
|
|
111
|
+
const reply = durable ? await retainedDaemonReply(stateDir, request.id, bound()) : null;
|
|
112
|
+
if (reply?.ready) return finish(null, reply.value);
|
|
113
|
+
if (reply) error = fault('settlement_reconciliation_required', 'native outcome retained; dispatch readiness is not confirmed', { operation_id: request.id, native_outcome: 'completed', native_result: reply.outcome, settlement: reply.settlement, reason:reply.reason, state_dir: stateDir });
|
|
114
|
+
else if (durable) error = fault(error.code, error.message, { ...error.details, operation_id: request.id, native_outcome: 'unknown', state_dir: stateDir });
|
|
115
|
+
finish(error);
|
|
116
|
+
} catch (cause) { finish(cause); }
|
|
117
|
+
};
|
|
118
|
+
const poll = () => {
|
|
119
|
+
if (done || polling) return pollFlight; polling = true;
|
|
120
|
+
pollFlight = (async () => {
|
|
121
|
+
try {
|
|
122
|
+
const progress = durable ? await readOperationProgress(stateDir, request.id, { ...binding, ...(frozenDaemon === undefined ? {} : { daemon_id: frozenDaemon }) }) : null;
|
|
123
|
+
if (done) return;
|
|
124
|
+
if (progress && progress.sequence > lastSequence) {
|
|
125
|
+
frozenDaemon ??= progress.daemon_id; lastSequence = progress.sequence;
|
|
126
|
+
binding.generation ??= progress.generation;
|
|
127
|
+
latestProgress = {cursor:progress.sequence,observed_at:Date.parse(progress.observed_at)};
|
|
128
|
+
process.stderr.write(`${JSON.stringify({ kind: 'operation_progress', ...progress })}\n`);
|
|
129
|
+
}
|
|
130
|
+
if (durable && disconnected) {
|
|
131
|
+
const reply = await retainedDaemonReply(stateDir, request.id, bound());
|
|
132
|
+
if (reply?.ready) return finish(null, reply.value);
|
|
133
|
+
}
|
|
134
|
+
const waiting = durable && await readOperationWaiting(stateDir,request.id,bound());
|
|
135
|
+
if (done) return;
|
|
136
|
+
if (clock && waiting) suspendOperationClock(clock);
|
|
137
|
+
const expired = !waiting && clock?.sample(latestProgress ?? (durable ? undefined : buffer.length || undefined));
|
|
138
|
+
if (durable && clock && (expired || Date.now()-lastSaved>=1_000) && await readOperationRecord(stateDir,request.id,'requested.json')) { await writeJsonAtomic(path.join(operationDirectory(stateDir,request.id),'observer-transport.json'),clock.state()); lastSaved=Date.now(); }
|
|
139
|
+
if (expired) await reconcile(fault('daemon_timeout', `${request.operation} timed out`));
|
|
140
|
+
} catch (error) { finish(error); } finally { polling = false; }
|
|
141
|
+
})();
|
|
142
|
+
return pollFlight;
|
|
143
|
+
};
|
|
144
|
+
const timer = setInterval(poll, Math.max(1, Math.min(100, (timeoutMs ?? 400) / 4)));
|
|
145
|
+
client.setEncoding('utf8'); client.on('connect', () => client.write(`${JSON.stringify(request)}\n`));
|
|
146
|
+
const lost = error => { if (durable) { disconnected = true; clock ??= new ObservationClock({timeoutMs: timeoutMs ?? 30_000,saved:savedClock}); void poll(); } else void reconcile(error); };
|
|
147
|
+
client.on('error', error => lost(fault('daemon_not_running', error.message)));
|
|
148
|
+
client.on('end', () => lost(fault('daemon_connection_closed', 'daemon closed before reply')));
|
|
149
|
+
client.on('close', () => lost(fault('daemon_connection_closed', 'daemon closed before reply')));
|
|
150
|
+
client.on('data', chunk => { void (async () => {
|
|
151
|
+
buffer += chunk; if (!retainNativeReplyBuffer(buffer, finish)) return;
|
|
152
|
+
const at = buffer.indexOf('\n'); if (at < 0) return;
|
|
153
|
+
try {
|
|
154
|
+
const response = JSON.parse(buffer.slice(0, at));
|
|
155
|
+
if (response.schema_id !== responseSchema || response.id !== request.id) throw fault('operation_observation_lost', 'daemon reply identity mismatch');
|
|
156
|
+
if (!response.ok) return finish(Object.assign(new Error(response.error?.message ?? 'daemon operation failed'), response.error));
|
|
157
|
+
if (durable && (binding.owner_id && !response.result?.settlement || response.result?.settlement && !['settled','not_required'].includes(response.result.settlement.state))) { disconnected = true; clock ??= new ObservationClock({timeoutMs:savedClock?.timeout_ms ?? timeoutMs ?? 30_000,saved:savedClock}); void poll(); return; }
|
|
158
|
+
if (durable) { const retained=await retainedDaemonReply(stateDir,request.id,bound()); if(retained) { if(retained.ready) return finish(null,retained.value); disconnected=true; clock ??= new ObservationClock({timeoutMs:timeoutMs ?? 30_000,saved:savedClock}); void poll(); return; } return void reconcile(fault('operation_observation_lost','productive native reply has no durable original receipt')); }
|
|
159
|
+
finish(null, response.result);
|
|
160
|
+
} catch (error) { void reconcile(error.code ? error : Object.assign(fault('operation_observation_lost', 'daemon reply could not be decoded', {native_outcome:'unknown'}), {cause:error})); }
|
|
161
|
+
})(); });
|
|
162
|
+
});
|
|
163
|
+
}
|
|
@@ -1,15 +1,123 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstatSync } from "node:fs";
|
|
2
3
|
import { mkdir, readFile, readdir, link, unlink, open } from "node:fs/promises";
|
|
3
4
|
import { randomUUID } from "node:crypto";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import { hashJson, writeJsonAtomic } from "./runner-events.mjs";
|
|
6
|
-
import { errorRecord, isObservationLoss, reportedError } from "./operation-errors.mjs";
|
|
7
|
+
import { errorRecord, isObservationLoss, isProvenShellCorrection, reportedError } from "./operation-errors.mjs";
|
|
7
8
|
import { assertDaemonOwnership, budgetObservationScope, releaseBudgetObservation, completeBudgetOperation } from "./managed-daemon.mjs";
|
|
8
9
|
import { readModelObservations, modelAttribution } from "./model-observations.mjs";
|
|
10
|
+
import { publishOperationProgress, publishOperationWaiting, withOperationProgress } from './operation-progress.mjs';
|
|
9
11
|
|
|
10
12
|
const productive = new Set(["session.create", "session.prompt", "session.start", "session.fork", "session.resume"]);
|
|
11
13
|
const controls = new Map();
|
|
12
14
|
const dispatchGenerations = new Map();
|
|
15
|
+
const shutdowns = new Map();
|
|
16
|
+
const settlements = new Map();
|
|
17
|
+
|
|
18
|
+
// Progress is not outcome authority. Once native effect is retained, a failed
|
|
19
|
+
// diagnostic write must keep its identity and prohibit redispatch.
|
|
20
|
+
async function publishRetainedProgress(root, id, phase, result, metadata) {
|
|
21
|
+
try { return await publishOperationProgress(root, id, phase, metadata); }
|
|
22
|
+
catch (cause) {
|
|
23
|
+
throw Object.assign(new Error('native outcome retained but operation progress could not be published'), {
|
|
24
|
+
code: 'operation_observation_lost', retryable: false, cause,
|
|
25
|
+
details: { operation_id: id, state_dir: root, native_outcome: 'completed', native_result: result, progress_error: errorRecord(cause) }
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function assertDaemonDispatch(root) {
|
|
31
|
+
const stop = shutdowns.get(root);
|
|
32
|
+
if (stop?.pending || stop?.committed) throw Object.assign(new Error("daemon shutdown fences productive dispatch"), { code: "daemon_stopping" });
|
|
33
|
+
}
|
|
34
|
+
export function assertDaemonRestartable(state) {
|
|
35
|
+
if (["unclean", "cleanup_failed", "stopping"].includes(state?.shutdown_state)) throw Object.assign(new Error("previous daemon shutdown is unresolved; do not restart a native bridge for cleanup"), { code: "daemon_state_terminal", details: { daemon_id: state.daemon_id ?? state.config?.daemonId, shutdown_state: state.shutdown_state } });
|
|
36
|
+
}
|
|
37
|
+
export function commitDaemonShutdown(root) {
|
|
38
|
+
if (typeof root !== 'string') return; // In-memory adapter diagnostics own no daemon endpoint.
|
|
39
|
+
const fence = shutdowns.get(root) ?? { pending: 0 };
|
|
40
|
+
fence.committed = true; shutdowns.set(root, fence);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The same incarnation may retry remaining cleanup, never a native Turn. */
|
|
44
|
+
export async function completeDaemonShutdown(root, state, persist, prepare, phases) {
|
|
45
|
+
const daemonId = state.daemon_id ?? state.config?.daemonId;
|
|
46
|
+
let shutdown = state.shutdown;
|
|
47
|
+
if (!shutdown || shutdown.daemon_id !== daemonId) shutdown = { schema_id: "dd-flow/daemon-shutdown@1", daemon_id: daemonId, generation: dispatchGenerations.get(root) ?? 0, phases: {} };
|
|
48
|
+
shutdown = { ...shutdown, required_phases: ["tree", ...phases.map(([phase]) => phase)] };
|
|
49
|
+
try {
|
|
50
|
+
if (!shutdown.phases.tree) {
|
|
51
|
+
const result = await prepare();
|
|
52
|
+
shutdown = { ...shutdown, phases: { tree: true }, tree_observed_at: new Date().toISOString(), result: result ?? { clean: true } };
|
|
53
|
+
commitDaemonShutdown(root);
|
|
54
|
+
await persist({ shutdown, shutdown_state: "stopping", active_tree: false });
|
|
55
|
+
}
|
|
56
|
+
commitDaemonShutdown(root);
|
|
57
|
+
for (const [phase, action] of phases) {
|
|
58
|
+
if (shutdown.phases[phase]) continue;
|
|
59
|
+
await action();
|
|
60
|
+
shutdown = { ...shutdown, phases: { ...shutdown.phases, [phase]: true } };
|
|
61
|
+
await persist({ shutdown });
|
|
62
|
+
}
|
|
63
|
+
const result = { ...shutdown.result, daemon_id: daemonId, stopped: true, settled: true, shutdown_contract: "dd-flow/daemon-shutdown@1", _shutdown: true };
|
|
64
|
+
await persist({ shutdown, shutdown_state: result.clean === true ? "clean" : "unclean", active_tree: false, stopped_at: new Date().toISOString(), cleanup_error: null });
|
|
65
|
+
return result;
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (error.code === "tree_not_settled" && !shutdowns.get(root)?.committed) throw error;
|
|
68
|
+
try { await persist({ shutdown_state: "cleanup_failed", cleanup_error: errorRecord(error) }); }
|
|
69
|
+
catch (storage) { error.details = { ...error.details, persistence_error: errorRecord(storage) }; }
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const daemonConnections = new WeakMap();
|
|
75
|
+
export function trackDaemonConnections(server) {
|
|
76
|
+
const sockets = new Set(); daemonConnections.set(server, sockets);
|
|
77
|
+
server.once("listening", () => {
|
|
78
|
+
const endpoint = server.address();
|
|
79
|
+
if (typeof endpoint !== "string") return;
|
|
80
|
+
try { const info = lstatSync(endpoint); daemonEndpoints.set(server, { endpoint, dev: info.dev, ino: info.ino }); }
|
|
81
|
+
catch (error) { daemonEndpoints.set(server, { endpoint, error }); }
|
|
82
|
+
});
|
|
83
|
+
server.on("connection", socket => { sockets.add(socket); socket.on("error", () => {}); socket.once("close", () => sockets.delete(socket)); });
|
|
84
|
+
}
|
|
85
|
+
const daemonEndpoints = new WeakMap();
|
|
86
|
+
|
|
87
|
+
/** Retire the listener after durable publication, before ACK; no provider IO. */
|
|
88
|
+
export function retireDaemonEndpoint(server, exitCode = 0) {
|
|
89
|
+
if (!server.listening) return;
|
|
90
|
+
const retained = daemonEndpoints.get(server);
|
|
91
|
+
let endpointError = retained?.error;
|
|
92
|
+
if (retained && !endpointError) {
|
|
93
|
+
try {
|
|
94
|
+
const current = lstatSync(retained.endpoint);
|
|
95
|
+
if (current.dev !== retained.dev || current.ino !== retained.ino || !current.isSocket()) endpointError = Object.assign(new Error("daemon endpoint was replaced; refusing to unlink another listener"), { code: "daemon_endpoint_identity_mismatch", details: { socket: retained.endpoint, phase: "retire_listener" } });
|
|
96
|
+
} catch (error) { if (error.code !== "ENOENT") endpointError = error; }
|
|
97
|
+
}
|
|
98
|
+
if (endpointError) {
|
|
99
|
+
// libuv's close unlinks by pathname, not inode. Exit without closing its
|
|
100
|
+
// listener handle when the name belongs to a different incarnation.
|
|
101
|
+
const timer = setTimeout(() => { for (const socket of daemonConnections.get(server) ?? []) socket.destroy(); process.exit(1); }, 1_000);
|
|
102
|
+
timer.unref();
|
|
103
|
+
throw endpointError;
|
|
104
|
+
}
|
|
105
|
+
const timer = setTimeout(() => { for (const socket of daemonConnections.get(server) ?? []) socket.destroy(); }, 1_000);
|
|
106
|
+
timer.unref();
|
|
107
|
+
server.close(() => { clearTimeout(timer); process.exit(exitCode); });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function retireFailedDaemon(server, state, error, persist) {
|
|
111
|
+
// An already-finished registry lease cannot own a listening productive daemon.
|
|
112
|
+
if (state.shutdown?.phases?.daemon_resource) {
|
|
113
|
+
// Durable result publication and listener retirement occur after the
|
|
114
|
+
// cleanup helper returns. Their failures still invalidate overall clean.
|
|
115
|
+
if (persist) try { await persist({ shutdown_state: "cleanup_failed", cleanup_error: errorRecord(error) }); }
|
|
116
|
+
catch (storage) { error.details = { ...error.details, persistence_error: errorRecord(storage) }; }
|
|
117
|
+
process.stderr.write(`${JSON.stringify({ shutdown_failure: errorRecord(error) })}\n`);
|
|
118
|
+
try { retireDaemonEndpoint(server, 1); } catch { /* Retirement already scheduled a nonzero exit without unlinking a foreign endpoint. */ }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
13
121
|
|
|
14
122
|
async function controlDispatch(root, request, action) {
|
|
15
123
|
const key = request.operation === "daemon.stop" ? `stop:${request.params?.cancelTree === true}` : `cancel:${request.params?.sessionId ?? ""}`;
|
|
@@ -21,8 +129,11 @@ async function controlDispatch(root, request, action) {
|
|
|
21
129
|
const predecessors = key.startsWith("stop:") ? [...pending.values()] : [];
|
|
22
130
|
const promise = (async () => { await Promise.allSettled(predecessors); return await action(); })();
|
|
23
131
|
pending.set(key, promise);
|
|
24
|
-
|
|
25
|
-
|
|
132
|
+
let completed = false;
|
|
133
|
+
try { const result = await promise; completed = true; return result; }
|
|
134
|
+
// Successful whole-tree stop remains idempotent for this daemon lifetime.
|
|
135
|
+
// Durable publication for a concurrent caller may arrive after cleanup ends.
|
|
136
|
+
finally { if (!completed || !key.startsWith('stop:')) { pending.delete(key); if (!pending.size) controls.delete(root); } }
|
|
26
137
|
}
|
|
27
138
|
function location(root, id) {
|
|
28
139
|
if (typeof id !== "string" || !id) throw Object.assign(new Error("operation id is required"), { code: "operation_id_required" });
|
|
@@ -37,26 +148,48 @@ export async function inspectDaemonOperation(root, id) {
|
|
|
37
148
|
if (!requested) throw Object.assign(new Error("daemon operation is not recorded"), { code: "operation_not_found", details: { operation_id: id } });
|
|
38
149
|
const settlement = await read(path.join(directory, "settlement.json"));
|
|
39
150
|
const hookFailures = [];
|
|
151
|
+
const hookCorrections = [];
|
|
152
|
+
const hookDiagnostics = [];
|
|
40
153
|
for (const name of await readdir(path.join(directory, "hooks")).catch(error => { if (error.code === "ENOENT") return []; throw error; })) {
|
|
41
154
|
if (!/^[A-Za-z0-9-]{1,128}\.json$/.test(name)) continue;
|
|
42
155
|
const binding = await read(path.join(directory, "hooks", name));
|
|
43
156
|
if (binding?.schema_id !== "dd-flow/hook-request@1" || binding.request_id !== name.slice(0, -5) || binding.operation_id !== id || binding.daemon_id !== requested.daemon_id) continue;
|
|
44
|
-
|
|
157
|
+
let failure;
|
|
158
|
+
try { failure = await read(path.join(root, "hook-failures", name)); }
|
|
159
|
+
catch (error) { hookDiagnostics.push({ code: "native_hook_receipt_invalid", message: error.message, request_id: binding.request_id, effect: "unknown", receipt_bound: false }); continue; }
|
|
45
160
|
if (failure?.request_id !== binding.request_id || typeof failure.code !== "string" || typeof failure.message !== "string") continue;
|
|
46
|
-
|
|
161
|
+
if (binding.command_sha256 && JSON.stringify(failure.native_hook_binding) !== JSON.stringify(binding)) continue;
|
|
162
|
+
const correction = isProvenShellCorrection(failure);
|
|
163
|
+
(correction ? hookCorrections : hookFailures).push({ ...errorRecord(failure), observed_at: failure.observed_at ?? null, details: { ...failure.details, native_hook_binding: binding, effect: correction ? "no_effect" : "unknown" } });
|
|
47
164
|
}
|
|
48
165
|
hookFailures.sort((a, b) => String(a.observed_at ?? "").localeCompare(String(b.observed_at ?? "")));
|
|
49
166
|
// Admission failure is causal evidence, not proof that the native tree stopped.
|
|
50
|
-
return { ...requested, ...(await read(path.join(directory, "result.json")) ?? await read(path.join(directory, "interrupted.json")) ?? await read(path.join(directory, "observation-lost.json")) ?? { state: "running" }), ...(settlement ? { settlement } : {}), ...(hookFailures[0] ? { primary_error: hookFailures[0] } : {}) };
|
|
167
|
+
return { ...requested, ...(await read(path.join(directory, "result.json")) ?? await read(path.join(directory, "interrupted.json")) ?? await read(path.join(directory, "observation-lost.json")) ?? { state: "running" }), ...(settlement ? { settlement } : {}), ...(hookFailures[0] ? { primary_error: hookFailures[0] } : {}), ...(hookCorrections.length ? { hook_corrections: hookCorrections } : {}), ...(hookDiagnostics.length ? { hook_diagnostics: hookDiagnostics.slice(0, 16) } : {}) };
|
|
51
168
|
}
|
|
52
169
|
|
|
53
170
|
/** Retry only the bookkeeping that follows a durable productive receipt.
|
|
54
171
|
* This is intentionally separate from dispatch: it never invokes a native
|
|
55
172
|
* prompt/create/fork operation, so recovery cannot duplicate agent work. */
|
|
56
173
|
export async function settleDaemonOperation(root, id) {
|
|
174
|
+
const key = `${root}\0${id}`;
|
|
175
|
+
if (settlements.has(key)) return await settlements.get(key);
|
|
176
|
+
const pending = settleOriginalOperation(root, id);
|
|
177
|
+
settlements.set(key, pending);
|
|
178
|
+
try { return await pending; } finally { if (settlements.get(key) === pending) settlements.delete(key); }
|
|
179
|
+
}
|
|
180
|
+
async function settleOriginalOperation(root, id) {
|
|
57
181
|
const current = await inspectDaemonOperation(root, id);
|
|
58
182
|
if (current.state !== "completed") return current;
|
|
59
|
-
if (current.settlement?.state
|
|
183
|
+
if (['settled', 'not_required'].includes(current.settlement?.state)) return current;
|
|
184
|
+
const daemon = await read(path.join(root, 'daemon.json'));
|
|
185
|
+
const daemonId = daemon?.daemon_id ?? daemon?.config?.daemonId ?? null;
|
|
186
|
+
const fence = await read(path.join(root, 'operation-fence.json'));
|
|
187
|
+
const generation = dispatchGenerations.get(root) ?? (fence?.daemon_id === daemonId ? fence.generation : 0);
|
|
188
|
+
if (current.daemon_id !== (daemon?.daemon_id ?? daemon?.config?.daemonId ?? null)
|
|
189
|
+
|| current.owner_id !== undefined && current.owner_id !== (daemon?.config?.runtime_owner?.owner_id ?? null)
|
|
190
|
+
|| current.generation !== undefined && current.generation !== generation
|
|
191
|
+
|| ['stopping','clean','unclean','cleanup_failed','failed'].includes(daemon?.shutdown_state)) throw Object.assign(new Error('settlement authority changed'), { code: 'settlement_reconciliation_required' });
|
|
192
|
+
assertDaemonDispatch(root);
|
|
60
193
|
let settlement;
|
|
61
194
|
try {
|
|
62
195
|
settlement = await completeBudgetOperation(root, {
|
|
@@ -97,7 +230,7 @@ export async function interruptDaemonOperation(root, id, barrier) {
|
|
|
97
230
|
/** At-most-once dispatch and a durable terminal reply, including when the socket
|
|
98
231
|
* observer disconnects. Recovery reads this receipt; it never resends a prompt. */
|
|
99
232
|
export async function durableDaemonDispatch(root, request, action, observe) {
|
|
100
|
-
if (request.operation === "operation.inspect") return await inspectDaemonOperation(root, request.params?.operationId);
|
|
233
|
+
if (request.operation === "operation.inspect") return request.params?.settle === true ? await settleDaemonOperation(root, request.params?.operationId) : await inspectDaemonOperation(root, request.params?.operationId);
|
|
101
234
|
if (request.operation === "daemon.status") {
|
|
102
235
|
const result = await action();
|
|
103
236
|
if (!result?.config?.journal) return result;
|
|
@@ -121,16 +254,24 @@ export async function durableDaemonDispatch(root, request, action, observe) {
|
|
|
121
254
|
const control = ["daemon.stop", "session.cancel"].includes(request.operation);
|
|
122
255
|
if (!productive.has(request.operation) && !control) return await action();
|
|
123
256
|
const generation = dispatchGenerations.get(root) ?? 0;
|
|
124
|
-
if (control) dispatchGenerations.set(root, generation + 1);
|
|
125
|
-
const assertCurrent = () => { if (generation !== (dispatchGenerations.get(root) ?? 0)) throw Object.assign(new Error("control cancelled a queued productive dispatch"), { code: "operation_cancelled" }); };
|
|
126
|
-
const
|
|
257
|
+
if (control) { dispatchGenerations.set(root, generation + 1); const state = await read(path.join(root,'daemon.json')); await writeJsonAtomic(path.join(root,'operation-fence.json'), { generation: generation + 1, daemon_id: state?.daemon_id ?? state?.config?.daemonId ?? null }); }
|
|
258
|
+
const assertCurrent = () => { assertDaemonDispatch(root); if (generation !== (dispatchGenerations.get(root) ?? 0)) throw Object.assign(new Error("control cancelled a queued productive dispatch"), { code: "operation_cancelled" }); };
|
|
259
|
+
const stop = request.operation === "daemon.stop" ? (shutdowns.get(root) ?? { pending: 0, committed: false }) : null;
|
|
260
|
+
if (stop) { stop.pending++; shutdowns.set(root, stop); }
|
|
261
|
+
let result;
|
|
262
|
+
try { result = await journalDispatch(root, request, async () => {
|
|
127
263
|
// Persist before admission so a racing fence can observe the writer.
|
|
128
|
-
if (!control) await assertDaemonOwnership(root, { "operation-id": request.id, operation: request.operation, "native-session-id": request.params?.sessionId, "prompt-sha256": typeof request.params?.prompt === "string" ? createHash("sha256").update(request.params.prompt).digest("hex") : undefined }, assertCurrent);
|
|
264
|
+
if (!control) await assertDaemonOwnership(root, { "operation-id": request.id, operation: request.operation, "native-session-id": request.params?.sessionId, "prompt-sha256": typeof request.params?.prompt === "string" ? createHash("sha256").update(request.params.prompt).digest("hex") : undefined }, assertCurrent, phase => publishOperationProgress(root, request.id, phase), receipt => publishOperationWaiting(root,request.id,receipt));
|
|
129
265
|
if (!control) assertCurrent();
|
|
130
|
-
|
|
131
|
-
|
|
266
|
+
await publishOperationProgress(root, request.id, 'native_dispatched');
|
|
267
|
+
return await withOperationProgress(root, request.id, () => control ? controlDispatch(root, request, action) : action());
|
|
268
|
+
}); } catch (error) {
|
|
269
|
+
if (control) controls.get(root)?.delete(request.operation === 'daemon.stop' ? `stop:${request.params?.cancelTree === true}` : `cancel:${request.params?.sessionId ?? ''}`);
|
|
270
|
+
throw error;
|
|
271
|
+
} finally { if (stop) { stop.pending--; if (!stop.pending && !stop.committed) shutdowns.delete(root); } }
|
|
132
272
|
if (control) return result;
|
|
133
273
|
let settlement;
|
|
274
|
+
await publishRetainedProgress(root, request.id, 'settlement_started', result);
|
|
134
275
|
try {
|
|
135
276
|
settlement = await completeBudgetOperation(root, request, result, observe);
|
|
136
277
|
} catch (error) {
|
|
@@ -140,6 +281,7 @@ export async function durableDaemonDispatch(root, request, action, observe) {
|
|
|
140
281
|
catch (error) {
|
|
141
282
|
settlement = { state: "blocked", reason: "settlement_storage_error", observed_at: new Date().toISOString(), error: errorRecord(error) };
|
|
142
283
|
}
|
|
284
|
+
await publishRetainedProgress(root, request.id, settlement?.state === 'settled' || settlement?.state === 'not_required' ? 'settlement_completed' : 'settlement_pending', result);
|
|
143
285
|
return settlement?.state === "not_required" || !result || typeof result !== "object" || Array.isArray(result)
|
|
144
286
|
? result
|
|
145
287
|
: { ...result, settlement };
|
|
@@ -172,7 +314,7 @@ async function journalDispatch(root, request, action) {
|
|
|
172
314
|
const daemon = await read(path.join(root, "daemon.json"));
|
|
173
315
|
const startup = request.operation === "daemon.start";
|
|
174
316
|
const daemonId = daemon?.daemon_id ?? daemon?.config?.daemonId ?? null;
|
|
175
|
-
await writeJsonAtomic(temporary, { schema_id: "dd-flow/daemon-operation@1", operation_id: request.id, operation: request.operation, session_id: request.params?.sessionId ?? null, daemon_id: startup ? null : daemonId, ...(startup ? { predecessor_daemon_id: daemonId, launch: request.params?.launch } : {}), params_sha256: fingerprint, requested_at: new Date().toISOString() });
|
|
317
|
+
await writeJsonAtomic(temporary, { schema_id: "dd-flow/daemon-operation@1", operation_id: request.id, operation: request.operation, session_id: request.params?.sessionId ?? null, daemon_id: startup ? null : daemonId, owner_id: daemon?.config?.runtime_owner?.owner_id ?? null, generation: dispatchGenerations.get(root) ?? 0, ...(startup ? { predecessor_daemon_id: daemonId, launch: request.params?.launch } : {}), params_sha256: fingerprint, requested_at: new Date().toISOString() });
|
|
176
318
|
try {
|
|
177
319
|
await link(temporary, path.join(directory, "requested.json"));
|
|
178
320
|
} catch (error) {
|
|
@@ -184,6 +326,7 @@ async function journalDispatch(root, request, action) {
|
|
|
184
326
|
throw Object.assign(new Error("provider operation has no confirmed terminal result; inspect the existing operation instead of resending it"), { code: "operation_observation_lost", details: { operation_id: request.id, receipt_directory: directory } });
|
|
185
327
|
} finally { await unlink(temporary); }
|
|
186
328
|
let result;
|
|
329
|
+
await publishOperationProgress(root, request.id, 'accepted');
|
|
187
330
|
try {
|
|
188
331
|
result = await action();
|
|
189
332
|
}
|
|
@@ -203,6 +346,7 @@ async function journalDispatch(root, request, action) {
|
|
|
203
346
|
if (cause?.code === "operation_interrupted" || cause?.code === "operation_terminal_conflict") throw cause;
|
|
204
347
|
throw Object.assign(new Error("provider returned but the durable reply could not be saved; do not resend the operation"), { code: "operation_observation_lost", cause, details: { operation_id: request.id, receipt_directory: directory } });
|
|
205
348
|
}
|
|
349
|
+
await publishRetainedProgress(root, request.id, 'native_outcome_retained', result, { session_id: result?.session_id ?? result?.sessionId });
|
|
206
350
|
return result;
|
|
207
351
|
}
|
|
208
352
|
|