@plurnk/plurnk-service 1.4.0 → 1.5.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 (227) hide show
  1. package/.env.defaults +20 -23
  2. package/INSTALL.md +6 -5
  3. package/README.md +1 -0
  4. package/SPEC.md +250 -149
  5. package/dist/Paths.d.ts.map +1 -1
  6. package/dist/Paths.js +2 -3
  7. package/dist/Paths.js.map +1 -1
  8. package/dist/build-info.json +1 -1
  9. package/dist/content/index.d.ts +1 -0
  10. package/dist/content/index.d.ts.map +1 -1
  11. package/dist/content/index.js +1 -0
  12. package/dist/content/index.js.map +1 -1
  13. package/dist/content/line-marker.d.ts +7 -3
  14. package/dist/content/line-marker.d.ts.map +1 -1
  15. package/dist/content/line-marker.js +2 -1
  16. package/dist/content/line-marker.js.map +1 -1
  17. package/dist/content/matcher.d.ts +1 -1
  18. package/dist/content/matcher.d.ts.map +1 -1
  19. package/dist/content/matcher.js +2 -2
  20. package/dist/content/matcher.js.map +1 -1
  21. package/dist/content/read-projector.d.ts +14 -0
  22. package/dist/content/read-projector.d.ts.map +1 -0
  23. package/dist/content/read-projector.js +86 -0
  24. package/dist/content/read-projector.js.map +1 -0
  25. package/dist/content/read-resolve.d.ts +2 -7
  26. package/dist/content/read-resolve.d.ts.map +1 -1
  27. package/dist/content/read-resolve.js +32 -107
  28. package/dist/content/read-resolve.js.map +1 -1
  29. package/dist/core/BranchReceipt.d.ts +0 -1
  30. package/dist/core/BranchReceipt.d.ts.map +1 -1
  31. package/dist/core/BranchReceipt.js +0 -3
  32. package/dist/core/BranchReceipt.js.map +1 -1
  33. package/dist/core/BudgetOverflow.d.ts +0 -3
  34. package/dist/core/BudgetOverflow.d.ts.map +1 -1
  35. package/dist/core/BudgetOverflow.js +3 -15
  36. package/dist/core/BudgetOverflow.js.map +1 -1
  37. package/dist/core/ChannelWrite.d.ts +3 -0
  38. package/dist/core/ChannelWrite.d.ts.map +1 -1
  39. package/dist/core/ChannelWrite.js +2 -2
  40. package/dist/core/ChannelWrite.js.map +1 -1
  41. package/dist/core/ChannelWrite.sql +4 -3
  42. package/dist/core/CoreSchemeServices.d.ts +10 -2
  43. package/dist/core/CoreSchemeServices.d.ts.map +1 -1
  44. package/dist/core/CoreSchemeServices.js.map +1 -1
  45. package/dist/core/Dispatcher.d.ts +3 -7
  46. package/dist/core/Dispatcher.d.ts.map +1 -1
  47. package/dist/core/Dispatcher.js +283 -217
  48. package/dist/core/Dispatcher.js.map +1 -1
  49. package/dist/core/Engine.d.ts +19 -12
  50. package/dist/core/Engine.d.ts.map +1 -1
  51. package/dist/core/Engine.js +261 -167
  52. package/dist/core/Engine.js.map +1 -1
  53. package/dist/core/Engine.sql +66 -63
  54. package/dist/core/LogBody.d.ts +2 -1
  55. package/dist/core/LogBody.d.ts.map +1 -1
  56. package/dist/core/LogBody.js +33 -1
  57. package/dist/core/LogBody.js.map +1 -1
  58. package/dist/core/LoopLifecycle.d.ts +1 -2
  59. package/dist/core/LoopLifecycle.d.ts.map +1 -1
  60. package/dist/core/LoopLifecycle.js +1 -4
  61. package/dist/core/LoopLifecycle.js.map +1 -1
  62. package/dist/core/LoopLifecycle.sql +1 -4
  63. package/dist/core/PacketBuilder.d.ts +3 -2
  64. package/dist/core/PacketBuilder.d.ts.map +1 -1
  65. package/dist/core/PacketBuilder.js +16 -16
  66. package/dist/core/PacketBuilder.js.map +1 -1
  67. package/dist/core/StrikeRail.d.ts +5 -1
  68. package/dist/core/StrikeRail.d.ts.map +1 -1
  69. package/dist/core/StrikeRail.js +4 -3
  70. package/dist/core/StrikeRail.js.map +1 -1
  71. package/dist/core/TerminalResult.d.ts +16 -0
  72. package/dist/core/TerminalResult.d.ts.map +1 -0
  73. package/dist/core/TerminalResult.js +49 -0
  74. package/dist/core/TerminalResult.js.map +1 -0
  75. package/dist/core/caps/CapsResolve.d.ts +5 -1
  76. package/dist/core/caps/CapsResolve.d.ts.map +1 -1
  77. package/dist/core/caps/CapsResolve.js +8 -4
  78. package/dist/core/caps/CapsResolve.js.map +1 -1
  79. package/dist/core/caps/DbChannelCaps.d.ts +1 -1
  80. package/dist/core/caps/DbChannelCaps.d.ts.map +1 -1
  81. package/dist/core/caps/DbChannelCaps.js +6 -4
  82. package/dist/core/caps/DbChannelCaps.js.map +1 -1
  83. package/dist/core/caps/DbEntryCaps.d.ts +1 -1
  84. package/dist/core/caps/DbEntryCaps.d.ts.map +1 -1
  85. package/dist/core/caps/DbEntryCaps.js +14 -4
  86. package/dist/core/caps/DbEntryCaps.js.map +1 -1
  87. package/dist/core/caps/DbNotifyCaps.d.ts +1 -1
  88. package/dist/core/caps/DbNotifyCaps.d.ts.map +1 -1
  89. package/dist/core/caps/DbNotifyCaps.js +6 -4
  90. package/dist/core/caps/DbNotifyCaps.js.map +1 -1
  91. package/dist/core/caps/DbSubscriptionCaps.d.ts +4 -6
  92. package/dist/core/caps/DbSubscriptionCaps.d.ts.map +1 -1
  93. package/dist/core/caps/DbSubscriptionCaps.js +27 -13
  94. package/dist/core/caps/DbSubscriptionCaps.js.map +1 -1
  95. package/dist/core/caps/DbTagCaps.d.ts +1 -1
  96. package/dist/core/caps/DbTagCaps.d.ts.map +1 -1
  97. package/dist/core/caps/DbTagCaps.js +6 -4
  98. package/dist/core/caps/DbTagCaps.js.map +1 -1
  99. package/dist/core/caps/SchemeCtxImpl.d.ts +6 -1
  100. package/dist/core/caps/SchemeCtxImpl.d.ts.map +1 -1
  101. package/dist/core/caps/SchemeCtxImpl.js +6 -6
  102. package/dist/core/caps/SchemeCtxImpl.js.map +1 -1
  103. package/dist/core/fork.sql +2 -2
  104. package/dist/core/git-iso.d.ts +3 -2
  105. package/dist/core/git-iso.d.ts.map +1 -1
  106. package/dist/core/git-iso.js +27 -12
  107. package/dist/core/git-iso.js.map +1 -1
  108. package/dist/core/git-membership.d.ts +1 -0
  109. package/dist/core/git-membership.d.ts.map +1 -1
  110. package/dist/core/git-membership.js +86 -24
  111. package/dist/core/git-membership.js.map +1 -1
  112. package/dist/core/git-state.d.ts +8 -1
  113. package/dist/core/git-state.d.ts.map +1 -1
  114. package/dist/core/git-state.js +35 -16
  115. package/dist/core/git-state.js.map +1 -1
  116. package/dist/core/namespace.d.ts +1 -0
  117. package/dist/core/namespace.d.ts.map +1 -1
  118. package/dist/core/namespace.js +20 -0
  119. package/dist/core/namespace.js.map +1 -1
  120. package/dist/core/optimistic-settlement.d.ts +2 -0
  121. package/dist/core/optimistic-settlement.d.ts.map +1 -0
  122. package/dist/core/optimistic-settlement.js +14 -0
  123. package/dist/core/optimistic-settlement.js.map +1 -0
  124. package/dist/core/packet-wire.d.ts +1 -1
  125. package/dist/core/packet-wire.d.ts.map +1 -1
  126. package/dist/core/packet-wire.js +81 -35
  127. package/dist/core/packet-wire.js.map +1 -1
  128. package/dist/core/results.d.ts +4 -2
  129. package/dist/core/results.d.ts.map +1 -1
  130. package/dist/core/results.js +6 -0
  131. package/dist/core/results.js.map +1 -1
  132. package/dist/core/scheme-types.d.ts +0 -12
  133. package/dist/core/scheme-types.d.ts.map +1 -1
  134. package/dist/core/scheme-types.js.map +1 -1
  135. package/dist/core/worker-ops.sql +4 -4
  136. package/dist/core/workspace-settings.js +1 -1
  137. package/dist/core/workspace-settings.js.map +1 -1
  138. package/dist/digest/Digest.d.ts.map +1 -1
  139. package/dist/digest/Digest.js +236 -74
  140. package/dist/digest/Digest.js.map +1 -1
  141. package/dist/digest/digest.sql +9 -5
  142. package/dist/schemes/Exec.d.ts +4 -4
  143. package/dist/schemes/Exec.d.ts.map +1 -1
  144. package/dist/schemes/Exec.js +56 -33
  145. package/dist/schemes/Exec.js.map +1 -1
  146. package/dist/schemes/ExecOutputScheme.d.ts +4 -5
  147. package/dist/schemes/ExecOutputScheme.d.ts.map +1 -1
  148. package/dist/schemes/ExecOutputScheme.js +12 -20
  149. package/dist/schemes/ExecOutputScheme.js.map +1 -1
  150. package/dist/schemes/File.d.ts +1 -3
  151. package/dist/schemes/File.d.ts.map +1 -1
  152. package/dist/schemes/File.js +11 -19
  153. package/dist/schemes/File.js.map +1 -1
  154. package/dist/schemes/Log.d.ts +5 -5
  155. package/dist/schemes/Log.d.ts.map +1 -1
  156. package/dist/schemes/Log.js +74 -172
  157. package/dist/schemes/Log.js.map +1 -1
  158. package/dist/schemes/Log.sql +20 -13
  159. package/dist/schemes/Prompt.d.ts +2 -3
  160. package/dist/schemes/Prompt.d.ts.map +1 -1
  161. package/dist/schemes/Prompt.js +1 -4
  162. package/dist/schemes/Prompt.js.map +1 -1
  163. package/dist/schemes/Skill.d.ts +2 -3
  164. package/dist/schemes/Skill.d.ts.map +1 -1
  165. package/dist/schemes/Skill.js +0 -3
  166. package/dist/schemes/Skill.js.map +1 -1
  167. package/dist/schemes/Worker.d.ts +5 -5
  168. package/dist/schemes/Worker.d.ts.map +1 -1
  169. package/dist/schemes/Worker.js +92 -81
  170. package/dist/schemes/Worker.js.map +1 -1
  171. package/dist/schemes/_entry-crud.d.ts +2 -1
  172. package/dist/schemes/_entry-crud.d.ts.map +1 -1
  173. package/dist/schemes/_entry-crud.js +14 -1
  174. package/dist/schemes/_entry-crud.js.map +1 -1
  175. package/dist/schemes/_entry-crud.sql +9 -3
  176. package/dist/schemes/_entry-find.d.ts +37 -11
  177. package/dist/schemes/_entry-find.d.ts.map +1 -1
  178. package/dist/schemes/_entry-find.js +141 -153
  179. package/dist/schemes/_entry-find.js.map +1 -1
  180. package/dist/schemes/_entry-ops.d.ts +9 -5
  181. package/dist/schemes/_entry-ops.d.ts.map +1 -1
  182. package/dist/schemes/_entry-ops.js +12 -73
  183. package/dist/schemes/_entry-ops.js.map +1 -1
  184. package/dist/schemes/_entry-ops.sql +1 -2
  185. package/dist/schemes/_entry-semantic.d.ts +5 -7
  186. package/dist/schemes/_entry-semantic.d.ts.map +1 -1
  187. package/dist/schemes/_entry-semantic.js +10 -31
  188. package/dist/schemes/_entry-semantic.js.map +1 -1
  189. package/dist/schemes/_entry-semantic.sql +2 -2
  190. package/dist/schemes/_search-index.d.ts.map +1 -1
  191. package/dist/schemes/_search-index.js +6 -4
  192. package/dist/schemes/_search-index.js.map +1 -1
  193. package/dist/server/BranchBatches.d.ts +1 -0
  194. package/dist/server/BranchBatches.d.ts.map +1 -1
  195. package/dist/server/BranchBatches.js +1 -0
  196. package/dist/server/BranchBatches.js.map +1 -1
  197. package/dist/server/Daemon.d.ts +5 -7
  198. package/dist/server/Daemon.d.ts.map +1 -1
  199. package/dist/server/Daemon.js +222 -54
  200. package/dist/server/Daemon.js.map +1 -1
  201. package/dist/server/DaemonModule.d.ts +3 -3
  202. package/dist/server/DaemonModule.d.ts.map +1 -1
  203. package/dist/server/branch-batch.sql +3 -1
  204. package/dist/server/client-input.d.ts +2 -1
  205. package/dist/server/client-input.d.ts.map +1 -1
  206. package/dist/server/client-input.js +7 -1
  207. package/dist/server/client-input.js.map +1 -1
  208. package/dist/server/drain.sql +6 -5
  209. package/dist/server/envelope.d.ts +2 -2
  210. package/dist/server/envelope.d.ts.map +1 -1
  211. package/dist/server/envelope.js +0 -1
  212. package/dist/server/envelope.js.map +1 -1
  213. package/dist/server/envelope.sql +1 -2
  214. package/dist/server/lifecycle-recovery.sql +34 -1
  215. package/dist/server/logEntry.d.ts +1 -1
  216. package/dist/server/logEntry.d.ts.map +1 -1
  217. package/dist/server/logEntry.js +2 -1
  218. package/dist/server/logEntry.js.map +1 -1
  219. package/dist/service.d.ts.map +1 -1
  220. package/dist/service.js +13 -1
  221. package/dist/service.js.map +1 -1
  222. package/migrations/001_schema.sql +238 -125
  223. package/package.json +48 -46
  224. package/dist/matrix/live-specimen.d.ts +0 -9
  225. package/dist/matrix/live-specimen.d.ts.map +0 -1
  226. package/dist/matrix/live-specimen.js +0 -37
  227. package/dist/matrix/live-specimen.js.map +0 -1
@@ -2,6 +2,7 @@
2
2
  // the transport-free plugin-module seam ({§rpc}).
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { resolve, dirname } from "node:path";
5
+ import { setTimeout as delay } from "node:timers/promises";
5
6
  import { execPollBackoffMs } from "./exec-poll-backoff.js";
6
7
  import ChannelWrite from "../core/ChannelWrite.js";
7
8
  import { Paths } from "../index.js";
@@ -32,6 +33,7 @@ import BranchBatches from "./BranchBatches.js";
32
33
  import ErrorDetail from "../core/ErrorDetail.js";
33
34
  import { observed, observedSync } from "../observe/spans.js";
34
35
  import { LOOP_TERMINALS, recordCounter } from "../observe/metrics.js";
36
+ import { readOptimisticSettlementMs } from "../core/optimistic-settlement.js";
35
37
  const clientActionFailure = (error) => {
36
38
  if (error instanceof OperationFailureError)
37
39
  return error.result;
@@ -72,6 +74,7 @@ export default class Daemon {
72
74
  // (subscriptions + Exec.idle), and a concluding stream routes through
73
75
  // inject() like any other loop source.
74
76
  #activeDrains = new Map();
77
+ #drainExitTasks = new Set();
75
78
  // Per-worker cancellation scope. Loops AND the streams they spawn (execs)
76
79
  // share this signal, so loop.cancel / shutdown abort it once and every
77
80
  // in-flight subscription tears down — even a spawn that registers AFTER the
@@ -93,6 +96,10 @@ export default class Daemon {
93
96
  // never deadlock. The drain honors the owed wake at the worker's next park, closing the conclude-
94
97
  // before-park race. (Only a live exec stream, unbounded absent a timeout, may hold a park open.)
95
98
  #owedWakes = new Set();
99
+ // {§worker-optimistic-settlement} — one non-sliding completion-wake batch
100
+ // per worker. Durable conclusions and client events land before this gate;
101
+ // only the parked loop's provider-dispatch requeue waits.
102
+ #completionWakeGates = new Map();
96
103
  constructor({ db, schemes, mimetypes, provider, nodeModulesPath, }) {
97
104
  this.#db = db;
98
105
  this.#lifecycle = new LoopLifecycle(db);
@@ -126,10 +133,9 @@ export default class Daemon {
126
133
  });
127
134
  this.#branchBatches = new BranchBatches(db, this.#workspaceGate, {
128
135
  settleWorkspace: async (workspaceId) => this.#engine.drainWorkspaceDerivations(workspaceId),
129
- createChild: async ({ workspaceId, parentWorkerId, op, name, prompt, flags, origin }) => {
130
- const providerSpec = resolveActiveAlias();
131
- if (providerSpec === null)
132
- throw new Error("Branch worker: active provider has no resolvable alias");
136
+ createChild: async ({ workspaceId, parentWorkerId, parentLoopId, op, name, prompt, flags, origin }) => {
137
+ const parentPolicy = await this.#providerPolicyForLoop(parentLoopId);
138
+ const providerSpec = parentPolicy.childProviderSpec ?? parentPolicy.providerSpec;
133
139
  const workerName = WorkerName.assert(name);
134
140
  const workerId = op === "FORK"
135
141
  ? await Fork.fork(this.#db, parentWorkerId, workerName)
@@ -145,6 +151,7 @@ export default class Daemon {
145
151
  workerId,
146
152
  prompt,
147
153
  providerSpec,
154
+ childProviderSpec: parentPolicy.childProviderSpec,
148
155
  flags,
149
156
  });
150
157
  return { workerId, loopId };
@@ -162,7 +169,7 @@ export default class Daemon {
162
169
  },
163
170
  wakeParent: async (workspaceId, workerId) => {
164
171
  const systemPrompt = await readFile(Paths.instructionsSystem, "utf8");
165
- await this.#wakeParkedWorker(workspaceId, workerId, systemPrompt, false);
172
+ await this.#settleCompletionWake(workspaceId, workerId, systemPrompt, false);
166
173
  },
167
174
  notify: (workspaceId, payload) => {
168
175
  this.#broadcast({ workspaceId }, "workspace/branch-batch", payload);
@@ -182,14 +189,25 @@ export default class Daemon {
182
189
  // daemon owns provider + the law-file system prompt; the worker scheme
183
190
  // handler carries neither. Fire-and-forget: the returned drain runs
184
191
  // independently (the sister is its own worker). {§machine-processes}
185
- injectWorker: async ({ workspaceId, workerId, prompt, flags }) => {
186
- if (this.#provider === null)
187
- throw new Error("injectWorker: no provider configured");
192
+ injectWorker: async ({ workspaceId, workerId, prompt, flags, parentLoopId }) => {
188
193
  const systemPrompt = await readFile(Paths.instructionsSystem, "utf8");
189
- const providerSpec = resolveActiveAlias();
194
+ const parentPolicy = parentLoopId === undefined
195
+ ? null
196
+ : await this.#providerPolicyForLoop(parentLoopId);
197
+ const providerSpec = parentPolicy === null
198
+ ? resolveActiveAlias()
199
+ : parentPolicy.childProviderSpec ?? parentPolicy.providerSpec;
190
200
  if (providerSpec === null)
191
201
  throw new Error("injectWorker: active provider has no resolvable alias");
192
- const { action, loopId } = await this.inject({ workspaceId, workerId, prompt, providerSpec, systemPrompt, ...(flags === undefined ? {} : { flags }) });
202
+ const { action, loopId } = await this.inject({
203
+ workspaceId,
204
+ workerId,
205
+ prompt,
206
+ providerSpec,
207
+ ...(parentPolicy === null ? {} : { childProviderSpec: parentPolicy.childProviderSpec }),
208
+ systemPrompt,
209
+ ...(flags === undefined ? {} : { flags }),
210
+ });
193
211
  return { action, loopId };
194
212
  },
195
213
  branchWorker: async (args) => this.#branchBatches.enqueue(args),
@@ -243,6 +261,11 @@ export default class Daemon {
243
261
  const openPaths = ClientInput.assertOpenPaths("runLoop", args.openPaths);
244
262
  const alias = ClientInput.assertOptionalSelector("runLoop", "alias", args.alias);
245
263
  const model = ClientInput.assertOptionalSelector("runLoop", "model", args.model);
264
+ const childAlias = ClientInput.assertOptionalChildAlias("runLoop", args.childAlias);
265
+ const childModel = ClientInput.assertOptionalSelector("runLoop", "childModel", args.childModel);
266
+ if (childAlias === null && childModel !== undefined) {
267
+ throw daemonFailure("daemon:input", "child-provider-conflict", 400, "childModel cannot accompany an inherited child provider policy.", { field: "childModel", recovery: "Omit childModel or select a child alias.", retryable: false });
268
+ }
246
269
  const flags = ClientInput.normalizeLoopFlags("runLoop", args.flags);
247
270
  // {§methods-loop-run-model} — a client sends alias/model on every loop, so a
248
271
  // switch takes effect turn-to-turn. `model` (client-resolved <provider>/<model>) wins
@@ -256,6 +279,14 @@ export default class Daemon {
256
279
  retryable: false,
257
280
  }));
258
281
  }
282
+ // {§methods-loop-run-child-provider}
283
+ const configuredChildAlias = childAlias === undefined && childModel === undefined
284
+ ? process.env.PLURNK_MODEL_CHILD
285
+ : childAlias;
286
+ const childSelection = configuredChildAlias === null
287
+ || (configuredChildAlias === undefined && childModel === undefined)
288
+ ? null
289
+ : await this.#resolveLoopProvider(configuredChildAlias, childModel);
259
290
  const systemPrompt = await readFile(Paths.instructionsSystem, "utf8");
260
291
  // {§machine-processes} — the model NEVER runs in a client-origin worker (its packets would carry
261
292
  // client-action rows). The module resolves the model worker via ensureModelWorker and passes it (or a
@@ -296,6 +327,7 @@ export default class Daemon {
296
327
  ...(openPaths !== undefined ? { openPaths } : {}),
297
328
  turnCeiling,
298
329
  providerSpec: selection,
330
+ childProviderSpec: childSelection,
299
331
  systemPrompt,
300
332
  });
301
333
  return { status: 100, action, loopId, ...(turnSeq !== undefined ? { turnSeq } : {}) };
@@ -332,26 +364,40 @@ export default class Daemon {
332
364
  }
333
365
  return spec;
334
366
  }
335
- async #providerSpecForLoop(loopId) {
336
- const row = await this.#db.drain_loop_provider_spec.get({ loop_id: loopId });
337
- if (row === undefined)
338
- throw new Error(`loop ${loopId}: provider selection row is missing`);
367
+ #parseProviderSpec(loopId, field, encoded) {
339
368
  let parsed;
340
369
  try {
341
- parsed = JSON.parse(row.provider_spec);
370
+ parsed = JSON.parse(encoded);
342
371
  }
343
372
  catch {
344
- throw new Error(`loop ${loopId}: persisted provider selection is malformed — refusing boot-default substitution`);
373
+ throw new Error(`loop ${loopId}: persisted ${field} is malformed`);
345
374
  }
346
- if (parsed === null
347
- || typeof parsed.alias !== "string" || parsed.alias.length === 0
375
+ if (parsed === null)
376
+ return null;
377
+ if (typeof parsed.alias !== "string" || parsed.alias.length === 0
348
378
  || typeof parsed.provider !== "string" || parsed.provider.length === 0
349
379
  || typeof parsed.model !== "string" || parsed.model.length === 0
350
380
  || (parsed.baseUrl !== undefined && typeof parsed.baseUrl !== "string")) {
351
- throw new Error(`loop ${loopId}: persisted provider selection is missing or invalid — refusing boot-default substitution`);
381
+ throw new Error(`loop ${loopId}: persisted ${field} is invalid`);
352
382
  }
353
383
  return parsed;
354
384
  }
385
+ async #providerPolicyForLoop(loopId) {
386
+ const row = await this.#db.drain_loop_provider_spec.get({ loop_id: loopId });
387
+ if (row === undefined)
388
+ throw new Error(`loop ${loopId}: provider selection row is missing`);
389
+ const providerSpec = this.#parseProviderSpec(loopId, "provider_spec", row.provider_spec);
390
+ if (providerSpec === null) {
391
+ throw new Error(`loop ${loopId}: persisted provider selection is missing or invalid — refusing boot-default substitution`);
392
+ }
393
+ return {
394
+ providerSpec,
395
+ childProviderSpec: this.#parseProviderSpec(loopId, "child_provider_spec", row.child_provider_spec),
396
+ };
397
+ }
398
+ async #providerSpecForLoop(loopId) {
399
+ return (await this.#providerPolicyForLoop(loopId)).providerSpec;
400
+ }
355
401
  async #providerForLoop(loopId) {
356
402
  return ProviderInstantiate.instantiateProvider(await this.#providerSpecForLoop(loopId));
357
403
  }
@@ -370,6 +416,19 @@ export default class Daemon {
370
416
  });
371
417
  }
372
418
  }
419
+ async #assertLoopChildProvider(loopId, requested) {
420
+ const selected = (await this.#providerPolicyForLoop(loopId)).childProviderSpec;
421
+ if (JSON.stringify(selected) !== JSON.stringify(requested)) {
422
+ throw daemonFailure("daemon:provider", "loop-child-provider-conflict", 409, `Loop ${loopId} already has a different child provider policy.`, {
423
+ loopId,
424
+ selectedChildAlias: selected?.alias ?? null,
425
+ requestedChildAlias: requested?.alias ?? null,
426
+ stage: "loop-injection",
427
+ recovery: "Cancel or conclude the loop before changing its child provider policy.",
428
+ retryable: false,
429
+ });
430
+ }
431
+ }
373
432
  async #assertLoopMaxTurns(loopId, requested) {
374
433
  if (requested === undefined)
375
434
  return;
@@ -960,6 +1019,7 @@ export default class Daemon {
960
1019
  }
961
1020
  async #recoverLifecycle() {
962
1021
  await this.#db.recovery_fail_active_loops.run({});
1022
+ await this.#db.recovery_fail_open_provider_attempts.run({});
963
1023
  await this.#db.recovery_fail_ownerless_proposals.run({});
964
1024
  await this.#db.recovery_error_orphan_subscription_channels.run({});
965
1025
  await this.#db.recovery_fail_orphan_subscriptions.run({});
@@ -1009,7 +1069,9 @@ export default class Daemon {
1009
1069
  // Settle the stopped world FIRST: a drain paused at a pending proposal awaits a resolution
1010
1070
  // that will never arrive once clients are gone — allSettled(drains) below would deadlock
1011
1071
  // the stop forever (a daemon with a pending HITL proposal could not shut down).
1072
+ const derivationAbort = new DOMException("daemon stopping", "AbortError");
1012
1073
  this.#engine.cancelAllProposals("daemon_stopping");
1074
+ this.#engine.cancelDerivations(derivationAbort);
1013
1075
  this.#branchBatches.beginStop();
1014
1076
  for (const scope of this.#workerAborts.values()) {
1015
1077
  if (!scope.signal.aborted)
@@ -1028,10 +1090,11 @@ export default class Daemon {
1028
1090
  await this.#branchBatches.idle();
1029
1091
  const drainPromises = [...this.#activeDrains.values()].map((d) => d.promise);
1030
1092
  await Promise.allSettled(drainPromises);
1093
+ await Promise.allSettled([...this.#drainExitTasks]);
1031
1094
  const closeResults = await moduleClose;
1032
1095
  const [streamingResult] = await Promise.allSettled([this.#drainStreamingSchemes()]);
1033
1096
  const [derivationResult] = await Promise.allSettled([
1034
- this.#engine.drainDerivations(), // active workspace warms finish before the db closes upstream
1097
+ this.#engine.drainDerivations(derivationAbort), // active workspace warms settle before the db closes upstream
1035
1098
  ]);
1036
1099
  const mimetypeResults = this.#ownsMimetypes
1037
1100
  ? await Promise.allSettled([this.#mimetypes.dispose()])
@@ -1108,6 +1171,8 @@ export default class Daemon {
1108
1171
  if (active !== undefined) {
1109
1172
  await this.#assertFoldPosture(workerId, args.flags, active.id); // compare with the exact durable loop
1110
1173
  await this.#assertLoopProvider(active.id, args.providerSpec);
1174
+ if (args.childProviderSpec !== undefined)
1175
+ await this.#assertLoopChildProvider(active.id, args.childProviderSpec);
1111
1176
  await this.#assertLoopMaxTurns(active.id, args.turnCeiling?.source === "explicit" ? args.turnCeiling.effective : undefined);
1112
1177
  }
1113
1178
  const result = await this.#engine.inject(workerId, prompt, args.openPaths ?? []);
@@ -1125,6 +1190,8 @@ export default class Daemon {
1125
1190
  if (slept !== undefined) {
1126
1191
  await this.#assertFoldPosture(workerId, args.flags, slept.id); // resume drops nothing silently
1127
1192
  await this.#assertLoopProvider(slept.id, args.providerSpec);
1193
+ if (args.childProviderSpec !== undefined)
1194
+ await this.#assertLoopChildProvider(slept.id, args.childProviderSpec);
1128
1195
  await this.#assertLoopMaxTurns(slept.id, args.turnCeiling?.source === "explicit" ? args.turnCeiling.effective : undefined);
1129
1196
  const injected = await this.#engine.inject(workerId, prompt, args.openPaths ?? []);
1130
1197
  await this.#lifecycle.wake(slept.id);
@@ -1138,6 +1205,7 @@ export default class Daemon {
1138
1205
  workerId,
1139
1206
  prompt,
1140
1207
  providerSpec: args.providerSpec,
1208
+ childProviderSpec: args.childProviderSpec ?? null,
1141
1209
  maxTurns: args.turnCeiling?.effective,
1142
1210
  flags: args.flags,
1143
1211
  openPaths: args.openPaths,
@@ -1168,6 +1236,7 @@ export default class Daemon {
1168
1236
  sequence: seqRow.next,
1169
1237
  prompt: args.prompt,
1170
1238
  provider_spec: JSON.stringify(args.providerSpec),
1239
+ child_provider_spec: JSON.stringify(args.childProviderSpec),
1171
1240
  max_turns: args.maxTurns ?? Number(process.env.PLURNK_SERVICE_MAX_TURNS ?? "50"),
1172
1241
  });
1173
1242
  if (loopRow === undefined)
@@ -1353,12 +1422,18 @@ export default class Daemon {
1353
1422
  // {§methods-loop-cancel} — loop.cancel / shutdown aborted the live drain. A cancellation
1354
1423
  // is the loop's TERMINAL state (499), delivered via loop/terminated (runLoop no
1355
1424
  // longer blocks to return it). A genuine error rejects firstLoopPromise.
1356
- const usage = currentLoopId === null
1357
- ? { promptTokens: 0, completionTokens: 0, costUsd: 0, costs: [], contextTokens: 0, promptBudget: null, meta: {} }
1358
- : await this.#engine.loopUsage(currentLoopId);
1359
- const attributions = currentLoopId === null
1360
- ? []
1361
- : await this.#engine.loopAttributions(currentLoopId);
1425
+ let usage = {
1426
+ promptTokens: 0,
1427
+ completionTokens: 0,
1428
+ reasoningTokens: 0,
1429
+ cachedTokens: 0,
1430
+ costUsd: 0,
1431
+ costs: [],
1432
+ contextTokens: 0,
1433
+ promptBudget: null,
1434
+ meta: {},
1435
+ };
1436
+ let attributions = [];
1362
1437
  const message = ErrorDetail.preview(controller.signal.reason ?? "user_cancelled")
1363
1438
  || "no reason was supplied";
1364
1439
  if (currentLoopId !== null) {
@@ -1369,6 +1444,10 @@ export default class Daemon {
1369
1444
  stage: "loop",
1370
1445
  retryable: false,
1371
1446
  }), { terminatedBy: "cancel" });
1447
+ [usage, attributions] = await Promise.all([
1448
+ this.#engine.loopUsage(currentLoopId),
1449
+ this.#engine.loopAttributions(currentLoopId),
1450
+ ]);
1372
1451
  if (cancelled !== null) {
1373
1452
  this.#broadcast({ workspaceId }, "loop/terminated", {
1374
1453
  workerId,
@@ -1456,8 +1535,12 @@ export default class Daemon {
1456
1535
  // Topology join ({§worker-loop-lifecycle}): when this drain exits having CONCLUDED the worker, wake its parent
1457
1536
  // if parked. Runs after the drain fully tears down (settled promise) so the quiescence check sees
1458
1537
  // final state; speculative (#onDrainExit no-ops unless the worker concluded AND the parent is parked).
1459
- void drainPromise.then(() => this.#onDrainExit(workspaceId, workerId, systemPrompt), () => this.#onDrainExit(workspaceId, workerId, systemPrompt)).catch((err) => {
1538
+ const drainExitTask = drainPromise.then(() => this.#onDrainExit(workspaceId, workerId, systemPrompt), () => this.#onDrainExit(workspaceId, workerId, systemPrompt));
1539
+ this.#drainExitTasks.add(drainExitTask);
1540
+ void drainExitTask.catch((err) => {
1460
1541
  console.error(`parent wake after worker ${workerId} settlement failed:`, err);
1542
+ }).finally(() => {
1543
+ this.#drainExitTasks.delete(drainExitTask);
1461
1544
  });
1462
1545
  // Swallow unhandled rejections (drain aborts with no awaiter); the
1463
1546
  // error already surfaced via firstLoopPromise or was logged inside.
@@ -1515,6 +1598,7 @@ export default class Daemon {
1515
1598
  prompt: first.body,
1516
1599
  flags: first.flags,
1517
1600
  provider_spec: first.provider_spec,
1601
+ child_provider_spec: first.child_provider_spec,
1518
1602
  max_turns: first.max_turns,
1519
1603
  open_paths: first.open_paths ?? "[]",
1520
1604
  orphan_source_loop_id: endedLoopId,
@@ -1632,23 +1716,24 @@ export default class Daemon {
1632
1716
  }
1633
1717
  /**
1634
1718
  * Wake-on-completion handler. Streaming schemes call this when a
1635
- * subscription closes. If the worker has an active loop, the channel
1636
- * transition will surface at that loop's next turn boundary — no new
1637
- * loop needed. Otherwise we open a fresh loop with the synthetic
1638
- * summary as the user prompt so the model gets a chance to react.
1719
+ * subscription closes. A parked loop resumes in place; an active loop
1720
+ * observes the channel transition at its next turn boundary. No synthetic
1721
+ * prompt or replacement loop is created.
1639
1722
  *
1640
1723
  * Skipped on result.status=499 (aborted): the model already knows about
1641
- * its own SEND[499], and a forcefully-cancelled loop's spawn-abort
1642
- * shouldn't resurrect into a wake loop (defeats the cancel).
1724
+ * its own SEND[499], and a forcefully-cancelled loop's spawn-abort must
1725
+ * not resurrect the worker.
1643
1726
  */
1644
1727
  async #handleWakeWorker(payload) {
1728
+ const { entryOwnerId, ...wake } = payload;
1729
+ const conclusion = { ...wake, workerId: entryOwnerId };
1645
1730
  // {§search-gate} — settle the dedup registration: promote on a 200 conclusion, drop on
1646
1731
  // failure (a dead search must never serve as a duplicate). No-op for non-search streams.
1647
1732
  this.#engine.searchGate.settle(payload.target.replace(/^[a-z+.-]+:\/\//, "/").replace(/^\/+/, "/"), payload.result.status);
1648
1733
  // Aborted streams don't wake — the abort was deliberate.
1649
1734
  if (payload.result.status === 499) {
1650
1735
  this.#broadcast({ workspaceId: payload.workspaceId }, "stream/concluded", {
1651
- ...payload, wakeAction: "skipped-aborted",
1736
+ ...conclusion, wakeAction: "skipped-aborted",
1652
1737
  });
1653
1738
  return;
1654
1739
  }
@@ -1662,7 +1747,7 @@ export default class Daemon {
1662
1747
  const scope = this.#workerAborts.get(payload.workerId);
1663
1748
  if (scope?.signal.aborted === true && !this.#activeDrains.has(payload.workerId)) {
1664
1749
  this.#broadcast({ workspaceId: payload.workspaceId }, "stream/concluded", {
1665
- ...payload, wakeAction: "skipped-cancelled",
1750
+ ...conclusion, wakeAction: "skipped-cancelled",
1666
1751
  });
1667
1752
  return;
1668
1753
  }
@@ -1676,16 +1761,14 @@ export default class Daemon {
1676
1761
  // the manifest. {§worker-lifecycle-wake-liveness}.
1677
1762
  const slept = await this.#db.drain_find_slept_loop.get({ worker_id: payload.workerId });
1678
1763
  if (slept !== undefined) {
1679
- await this.#lifecycle.wake(slept.id);
1680
- const started = await this.#ensureDrain({
1681
- workspaceId: payload.workspaceId, workerId: payload.workerId,
1682
- systemPrompt,
1764
+ // {§worker-optimistic-settlement} — publish this conclusion now,
1765
+ // then let the worker-local gate coalesce only the provider
1766
+ // dispatch. Concurrent stream/child callbacks join that one gate.
1767
+ void this.#settleCompletionWake(payload.workspaceId, payload.workerId, systemPrompt, false).catch((err) => {
1768
+ console.error("completion wake settlement failed:", err instanceof Error ? err.message : String(err));
1683
1769
  });
1684
1770
  this.#broadcast({ workspaceId: payload.workspaceId }, "stream/concluded", {
1685
- ...payload, wakeAction: "resumed-loop", wakeLoopId: slept.id,
1686
- });
1687
- started?.drainPromise?.catch((err) => {
1688
- console.error("wake resume drain failed:", err instanceof Error ? err.message : String(err));
1771
+ ...conclusion, wakeAction: "resumed-loop", wakeLoopId: slept.id,
1689
1772
  });
1690
1773
  return;
1691
1774
  }
@@ -1695,14 +1778,14 @@ export default class Daemon {
1695
1778
  // synthesis (which clobbered the model's actual goal) is retired; just tell the client.
1696
1779
  if (this.#activeDrains.has(payload.workerId)) {
1697
1780
  this.#broadcast({ workspaceId: payload.workspaceId }, "stream/concluded", {
1698
- ...payload, wakeAction: "no-op-active-loop",
1781
+ ...conclusion, wakeAction: "no-op-active-loop",
1699
1782
  });
1700
1783
  return;
1701
1784
  }
1702
1785
  // No slept loop, no active drain — nothing to resume (e.g. a SEND[200]-done worker whose
1703
1786
  // streams were swept). Surface the conclusion without opening a loop.
1704
1787
  this.#broadcast({ workspaceId: payload.workspaceId }, "stream/concluded", {
1705
- ...payload, wakeAction: "no-loop",
1788
+ ...conclusion, wakeAction: "no-loop",
1706
1789
  });
1707
1790
  }
1708
1791
  catch (err) {
@@ -1748,13 +1831,13 @@ export default class Daemon {
1748
1831
  delayMs = execPollBackoffMs(step, base, turns);
1749
1832
  this.#pollBackoff.set(workerId, step + 1);
1750
1833
  }
1751
- // Floored by the post-EXEC breath (PLURNK_SERVICE_EXEC_WAIT_MS) so a `<…,1>` can't wake the loop
1752
- // faster than a turn settles — {§exec-poll}.
1753
- const execWaitMs = Number(process.env.PLURNK_SERVICE_EXEC_WAIT_MS ?? "0");
1834
+ // Floored by the optimistic settlement cap so a `<…,1>` cannot wake a
1835
+ // parked loop faster than the preceding turn's settlement scale.
1836
+ const optimisticWaitMs = readOptimisticSettlementMs();
1754
1837
  const timer = setTimeout(() => {
1755
1838
  this.#pollTimers.delete(workerId);
1756
1839
  void this.#wakeParkedWorker(workspaceId, workerId, systemPrompt);
1757
- }, Math.max(delayMs, execWaitMs));
1840
+ }, Math.max(delayMs, optimisticWaitMs));
1758
1841
  timer.unref();
1759
1842
  this.#pollTimers.set(workerId, timer);
1760
1843
  }
@@ -1763,6 +1846,8 @@ export default class Daemon {
1763
1846
  * or a child worker finishing ({§worker-loop-lifecycle} topology join) all call this. A no-op if the worker was
1764
1847
  * cancelled or isn't actually parked (no slept loop) — so calling it speculatively is safe. */
1765
1848
  async #wakeParkedWorker(workspaceId, workerId, systemPrompt, oweIfActive = true) {
1849
+ if (!this.#started)
1850
+ return;
1766
1851
  const scope = this.#workerAborts.get(workerId);
1767
1852
  if (scope?.signal.aborted === true && !this.#activeDrains.has(workerId))
1768
1853
  return; // cancelled — no resurrection
@@ -1772,17 +1857,100 @@ export default class Daemon {
1772
1857
  // conclusion that fired this wake arrived before the 202 committed (the conclude-before-park
1773
1858
  // race). Owe the wake: the drain honors it at park so a worker hibernation never deadlocks.
1774
1859
  // (No active drain → already concluded/running; nothing to wake.)
1775
- if (oweIfActive && this.#activeDrains.has(workerId))
1776
- this.#owedWakes.add(workerId);
1860
+ if (this.#activeDrains.has(workerId)) {
1861
+ if (oweIfActive)
1862
+ this.#owedWakes.add(workerId);
1863
+ }
1777
1864
  return;
1778
1865
  }
1779
- await this.#lifecycle.wake(slept.id);
1866
+ const woke = await this.#lifecycle.wake(slept.id);
1867
+ if (!woke)
1868
+ return;
1780
1869
  const started = await this.#ensureDrain({
1781
1870
  workspaceId, workerId, systemPrompt,
1782
1871
  });
1783
1872
  started?.drainPromise?.catch((err) => {
1784
- console.error("wake-parked resume drain failed:", err instanceof Error ? err.message : String(err));
1873
+ if (this.#started) {
1874
+ console.error("wake-parked resume drain failed:", err instanceof Error ? err.message : String(err));
1875
+ }
1876
+ });
1877
+ }
1878
+ async #workerHasLiveObligation(workerId) {
1879
+ const [openSubscriptions, liveChild] = await Promise.all([
1880
+ this.#db.find_open_subscriptions_for_worker.all({ worker_id: workerId }),
1881
+ this.#db.engine_worker_has_live_child.get({ worker_id: workerId }),
1882
+ ]);
1883
+ return openSubscriptions.length > 0 || liveChild !== undefined;
1884
+ }
1885
+ #settleCompletionWake(workspaceId, workerId, systemPrompt, oweIfActive = true) {
1886
+ const existing = this.#completionWakeGates.get(workerId);
1887
+ if (existing !== undefined) {
1888
+ existing.conclusions++;
1889
+ existing.poke.resolve();
1890
+ return existing.promise;
1891
+ }
1892
+ const completed = Promise.withResolvers();
1893
+ const gate = {
1894
+ conclusions: 1,
1895
+ poke: Promise.withResolvers(),
1896
+ promise: completed.promise,
1897
+ };
1898
+ this.#completionWakeGates.set(workerId, gate);
1899
+ void this.#runCompletionWake(workspaceId, workerId, systemPrompt, oweIfActive, gate).then(completed.resolve, completed.reject).finally(() => {
1900
+ if (this.#completionWakeGates.get(workerId) === gate) {
1901
+ this.#completionWakeGates.delete(workerId);
1902
+ }
1785
1903
  });
1904
+ return gate.promise;
1905
+ }
1906
+ async #runCompletionWake(workspaceId, workerId, systemPrompt, oweIfActive, gate) {
1907
+ const slept = await this.#db.drain_find_slept_loop.get({ worker_id: workerId });
1908
+ if (slept === undefined) {
1909
+ this.#releaseCompletionWake(workerId, gate);
1910
+ return this.#wakeParkedWorker(workspaceId, workerId, systemPrompt, oweIfActive);
1911
+ }
1912
+ const timeoutMs = readOptimisticSettlementMs();
1913
+ if (timeoutMs === 0 || !(await this.#workerHasLiveObligation(workerId))) {
1914
+ this.#releaseCompletionWake(workerId, gate);
1915
+ return this.#wakeParkedWorker(workspaceId, workerId, systemPrompt, false);
1916
+ }
1917
+ return observed("worker.wake.settlement", { "window.ms": timeoutMs }, async (span) => {
1918
+ const startedAt = performance.now();
1919
+ const signal = this.#workerAborts.get(workerId)?.signal;
1920
+ const deadline = delay(timeoutMs, "deadline", { signal, ref: false })
1921
+ .catch((cause) => {
1922
+ if (signal?.aborted === true)
1923
+ return "cancelled";
1924
+ throw cause;
1925
+ });
1926
+ let release = "quiescent";
1927
+ while (await this.#workerHasLiveObligation(workerId)) {
1928
+ const poke = gate.poke;
1929
+ const outcome = await Promise.race([
1930
+ poke.promise.then(() => "arrival"),
1931
+ deadline,
1932
+ ]);
1933
+ if (outcome === "arrival") {
1934
+ if (gate.poke === poke)
1935
+ gate.poke = Promise.withResolvers();
1936
+ continue;
1937
+ }
1938
+ release = outcome;
1939
+ break;
1940
+ }
1941
+ span.setAttribute("release", release);
1942
+ span.setAttribute("conclusions", gate.conclusions);
1943
+ span.setAttribute("elapsed.ms", Math.round(performance.now() - startedAt));
1944
+ this.#releaseCompletionWake(workerId, gate);
1945
+ if (release === "cancelled")
1946
+ return;
1947
+ return this.#wakeParkedWorker(workspaceId, workerId, systemPrompt, false);
1948
+ });
1949
+ }
1950
+ #releaseCompletionWake(workerId, gate) {
1951
+ if (this.#completionWakeGates.get(workerId) === gate) {
1952
+ this.#completionWakeGates.delete(workerId);
1953
+ }
1786
1954
  }
1787
1955
  /** A worker's drain exited. If the worker truly CONCLUDED — no 202-blocked loop, no open stream — then
1788
1956
  * wake its PARENT in place if the parent is blocked on the join (the structured-concurrency join — a
@@ -1800,7 +1968,7 @@ export default class Daemon {
1800
1968
  const parent = await this.#db.worker_parent_id.get({ worker_id: workerId });
1801
1969
  if (parent?.parent_worker_id == null)
1802
1970
  return; // a root worker — nobody to wake
1803
- await this.#wakeParkedWorker(workspaceId, parent.parent_worker_id, systemPrompt);
1971
+ await this.#settleCompletionWake(workspaceId, parent.parent_worker_id, systemPrompt);
1804
1972
  }
1805
1973
  // {§methods-event-subscribe} — subscriber failures are transport-local:
1806
1974
  // log them at this boundary and never re-enter engine control flow.