@deksden-com/dd-flow-cli 0.9.0-beta.124 → 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 +16 -0
- package/README.md +10 -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/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/daemon-observation.d.mts +2 -0
- package/dist/harness-runtime/lib/daemon-observation.mjs +163 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +60 -13
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +151 -72
- package/dist/harness-runtime/lib/dd-agy.mjs +11 -23
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +9 -14
- package/dist/harness-runtime/lib/dd-codex.mjs +252 -80
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +14 -19
- package/dist/harness-runtime/lib/dd-droid.mjs +117 -31
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +56 -30
- package/dist/harness-runtime/lib/dd-grok.mjs +15 -13
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +18 -27
- package/dist/harness-runtime/lib/dd-opencode.mjs +44 -14
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +56 -53
- package/dist/harness-runtime/lib/dd-zcode.mjs +200 -111
- 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 +10 -1
- package/dist/harness-runtime/lib/managed-daemon.mjs +228 -40
- 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/run-control-receipt.schema.json +19 -0
- package/dist/services/code-checks.js +79 -9
- package/dist/services/codex-capacity.js +117 -41
- package/dist/services/codex-hook-delivery.js +2 -2
- package/dist/services/compatibility-preflight.js +4 -1
- package/dist/services/controller-fanout.js +9 -76
- package/dist/services/engines.js +70 -20
- package/dist/services/eval-snapshots.js +179 -40
- package/dist/services/external-work-launch.js +13 -8
- package/dist/services/harness-adapter.js +175 -11
- 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 +103 -47
- package/dist/services/merge-server.js +58 -21
- package/dist/services/recovery-observation-budget.js +67 -7
- package/dist/services/recovery-snapshot-database.js +7 -4
- 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 +169 -46
- package/dist/services/run-controller-capture.js +14 -2
- package/dist/services/run-controller-process.js +11 -4
- package/dist/services/run-controller-state.js +68 -7
- package/dist/services/run-controller.js +166 -94
- package/dist/services/run-fork.js +8 -3
- package/dist/services/run-recovery-guard.js +75 -0
- package/dist/services/run-recovery-runtime.js +1 -56
- package/dist/services/run-recovery.js +17 -13
- package/dist/services/runs.js +39 -6
- 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/vnext-code-review.js +10 -9
- 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 +10 -9
- package/dist/services/vnext-plan-review.js +21 -17
- 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 +34 -4
- 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,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
|
+
}
|
|
@@ -4,14 +4,28 @@ import { mkdir, readFile, readdir, link, unlink, open } from "node:fs/promises";
|
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { hashJson, writeJsonAtomic } from "./runner-events.mjs";
|
|
7
|
-
import { errorRecord, isObservationLoss, reportedError } from "./operation-errors.mjs";
|
|
7
|
+
import { errorRecord, isObservationLoss, isProvenShellCorrection, reportedError } from "./operation-errors.mjs";
|
|
8
8
|
import { assertDaemonOwnership, budgetObservationScope, releaseBudgetObservation, completeBudgetOperation } from "./managed-daemon.mjs";
|
|
9
9
|
import { readModelObservations, modelAttribution } from "./model-observations.mjs";
|
|
10
|
+
import { publishOperationProgress, publishOperationWaiting, withOperationProgress } from './operation-progress.mjs';
|
|
10
11
|
|
|
11
12
|
const productive = new Set(["session.create", "session.prompt", "session.start", "session.fork", "session.resume"]);
|
|
12
13
|
const controls = new Map();
|
|
13
14
|
const dispatchGenerations = new Map();
|
|
14
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
|
+
}
|
|
15
29
|
|
|
16
30
|
export function assertDaemonDispatch(root) {
|
|
17
31
|
const stop = shutdowns.get(root);
|
|
@@ -115,8 +129,11 @@ async function controlDispatch(root, request, action) {
|
|
|
115
129
|
const predecessors = key.startsWith("stop:") ? [...pending.values()] : [];
|
|
116
130
|
const promise = (async () => { await Promise.allSettled(predecessors); return await action(); })();
|
|
117
131
|
pending.set(key, promise);
|
|
118
|
-
|
|
119
|
-
|
|
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); } }
|
|
120
137
|
}
|
|
121
138
|
function location(root, id) {
|
|
122
139
|
if (typeof id !== "string" || !id) throw Object.assign(new Error("operation id is required"), { code: "operation_id_required" });
|
|
@@ -131,26 +148,48 @@ export async function inspectDaemonOperation(root, id) {
|
|
|
131
148
|
if (!requested) throw Object.assign(new Error("daemon operation is not recorded"), { code: "operation_not_found", details: { operation_id: id } });
|
|
132
149
|
const settlement = await read(path.join(directory, "settlement.json"));
|
|
133
150
|
const hookFailures = [];
|
|
151
|
+
const hookCorrections = [];
|
|
152
|
+
const hookDiagnostics = [];
|
|
134
153
|
for (const name of await readdir(path.join(directory, "hooks")).catch(error => { if (error.code === "ENOENT") return []; throw error; })) {
|
|
135
154
|
if (!/^[A-Za-z0-9-]{1,128}\.json$/.test(name)) continue;
|
|
136
155
|
const binding = await read(path.join(directory, "hooks", name));
|
|
137
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;
|
|
138
|
-
|
|
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; }
|
|
139
160
|
if (failure?.request_id !== binding.request_id || typeof failure.code !== "string" || typeof failure.message !== "string") continue;
|
|
140
|
-
|
|
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" } });
|
|
141
164
|
}
|
|
142
165
|
hookFailures.sort((a, b) => String(a.observed_at ?? "").localeCompare(String(b.observed_at ?? "")));
|
|
143
166
|
// Admission failure is causal evidence, not proof that the native tree stopped.
|
|
144
|
-
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) } : {}) };
|
|
145
168
|
}
|
|
146
169
|
|
|
147
170
|
/** Retry only the bookkeeping that follows a durable productive receipt.
|
|
148
171
|
* This is intentionally separate from dispatch: it never invokes a native
|
|
149
172
|
* prompt/create/fork operation, so recovery cannot duplicate agent work. */
|
|
150
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) {
|
|
151
181
|
const current = await inspectDaemonOperation(root, id);
|
|
152
182
|
if (current.state !== "completed") return current;
|
|
153
|
-
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);
|
|
154
193
|
let settlement;
|
|
155
194
|
try {
|
|
156
195
|
settlement = await completeBudgetOperation(root, {
|
|
@@ -191,7 +230,7 @@ export async function interruptDaemonOperation(root, id, barrier) {
|
|
|
191
230
|
/** At-most-once dispatch and a durable terminal reply, including when the socket
|
|
192
231
|
* observer disconnects. Recovery reads this receipt; it never resends a prompt. */
|
|
193
232
|
export async function durableDaemonDispatch(root, request, action, observe) {
|
|
194
|
-
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);
|
|
195
234
|
if (request.operation === "daemon.status") {
|
|
196
235
|
const result = await action();
|
|
197
236
|
if (!result?.config?.journal) return result;
|
|
@@ -215,19 +254,24 @@ export async function durableDaemonDispatch(root, request, action, observe) {
|
|
|
215
254
|
const control = ["daemon.stop", "session.cancel"].includes(request.operation);
|
|
216
255
|
if (!productive.has(request.operation) && !control) return await action();
|
|
217
256
|
const generation = dispatchGenerations.get(root) ?? 0;
|
|
218
|
-
if (control) dispatchGenerations.set(root, generation + 1);
|
|
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 }); }
|
|
219
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" }); };
|
|
220
259
|
const stop = request.operation === "daemon.stop" ? (shutdowns.get(root) ?? { pending: 0, committed: false }) : null;
|
|
221
260
|
if (stop) { stop.pending++; shutdowns.set(root, stop); }
|
|
222
261
|
let result;
|
|
223
262
|
try { result = await journalDispatch(root, request, async () => {
|
|
224
263
|
// Persist before admission so a racing fence can observe the writer.
|
|
225
|
-
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));
|
|
226
265
|
if (!control) assertCurrent();
|
|
227
|
-
|
|
228
|
-
|
|
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); } }
|
|
229
272
|
if (control) return result;
|
|
230
273
|
let settlement;
|
|
274
|
+
await publishRetainedProgress(root, request.id, 'settlement_started', result);
|
|
231
275
|
try {
|
|
232
276
|
settlement = await completeBudgetOperation(root, request, result, observe);
|
|
233
277
|
} catch (error) {
|
|
@@ -237,6 +281,7 @@ export async function durableDaemonDispatch(root, request, action, observe) {
|
|
|
237
281
|
catch (error) {
|
|
238
282
|
settlement = { state: "blocked", reason: "settlement_storage_error", observed_at: new Date().toISOString(), error: errorRecord(error) };
|
|
239
283
|
}
|
|
284
|
+
await publishRetainedProgress(root, request.id, settlement?.state === 'settled' || settlement?.state === 'not_required' ? 'settlement_completed' : 'settlement_pending', result);
|
|
240
285
|
return settlement?.state === "not_required" || !result || typeof result !== "object" || Array.isArray(result)
|
|
241
286
|
? result
|
|
242
287
|
: { ...result, settlement };
|
|
@@ -269,7 +314,7 @@ async function journalDispatch(root, request, action) {
|
|
|
269
314
|
const daemon = await read(path.join(root, "daemon.json"));
|
|
270
315
|
const startup = request.operation === "daemon.start";
|
|
271
316
|
const daemonId = daemon?.daemon_id ?? daemon?.config?.daemonId ?? null;
|
|
272
|
-
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() });
|
|
273
318
|
try {
|
|
274
319
|
await link(temporary, path.join(directory, "requested.json"));
|
|
275
320
|
} catch (error) {
|
|
@@ -281,6 +326,7 @@ async function journalDispatch(root, request, action) {
|
|
|
281
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 } });
|
|
282
327
|
} finally { await unlink(temporary); }
|
|
283
328
|
let result;
|
|
329
|
+
await publishOperationProgress(root, request.id, 'accepted');
|
|
284
330
|
try {
|
|
285
331
|
result = await action();
|
|
286
332
|
}
|
|
@@ -300,6 +346,7 @@ async function journalDispatch(root, request, action) {
|
|
|
300
346
|
if (cause?.code === "operation_interrupted" || cause?.code === "operation_terminal_conflict") throw cause;
|
|
301
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 } });
|
|
302
348
|
}
|
|
349
|
+
await publishRetainedProgress(root, request.id, 'native_outcome_retained', result, { session_id: result?.session_id ?? result?.sessionId });
|
|
303
350
|
return result;
|
|
304
351
|
}
|
|
305
352
|
|