@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.
Files changed (122) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +33 -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/domain/stage-catalog.js +1 -1
  14. package/dist/harness-runtime/bin/dd-agy.mjs +3 -2
  15. package/dist/harness-runtime/bin/dd-codex.mjs +3 -2
  16. package/dist/harness-runtime/bin/dd-droid.mjs +3 -2
  17. package/dist/harness-runtime/bin/dd-grok.mjs +16 -6
  18. package/dist/harness-runtime/bin/dd-opencode.mjs +3 -2
  19. package/dist/harness-runtime/bin/dd-zcode.mjs +20 -16
  20. package/dist/harness-runtime/lib/adapter-timeouts.d.mts +5 -0
  21. package/dist/harness-runtime/lib/adapter-timeouts.mjs +34 -0
  22. package/dist/harness-runtime/lib/codex-capacity-policy.d.mts +10 -0
  23. package/dist/harness-runtime/lib/codex-capacity-policy.mjs +89 -0
  24. package/dist/harness-runtime/lib/daemon-observation.d.mts +2 -0
  25. package/dist/harness-runtime/lib/daemon-observation.mjs +163 -0
  26. package/dist/harness-runtime/lib/daemon-operations.mjs +159 -15
  27. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +215 -86
  28. package/dist/harness-runtime/lib/dd-agy.mjs +11 -13
  29. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +47 -23
  30. package/dist/harness-runtime/lib/dd-codex.mjs +257 -99
  31. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +32 -30
  32. package/dist/harness-runtime/lib/dd-droid.mjs +117 -31
  33. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +96 -38
  34. package/dist/harness-runtime/lib/dd-grok.mjs +15 -13
  35. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +54 -20
  36. package/dist/harness-runtime/lib/dd-opencode.mjs +44 -14
  37. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +81 -76
  38. package/dist/harness-runtime/lib/dd-zcode.mjs +204 -116
  39. package/dist/harness-runtime/lib/driver-recovery.mjs +12 -2
  40. package/dist/harness-runtime/lib/lease-renewal.d.mts +8 -0
  41. package/dist/harness-runtime/lib/lease-renewal.mjs +23 -0
  42. package/dist/harness-runtime/lib/lifecycle-command.d.mts +3 -3
  43. package/dist/harness-runtime/lib/lifecycle-command.mjs +74 -20
  44. package/dist/harness-runtime/lib/managed-daemon.d.mts +11 -0
  45. package/dist/harness-runtime/lib/managed-daemon.mjs +321 -65
  46. package/dist/harness-runtime/lib/native-children.d.mts +13 -0
  47. package/dist/harness-runtime/lib/native-children.mjs +78 -0
  48. package/dist/harness-runtime/lib/native-hook-command.d.mts +4 -0
  49. package/dist/harness-runtime/lib/native-hook-command.mjs +115 -11
  50. package/dist/harness-runtime/lib/native-streams.mjs +45 -0
  51. package/dist/harness-runtime/lib/observation-clock.d.mts +18 -0
  52. package/dist/harness-runtime/lib/observation-clock.mjs +73 -17
  53. package/dist/harness-runtime/lib/operation-errors.d.mts +14 -0
  54. package/dist/harness-runtime/lib/operation-errors.mjs +105 -8
  55. package/dist/harness-runtime/lib/operation-progress.d.mts +7 -0
  56. package/dist/harness-runtime/lib/operation-progress.mjs +112 -0
  57. package/dist/harness-runtime/lib/process-json.mjs +136 -38
  58. package/dist/harness-runtime/lib/tool-observations.d.mts +1 -0
  59. package/dist/harness-runtime/lib/tool-observations.mjs +5 -2
  60. package/dist/harness-runtime/lib/zcode-usage.mjs +3 -2
  61. package/dist/runtime/context.js +8 -4
  62. package/dist/schemas/flow-run.schema.json +1 -0
  63. package/dist/schemas/plan-review-decision.schema.json +3 -3
  64. package/dist/schemas/run-control-receipt.schema.json +19 -0
  65. package/dist/services/code-checks.js +93 -27
  66. package/dist/services/codex-capacity.js +251 -74
  67. package/dist/services/codex-hook-delivery.js +2 -2
  68. package/dist/services/compatibility-preflight.js +4 -1
  69. package/dist/services/controller-fanout.js +9 -75
  70. package/dist/services/engines.js +70 -20
  71. package/dist/services/eval-snapshots.js +179 -40
  72. package/dist/services/external-work-launch.js +21 -9
  73. package/dist/services/harness-adapter.js +195 -25
  74. package/dist/services/hooks.js +65 -21
  75. package/dist/services/lifecycle-invocations.js +404 -32
  76. package/dist/services/managed-lease-monitor.js +128 -0
  77. package/dist/services/managed-processes.js +117 -53
  78. package/dist/services/merge-server.js +66 -23
  79. package/dist/services/recovery-observation-budget.js +67 -7
  80. package/dist/services/recovery-snapshot-database.js +7 -4
  81. package/dist/services/review-deferral.js +23 -0
  82. package/dist/services/run-control-receipt.js +1 -1
  83. package/dist/services/run-control-worker.js +177 -118
  84. package/dist/services/run-control.js +99 -8
  85. package/dist/services/run-controller-adapter.js +177 -51
  86. package/dist/services/run-controller-capture.js +14 -2
  87. package/dist/services/run-controller-process.js +11 -4
  88. package/dist/services/run-controller-recovery.js +17 -0
  89. package/dist/services/run-controller-state.js +68 -7
  90. package/dist/services/run-controller.js +207 -107
  91. package/dist/services/run-fork.js +8 -3
  92. package/dist/services/run-recovery-guard.js +75 -0
  93. package/dist/services/run-recovery-runtime.js +21 -30
  94. package/dist/services/run-recovery.js +17 -13
  95. package/dist/services/runs.js +40 -7
  96. package/dist/services/runtime-budget.js +32 -15
  97. package/dist/services/runtime-scope-control.js +8 -0
  98. package/dist/services/runtime-scope-resume.js +30 -8
  99. package/dist/services/runtime-scope-worker.js +118 -73
  100. package/dist/services/runtime-service.js +6 -7
  101. package/dist/services/scope-writer-cleanup.js +3 -2
  102. package/dist/services/stage-context.js +135 -0
  103. package/dist/services/stage-lifecycle.js +3 -15
  104. package/dist/services/stage-pause.js +3 -2
  105. package/dist/services/usage.js +14 -3
  106. package/dist/services/verification-matrix.js +30 -1
  107. package/dist/services/vnext-code-review.js +26 -13
  108. package/dist/services/vnext-code.js +10 -8
  109. package/dist/services/vnext-contracts.js +10 -1
  110. package/dist/services/vnext-execution-profile.js +75 -2
  111. package/dist/services/vnext-fanout.js +5 -0
  112. package/dist/services/vnext-merge.js +30 -11
  113. package/dist/services/vnext-plan-review.js +102 -35
  114. package/dist/services/vnext-plan.js +9 -7
  115. package/dist/services/vnext-protocolize.js +15 -9
  116. package/dist/services/vnext-specify.js +6 -6
  117. package/dist/services/work-registry.js +43 -6
  118. package/dist/services/workspace-bootstrap.js +14 -3
  119. package/dist/shared/errors.js +11 -6
  120. package/dist/storage/agent-output.js +26 -0
  121. package/dist/storage/database.js +80 -21
  122. 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
- try { return await promise; }
25
- 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); } }
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
- 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; }
45
160
  if (failure?.request_id !== binding.request_id || typeof failure.code !== "string" || typeof failure.message !== "string") continue;
46
- 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" } });
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 === "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);
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 result = await journalDispatch(root, request, async () => {
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
- return control ? await controlDispatch(root, request, action) : await action();
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