@plurnk/plurnk-service 1.6.0 → 1.7.0

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 (164) hide show
  1. package/.env.defaults +7 -21
  2. package/INSTALL.md +18 -17
  3. package/README.md +1 -1
  4. package/SPEC.md +209 -136
  5. package/dist/build-info.json +1 -1
  6. package/dist/core/ChannelWrite.sql +3 -3
  7. package/dist/core/ClientInteractions.d.ts +19 -0
  8. package/dist/core/ClientInteractions.d.ts.map +1 -0
  9. package/dist/core/ClientInteractions.js +124 -0
  10. package/dist/core/ClientInteractions.js.map +1 -0
  11. package/dist/core/ClientInteractions.sql +34 -0
  12. package/dist/core/CoreSchemeServices.d.ts +9 -3
  13. package/dist/core/CoreSchemeServices.d.ts.map +1 -1
  14. package/dist/core/CoreSchemeServices.js +8 -2
  15. package/dist/core/CoreSchemeServices.js.map +1 -1
  16. package/dist/core/Dispatcher.d.ts +4 -2
  17. package/dist/core/Dispatcher.d.ts.map +1 -1
  18. package/dist/core/Dispatcher.js +116 -1149
  19. package/dist/core/Dispatcher.js.map +1 -1
  20. package/dist/core/Engine.d.ts +24 -34
  21. package/dist/core/Engine.d.ts.map +1 -1
  22. package/dist/core/Engine.js +137 -1672
  23. package/dist/core/Engine.js.map +1 -1
  24. package/dist/core/Engine.sql +46 -32
  25. package/dist/core/ExecutableTools.d.ts +1 -0
  26. package/dist/core/ExecutableTools.d.ts.map +1 -1
  27. package/dist/core/ExecutableTools.js +17 -9
  28. package/dist/core/ExecutableTools.js.map +1 -1
  29. package/dist/core/ExecutorRegistry.d.ts +11 -3
  30. package/dist/core/ExecutorRegistry.d.ts.map +1 -1
  31. package/dist/core/ExecutorRegistry.js +139 -16
  32. package/dist/core/ExecutorRegistry.js.map +1 -1
  33. package/dist/core/LogBody.d.ts +1 -0
  34. package/dist/core/LogBody.d.ts.map +1 -1
  35. package/dist/core/LogBody.js +4 -0
  36. package/dist/core/LogBody.js.map +1 -1
  37. package/dist/core/ModelCall.d.ts +2 -2
  38. package/dist/core/ModelCall.d.ts.map +1 -1
  39. package/dist/core/ModelCall.js +4 -2
  40. package/dist/core/ModelCall.js.map +1 -1
  41. package/dist/core/PacketBuilder.d.ts +11 -22
  42. package/dist/core/PacketBuilder.d.ts.map +1 -1
  43. package/dist/core/PacketBuilder.js +90 -180
  44. package/dist/core/PacketBuilder.js.map +1 -1
  45. package/dist/core/ProblemLog.d.ts +1 -1
  46. package/dist/core/ProblemLog.d.ts.map +1 -1
  47. package/dist/core/ProblemLog.js +14 -3
  48. package/dist/core/ProblemLog.js.map +1 -1
  49. package/dist/core/ProposalLifecycle.d.ts +4 -2
  50. package/dist/core/ProposalLifecycle.d.ts.map +1 -1
  51. package/dist/core/ProposalLifecycle.js +19 -7
  52. package/dist/core/ProposalLifecycle.js.map +1 -1
  53. package/dist/core/ProviderInstantiate.js +2 -2
  54. package/dist/core/ProviderInstantiate.js.map +1 -1
  55. package/dist/core/ResourceMutations.d.ts +73 -0
  56. package/dist/core/ResourceMutations.d.ts.map +1 -0
  57. package/dist/core/ResourceMutations.js +1142 -0
  58. package/dist/core/ResourceMutations.js.map +1 -0
  59. package/dist/core/SchemeRegistry.d.ts +18 -10
  60. package/dist/core/SchemeRegistry.d.ts.map +1 -1
  61. package/dist/core/SchemeRegistry.js +195 -43
  62. package/dist/core/SchemeRegistry.js.map +1 -1
  63. package/dist/core/StoredPacket.d.ts +2 -2
  64. package/dist/core/StoredPacket.js +7 -7
  65. package/dist/core/TurnRunner.d.ts +74 -0
  66. package/dist/core/TurnRunner.d.ts.map +1 -0
  67. package/dist/core/TurnRunner.js +1733 -0
  68. package/dist/core/TurnRunner.js.map +1 -0
  69. package/dist/core/WorkspaceGate.d.ts +1 -0
  70. package/dist/core/WorkspaceGate.d.ts.map +1 -1
  71. package/dist/core/WorkspaceGate.js +39 -0
  72. package/dist/core/WorkspaceGate.js.map +1 -1
  73. package/dist/core/caps/CoreInteractionCaps.d.ts +9 -0
  74. package/dist/core/caps/CoreInteractionCaps.d.ts.map +1 -0
  75. package/dist/core/caps/CoreInteractionCaps.js +14 -0
  76. package/dist/core/caps/CoreInteractionCaps.js.map +1 -0
  77. package/dist/core/caps/DbChannelCaps.js +4 -4
  78. package/dist/core/caps/DbChannelCaps.js.map +1 -1
  79. package/dist/core/caps/SchemeCtxImpl.d.ts +2 -1
  80. package/dist/core/caps/SchemeCtxImpl.d.ts.map +1 -1
  81. package/dist/core/caps/SchemeCtxImpl.js +3 -0
  82. package/dist/core/caps/SchemeCtxImpl.js.map +1 -1
  83. package/dist/core/content-hash.js +2 -2
  84. package/dist/core/content-hash.js.map +1 -1
  85. package/dist/core/content-weight.d.ts +2 -0
  86. package/dist/core/content-weight.d.ts.map +1 -0
  87. package/dist/core/content-weight.js +4 -0
  88. package/dist/core/content-weight.js.map +1 -0
  89. package/dist/core/content_weight.d.ts +4 -0
  90. package/dist/core/content_weight.d.ts.map +1 -0
  91. package/dist/core/content_weight.js +6 -0
  92. package/dist/core/content_weight.js.map +1 -0
  93. package/dist/core/fork.sql +5 -5
  94. package/dist/core/packet-wire.d.ts +4 -4
  95. package/dist/core/packet-wire.d.ts.map +1 -1
  96. package/dist/core/packet-wire.js +21 -21
  97. package/dist/core/packet-wire.js.map +1 -1
  98. package/dist/core/scheme-types.d.ts +3 -2
  99. package/dist/core/scheme-types.d.ts.map +1 -1
  100. package/dist/core/scheme-types.js.map +1 -1
  101. package/dist/digest/Digest.js +1 -1
  102. package/dist/digest/Digest.js.map +1 -1
  103. package/dist/schemes/Exec.d.ts.map +1 -1
  104. package/dist/schemes/Exec.js +53 -15
  105. package/dist/schemes/Exec.js.map +1 -1
  106. package/dist/schemes/ExecOutputScheme.js +1 -1
  107. package/dist/schemes/ExecOutputScheme.js.map +1 -1
  108. package/dist/schemes/File.d.ts.map +1 -1
  109. package/dist/schemes/File.js +1 -1
  110. package/dist/schemes/File.js.map +1 -1
  111. package/dist/schemes/Log.d.ts.map +1 -1
  112. package/dist/schemes/Log.js +2 -2
  113. package/dist/schemes/Log.sql +2 -2
  114. package/dist/schemes/Worker.js +2 -2
  115. package/dist/schemes/Worker.js.map +1 -1
  116. package/dist/schemes/_entry-crud.js +4 -4
  117. package/dist/schemes/_entry-crud.js.map +1 -1
  118. package/dist/schemes/_entry-crud.sql +2 -2
  119. package/dist/schemes/_entry-find.d.ts +5 -5
  120. package/dist/schemes/_entry-find.d.ts.map +1 -1
  121. package/dist/schemes/_entry-find.js +29 -28
  122. package/dist/schemes/_entry-find.js.map +1 -1
  123. package/dist/schemes/_entry-manifest.js +4 -4
  124. package/dist/schemes/_entry-manifest.js.map +1 -1
  125. package/dist/schemes/_entry-ops.d.ts.map +1 -1
  126. package/dist/schemes/_entry-ops.js +3 -3
  127. package/dist/schemes/_entry-ops.js.map +1 -1
  128. package/dist/schemes/_entry-ops.sql +3 -3
  129. package/dist/server/Daemon.d.ts +20 -49
  130. package/dist/server/Daemon.d.ts.map +1 -1
  131. package/dist/server/Daemon.js +311 -908
  132. package/dist/server/Daemon.js.map +1 -1
  133. package/dist/server/DaemonModule.d.ts +31 -3
  134. package/dist/server/DaemonModule.d.ts.map +1 -1
  135. package/dist/server/DrainSupervisor.d.ts +117 -0
  136. package/dist/server/DrainSupervisor.d.ts.map +1 -0
  137. package/dist/server/DrainSupervisor.js +802 -0
  138. package/dist/server/DrainSupervisor.js.map +1 -0
  139. package/dist/server/client-input.d.ts +2 -0
  140. package/dist/server/client-input.d.ts.map +1 -1
  141. package/dist/server/client-input.js +12 -0
  142. package/dist/server/client-input.js.map +1 -1
  143. package/dist/server/lifecycle-recovery.sql +9 -0
  144. package/dist/server/logEntry.d.ts +1 -1
  145. package/dist/server/logEntry.js +1 -1
  146. package/dist/server/loopDocs.d.ts +1 -0
  147. package/dist/server/loopDocs.d.ts.map +1 -1
  148. package/dist/server/loopDocs.js +37 -8
  149. package/dist/server/loopDocs.js.map +1 -1
  150. package/dist/server/loopDocs.sql +10 -0
  151. package/dist/server/seam-entry-read.sql +2 -2
  152. package/dist/server/workspace-capabilities.sql +19 -0
  153. package/dist/service.js +1 -1
  154. package/dist/service.js.map +1 -1
  155. package/migrations/001_schema.sql +51 -10
  156. package/package.json +34 -33
  157. package/dist/core/ruler_count.d.ts +0 -4
  158. package/dist/core/ruler_count.d.ts.map +0 -1
  159. package/dist/core/ruler_count.js +0 -7
  160. package/dist/core/ruler_count.js.map +0 -1
  161. package/dist/core/token-ruler.d.ts +0 -2
  162. package/dist/core/token-ruler.d.ts.map +0 -1
  163. package/dist/core/token-ruler.js +0 -5
  164. package/dist/core/token-ruler.js.map +0 -1
@@ -0,0 +1,802 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ import { aggregateProviderAccounting } from "@plurnk/plurnk-providers";
3
+ import ChannelWrite from "../core/ChannelWrite.js";
4
+ import ErrorDetail from "../core/ErrorDetail.js";
5
+ import { DEFAULT_LOOP_FLAGS } from "../core/scheme-types.js";
6
+ import Results, { OperationFailureError } from "../core/results.js";
7
+ import { observed } from "../observe/spans.js";
8
+ import { LOOP_TERMINALS, recordCounter } from "../observe/metrics.js";
9
+ import { readOptimisticSettlementMs } from "../core/optimistic-settlement.js";
10
+ import { execPollBackoffMs } from "./exec-poll-backoff.js";
11
+ // Owns the worker-local queue consumer and every process-local edge that may
12
+ // wake, cancel, or retire it. Daemon retains provider/process policy and the
13
+ // external facade, supplying only the named capabilities below.
14
+ export default class DrainSupervisor {
15
+ #db;
16
+ #lifecycle;
17
+ #injectPrompt;
18
+ #assertInjectionCompatibility;
19
+ #reconcilePrompts;
20
+ #runLoop;
21
+ #loopUsage;
22
+ #loopAttributions;
23
+ #takeParkDeadline;
24
+ #settleSearch;
25
+ #cancelSubscription;
26
+ #hasActiveStreams;
27
+ #readSystemPrompt;
28
+ #emitLogEntry;
29
+ #emit;
30
+ // The handle is the drain identity. Start/exit compare by reference so an
31
+ // exiting drain cannot clobber a successor that raced in.
32
+ #activeDrains = new Map();
33
+ #drainExitTasks = new Set();
34
+ #wakeTasks = new Set();
35
+ #wakeFailures = [];
36
+ // One cancellation scope spans a worker's loops and streams. It outlives
37
+ // any single drain and is replaced only after it has been aborted.
38
+ #workerAborts = new Map();
39
+ #parkTimers = new Map();
40
+ #pollTimers = new Map();
41
+ #pollBackoff = new Map();
42
+ #drainLocks = new Map();
43
+ #owedWakes = new Set();
44
+ #completionWakeGates = new Map();
45
+ #acceptingWork = false;
46
+ constructor({ db, lifecycle, injectPrompt, assertInjectionCompatibility, reconcilePrompts, runLoop, loopUsage, loopAttributions, takeParkDeadline, settleSearch, cancelSubscription, hasActiveStreams, readSystemPrompt, emitLogEntry, emit, }) {
47
+ this.#db = db;
48
+ this.#lifecycle = lifecycle;
49
+ this.#injectPrompt = injectPrompt;
50
+ this.#assertInjectionCompatibility = assertInjectionCompatibility;
51
+ this.#reconcilePrompts = reconcilePrompts;
52
+ this.#runLoop = runLoop;
53
+ this.#loopUsage = loopUsage;
54
+ this.#loopAttributions = loopAttributions;
55
+ this.#takeParkDeadline = takeParkDeadline;
56
+ this.#settleSearch = settleSearch;
57
+ this.#cancelSubscription = cancelSubscription;
58
+ this.#hasActiveStreams = hasActiveStreams;
59
+ this.#readSystemPrompt = readSystemPrompt;
60
+ this.#emitLogEntry = emitLogEntry;
61
+ this.#emit = emit;
62
+ }
63
+ start() {
64
+ this.#acceptingWork = true;
65
+ }
66
+ beginStop(reason) {
67
+ this.#acceptingWork = false;
68
+ for (const scope of this.#workerAborts.values()) {
69
+ if (!scope.signal.aborted)
70
+ scope.abort(reason);
71
+ }
72
+ for (const timer of this.#pollTimers.values())
73
+ clearTimeout(timer);
74
+ for (const timer of this.#parkTimers.values())
75
+ clearTimeout(timer);
76
+ this.#pollBackoff.clear();
77
+ this.#pollTimers.clear();
78
+ this.#parkTimers.clear();
79
+ }
80
+ async idle() {
81
+ for (;;) {
82
+ const pending = [
83
+ ...[...this.#activeDrains.values()].map(({ promise }) => promise),
84
+ ...this.#drainExitTasks,
85
+ ...this.#wakeTasks,
86
+ ];
87
+ if (pending.length === 0)
88
+ break;
89
+ await Promise.allSettled(pending);
90
+ }
91
+ const failures = this.#wakeFailures.splice(0);
92
+ if (failures.length > 0) {
93
+ throw new AggregateError(failures, "wake-on-completion settlement failed");
94
+ }
95
+ }
96
+ async inject(args) {
97
+ const { workspaceId, workerId, prompt } = args;
98
+ if (this.#activeDrains.has(workerId)) {
99
+ const active = await this.#db.drain_current_loop_for_worker.get({ worker_id: workerId });
100
+ if (active !== undefined) {
101
+ await this.#assertInjectionCompatibility({
102
+ workerId,
103
+ loopId: active.id,
104
+ providerSpec: args.providerSpec,
105
+ ...(args.childProviderSpec === undefined ? {} : { childProviderSpec: args.childProviderSpec }),
106
+ ...(args.turnCeiling === undefined ? {} : { turnCeiling: args.turnCeiling }),
107
+ ...(args.flags === undefined ? {} : { flags: args.flags }),
108
+ });
109
+ }
110
+ const result = await this.#injectPrompt(workerId, prompt, args.openPaths ?? []);
111
+ if (result !== null) {
112
+ return { action: "injected_next_turn", loopId: result.loopId, turnSeq: result.turnSeq };
113
+ }
114
+ }
115
+ // A parked worker resumes the same durable loop; a wake is not a new
116
+ // loop and cannot silently replace its flags/provider/turn ceiling.
117
+ if (!this.#activeDrains.has(workerId)) {
118
+ const slept = await this.#db.drain_find_slept_loop.get({ worker_id: workerId });
119
+ if (slept !== undefined) {
120
+ await this.#assertInjectionCompatibility({
121
+ workerId,
122
+ loopId: slept.id,
123
+ providerSpec: args.providerSpec,
124
+ ...(args.childProviderSpec === undefined ? {} : { childProviderSpec: args.childProviderSpec }),
125
+ ...(args.turnCeiling === undefined ? {} : { turnCeiling: args.turnCeiling }),
126
+ ...(args.flags === undefined ? {} : { flags: args.flags }),
127
+ });
128
+ const injected = await this.#injectPrompt(workerId, prompt, args.openPaths ?? []);
129
+ await this.#lifecycle.wake(slept.id);
130
+ const started = await this.ensureDrain({ workspaceId, workerId, systemPrompt: args.systemPrompt });
131
+ return {
132
+ action: "injected_next_turn",
133
+ loopId: slept.id,
134
+ ...(injected?.turnSeq === undefined ? {} : { turnSeq: injected.turnSeq }),
135
+ ...(started ?? {}),
136
+ };
137
+ }
138
+ }
139
+ const loopId = await this.enqueueFreshLoop({
140
+ workerId,
141
+ prompt,
142
+ providerSpec: args.providerSpec,
143
+ childProviderSpec: args.childProviderSpec ?? null,
144
+ maxTurns: args.turnCeiling?.effective,
145
+ flags: args.flags,
146
+ openPaths: args.openPaths,
147
+ });
148
+ const started = await this.ensureDrain({ workspaceId, workerId, systemPrompt: args.systemPrompt });
149
+ return { action: "enqueued_new_loop", loopId, ...(started ?? {}) };
150
+ }
151
+ enqueueFreshLoop(args) {
152
+ return this.#withDrainLock(args.workerId, async () => {
153
+ const seqRow = await this.#db.loop_run_next_sequence.get({ worker_id: args.workerId });
154
+ if (seqRow === undefined)
155
+ throw new Error("enqueueFreshLoop: next-sequence query returned no row");
156
+ const loopRow = await this.#db.drain_enqueue_loop.get({
157
+ worker_id: args.workerId,
158
+ sequence: seqRow.next,
159
+ prompt: args.prompt,
160
+ provider_spec: JSON.stringify(args.providerSpec),
161
+ child_provider_spec: JSON.stringify(args.childProviderSpec),
162
+ max_turns: args.maxTurns ?? Number(process.env.PLURNK_SERVICE_MAX_TURNS ?? "50"),
163
+ });
164
+ if (loopRow === undefined)
165
+ throw new Error("enqueueFreshLoop: loop enqueue returned no row");
166
+ if (args.flags !== undefined) {
167
+ await this.#db.engine_set_loop_flags.run({
168
+ loop_id: loopRow.id,
169
+ flags: JSON.stringify({ ...DEFAULT_LOOP_FLAGS, ...args.flags }),
170
+ });
171
+ }
172
+ if (args.openPaths !== undefined && args.openPaths.length > 0) {
173
+ await this.#db.engine_set_loop_open_paths.run({
174
+ loop_id: loopRow.id,
175
+ open_paths: JSON.stringify(args.openPaths),
176
+ });
177
+ }
178
+ return loopRow.id;
179
+ });
180
+ }
181
+ // Consume one worker's durable queue until a lock-held empty re-claim
182
+ // relinquishes this exact drain identity.
183
+ #startDrain(opts) {
184
+ const { workspaceId, workerId, systemPrompt } = opts;
185
+ // The drain runs under the worker's cancellation scope (shared with the
186
+ // execs its loops spawn), so loop.cancel/shutdown abort it as a unit.
187
+ const controller = this.#workerSignal(workerId);
188
+ const handle = {
189
+ controller, promise: Promise.resolve(),
190
+ };
191
+ let resolveFirst = () => { };
192
+ let rejectFirst = () => { };
193
+ const firstLoopPromise = new Promise((res, rej) => {
194
+ resolveFirst = res;
195
+ rejectFirst = rej;
196
+ });
197
+ let firstSettled = false;
198
+ const claim = () => this.#db.drain_claim_next_loop.get({ worker_id: workerId });
199
+ const drainPromise = (async () => {
200
+ let loopsDrained = 0;
201
+ let lastResult = null;
202
+ let currentLoopId = null; // the loop being drained — for abort→499 settlement
203
+ try {
204
+ while (true) {
205
+ controller.signal.throwIfAborted();
206
+ let loopRow = await claim();
207
+ if (loopRow === undefined) {
208
+ // Queue empty → teardown UNDER the per-worker drain lock (R4 / I1),
209
+ // serialized against ensureDrain so a concurrent inject can't
210
+ // start a 2nd drain in the gap. Re-claim while holding the lock;
211
+ // relinquish the registry slot only if it's empty too. A loop
212
+ // that raced in is returned and run — we stay registered, so
213
+ // there's no transient delete for ensureDrain to catch.
214
+ loopRow = await this.#withDrainLock(workerId, async () => {
215
+ const claimed = await claim();
216
+ if (claimed === undefined && this.#activeDrains.get(workerId) === handle) {
217
+ this.#activeDrains.delete(workerId);
218
+ }
219
+ return claimed;
220
+ });
221
+ if (loopRow === undefined)
222
+ break;
223
+ }
224
+ currentLoopId = loopRow.id;
225
+ const onDispatch = (logEntryId) => {
226
+ void this.#emitLogEntry(workspaceId, logEntryId).catch((error) => {
227
+ console.error("log/entry broadcast failed:", error instanceof Error ? error.message : String(error));
228
+ });
229
+ };
230
+ const result = await observed("loop.run", { workspaceId, workerId, "loop.id": loopRow.id }, async (span) => {
231
+ const loopResult = await this.#runLoop({
232
+ workspaceId,
233
+ workerId,
234
+ loopId: loopRow.id,
235
+ maxTurns: loopRow.max_turns,
236
+ prompt: loopRow.prompt,
237
+ systemPrompt,
238
+ signal: controller.signal,
239
+ onDispatch,
240
+ });
241
+ span.setAttribute("status", loopResult.result.status);
242
+ recordCounter(LOOP_TERMINALS, { status: loopResult.result.status });
243
+ return loopResult;
244
+ });
245
+ if (result.result.status === 202) {
246
+ // The loop slept via SEND signal 202 — suspended, not terminated. Leave it at 202
247
+ // (resumable); no loop/terminated, no orphan-reconcile. A stream conclusion
248
+ // through handleWakeWorker re-queues it; if it holds a polled stream, a poll timer
249
+ // wakes it every P to inspect ({§exec-poll}). {§worker-lifecycle-wake-liveness}.
250
+ void this.schedulePollWake(workspaceId, workerId, systemPrompt).catch((err) => console.error("poll-wake scheduling failed:", err instanceof Error ? err.message : String(err)));
251
+ // {§send-premature-terminate}/scoped SEND signal 202 — the park deadline:
252
+ // dispatcher recorded the marker's seconds; a bounded park is woken at T
253
+ // regardless of arrivals, so a park always has a next turn. -1 (indefinite:
254
+ // the butler, a [300] ask) schedules nothing — irc/inject/conclusions wake it.
255
+ // In-memory: a daemon restart drops pending deadlines.
256
+ if (currentLoopId !== null) {
257
+ const deadline = this.#takeParkDeadline(currentLoopId);
258
+ const prior = this.#parkTimers.get(workerId);
259
+ if (prior !== undefined) {
260
+ clearTimeout(prior);
261
+ this.#parkTimers.delete(workerId);
262
+ }
263
+ if (deadline !== undefined && deadline > 0) {
264
+ const t = setTimeout(() => {
265
+ this.#parkTimers.delete(workerId);
266
+ void this.#wakeParkedWorker(workspaceId, workerId, systemPrompt).catch((err) => console.error("park-deadline wake failed:", err instanceof Error ? err.message : String(err)));
267
+ }, deadline * 1000);
268
+ t.unref();
269
+ this.#parkTimers.set(workerId, t);
270
+ }
271
+ }
272
+ // Honor an OWED wake ({§worker-lifecycle-child-wake}): a child/stream concluded while
273
+ // this worker was mid-turn, before it slept — resume in place rather than park blind,
274
+ // so a worker hibernation always returns. The loop is 202 here; reset to
275
+ // claimable and the drain re-runs it on the next claim below.
276
+ if (this.#owedWakes.delete(workerId)) {
277
+ await this.#lifecycle.wake(loopRow.id);
278
+ currentLoopId = null;
279
+ continue;
280
+ }
281
+ // The loop is blocked at 202 on a live obligation ({§wait-obligation-matrix});
282
+ // that obligation's conclusion is its wake edge (the owed-wake above covers the
283
+ // conclude-before-block race). An idle wait never reaches here — it concluded at dispatch.
284
+ currentLoopId = null;
285
+ continue;
286
+ }
287
+ this.#owedWakes.delete(workerId); // the loop concluded (non-202) — no park to honor a held wake at
288
+ const [usage, attributions, turnIds] = await Promise.all([
289
+ this.#loopUsage(loopRow.id),
290
+ this.#loopAttributions(loopRow.id),
291
+ this.#lifecycle.turnIds(loopRow.id),
292
+ ]);
293
+ this.#emit(workspaceId, "loop/terminated", {
294
+ workerId,
295
+ loopId: loopRow.id,
296
+ result: result.result,
297
+ hitMaxTurns: result.hitMaxTurns,
298
+ turnIds,
299
+ usage,
300
+ attributions,
301
+ });
302
+ loopsDrained++;
303
+ const loopResult = {
304
+ loopId: loopRow.id,
305
+ turnIds,
306
+ result: result.result,
307
+ hitMaxTurns: result.hitMaxTurns,
308
+ usage,
309
+ attributions,
310
+ };
311
+ lastResult = loopResult;
312
+ if (!firstSettled) {
313
+ firstSettled = true;
314
+ resolveFirst(loopResult);
315
+ }
316
+ // A next-turn prompt this loop ended before consuming (a
317
+ // wake conclusion or a runLoop-while-active prompt) is promoted to
318
+ // a fresh queued loop so it's never silently dropped.
319
+ await this.reconcileOrphanedPrompts(workerId, loopRow.id);
320
+ currentLoopId = null;
321
+ }
322
+ }
323
+ catch (err) {
324
+ if (controller.signal.aborted) {
325
+ // {§methods-loop-cancel} — loop.cancel / shutdown aborted the live drain. A cancellation
326
+ // is the loop's TERMINAL state (499), delivered via loop/terminated (runLoop no
327
+ // longer blocks to return it). A genuine error rejects firstLoopPromise.
328
+ let usage = {
329
+ accounting: aggregateProviderAccounting([]),
330
+ curationWeight: null,
331
+ curationBudget: null,
332
+ contextTokens: null,
333
+ contextCapacity: null,
334
+ meta: {},
335
+ };
336
+ let attributions = [];
337
+ const message = ErrorDetail.preview(controller.signal.reason ?? "user_cancelled")
338
+ || "no reason was supplied";
339
+ if (currentLoopId !== null) {
340
+ // {§methods-loop-cancel}/{§worker-lifecycle-terminal-result} —
341
+ // persist the exact 499 cancellation result before broadcasting it.
342
+ const cancelled = await this.#lifecycle.finish(currentLoopId, Results.failure("lifecycle:cancel", "loop-cancelled", 499, `The loop was cancelled: ${message}.`, {}, {
343
+ reason: message,
344
+ stage: "loop",
345
+ retryable: false,
346
+ }), { terminatedBy: "cancel" });
347
+ [usage, attributions] = await Promise.all([
348
+ this.#loopUsage(currentLoopId),
349
+ this.#loopAttributions(currentLoopId),
350
+ ]);
351
+ if (cancelled !== null) {
352
+ this.#emit(workspaceId, "loop/terminated", {
353
+ workerId,
354
+ loopId: currentLoopId,
355
+ result: cancelled,
356
+ hitMaxTurns: false,
357
+ turnIds: await this.#lifecycle.turnIds(currentLoopId),
358
+ usage,
359
+ attributions,
360
+ });
361
+ }
362
+ }
363
+ if (!firstSettled) {
364
+ firstSettled = true;
365
+ resolveFirst({
366
+ loopId: currentLoopId ?? 0,
367
+ turnIds: [],
368
+ result: currentLoopId === null
369
+ ? Results.failure("lifecycle:cancel", "loop-cancelled", 499, `The loop was cancelled: ${message}.`, {}, {
370
+ reason: message,
371
+ stage: "loop",
372
+ retryable: false,
373
+ })
374
+ : await this.#lifecycle.result(currentLoopId)
375
+ ?? Results.failure("lifecycle:cancel", "loop-cancelled", 499, `The loop was cancelled: ${message}.`, {}, {
376
+ reason: message,
377
+ stage: "loop",
378
+ retryable: false,
379
+ }),
380
+ hitMaxTurns: false,
381
+ usage,
382
+ });
383
+ }
384
+ }
385
+ else {
386
+ // {§worker-lifecycle-terminal-result} — a non-abort drain
387
+ // failure becomes an exact durable 500 and terminal notification;
388
+ // daemon diagnostics retain the complete caught error.
389
+ console.error(`drain error (workspace ${workspaceId}, worker ${workerId}, loop ${currentLoopId ?? "?"}):`, err);
390
+ if (currentLoopId !== null) {
391
+ const failure = err instanceof OperationFailureError
392
+ ? err.result
393
+ : Results.failure("daemon:drain", "loop-threw", 500, "The loop failed outside its operation result contract.", {}, {
394
+ stage: "loop",
395
+ retryable: false,
396
+ });
397
+ const settled = await this.#lifecycle.finish(currentLoopId, failure)
398
+ ?? await this.#lifecycle.result(currentLoopId);
399
+ if (settled === null) {
400
+ throw new Error(`drain could not settle loop ${currentLoopId}`, { cause: err });
401
+ }
402
+ const [usage, attributions] = await Promise.all([
403
+ this.#loopUsage(currentLoopId),
404
+ this.#loopAttributions(currentLoopId),
405
+ ]);
406
+ this.#emit(workspaceId, "loop/terminated", {
407
+ workerId,
408
+ loopId: currentLoopId,
409
+ result: settled,
410
+ hitMaxTurns: false,
411
+ turnIds: await this.#lifecycle.turnIds(currentLoopId),
412
+ usage,
413
+ attributions,
414
+ });
415
+ }
416
+ if (!firstSettled) {
417
+ firstSettled = true;
418
+ rejectFirst(err);
419
+ }
420
+ }
421
+ throw err;
422
+ }
423
+ finally {
424
+ if (!firstSettled) {
425
+ firstSettled = true;
426
+ rejectFirst(new Error("drain exited without producing a result"));
427
+ }
428
+ if (this.#activeDrains.get(workerId) === handle)
429
+ this.#activeDrains.delete(workerId);
430
+ }
431
+ return { loopsDrained, lastResult };
432
+ })();
433
+ handle.promise = drainPromise;
434
+ this.#activeDrains.set(workerId, handle);
435
+ // Topology join ({§worker-loop-lifecycle}): when this drain exits having CONCLUDED the worker, wake its parent
436
+ // if parked. Runs after the drain fully tears down (settled promise) so the quiescence check sees
437
+ // final state; speculative (#onDrainExit no-ops unless the worker concluded AND the parent is parked).
438
+ const drainExitTask = drainPromise.then(() => this.#onDrainExit(workspaceId, workerId, systemPrompt), () => this.#onDrainExit(workspaceId, workerId, systemPrompt));
439
+ this.#drainExitTasks.add(drainExitTask);
440
+ void drainExitTask.catch((err) => {
441
+ console.error(`parent wake after worker ${workerId} settlement failed:`, err);
442
+ }).finally(() => {
443
+ this.#drainExitTasks.delete(drainExitTask);
444
+ });
445
+ // Swallow unhandled rejections (drain aborts with no awaiter); the
446
+ // error already surfaced via firstLoopPromise or was logged inside.
447
+ drainPromise.catch(() => { });
448
+ firstLoopPromise.catch(() => { });
449
+ return { firstLoopPromise, drainPromise };
450
+ }
451
+ // Per-worker drain-transition lock (R4 / {§worker-lifecycle-single-drain}). ensureDrain's
452
+ // start and a drain's teardown relinquish both run under it, serialized, so the two
453
+ // can't interleave and register two drains for one worker. The critical section is the
454
+ // registry decision only (never a loop's work) — a sub-ms hop at drain boundaries.
455
+ // A promise-chain mutex: each caller awaits the prior holder; the tail self-prunes
456
+ // when idle so the Map stays bounded to workers mid-transition.
457
+ #withDrainLock(workerId, fn) {
458
+ const prev = this.#drainLocks.get(workerId) ?? Promise.resolve();
459
+ const run = prev.then(fn, fn);
460
+ const tail = run.catch(() => { });
461
+ this.#drainLocks.set(workerId, tail);
462
+ void tail.then(() => { if (this.#drainLocks.get(workerId) === tail)
463
+ this.#drainLocks.delete(workerId); });
464
+ return run;
465
+ }
466
+ // The drain guarantee, serialized per worker via #withDrainLock so it can't race a
467
+ // sibling drain's teardown relinquish into a double-drain (R4). A live drain
468
+ // (registered, NOT aborting) will claim the just-enqueued loop in its own iteration
469
+ // or its lock-held exit re-claim → return null. A registered-but-ABORTING drain is
470
+ // in teardown and won't claim, so we don't defer to it — start fresh, or the loop
471
+ // strands on a cancel/resume race (I6 no-lost-loop). Otherwise start one.
472
+ ensureDrain(opts) {
473
+ return this.#withDrainLock(opts.workerId, async () => {
474
+ const existing = this.#activeDrains.get(opts.workerId);
475
+ if (existing !== undefined && !existing.controller.signal.aborted)
476
+ return null;
477
+ return this.#startDrain(opts);
478
+ });
479
+ }
480
+ // Prompt promotion shares the worker lock with enqueue and drain teardown,
481
+ // while Daemon retains the durable prompt-policy implementation.
482
+ reconcileOrphanedPrompts(workerId, endedLoopId) {
483
+ return this.#withDrainLock(workerId, () => this.#reconcilePrompts(workerId, endedLoopId));
484
+ }
485
+ #workerSignal(workerId) {
486
+ const existing = this.#workerAborts.get(workerId);
487
+ if (existing !== undefined && !existing.signal.aborted)
488
+ return existing;
489
+ const fresh = new AbortController();
490
+ this.#workerAborts.set(workerId, fresh);
491
+ return fresh;
492
+ }
493
+ async #cancelTree(workerId, reason, includeRoot) {
494
+ const cancelled = await this.#lifecycle.cancelTree(workerId, reason, includeRoot);
495
+ for (const targetWorkerId of cancelled.workerIds) {
496
+ const pollTimer = this.#pollTimers.get(targetWorkerId);
497
+ if (pollTimer !== undefined) {
498
+ clearTimeout(pollTimer);
499
+ this.#pollTimers.delete(targetWorkerId);
500
+ }
501
+ const parkTimer = this.#parkTimers.get(targetWorkerId);
502
+ if (parkTimer !== undefined) {
503
+ clearTimeout(parkTimer);
504
+ this.#parkTimers.delete(targetWorkerId);
505
+ }
506
+ this.#pollBackoff.delete(targetWorkerId);
507
+ this.#owedWakes.delete(targetWorkerId);
508
+ const scope = this.#workerAborts.get(targetWorkerId);
509
+ if (scope !== undefined && !scope.signal.aborted)
510
+ scope.abort(reason);
511
+ }
512
+ await Promise.all(cancelled.workerIds.map(async (targetWorkerId) => this.#reapWorkerStreams(targetWorkerId)));
513
+ for (const { loopId, workerId: targetWorkerId, result } of cancelled.loops) {
514
+ const row = await this.#db.drain_get_worker_workspace.get({
515
+ worker_id: targetWorkerId,
516
+ });
517
+ if (row === undefined)
518
+ continue;
519
+ const [usage, attributions] = await Promise.all([
520
+ this.#loopUsage(loopId),
521
+ this.#loopAttributions(loopId),
522
+ ]);
523
+ this.#emit(row.workspace_id, "loop/terminated", {
524
+ workerId: targetWorkerId,
525
+ loopId,
526
+ result,
527
+ hitMaxTurns: false,
528
+ turnIds: await this.#lifecycle.turnIds(loopId),
529
+ usage,
530
+ attributions,
531
+ });
532
+ }
533
+ }
534
+ cancelWorkerTree(workerId, reason) {
535
+ return this.#cancelTree(workerId, reason, true);
536
+ }
537
+ cancelDescendants(workerId, reason) {
538
+ return this.#cancelTree(workerId, reason, false);
539
+ }
540
+ cancel(workerId, reason = "user_cancelled") {
541
+ const hadDrain = this.#activeDrains.has(workerId);
542
+ const hadWork = hadDrain || this.#hasActiveStreams(workerId);
543
+ const pollTimer = this.#pollTimers.get(workerId);
544
+ if (pollTimer !== undefined) {
545
+ clearTimeout(pollTimer);
546
+ this.#pollTimers.delete(workerId);
547
+ }
548
+ void this.cancelWorkerTree(workerId, reason).catch((error) => {
549
+ console.error(`cancelTree(${workerId}) failed:`, error);
550
+ });
551
+ return hadWork;
552
+ }
553
+ async #reapWorkerStreams(workerId) {
554
+ const open = await ChannelWrite.findOpenSubscriptionsForWorker(this.#db, workerId);
555
+ await Promise.all(open.map(({ id }) => this.#cancelSubscription(id)));
556
+ }
557
+ // {§module-shutdown-order}: the producer emits synchronously, while the
558
+ // supervisor owns the asynchronous scheduler work and its shutdown truth.
559
+ notifyWakeWorker(payload) {
560
+ const task = this.#handleWakeWorker(payload);
561
+ this.#wakeTasks.add(task);
562
+ void task.then(() => { this.#wakeTasks.delete(task); }, (error) => {
563
+ this.#wakeTasks.delete(task);
564
+ this.#wakeFailures.push(error);
565
+ console.error("wake-on-completion failed:", error);
566
+ });
567
+ }
568
+ async #handleWakeWorker(payload) {
569
+ const { entryOwnerId, ...wake } = payload;
570
+ const conclusion = { ...wake, workerId: entryOwnerId };
571
+ // {§search-gate} — settle the dedup registration: promote on a 200 conclusion, drop on
572
+ // failure (a dead search must never serve as a duplicate). No-op for non-search streams.
573
+ this.#settleSearch(payload.target.replace(/^[a-z+.-]+:\/\//, "/").replace(/^\/+/, "/"), payload.result.status);
574
+ // Aborted streams don't wake — the abort was deliberate.
575
+ if (payload.result.status === 499) {
576
+ this.#emit(payload.workspaceId, "stream/concluded", {
577
+ ...conclusion, wakeAction: "skipped-aborted",
578
+ });
579
+ return;
580
+ }
581
+ // No resurrection ({§worker-lifecycle-no-resurrection}): a non-499 completion whose
582
+ // worker was cancelled (idle + its scope aborted) must not start a fresh drain —
583
+ // the cancel was deliberate. The deliverable is already in the channel/log and
584
+ // surfaces as a `collect` environment delta ({§env-delta}) if the worker is read or
585
+ // resumed; we just don't inject a turn. (An active worker folds the wake into its
586
+ // next turn via inject below; a resumed worker is active, never aborted, so it is
587
+ // unaffected.)
588
+ const scope = this.#workerAborts.get(payload.workerId);
589
+ if (scope?.signal.aborted === true && !this.#activeDrains.has(payload.workerId)) {
590
+ this.#emit(payload.workspaceId, "stream/concluded", {
591
+ ...conclusion, wakeAction: "skipped-cancelled",
592
+ });
593
+ return;
594
+ }
595
+ const systemPrompt = await this.#readSystemPrompt();
596
+ // A slept (202) loop means the worker parked via SEND signal 202 → resume it in place: re-queue
597
+ // it (202→100) so the drain re-claims and CONTINUES it (seq>1 → no re-foist). Checked
598
+ // FIRST: the slept status is the worker's true disposition regardless of a draining
599
+ // sibling mid-teardown (the ensureDrain lock serializes the re-claim). No fresh loop,
600
+ // no summary-as-prompt — the resumed loop reads the concluded stream's own state from
601
+ // the manifest. {§worker-lifecycle-wake-liveness}.
602
+ const slept = await this.#db.drain_find_slept_loop.get({ worker_id: payload.workerId });
603
+ if (slept !== undefined) {
604
+ // {§worker-optimistic-settlement} — publish this conclusion now,
605
+ // then let the worker-local gate coalesce only the provider
606
+ // dispatch. Concurrent stream/child callbacks join that one gate.
607
+ const settlement = this.settleCompletionWake(payload.workspaceId, payload.workerId, systemPrompt, false);
608
+ this.#emit(payload.workspaceId, "stream/concluded", {
609
+ ...conclusion, wakeAction: "resumed-loop", wakeLoopId: slept.id,
610
+ });
611
+ await settlement;
612
+ return;
613
+ }
614
+ // No slept loop. A live loop surfaces the concluded stream ambiently via the
615
+ // environment-observation injector ({§exec-stream}) on its next turn — there is no prompt
616
+ // to inject and NO task to overwrite. The obsolete "automated environment update"
617
+ // synthesis (which clobbered the model's actual goal) is retired; just tell the client.
618
+ if (this.#activeDrains.has(payload.workerId)) {
619
+ this.#emit(payload.workspaceId, "stream/concluded", {
620
+ ...conclusion, wakeAction: "no-op-active-loop",
621
+ });
622
+ return;
623
+ }
624
+ // No slept loop, no active drain — nothing to resume (e.g. a SEND-200-done worker whose
625
+ // streams were swept). Surface the conclusion without opening a loop.
626
+ this.#emit(payload.workspaceId, "stream/concluded", {
627
+ ...conclusion, wakeAction: "no-loop",
628
+ });
629
+ }
630
+ async schedulePollWake(workspaceId, workerId, systemPrompt) {
631
+ const existing = this.#pollTimers.get(workerId);
632
+ if (existing !== undefined) {
633
+ clearTimeout(existing);
634
+ this.#pollTimers.delete(workerId);
635
+ }
636
+ const row = await this.#db.drain_worker_min_poll.get({ worker_id: workerId });
637
+ if ((row?.open_count ?? 0) === 0) {
638
+ this.#pollBackoff.delete(workerId);
639
+ return;
640
+ }
641
+ const pollSec = row?.poll_seconds ?? null;
642
+ // {§exec-poll} — a positive explicit cadence wins, zero opts out,
643
+ // and an absent cadence uses the worker's exponential-backoff step.
644
+ let delayMs;
645
+ if (pollSec !== null && pollSec > 0) {
646
+ this.#pollBackoff.delete(workerId);
647
+ delayMs = pollSec * 1000;
648
+ }
649
+ else if (pollSec === 0) {
650
+ this.#pollBackoff.delete(workerId);
651
+ return; // explicit opt-out
652
+ }
653
+ else {
654
+ // An open stream without an explicit cadence uses the stream polling floor.
655
+ // Child joins never enter this branch: durable child settlement is their only wake edge.
656
+ const base = Number(process.env.PLURNK_SERVICE_EXEC_POLL_SEC ?? "60");
657
+ const turns = Number(process.env.PLURNK_SERVICE_EXEC_POLL_TURNS ?? "8");
658
+ const step = this.#pollBackoff.get(workerId) ?? 0;
659
+ delayMs = execPollBackoffMs(step, base, turns);
660
+ this.#pollBackoff.set(workerId, step + 1);
661
+ }
662
+ // Floored by the optimistic settlement cap so a `<…,1>` cannot wake a
663
+ // parked loop faster than the preceding turn's settlement scale.
664
+ const optimisticWaitMs = readOptimisticSettlementMs();
665
+ const timer = setTimeout(() => {
666
+ this.#pollTimers.delete(workerId);
667
+ void this.#wakeParkedWorker(workspaceId, workerId, systemPrompt);
668
+ }, Math.max(delayMs, optimisticWaitMs));
669
+ timer.unref();
670
+ this.#pollTimers.set(workerId, timer);
671
+ }
672
+ /** Resume `workerId`'s slept (202) loop in place — the same 202→100 resume handleWakeWorker uses, minus a
673
+ * wake payload. The shared wake primitive: a poll cadence ({§exec-poll}), a watched stream concluding,
674
+ * or a child worker finishing ({§worker-loop-lifecycle} topology join) all call this. A no-op if the worker was
675
+ * cancelled or isn't actually parked (no slept loop) — so calling it speculatively is safe. */
676
+ async #wakeParkedWorker(workspaceId, workerId, systemPrompt, oweIfActive = true) {
677
+ if (!this.#acceptingWork)
678
+ return;
679
+ const scope = this.#workerAborts.get(workerId);
680
+ if (scope?.signal.aborted === true && !this.#activeDrains.has(workerId))
681
+ return; // cancelled — no resurrection
682
+ const slept = await this.#db.drain_find_slept_loop.get({ worker_id: workerId });
683
+ if (slept === undefined) {
684
+ // Not parked. If a drain is still ACTIVE, the worker is mid-turn and about to park — the
685
+ // conclusion that fired this wake arrived before the 202 committed (the conclude-before-park
686
+ // race). Owe the wake: the drain honors it at park so a worker hibernation never deadlocks.
687
+ // (No active drain → already concluded/running; nothing to wake.)
688
+ if (this.#activeDrains.has(workerId)) {
689
+ if (oweIfActive)
690
+ this.#owedWakes.add(workerId);
691
+ }
692
+ return;
693
+ }
694
+ const woke = await this.#lifecycle.wake(slept.id);
695
+ if (!woke)
696
+ return;
697
+ const started = await this.ensureDrain({
698
+ workspaceId, workerId, systemPrompt,
699
+ });
700
+ started?.drainPromise?.catch((err) => {
701
+ if (this.#acceptingWork) {
702
+ console.error("wake-parked resume drain failed:", err instanceof Error ? err.message : String(err));
703
+ }
704
+ });
705
+ }
706
+ async #workerHasLiveObligation(workerId) {
707
+ const [openSubscriptions, liveChild] = await Promise.all([
708
+ this.#db.find_open_subscriptions_for_worker.all({ worker_id: workerId }),
709
+ this.#db.engine_worker_has_live_child.get({ worker_id: workerId }),
710
+ ]);
711
+ return openSubscriptions.length > 0 || liveChild !== undefined;
712
+ }
713
+ settleCompletionWake(workspaceId, workerId, systemPrompt, oweIfActive = true) {
714
+ const existing = this.#completionWakeGates.get(workerId);
715
+ if (existing !== undefined) {
716
+ existing.conclusions++;
717
+ existing.poke.resolve();
718
+ return existing.promise;
719
+ }
720
+ const completed = Promise.withResolvers();
721
+ const gate = {
722
+ conclusions: 1,
723
+ poke: Promise.withResolvers(),
724
+ promise: completed.promise,
725
+ };
726
+ this.#completionWakeGates.set(workerId, gate);
727
+ void this.#runCompletionWake(workspaceId, workerId, systemPrompt, oweIfActive, gate).then(completed.resolve, completed.reject).finally(() => {
728
+ if (this.#completionWakeGates.get(workerId) === gate) {
729
+ this.#completionWakeGates.delete(workerId);
730
+ }
731
+ });
732
+ return gate.promise;
733
+ }
734
+ async #runCompletionWake(workspaceId, workerId, systemPrompt, oweIfActive, gate) {
735
+ const slept = await this.#db.drain_find_slept_loop.get({ worker_id: workerId });
736
+ if (slept === undefined) {
737
+ this.#releaseCompletionWake(workerId, gate);
738
+ return this.#wakeParkedWorker(workspaceId, workerId, systemPrompt, oweIfActive);
739
+ }
740
+ const timeoutMs = readOptimisticSettlementMs();
741
+ if (timeoutMs === 0 || !(await this.#workerHasLiveObligation(workerId))) {
742
+ this.#releaseCompletionWake(workerId, gate);
743
+ return this.#wakeParkedWorker(workspaceId, workerId, systemPrompt, false);
744
+ }
745
+ return observed("worker.wake.settlement", { "window.ms": timeoutMs }, async (span) => {
746
+ const startedAt = performance.now();
747
+ const signal = this.#workerAborts.get(workerId)?.signal;
748
+ const deadline = delay(timeoutMs, "deadline", { signal, ref: false })
749
+ .catch((cause) => {
750
+ if (signal?.aborted === true)
751
+ return "cancelled";
752
+ throw cause;
753
+ });
754
+ let release = "quiescent";
755
+ while (await this.#workerHasLiveObligation(workerId)) {
756
+ const poke = gate.poke;
757
+ const outcome = await Promise.race([
758
+ poke.promise.then(() => "arrival"),
759
+ deadline,
760
+ ]);
761
+ if (outcome === "arrival") {
762
+ if (gate.poke === poke)
763
+ gate.poke = Promise.withResolvers();
764
+ continue;
765
+ }
766
+ release = outcome;
767
+ break;
768
+ }
769
+ span.setAttribute("release", release);
770
+ span.setAttribute("conclusions", gate.conclusions);
771
+ span.setAttribute("elapsed.ms", Math.round(performance.now() - startedAt));
772
+ this.#releaseCompletionWake(workerId, gate);
773
+ if (release === "cancelled")
774
+ return;
775
+ return this.#wakeParkedWorker(workspaceId, workerId, systemPrompt, false);
776
+ });
777
+ }
778
+ #releaseCompletionWake(workerId, gate) {
779
+ if (this.#completionWakeGates.get(workerId) === gate) {
780
+ this.#completionWakeGates.delete(workerId);
781
+ }
782
+ }
783
+ /** A worker's drain exited. If the worker truly CONCLUDED — no 202-blocked loop, no open stream — then
784
+ * wake its PARENT in place if the parent is blocked on the join (the structured-concurrency join — a
785
+ * child finishing is the wake edge for a parent that waited on it, {§worker-lifecycle-child-wake}). A worker
786
+ * blocked at 202, or still holding a stream, is NOT concluded — its own wake edges drive it, not this.
787
+ * The parent reads the child's deliverable from its own log (the {§worker-scheme-collect} delta) on
788
+ * resume — control edge here, never an injected prompt. Recurses up via the parent's own drain-exit. */
789
+ async #onDrainExit(workspaceId, workerId, systemPrompt) {
790
+ const slept = await this.#db.drain_find_slept_loop.get({ worker_id: workerId });
791
+ if (slept !== undefined)
792
+ return; // parked at 202 — not concluded, the worker is still alive
793
+ const openSubs = await this.#db.find_open_subscriptions_for_worker.all({ worker_id: workerId });
794
+ if (openSubs.length > 0)
795
+ return; // a stream still runs — its conclusion re-evaluates, not this exit
796
+ const parent = await this.#db.worker_parent_id.get({ worker_id: workerId });
797
+ if (parent?.parent_worker_id == null)
798
+ return; // a root worker — nobody to wake
799
+ await this.settleCompletionWake(workspaceId, parent.parent_worker_id, systemPrompt);
800
+ }
801
+ }
802
+ //# sourceMappingURL=DrainSupervisor.js.map