@plurnk/plurnk-service 1.4.0 → 1.6.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 (302) hide show
  1. package/.env.defaults +37 -31
  2. package/INSTALL.md +16 -11
  3. package/README.md +1 -0
  4. package/SPEC.md +631 -397
  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/edit-collision.d.ts +5 -0
  10. package/dist/content/edit-collision.d.ts.map +1 -0
  11. package/dist/content/edit-collision.js +14 -0
  12. package/dist/content/edit-collision.js.map +1 -0
  13. package/dist/content/edit-receipt.d.ts +3 -2
  14. package/dist/content/edit-receipt.d.ts.map +1 -1
  15. package/dist/content/edit-receipt.js +58 -13
  16. package/dist/content/edit-receipt.js.map +1 -1
  17. package/dist/content/index.d.ts +5 -1
  18. package/dist/content/index.d.ts.map +1 -1
  19. package/dist/content/index.js +4 -1
  20. package/dist/content/index.js.map +1 -1
  21. package/dist/content/line-anchors.d.ts +41 -0
  22. package/dist/content/line-anchors.d.ts.map +1 -0
  23. package/dist/content/line-anchors.js +191 -0
  24. package/dist/content/line-anchors.js.map +1 -0
  25. package/dist/content/line-marker.d.ts +7 -3
  26. package/dist/content/line-marker.d.ts.map +1 -1
  27. package/dist/content/line-marker.js +2 -1
  28. package/dist/content/line-marker.js.map +1 -1
  29. package/dist/content/matcher.d.ts +1 -1
  30. package/dist/content/matcher.d.ts.map +1 -1
  31. package/dist/content/matcher.js +2 -2
  32. package/dist/content/matcher.js.map +1 -1
  33. package/dist/content/read-projector.d.ts +20 -0
  34. package/dist/content/read-projector.d.ts.map +1 -0
  35. package/dist/content/read-projector.js +122 -0
  36. package/dist/content/read-projector.js.map +1 -0
  37. package/dist/content/read-resolve.d.ts +2 -7
  38. package/dist/content/read-resolve.d.ts.map +1 -1
  39. package/dist/content/read-resolve.js +32 -107
  40. package/dist/content/read-resolve.js.map +1 -1
  41. package/dist/core/BranchReceipt.d.ts +0 -1
  42. package/dist/core/BranchReceipt.d.ts.map +1 -1
  43. package/dist/core/BranchReceipt.js +0 -3
  44. package/dist/core/BranchReceipt.js.map +1 -1
  45. package/dist/core/BudgetReadout.d.ts.map +1 -1
  46. package/dist/core/BudgetReadout.js +9 -2
  47. package/dist/core/BudgetReadout.js.map +1 -1
  48. package/dist/core/ChannelWrite.d.ts +3 -0
  49. package/dist/core/ChannelWrite.d.ts.map +1 -1
  50. package/dist/core/ChannelWrite.js +3 -3
  51. package/dist/core/ChannelWrite.js.map +1 -1
  52. package/dist/core/ChannelWrite.sql +4 -3
  53. package/dist/core/CoreSchemeServices.d.ts +10 -2
  54. package/dist/core/CoreSchemeServices.d.ts.map +1 -1
  55. package/dist/core/CoreSchemeServices.js.map +1 -1
  56. package/dist/core/Dispatcher.d.ts +14 -8
  57. package/dist/core/Dispatcher.d.ts.map +1 -1
  58. package/dist/core/Dispatcher.js +656 -312
  59. package/dist/core/Dispatcher.js.map +1 -1
  60. package/dist/core/DurableStatement.d.ts.map +1 -1
  61. package/dist/core/DurableStatement.js +2 -0
  62. package/dist/core/DurableStatement.js.map +1 -1
  63. package/dist/core/Engine.d.ts +24 -18
  64. package/dist/core/Engine.d.ts.map +1 -1
  65. package/dist/core/Engine.js +620 -392
  66. package/dist/core/Engine.js.map +1 -1
  67. package/dist/core/Engine.sql +188 -119
  68. package/dist/core/ExecutableTools.d.ts +10 -0
  69. package/dist/core/ExecutableTools.d.ts.map +1 -0
  70. package/dist/core/ExecutableTools.js +41 -0
  71. package/dist/core/ExecutableTools.js.map +1 -0
  72. package/dist/core/ExecutorRegistry.d.ts +3 -3
  73. package/dist/core/ExecutorRegistry.d.ts.map +1 -1
  74. package/dist/core/ExecutorRegistry.js +2 -2
  75. package/dist/core/ExecutorRegistry.js.map +1 -1
  76. package/dist/core/LogBody.d.ts +4 -1
  77. package/dist/core/LogBody.d.ts.map +1 -1
  78. package/dist/core/LogBody.js +46 -2
  79. package/dist/core/LogBody.js.map +1 -1
  80. package/dist/core/LogEntryProjection.d.ts +14 -0
  81. package/dist/core/LogEntryProjection.d.ts.map +1 -0
  82. package/dist/core/LogEntryProjection.js +38 -0
  83. package/dist/core/LogEntryProjection.js.map +1 -0
  84. package/dist/core/LoopLifecycle.d.ts +1 -2
  85. package/dist/core/LoopLifecycle.d.ts.map +1 -1
  86. package/dist/core/LoopLifecycle.js +1 -4
  87. package/dist/core/LoopLifecycle.js.map +1 -1
  88. package/dist/core/LoopLifecycle.sql +1 -4
  89. package/dist/core/ModelCall.d.ts +27 -0
  90. package/dist/core/ModelCall.d.ts.map +1 -0
  91. package/dist/core/ModelCall.js +125 -0
  92. package/dist/core/ModelCall.js.map +1 -0
  93. package/dist/core/PacketBuilder.d.ts +13 -12
  94. package/dist/core/PacketBuilder.d.ts.map +1 -1
  95. package/dist/core/PacketBuilder.js +146 -118
  96. package/dist/core/PacketBuilder.js.map +1 -1
  97. package/dist/core/ProblemLog.d.ts.map +1 -1
  98. package/dist/core/ProblemLog.js +1 -0
  99. package/dist/core/ProblemLog.js.map +1 -1
  100. package/dist/core/ProposalLifecycle.d.ts.map +1 -1
  101. package/dist/core/ProposalLifecycle.js +3 -4
  102. package/dist/core/ProposalLifecycle.js.map +1 -1
  103. package/dist/core/ProviderInstantiate.d.ts.map +1 -1
  104. package/dist/core/ProviderInstantiate.js +23 -6
  105. package/dist/core/ProviderInstantiate.js.map +1 -1
  106. package/dist/core/SchemeRegistry.js +7 -7
  107. package/dist/core/SchemeRegistry.js.map +1 -1
  108. package/dist/core/StrikeRail.d.ts +5 -2
  109. package/dist/core/StrikeRail.d.ts.map +1 -1
  110. package/dist/core/StrikeRail.js +10 -7
  111. package/dist/core/StrikeRail.js.map +1 -1
  112. package/dist/core/TerminalResult.d.ts +16 -0
  113. package/dist/core/TerminalResult.d.ts.map +1 -0
  114. package/dist/core/TerminalResult.js +49 -0
  115. package/dist/core/TerminalResult.js.map +1 -0
  116. package/dist/core/caps/CapsResolve.d.ts +5 -1
  117. package/dist/core/caps/CapsResolve.d.ts.map +1 -1
  118. package/dist/core/caps/CapsResolve.js +9 -5
  119. package/dist/core/caps/CapsResolve.js.map +1 -1
  120. package/dist/core/caps/DbChannelCaps.d.ts +1 -1
  121. package/dist/core/caps/DbChannelCaps.d.ts.map +1 -1
  122. package/dist/core/caps/DbChannelCaps.js +6 -4
  123. package/dist/core/caps/DbChannelCaps.js.map +1 -1
  124. package/dist/core/caps/DbEntryCaps.d.ts +2 -1
  125. package/dist/core/caps/DbEntryCaps.d.ts.map +1 -1
  126. package/dist/core/caps/DbEntryCaps.js +21 -8
  127. package/dist/core/caps/DbEntryCaps.js.map +1 -1
  128. package/dist/core/caps/DbNotifyCaps.d.ts +1 -1
  129. package/dist/core/caps/DbNotifyCaps.d.ts.map +1 -1
  130. package/dist/core/caps/DbNotifyCaps.js +6 -4
  131. package/dist/core/caps/DbNotifyCaps.js.map +1 -1
  132. package/dist/core/caps/DbProjectionCaps.d.ts +1 -0
  133. package/dist/core/caps/DbProjectionCaps.d.ts.map +1 -1
  134. package/dist/core/caps/DbProjectionCaps.js +29 -0
  135. package/dist/core/caps/DbProjectionCaps.js.map +1 -1
  136. package/dist/core/caps/DbSubscriptionCaps.d.ts +4 -6
  137. package/dist/core/caps/DbSubscriptionCaps.d.ts.map +1 -1
  138. package/dist/core/caps/DbSubscriptionCaps.js +27 -13
  139. package/dist/core/caps/DbSubscriptionCaps.js.map +1 -1
  140. package/dist/core/caps/SchemeCtxImpl.d.ts +11 -3
  141. package/dist/core/caps/SchemeCtxImpl.d.ts.map +1 -1
  142. package/dist/core/caps/SchemeCtxImpl.js +10 -8
  143. package/dist/core/caps/SchemeCtxImpl.js.map +1 -1
  144. package/dist/core/fork.d.ts.map +1 -1
  145. package/dist/core/fork.js +23 -7
  146. package/dist/core/fork.js.map +1 -1
  147. package/dist/core/fork.sql +35 -14
  148. package/dist/core/git-iso.d.ts +3 -2
  149. package/dist/core/git-iso.d.ts.map +1 -1
  150. package/dist/core/git-iso.js +27 -12
  151. package/dist/core/git-iso.js.map +1 -1
  152. package/dist/core/git-membership.d.ts +1 -0
  153. package/dist/core/git-membership.d.ts.map +1 -1
  154. package/dist/core/git-membership.js +87 -26
  155. package/dist/core/git-membership.js.map +1 -1
  156. package/dist/core/git-state.d.ts +8 -1
  157. package/dist/core/git-state.d.ts.map +1 -1
  158. package/dist/core/git-state.js +36 -17
  159. package/dist/core/git-state.js.map +1 -1
  160. package/dist/core/namespace.d.ts +1 -0
  161. package/dist/core/namespace.d.ts.map +1 -1
  162. package/dist/core/namespace.js +20 -0
  163. package/dist/core/namespace.js.map +1 -1
  164. package/dist/core/optimistic-settlement.d.ts +2 -0
  165. package/dist/core/optimistic-settlement.d.ts.map +1 -0
  166. package/dist/core/optimistic-settlement.js +14 -0
  167. package/dist/core/optimistic-settlement.js.map +1 -0
  168. package/dist/core/packet-wire.d.ts +5 -2
  169. package/dist/core/packet-wire.d.ts.map +1 -1
  170. package/dist/core/packet-wire.js +318 -114
  171. package/dist/core/packet-wire.js.map +1 -1
  172. package/dist/core/provider-accounting.d.ts +24 -0
  173. package/dist/core/provider-accounting.d.ts.map +1 -0
  174. package/dist/core/provider-accounting.js +76 -0
  175. package/dist/core/provider-accounting.js.map +1 -0
  176. package/dist/core/results.d.ts +4 -2
  177. package/dist/core/results.d.ts.map +1 -1
  178. package/dist/core/results.js +6 -0
  179. package/dist/core/results.js.map +1 -1
  180. package/dist/core/scheme-types.d.ts +0 -12
  181. package/dist/core/scheme-types.d.ts.map +1 -1
  182. package/dist/core/scheme-types.js.map +1 -1
  183. package/dist/core/search-gate.d.ts +2 -2
  184. package/dist/core/search-gate.d.ts.map +1 -1
  185. package/dist/core/search-gate.js +8 -8
  186. package/dist/core/search-gate.js.map +1 -1
  187. package/dist/core/token-ruler.js +1 -1
  188. package/dist/core/token-ruler.js.map +1 -1
  189. package/dist/core/turn-scheduler.js +1 -1
  190. package/dist/core/turn-scheduler.js.map +1 -1
  191. package/dist/core/worker-ops.sql +4 -4
  192. package/dist/core/workspace-settings.js +1 -1
  193. package/dist/core/workspace-settings.js.map +1 -1
  194. package/dist/digest/Digest.d.ts.map +1 -1
  195. package/dist/digest/Digest.js +349 -76
  196. package/dist/digest/Digest.js.map +1 -1
  197. package/dist/digest/digest.sql +42 -16
  198. package/dist/schemes/Exec.d.ts +4 -4
  199. package/dist/schemes/Exec.d.ts.map +1 -1
  200. package/dist/schemes/Exec.js +167 -134
  201. package/dist/schemes/Exec.js.map +1 -1
  202. package/dist/schemes/ExecOutputScheme.d.ts +4 -5
  203. package/dist/schemes/ExecOutputScheme.d.ts.map +1 -1
  204. package/dist/schemes/ExecOutputScheme.js +12 -20
  205. package/dist/schemes/ExecOutputScheme.js.map +1 -1
  206. package/dist/schemes/File.d.ts +5 -6
  207. package/dist/schemes/File.d.ts.map +1 -1
  208. package/dist/schemes/File.js +35 -41
  209. package/dist/schemes/File.js.map +1 -1
  210. package/dist/schemes/Log.d.ts +16 -5
  211. package/dist/schemes/Log.d.ts.map +1 -1
  212. package/dist/schemes/Log.js +229 -262
  213. package/dist/schemes/Log.js.map +1 -1
  214. package/dist/schemes/Log.sql +50 -27
  215. package/dist/schemes/Prompt.d.ts +3 -4
  216. package/dist/schemes/Prompt.d.ts.map +1 -1
  217. package/dist/schemes/Prompt.js +5 -5
  218. package/dist/schemes/Prompt.js.map +1 -1
  219. package/dist/schemes/Skill.d.ts +4 -5
  220. package/dist/schemes/Skill.d.ts.map +1 -1
  221. package/dist/schemes/Skill.js +3 -3
  222. package/dist/schemes/Skill.js.map +1 -1
  223. package/dist/schemes/Worker.d.ts +7 -7
  224. package/dist/schemes/Worker.d.ts.map +1 -1
  225. package/dist/schemes/Worker.js +99 -84
  226. package/dist/schemes/Worker.js.map +1 -1
  227. package/dist/schemes/_entry-crud.d.ts +2 -2
  228. package/dist/schemes/_entry-crud.d.ts.map +1 -1
  229. package/dist/schemes/_entry-crud.js +29 -10
  230. package/dist/schemes/_entry-crud.js.map +1 -1
  231. package/dist/schemes/_entry-crud.sql +11 -17
  232. package/dist/schemes/_entry-find.d.ts +41 -14
  233. package/dist/schemes/_entry-find.d.ts.map +1 -1
  234. package/dist/schemes/_entry-find.js +159 -166
  235. package/dist/schemes/_entry-find.js.map +1 -1
  236. package/dist/schemes/_entry-find.sql +2 -21
  237. package/dist/schemes/_entry-graph.sql +1 -1
  238. package/dist/schemes/_entry-manifest.d.ts +5 -17
  239. package/dist/schemes/_entry-manifest.d.ts.map +1 -1
  240. package/dist/schemes/_entry-manifest.js +28 -24
  241. package/dist/schemes/_entry-manifest.js.map +1 -1
  242. package/dist/schemes/_entry-ops.d.ts +13 -8
  243. package/dist/schemes/_entry-ops.d.ts.map +1 -1
  244. package/dist/schemes/_entry-ops.js +98 -119
  245. package/dist/schemes/_entry-ops.js.map +1 -1
  246. package/dist/schemes/_entry-ops.sql +31 -6
  247. package/dist/schemes/_entry-semantic.d.ts +5 -7
  248. package/dist/schemes/_entry-semantic.d.ts.map +1 -1
  249. package/dist/schemes/_entry-semantic.js +10 -31
  250. package/dist/schemes/_entry-semantic.js.map +1 -1
  251. package/dist/schemes/_entry-semantic.sql +2 -2
  252. package/dist/schemes/_entry-send.js +4 -4
  253. package/dist/schemes/_entry-send.js.map +1 -1
  254. package/dist/schemes/_search-index.d.ts.map +1 -1
  255. package/dist/schemes/_search-index.js +16 -6
  256. package/dist/schemes/_search-index.js.map +1 -1
  257. package/dist/schemes/exec-abort.js +2 -2
  258. package/dist/schemes/exec-abort.js.map +1 -1
  259. package/dist/server/BranchBatches.d.ts +1 -0
  260. package/dist/server/BranchBatches.d.ts.map +1 -1
  261. package/dist/server/BranchBatches.js +1 -0
  262. package/dist/server/BranchBatches.js.map +1 -1
  263. package/dist/server/Daemon.d.ts +6 -8
  264. package/dist/server/Daemon.d.ts.map +1 -1
  265. package/dist/server/Daemon.js +238 -68
  266. package/dist/server/Daemon.js.map +1 -1
  267. package/dist/server/DaemonModule.d.ts +3 -3
  268. package/dist/server/DaemonModule.d.ts.map +1 -1
  269. package/dist/server/branch-batch.sql +3 -1
  270. package/dist/server/client-input.d.ts +2 -1
  271. package/dist/server/client-input.d.ts.map +1 -1
  272. package/dist/server/client-input.js +7 -1
  273. package/dist/server/client-input.js.map +1 -1
  274. package/dist/server/drain.sql +8 -7
  275. package/dist/server/envelope.d.ts +0 -2
  276. package/dist/server/envelope.d.ts.map +1 -1
  277. package/dist/server/envelope.js +0 -1
  278. package/dist/server/envelope.js.map +1 -1
  279. package/dist/server/envelope.sql +3 -4
  280. package/dist/server/lifecycle-recovery.sql +44 -1
  281. package/dist/server/logEntry.d.ts +2 -1
  282. package/dist/server/logEntry.d.ts.map +1 -1
  283. package/dist/server/logEntry.js +15 -2
  284. package/dist/server/logEntry.js.map +1 -1
  285. package/dist/server/logEntry.sql +5 -1
  286. package/dist/service.d.ts.map +1 -1
  287. package/dist/service.js +13 -1
  288. package/dist/service.js.map +1 -1
  289. package/migrations/001_schema.sql +730 -221
  290. package/package.json +53 -46
  291. package/dist/core/BudgetOverflow.d.ts +0 -23
  292. package/dist/core/BudgetOverflow.d.ts.map +0 -1
  293. package/dist/core/BudgetOverflow.js +0 -61
  294. package/dist/core/BudgetOverflow.js.map +0 -1
  295. package/dist/core/caps/DbTagCaps.d.ts +0 -10
  296. package/dist/core/caps/DbTagCaps.d.ts.map +0 -1
  297. package/dist/core/caps/DbTagCaps.js +0 -44
  298. package/dist/core/caps/DbTagCaps.js.map +0 -1
  299. package/dist/matrix/live-specimen.d.ts +0 -9
  300. package/dist/matrix/live-specimen.d.ts.map +0 -1
  301. package/dist/matrix/live-specimen.js +0 -37
  302. 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";
@@ -9,6 +10,8 @@ import Engine from "../core/Engine.js";
9
10
  import ExecutorRegistry from "../core/ExecutorRegistry.js";
10
11
  import SchemeRegistry from "../core/SchemeRegistry.js";
11
12
  import { Mimetypes } from "@plurnk/plurnk-mimetypes";
13
+ import { RuntimeDeclaration } from "@plurnk/plurnk-execs";
14
+ import { aggregateProviderAccounting } from "@plurnk/plurnk-providers";
12
15
  import { parsePath, Validator, } from "@plurnk/plurnk-contracts";
13
16
  import LogEntry from "./logEntry.js";
14
17
  import Envelope from "./envelope.js";
@@ -32,6 +35,7 @@ import BranchBatches from "./BranchBatches.js";
32
35
  import ErrorDetail from "../core/ErrorDetail.js";
33
36
  import { observed, observedSync } from "../observe/spans.js";
34
37
  import { LOOP_TERMINALS, recordCounter } from "../observe/metrics.js";
38
+ import { readOptimisticSettlementMs } from "../core/optimistic-settlement.js";
35
39
  const clientActionFailure = (error) => {
36
40
  if (error instanceof OperationFailureError)
37
41
  return error.result;
@@ -72,6 +76,7 @@ export default class Daemon {
72
76
  // (subscriptions + Exec.idle), and a concluding stream routes through
73
77
  // inject() like any other loop source.
74
78
  #activeDrains = new Map();
79
+ #drainExitTasks = new Set();
75
80
  // Per-worker cancellation scope. Loops AND the streams they spawn (execs)
76
81
  // share this signal, so loop.cancel / shutdown abort it once and every
77
82
  // in-flight subscription tears down — even a spawn that registers AFTER the
@@ -93,6 +98,10 @@ export default class Daemon {
93
98
  // never deadlock. The drain honors the owed wake at the worker's next park, closing the conclude-
94
99
  // before-park race. (Only a live exec stream, unbounded absent a timeout, may hold a park open.)
95
100
  #owedWakes = new Set();
101
+ // {§worker-optimistic-settlement} — one non-sliding completion-wake batch
102
+ // per worker. Durable conclusions and client events land before this gate;
103
+ // only the parked loop's provider-dispatch requeue waits.
104
+ #completionWakeGates = new Map();
96
105
  constructor({ db, schemes, mimetypes, provider, nodeModulesPath, }) {
97
106
  this.#db = db;
98
107
  this.#lifecycle = new LoopLifecycle(db);
@@ -126,10 +135,9 @@ export default class Daemon {
126
135
  });
127
136
  this.#branchBatches = new BranchBatches(db, this.#workspaceGate, {
128
137
  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");
138
+ createChild: async ({ workspaceId, parentWorkerId, parentLoopId, op, name, prompt, flags, origin }) => {
139
+ const parentPolicy = await this.#providerPolicyForLoop(parentLoopId);
140
+ const providerSpec = parentPolicy.childProviderSpec ?? parentPolicy.providerSpec;
133
141
  const workerName = WorkerName.assert(name);
134
142
  const workerId = op === "FORK"
135
143
  ? await Fork.fork(this.#db, parentWorkerId, workerName)
@@ -145,6 +153,7 @@ export default class Daemon {
145
153
  workerId,
146
154
  prompt,
147
155
  providerSpec,
156
+ childProviderSpec: parentPolicy.childProviderSpec,
148
157
  flags,
149
158
  });
150
159
  return { workerId, loopId };
@@ -162,7 +171,7 @@ export default class Daemon {
162
171
  },
163
172
  wakeParent: async (workspaceId, workerId) => {
164
173
  const systemPrompt = await readFile(Paths.instructionsSystem, "utf8");
165
- await this.#wakeParkedWorker(workspaceId, workerId, systemPrompt, false);
174
+ await this.#settleCompletionWake(workspaceId, workerId, systemPrompt, false);
166
175
  },
167
176
  notify: (workspaceId, payload) => {
168
177
  this.#broadcast({ workspaceId }, "workspace/branch-batch", payload);
@@ -182,14 +191,25 @@ export default class Daemon {
182
191
  // daemon owns provider + the law-file system prompt; the worker scheme
183
192
  // handler carries neither. Fire-and-forget: the returned drain runs
184
193
  // 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");
194
+ injectWorker: async ({ workspaceId, workerId, prompt, flags, parentLoopId }) => {
188
195
  const systemPrompt = await readFile(Paths.instructionsSystem, "utf8");
189
- const providerSpec = resolveActiveAlias();
196
+ const parentPolicy = parentLoopId === undefined
197
+ ? null
198
+ : await this.#providerPolicyForLoop(parentLoopId);
199
+ const providerSpec = parentPolicy === null
200
+ ? resolveActiveAlias()
201
+ : parentPolicy.childProviderSpec ?? parentPolicy.providerSpec;
190
202
  if (providerSpec === null)
191
203
  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 }) });
204
+ const { action, loopId } = await this.inject({
205
+ workspaceId,
206
+ workerId,
207
+ prompt,
208
+ providerSpec,
209
+ ...(parentPolicy === null ? {} : { childProviderSpec: parentPolicy.childProviderSpec }),
210
+ systemPrompt,
211
+ ...(flags === undefined ? {} : { flags }),
212
+ });
193
213
  return { action, loopId };
194
214
  },
195
215
  branchWorker: async (args) => this.#branchBatches.enqueue(args),
@@ -243,6 +263,11 @@ export default class Daemon {
243
263
  const openPaths = ClientInput.assertOpenPaths("runLoop", args.openPaths);
244
264
  const alias = ClientInput.assertOptionalSelector("runLoop", "alias", args.alias);
245
265
  const model = ClientInput.assertOptionalSelector("runLoop", "model", args.model);
266
+ const childAlias = ClientInput.assertOptionalChildAlias("runLoop", args.childAlias);
267
+ const childModel = ClientInput.assertOptionalSelector("runLoop", "childModel", args.childModel);
268
+ if (childAlias === null && childModel !== undefined) {
269
+ 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 });
270
+ }
246
271
  const flags = ClientInput.normalizeLoopFlags("runLoop", args.flags);
247
272
  // {§methods-loop-run-model} — a client sends alias/model on every loop, so a
248
273
  // switch takes effect turn-to-turn. `model` (client-resolved <provider>/<model>) wins
@@ -256,6 +281,14 @@ export default class Daemon {
256
281
  retryable: false,
257
282
  }));
258
283
  }
284
+ // {§methods-loop-run-child-provider}
285
+ const configuredChildAlias = childAlias === undefined && childModel === undefined
286
+ ? process.env.PLURNK_MODEL_CHILD
287
+ : childAlias;
288
+ const childSelection = configuredChildAlias === null
289
+ || (configuredChildAlias === undefined && childModel === undefined)
290
+ ? null
291
+ : await this.#resolveLoopProvider(configuredChildAlias, childModel);
259
292
  const systemPrompt = await readFile(Paths.instructionsSystem, "utf8");
260
293
  // {§machine-processes} — the model NEVER runs in a client-origin worker (its packets would carry
261
294
  // client-action rows). The module resolves the model worker via ensureModelWorker and passes it (or a
@@ -296,6 +329,7 @@ export default class Daemon {
296
329
  ...(openPaths !== undefined ? { openPaths } : {}),
297
330
  turnCeiling,
298
331
  providerSpec: selection,
332
+ childProviderSpec: childSelection,
299
333
  systemPrompt,
300
334
  });
301
335
  return { status: 100, action, loopId, ...(turnSeq !== undefined ? { turnSeq } : {}) };
@@ -332,28 +366,47 @@ export default class Daemon {
332
366
  }
333
367
  return spec;
334
368
  }
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`);
369
+ #parseProviderSpec(loopId, field, encoded) {
339
370
  let parsed;
340
371
  try {
341
- parsed = JSON.parse(row.provider_spec);
372
+ parsed = JSON.parse(encoded);
342
373
  }
343
374
  catch {
344
- throw new Error(`loop ${loopId}: persisted provider selection is malformed — refusing boot-default substitution`);
375
+ throw new Error(`loop ${loopId}: persisted ${field} is malformed`);
345
376
  }
346
- if (parsed === null
347
- || typeof parsed.alias !== "string" || parsed.alias.length === 0
377
+ if (parsed === null)
378
+ return null;
379
+ if (typeof parsed.alias !== "string" || parsed.alias.length === 0
348
380
  || typeof parsed.provider !== "string" || parsed.provider.length === 0
349
381
  || typeof parsed.model !== "string" || parsed.model.length === 0
350
382
  || (parsed.baseUrl !== undefined && typeof parsed.baseUrl !== "string")) {
351
- throw new Error(`loop ${loopId}: persisted provider selection is missing or invalid — refusing boot-default substitution`);
383
+ throw new Error(`loop ${loopId}: persisted ${field} is invalid`);
352
384
  }
353
385
  return parsed;
354
386
  }
355
- async #providerForLoop(loopId) {
356
- return ProviderInstantiate.instantiateProvider(await this.#providerSpecForLoop(loopId));
387
+ async #providerPolicyForLoop(loopId) {
388
+ const row = await this.#db.drain_loop_provider_spec.get({ loop_id: loopId });
389
+ if (row === undefined)
390
+ throw new Error(`loop ${loopId}: provider selection row is missing`);
391
+ const providerSpec = this.#parseProviderSpec(loopId, "provider_spec", row.provider_spec);
392
+ if (providerSpec === null) {
393
+ throw new Error(`loop ${loopId}: persisted provider selection is missing or invalid — refusing boot-default substitution`);
394
+ }
395
+ return {
396
+ providerSpec,
397
+ childProviderSpec: this.#parseProviderSpec(loopId, "child_provider_spec", row.child_provider_spec),
398
+ };
399
+ }
400
+ async #providerSpecForLoop(loopId) {
401
+ return (await this.#providerPolicyForLoop(loopId)).providerSpec;
402
+ }
403
+ async #providersForLoop(loopId) {
404
+ const policy = await this.#providerPolicyForLoop(loopId);
405
+ const provider = await ProviderInstantiate.instantiateProvider(policy.providerSpec);
406
+ const childProvider = policy.childProviderSpec === null
407
+ ? provider
408
+ : await ProviderInstantiate.instantiateProvider(policy.childProviderSpec);
409
+ return { provider, childProvider };
357
410
  }
358
411
  async #assertLoopProvider(loopId, requested) {
359
412
  const selected = await this.#providerSpecForLoop(loopId);
@@ -370,6 +423,19 @@ export default class Daemon {
370
423
  });
371
424
  }
372
425
  }
426
+ async #assertLoopChildProvider(loopId, requested) {
427
+ const selected = (await this.#providerPolicyForLoop(loopId)).childProviderSpec;
428
+ if (JSON.stringify(selected) !== JSON.stringify(requested)) {
429
+ throw daemonFailure("daemon:provider", "loop-child-provider-conflict", 409, `Loop ${loopId} already has a different child provider policy.`, {
430
+ loopId,
431
+ selectedChildAlias: selected?.alias ?? null,
432
+ requestedChildAlias: requested?.alias ?? null,
433
+ stage: "loop-injection",
434
+ recovery: "Cancel or conclude the loop before changing its child provider policy.",
435
+ retryable: false,
436
+ });
437
+ }
438
+ }
373
439
  async #assertLoopMaxTurns(loopId, requested) {
374
440
  if (requested === undefined)
375
441
  return;
@@ -798,14 +864,12 @@ export default class Daemon {
798
864
  state: c.state,
799
865
  };
800
866
  }
801
- const tagRows = await this.#db.crud_read_tags.all({ entry_id: row.id });
802
867
  return entryReadResult({
803
868
  status: 200,
804
869
  entry: {
805
870
  entryId: row.id,
806
871
  target: location.target,
807
872
  channels,
808
- tags: tagRows.map((tag) => tag.tag),
809
873
  },
810
874
  });
811
875
  }
@@ -863,12 +927,13 @@ export default class Daemon {
863
927
  if (typeof namespaceOwner !== "string" || namespaceOwner.trim().length === 0) {
864
928
  throw new Error("registerRuntime: namespaceOwner must be a non-empty string");
865
929
  }
866
- this.#engine.registerRuntime(decl.name, {
930
+ const runtime = RuntimeDeclaration.assert(decl, namespaceOwner);
931
+ this.#engine.registerRuntime(runtime.name, {
867
932
  executor,
868
933
  namespaceOwner: { kind: "module", name: namespaceOwner },
869
- glyph: decl.glyph ?? "",
870
- example: decl.example ?? "",
871
- documentation: decl.documentation ?? "",
934
+ glyph: runtime.glyph ?? "",
935
+ invocation: runtime.invocation,
936
+ documentation: runtime.documentation ?? "",
872
937
  available: availability.available,
873
938
  detail: availability.detail,
874
939
  }, scheme);
@@ -960,6 +1025,8 @@ export default class Daemon {
960
1025
  }
961
1026
  async #recoverLifecycle() {
962
1027
  await this.#db.recovery_fail_active_loops.run({});
1028
+ await this.#db.recovery_settle_open_provider_requests.run({});
1029
+ await this.#db.recovery_fail_open_model_calls.run({});
963
1030
  await this.#db.recovery_fail_ownerless_proposals.run({});
964
1031
  await this.#db.recovery_error_orphan_subscription_channels.run({});
965
1032
  await this.#db.recovery_fail_orphan_subscriptions.run({});
@@ -1009,7 +1076,9 @@ export default class Daemon {
1009
1076
  // Settle the stopped world FIRST: a drain paused at a pending proposal awaits a resolution
1010
1077
  // that will never arrive once clients are gone — allSettled(drains) below would deadlock
1011
1078
  // the stop forever (a daemon with a pending HITL proposal could not shut down).
1079
+ const derivationAbort = new DOMException("daemon stopping", "AbortError");
1012
1080
  this.#engine.cancelAllProposals("daemon_stopping");
1081
+ this.#engine.cancelDerivations(derivationAbort);
1013
1082
  this.#branchBatches.beginStop();
1014
1083
  for (const scope of this.#workerAborts.values()) {
1015
1084
  if (!scope.signal.aborted)
@@ -1028,10 +1097,11 @@ export default class Daemon {
1028
1097
  await this.#branchBatches.idle();
1029
1098
  const drainPromises = [...this.#activeDrains.values()].map((d) => d.promise);
1030
1099
  await Promise.allSettled(drainPromises);
1100
+ await Promise.allSettled([...this.#drainExitTasks]);
1031
1101
  const closeResults = await moduleClose;
1032
1102
  const [streamingResult] = await Promise.allSettled([this.#drainStreamingSchemes()]);
1033
1103
  const [derivationResult] = await Promise.allSettled([
1034
- this.#engine.drainDerivations(), // active workspace warms finish before the db closes upstream
1104
+ this.#engine.drainDerivations(derivationAbort), // active workspace warms settle before the db closes upstream
1035
1105
  ]);
1036
1106
  const mimetypeResults = this.#ownsMimetypes
1037
1107
  ? await Promise.allSettled([this.#mimetypes.dispose()])
@@ -1108,6 +1178,8 @@ export default class Daemon {
1108
1178
  if (active !== undefined) {
1109
1179
  await this.#assertFoldPosture(workerId, args.flags, active.id); // compare with the exact durable loop
1110
1180
  await this.#assertLoopProvider(active.id, args.providerSpec);
1181
+ if (args.childProviderSpec !== undefined)
1182
+ await this.#assertLoopChildProvider(active.id, args.childProviderSpec);
1111
1183
  await this.#assertLoopMaxTurns(active.id, args.turnCeiling?.source === "explicit" ? args.turnCeiling.effective : undefined);
1112
1184
  }
1113
1185
  const result = await this.#engine.inject(workerId, prompt, args.openPaths ?? []);
@@ -1125,6 +1197,8 @@ export default class Daemon {
1125
1197
  if (slept !== undefined) {
1126
1198
  await this.#assertFoldPosture(workerId, args.flags, slept.id); // resume drops nothing silently
1127
1199
  await this.#assertLoopProvider(slept.id, args.providerSpec);
1200
+ if (args.childProviderSpec !== undefined)
1201
+ await this.#assertLoopChildProvider(slept.id, args.childProviderSpec);
1128
1202
  await this.#assertLoopMaxTurns(slept.id, args.turnCeiling?.source === "explicit" ? args.turnCeiling.effective : undefined);
1129
1203
  const injected = await this.#engine.inject(workerId, prompt, args.openPaths ?? []);
1130
1204
  await this.#lifecycle.wake(slept.id);
@@ -1138,6 +1212,7 @@ export default class Daemon {
1138
1212
  workerId,
1139
1213
  prompt,
1140
1214
  providerSpec: args.providerSpec,
1215
+ childProviderSpec: args.childProviderSpec ?? null,
1141
1216
  maxTurns: args.turnCeiling?.effective,
1142
1217
  flags: args.flags,
1143
1218
  openPaths: args.openPaths,
@@ -1168,6 +1243,7 @@ export default class Daemon {
1168
1243
  sequence: seqRow.next,
1169
1244
  prompt: args.prompt,
1170
1245
  provider_spec: JSON.stringify(args.providerSpec),
1246
+ child_provider_spec: JSON.stringify(args.childProviderSpec),
1171
1247
  max_turns: args.maxTurns ?? Number(process.env.PLURNK_SERVICE_MAX_TURNS ?? "50"),
1172
1248
  });
1173
1249
  if (loopRow === undefined)
@@ -1245,7 +1321,7 @@ export default class Daemon {
1245
1321
  // {§methods-loop-run-model} — provider identity belongs to the claimed loop, not the
1246
1322
  // drain that happened to claim it. A drain can consume multiple
1247
1323
  // queued loops; resolve each durable selection at this boundary.
1248
- const provider = await this.#providerForLoop(loopRow.id);
1324
+ const { provider, childProvider } = await this.#providersForLoop(loopRow.id);
1249
1325
  const onDispatch = (logEntryId) => {
1250
1326
  // {§methods-event-subscribe} — a log-broadcast failure must never crash the drain.
1251
1327
  void (async () => {
@@ -1256,7 +1332,7 @@ export default class Daemon {
1256
1332
  const result = await observed(// {§observability-boundary}
1257
1333
  "loop.run", { workspaceId, workerId, "loop.id": loopRow.id }, async (span) => {
1258
1334
  const loopResult = await this.#engine.runLoop({
1259
- provider, workspaceId, workerId, loopId: loopRow.id, maxTurns: loopRow.max_turns,
1335
+ provider, childProvider, workspaceId, workerId, loopId: loopRow.id, maxTurns: loopRow.max_turns,
1260
1336
  messages: [
1261
1337
  { role: "system", content: systemPrompt },
1262
1338
  { role: "user", content: loopRow.prompt },
@@ -1270,12 +1346,12 @@ export default class Daemon {
1270
1346
  return loopResult;
1271
1347
  });
1272
1348
  if (result.result.status === 202) {
1273
- // The loop slept via SEND[202] — suspended, not terminated. Leave it at 202
1349
+ // The loop slept via SEND signal 202 — suspended, not terminated. Leave it at 202
1274
1350
  // (resumable); no loop/terminated, no orphan-reconcile. A stream conclusion
1275
1351
  // (#handleWakeWorker) re-queues it; and if it holds a polled stream, a poll timer
1276
1352
  // wakes it every P to inspect ({§exec-poll}). {§worker-lifecycle-wake-liveness}.
1277
1353
  void this.#schedulePollWake(workspaceId, workerId, systemPrompt).catch((err) => console.error("poll-wake scheduling failed:", err instanceof Error ? err.message : String(err)));
1278
- // {§send-premature-terminate}/SEND[202]<T> — the park deadline:
1354
+ // {§send-premature-terminate}/scoped SEND signal 202 — the park deadline:
1279
1355
  // dispatcher recorded the marker's seconds; a bounded park is woken at T
1280
1356
  // regardless of arrivals, so a park always has a next turn. -1 (indefinite:
1281
1357
  // the butler, a [300] ask) schedules nothing — irc/inject/conclusions wake it.
@@ -1353,12 +1429,13 @@ export default class Daemon {
1353
1429
  // {§methods-loop-cancel} — loop.cancel / shutdown aborted the live drain. A cancellation
1354
1430
  // is the loop's TERMINAL state (499), delivered via loop/terminated (runLoop no
1355
1431
  // 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);
1432
+ let usage = {
1433
+ accounting: aggregateProviderAccounting([]),
1434
+ contextTokens: null,
1435
+ promptBudget: null,
1436
+ meta: {},
1437
+ };
1438
+ let attributions = [];
1362
1439
  const message = ErrorDetail.preview(controller.signal.reason ?? "user_cancelled")
1363
1440
  || "no reason was supplied";
1364
1441
  if (currentLoopId !== null) {
@@ -1369,6 +1446,10 @@ export default class Daemon {
1369
1446
  stage: "loop",
1370
1447
  retryable: false,
1371
1448
  }), { terminatedBy: "cancel" });
1449
+ [usage, attributions] = await Promise.all([
1450
+ this.#engine.loopUsage(currentLoopId),
1451
+ this.#engine.loopAttributions(currentLoopId),
1452
+ ]);
1372
1453
  if (cancelled !== null) {
1373
1454
  this.#broadcast({ workspaceId }, "loop/terminated", {
1374
1455
  workerId,
@@ -1456,8 +1537,12 @@ export default class Daemon {
1456
1537
  // Topology join ({§worker-loop-lifecycle}): when this drain exits having CONCLUDED the worker, wake its parent
1457
1538
  // if parked. Runs after the drain fully tears down (settled promise) so the quiescence check sees
1458
1539
  // 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) => {
1540
+ const drainExitTask = drainPromise.then(() => this.#onDrainExit(workspaceId, workerId, systemPrompt), () => this.#onDrainExit(workspaceId, workerId, systemPrompt));
1541
+ this.#drainExitTasks.add(drainExitTask);
1542
+ void drainExitTask.catch((err) => {
1460
1543
  console.error(`parent wake after worker ${workerId} settlement failed:`, err);
1544
+ }).finally(() => {
1545
+ this.#drainExitTasks.delete(drainExitTask);
1461
1546
  });
1462
1547
  // Swallow unhandled rejections (drain aborts with no awaiter); the
1463
1548
  // error already surfaced via firstLoopPromise or was logged inside.
@@ -1515,6 +1600,7 @@ export default class Daemon {
1515
1600
  prompt: first.body,
1516
1601
  flags: first.flags,
1517
1602
  provider_spec: first.provider_spec,
1603
+ child_provider_spec: first.child_provider_spec,
1518
1604
  max_turns: first.max_turns,
1519
1605
  open_paths: first.open_paths ?? "[]",
1520
1606
  orphan_source_loop_id: endedLoopId,
@@ -1632,23 +1718,24 @@ export default class Daemon {
1632
1718
  }
1633
1719
  /**
1634
1720
  * 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.
1721
+ * subscription closes. A parked loop resumes in place; an active loop
1722
+ * observes the channel transition at its next turn boundary. No synthetic
1723
+ * prompt or replacement loop is created.
1639
1724
  *
1640
1725
  * 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).
1726
+ * its own SEND signal 499, and a forcefully-cancelled loop's spawn-abort must
1727
+ * not resurrect the worker.
1643
1728
  */
1644
1729
  async #handleWakeWorker(payload) {
1730
+ const { entryOwnerId, ...wake } = payload;
1731
+ const conclusion = { ...wake, workerId: entryOwnerId };
1645
1732
  // {§search-gate} — settle the dedup registration: promote on a 200 conclusion, drop on
1646
1733
  // failure (a dead search must never serve as a duplicate). No-op for non-search streams.
1647
1734
  this.#engine.searchGate.settle(payload.target.replace(/^[a-z+.-]+:\/\//, "/").replace(/^\/+/, "/"), payload.result.status);
1648
1735
  // Aborted streams don't wake — the abort was deliberate.
1649
1736
  if (payload.result.status === 499) {
1650
1737
  this.#broadcast({ workspaceId: payload.workspaceId }, "stream/concluded", {
1651
- ...payload, wakeAction: "skipped-aborted",
1738
+ ...conclusion, wakeAction: "skipped-aborted",
1652
1739
  });
1653
1740
  return;
1654
1741
  }
@@ -1662,13 +1749,13 @@ export default class Daemon {
1662
1749
  const scope = this.#workerAborts.get(payload.workerId);
1663
1750
  if (scope?.signal.aborted === true && !this.#activeDrains.has(payload.workerId)) {
1664
1751
  this.#broadcast({ workspaceId: payload.workspaceId }, "stream/concluded", {
1665
- ...payload, wakeAction: "skipped-cancelled",
1752
+ ...conclusion, wakeAction: "skipped-cancelled",
1666
1753
  });
1667
1754
  return;
1668
1755
  }
1669
1756
  try {
1670
1757
  const systemPrompt = await readFile(Paths.instructionsSystem, "utf8");
1671
- // A slept (202) loop means the worker parked via SEND[202] → resume it in place: re-queue
1758
+ // A slept (202) loop means the worker parked via SEND signal 202 → resume it in place: re-queue
1672
1759
  // it (202→100) so the drain re-claims and CONTINUES it (seq>1 → no re-foist). Checked
1673
1760
  // FIRST: the slept status is the worker's true disposition regardless of a draining
1674
1761
  // sibling mid-teardown (the #ensureDrain lock serializes the re-claim). No fresh loop,
@@ -1676,16 +1763,14 @@ export default class Daemon {
1676
1763
  // the manifest. {§worker-lifecycle-wake-liveness}.
1677
1764
  const slept = await this.#db.drain_find_slept_loop.get({ worker_id: payload.workerId });
1678
1765
  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,
1766
+ // {§worker-optimistic-settlement} — publish this conclusion now,
1767
+ // then let the worker-local gate coalesce only the provider
1768
+ // dispatch. Concurrent stream/child callbacks join that one gate.
1769
+ void this.#settleCompletionWake(payload.workspaceId, payload.workerId, systemPrompt, false).catch((err) => {
1770
+ console.error("completion wake settlement failed:", err instanceof Error ? err.message : String(err));
1683
1771
  });
1684
1772
  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));
1773
+ ...conclusion, wakeAction: "resumed-loop", wakeLoopId: slept.id,
1689
1774
  });
1690
1775
  return;
1691
1776
  }
@@ -1695,14 +1780,14 @@ export default class Daemon {
1695
1780
  // synthesis (which clobbered the model's actual goal) is retired; just tell the client.
1696
1781
  if (this.#activeDrains.has(payload.workerId)) {
1697
1782
  this.#broadcast({ workspaceId: payload.workspaceId }, "stream/concluded", {
1698
- ...payload, wakeAction: "no-op-active-loop",
1783
+ ...conclusion, wakeAction: "no-op-active-loop",
1699
1784
  });
1700
1785
  return;
1701
1786
  }
1702
- // No slept loop, no active drain — nothing to resume (e.g. a SEND[200]-done worker whose
1787
+ // No slept loop, no active drain — nothing to resume (e.g. a SEND-200-done worker whose
1703
1788
  // streams were swept). Surface the conclusion without opening a loop.
1704
1789
  this.#broadcast({ workspaceId: payload.workspaceId }, "stream/concluded", {
1705
- ...payload, wakeAction: "no-loop",
1790
+ ...conclusion, wakeAction: "no-loop",
1706
1791
  });
1707
1792
  }
1708
1793
  catch (err) {
@@ -1748,13 +1833,13 @@ export default class Daemon {
1748
1833
  delayMs = execPollBackoffMs(step, base, turns);
1749
1834
  this.#pollBackoff.set(workerId, step + 1);
1750
1835
  }
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");
1836
+ // Floored by the optimistic settlement cap so a `<…,1>` cannot wake a
1837
+ // parked loop faster than the preceding turn's settlement scale.
1838
+ const optimisticWaitMs = readOptimisticSettlementMs();
1754
1839
  const timer = setTimeout(() => {
1755
1840
  this.#pollTimers.delete(workerId);
1756
1841
  void this.#wakeParkedWorker(workspaceId, workerId, systemPrompt);
1757
- }, Math.max(delayMs, execWaitMs));
1842
+ }, Math.max(delayMs, optimisticWaitMs));
1758
1843
  timer.unref();
1759
1844
  this.#pollTimers.set(workerId, timer);
1760
1845
  }
@@ -1763,6 +1848,8 @@ export default class Daemon {
1763
1848
  * or a child worker finishing ({§worker-loop-lifecycle} topology join) all call this. A no-op if the worker was
1764
1849
  * cancelled or isn't actually parked (no slept loop) — so calling it speculatively is safe. */
1765
1850
  async #wakeParkedWorker(workspaceId, workerId, systemPrompt, oweIfActive = true) {
1851
+ if (!this.#started)
1852
+ return;
1766
1853
  const scope = this.#workerAborts.get(workerId);
1767
1854
  if (scope?.signal.aborted === true && !this.#activeDrains.has(workerId))
1768
1855
  return; // cancelled — no resurrection
@@ -1772,17 +1859,100 @@ export default class Daemon {
1772
1859
  // conclusion that fired this wake arrived before the 202 committed (the conclude-before-park
1773
1860
  // race). Owe the wake: the drain honors it at park so a worker hibernation never deadlocks.
1774
1861
  // (No active drain → already concluded/running; nothing to wake.)
1775
- if (oweIfActive && this.#activeDrains.has(workerId))
1776
- this.#owedWakes.add(workerId);
1862
+ if (this.#activeDrains.has(workerId)) {
1863
+ if (oweIfActive)
1864
+ this.#owedWakes.add(workerId);
1865
+ }
1777
1866
  return;
1778
1867
  }
1779
- await this.#lifecycle.wake(slept.id);
1868
+ const woke = await this.#lifecycle.wake(slept.id);
1869
+ if (!woke)
1870
+ return;
1780
1871
  const started = await this.#ensureDrain({
1781
1872
  workspaceId, workerId, systemPrompt,
1782
1873
  });
1783
1874
  started?.drainPromise?.catch((err) => {
1784
- console.error("wake-parked resume drain failed:", err instanceof Error ? err.message : String(err));
1875
+ if (this.#started) {
1876
+ console.error("wake-parked resume drain failed:", err instanceof Error ? err.message : String(err));
1877
+ }
1878
+ });
1879
+ }
1880
+ async #workerHasLiveObligation(workerId) {
1881
+ const [openSubscriptions, liveChild] = await Promise.all([
1882
+ this.#db.find_open_subscriptions_for_worker.all({ worker_id: workerId }),
1883
+ this.#db.engine_worker_has_live_child.get({ worker_id: workerId }),
1884
+ ]);
1885
+ return openSubscriptions.length > 0 || liveChild !== undefined;
1886
+ }
1887
+ #settleCompletionWake(workspaceId, workerId, systemPrompt, oweIfActive = true) {
1888
+ const existing = this.#completionWakeGates.get(workerId);
1889
+ if (existing !== undefined) {
1890
+ existing.conclusions++;
1891
+ existing.poke.resolve();
1892
+ return existing.promise;
1893
+ }
1894
+ const completed = Promise.withResolvers();
1895
+ const gate = {
1896
+ conclusions: 1,
1897
+ poke: Promise.withResolvers(),
1898
+ promise: completed.promise,
1899
+ };
1900
+ this.#completionWakeGates.set(workerId, gate);
1901
+ void this.#runCompletionWake(workspaceId, workerId, systemPrompt, oweIfActive, gate).then(completed.resolve, completed.reject).finally(() => {
1902
+ if (this.#completionWakeGates.get(workerId) === gate) {
1903
+ this.#completionWakeGates.delete(workerId);
1904
+ }
1785
1905
  });
1906
+ return gate.promise;
1907
+ }
1908
+ async #runCompletionWake(workspaceId, workerId, systemPrompt, oweIfActive, gate) {
1909
+ const slept = await this.#db.drain_find_slept_loop.get({ worker_id: workerId });
1910
+ if (slept === undefined) {
1911
+ this.#releaseCompletionWake(workerId, gate);
1912
+ return this.#wakeParkedWorker(workspaceId, workerId, systemPrompt, oweIfActive);
1913
+ }
1914
+ const timeoutMs = readOptimisticSettlementMs();
1915
+ if (timeoutMs === 0 || !(await this.#workerHasLiveObligation(workerId))) {
1916
+ this.#releaseCompletionWake(workerId, gate);
1917
+ return this.#wakeParkedWorker(workspaceId, workerId, systemPrompt, false);
1918
+ }
1919
+ return observed("worker.wake.settlement", { "window.ms": timeoutMs }, async (span) => {
1920
+ const startedAt = performance.now();
1921
+ const signal = this.#workerAborts.get(workerId)?.signal;
1922
+ const deadline = delay(timeoutMs, "deadline", { signal, ref: false })
1923
+ .catch((cause) => {
1924
+ if (signal?.aborted === true)
1925
+ return "cancelled";
1926
+ throw cause;
1927
+ });
1928
+ let release = "quiescent";
1929
+ while (await this.#workerHasLiveObligation(workerId)) {
1930
+ const poke = gate.poke;
1931
+ const outcome = await Promise.race([
1932
+ poke.promise.then(() => "arrival"),
1933
+ deadline,
1934
+ ]);
1935
+ if (outcome === "arrival") {
1936
+ if (gate.poke === poke)
1937
+ gate.poke = Promise.withResolvers();
1938
+ continue;
1939
+ }
1940
+ release = outcome;
1941
+ break;
1942
+ }
1943
+ span.setAttribute("release", release);
1944
+ span.setAttribute("conclusions", gate.conclusions);
1945
+ span.setAttribute("elapsed.ms", Math.round(performance.now() - startedAt));
1946
+ this.#releaseCompletionWake(workerId, gate);
1947
+ if (release === "cancelled")
1948
+ return;
1949
+ return this.#wakeParkedWorker(workspaceId, workerId, systemPrompt, false);
1950
+ });
1951
+ }
1952
+ #releaseCompletionWake(workerId, gate) {
1953
+ if (this.#completionWakeGates.get(workerId) === gate) {
1954
+ this.#completionWakeGates.delete(workerId);
1955
+ }
1786
1956
  }
1787
1957
  /** A worker's drain exited. If the worker truly CONCLUDED — no 202-blocked loop, no open stream — then
1788
1958
  * wake its PARENT in place if the parent is blocked on the join (the structured-concurrency join — a
@@ -1800,7 +1970,7 @@ export default class Daemon {
1800
1970
  const parent = await this.#db.worker_parent_id.get({ worker_id: workerId });
1801
1971
  if (parent?.parent_worker_id == null)
1802
1972
  return; // a root worker — nobody to wake
1803
- await this.#wakeParkedWorker(workspaceId, parent.parent_worker_id, systemPrompt);
1973
+ await this.#settleCompletionWake(workspaceId, parent.parent_worker_id, systemPrompt);
1804
1974
  }
1805
1975
  // {§methods-event-subscribe} — subscriber failures are transport-local:
1806
1976
  // log them at this boundary and never re-enter engine control flow.