@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.
Files changed (113) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +10 -1
  3. package/dist/build-info.json +7 -7
  4. package/dist/cli/argument-values.js +38 -0
  5. package/dist/cli/command-inputs.js +4 -3
  6. package/dist/cli/help.js +1 -0
  7. package/dist/cli/hook-ingress.js +1 -1
  8. package/dist/cli/run-cli.js +126 -193
  9. package/dist/cli/runtime-maintenance-diagnostics.js +46 -0
  10. package/dist/cli/runtime-maintenance-ingress.js +51 -0
  11. package/dist/cli/runtime-process.js +128 -0
  12. package/dist/cli.js +8 -5
  13. package/dist/harness-runtime/bin/dd-agy.mjs +3 -2
  14. package/dist/harness-runtime/bin/dd-codex.mjs +3 -2
  15. package/dist/harness-runtime/bin/dd-droid.mjs +3 -2
  16. package/dist/harness-runtime/bin/dd-grok.mjs +16 -6
  17. package/dist/harness-runtime/bin/dd-opencode.mjs +3 -2
  18. package/dist/harness-runtime/bin/dd-zcode.mjs +20 -16
  19. package/dist/harness-runtime/lib/adapter-timeouts.d.mts +5 -0
  20. package/dist/harness-runtime/lib/adapter-timeouts.mjs +34 -0
  21. package/dist/harness-runtime/lib/daemon-observation.d.mts +2 -0
  22. package/dist/harness-runtime/lib/daemon-observation.mjs +163 -0
  23. package/dist/harness-runtime/lib/daemon-operations.mjs +60 -13
  24. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +151 -72
  25. package/dist/harness-runtime/lib/dd-agy.mjs +11 -23
  26. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +9 -14
  27. package/dist/harness-runtime/lib/dd-codex.mjs +252 -80
  28. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +14 -19
  29. package/dist/harness-runtime/lib/dd-droid.mjs +117 -31
  30. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +56 -30
  31. package/dist/harness-runtime/lib/dd-grok.mjs +15 -13
  32. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +18 -27
  33. package/dist/harness-runtime/lib/dd-opencode.mjs +44 -14
  34. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +56 -53
  35. package/dist/harness-runtime/lib/dd-zcode.mjs +200 -111
  36. package/dist/harness-runtime/lib/lease-renewal.d.mts +8 -0
  37. package/dist/harness-runtime/lib/lease-renewal.mjs +23 -0
  38. package/dist/harness-runtime/lib/lifecycle-command.d.mts +3 -3
  39. package/dist/harness-runtime/lib/lifecycle-command.mjs +74 -20
  40. package/dist/harness-runtime/lib/managed-daemon.d.mts +10 -1
  41. package/dist/harness-runtime/lib/managed-daemon.mjs +228 -40
  42. package/dist/harness-runtime/lib/native-children.d.mts +13 -0
  43. package/dist/harness-runtime/lib/native-children.mjs +78 -0
  44. package/dist/harness-runtime/lib/native-hook-command.d.mts +4 -0
  45. package/dist/harness-runtime/lib/native-hook-command.mjs +115 -11
  46. package/dist/harness-runtime/lib/native-streams.mjs +45 -0
  47. package/dist/harness-runtime/lib/observation-clock.d.mts +18 -0
  48. package/dist/harness-runtime/lib/observation-clock.mjs +73 -17
  49. package/dist/harness-runtime/lib/operation-errors.d.mts +14 -0
  50. package/dist/harness-runtime/lib/operation-errors.mjs +105 -8
  51. package/dist/harness-runtime/lib/operation-progress.d.mts +7 -0
  52. package/dist/harness-runtime/lib/operation-progress.mjs +112 -0
  53. package/dist/harness-runtime/lib/process-json.mjs +136 -38
  54. package/dist/harness-runtime/lib/tool-observations.d.mts +1 -0
  55. package/dist/harness-runtime/lib/tool-observations.mjs +5 -2
  56. package/dist/harness-runtime/lib/zcode-usage.mjs +3 -2
  57. package/dist/runtime/context.js +8 -4
  58. package/dist/schemas/run-control-receipt.schema.json +19 -0
  59. package/dist/services/code-checks.js +79 -9
  60. package/dist/services/codex-capacity.js +117 -41
  61. package/dist/services/codex-hook-delivery.js +2 -2
  62. package/dist/services/compatibility-preflight.js +4 -1
  63. package/dist/services/controller-fanout.js +9 -76
  64. package/dist/services/engines.js +70 -20
  65. package/dist/services/eval-snapshots.js +179 -40
  66. package/dist/services/external-work-launch.js +13 -8
  67. package/dist/services/harness-adapter.js +175 -11
  68. package/dist/services/hooks.js +65 -21
  69. package/dist/services/lifecycle-invocations.js +404 -32
  70. package/dist/services/managed-lease-monitor.js +128 -0
  71. package/dist/services/managed-processes.js +103 -47
  72. package/dist/services/merge-server.js +58 -21
  73. package/dist/services/recovery-observation-budget.js +67 -7
  74. package/dist/services/recovery-snapshot-database.js +7 -4
  75. package/dist/services/run-control-receipt.js +1 -1
  76. package/dist/services/run-control-worker.js +177 -118
  77. package/dist/services/run-control.js +99 -8
  78. package/dist/services/run-controller-adapter.js +169 -46
  79. package/dist/services/run-controller-capture.js +14 -2
  80. package/dist/services/run-controller-process.js +11 -4
  81. package/dist/services/run-controller-state.js +68 -7
  82. package/dist/services/run-controller.js +166 -94
  83. package/dist/services/run-fork.js +8 -3
  84. package/dist/services/run-recovery-guard.js +75 -0
  85. package/dist/services/run-recovery-runtime.js +1 -56
  86. package/dist/services/run-recovery.js +17 -13
  87. package/dist/services/runs.js +39 -6
  88. package/dist/services/runtime-budget.js +32 -15
  89. package/dist/services/runtime-scope-control.js +8 -0
  90. package/dist/services/runtime-scope-resume.js +30 -8
  91. package/dist/services/runtime-scope-worker.js +118 -73
  92. package/dist/services/runtime-service.js +6 -7
  93. package/dist/services/scope-writer-cleanup.js +3 -2
  94. package/dist/services/stage-context.js +135 -0
  95. package/dist/services/stage-lifecycle.js +3 -15
  96. package/dist/services/stage-pause.js +3 -2
  97. package/dist/services/usage.js +14 -3
  98. package/dist/services/vnext-code-review.js +10 -9
  99. package/dist/services/vnext-code.js +10 -8
  100. package/dist/services/vnext-contracts.js +10 -1
  101. package/dist/services/vnext-execution-profile.js +75 -2
  102. package/dist/services/vnext-fanout.js +5 -0
  103. package/dist/services/vnext-merge.js +10 -9
  104. package/dist/services/vnext-plan-review.js +21 -17
  105. package/dist/services/vnext-plan.js +9 -7
  106. package/dist/services/vnext-protocolize.js +15 -9
  107. package/dist/services/vnext-specify.js +6 -6
  108. package/dist/services/work-registry.js +34 -4
  109. package/dist/services/workspace-bootstrap.js +14 -3
  110. package/dist/shared/errors.js +11 -6
  111. package/dist/storage/agent-output.js +26 -0
  112. package/dist/storage/database.js +80 -21
  113. 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
- try { return await promise; }
119
- finally { pending.delete(key); if (!pending.size) controls.delete(root); }
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
- const failure = await read(path.join(root, "hook-failures", name));
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
- hookFailures.push({ code: failure.code, message: failure.message, observed_at: failure.observed_at ?? null, details: { ...failure.details, ...binding, effect: "unknown" } });
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 === "settled") return current;
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
- return control ? await controlDispatch(root, request, action) : await action();
228
- }); } finally { if (stop) { stop.pending--; if (!stop.pending && !stop.committed) shutdowns.delete(root); } }
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