@plurnk/plurnk-service 1.5.0 → 1.6.1

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 (248) hide show
  1. package/.env.defaults +22 -27
  2. package/INSTALL.md +22 -17
  3. package/README.md +1 -1
  4. package/SPEC.md +505 -362
  5. package/dist/build-info.json +1 -1
  6. package/dist/content/edit-collision.d.ts +5 -0
  7. package/dist/content/edit-collision.d.ts.map +1 -0
  8. package/dist/content/edit-collision.js +14 -0
  9. package/dist/content/edit-collision.js.map +1 -0
  10. package/dist/content/edit-receipt.d.ts +3 -2
  11. package/dist/content/edit-receipt.d.ts.map +1 -1
  12. package/dist/content/edit-receipt.js +58 -13
  13. package/dist/content/edit-receipt.js.map +1 -1
  14. package/dist/content/index.d.ts +4 -1
  15. package/dist/content/index.d.ts.map +1 -1
  16. package/dist/content/index.js +3 -1
  17. package/dist/content/index.js.map +1 -1
  18. package/dist/content/line-anchors.d.ts +41 -0
  19. package/dist/content/line-anchors.d.ts.map +1 -0
  20. package/dist/content/line-anchors.js +191 -0
  21. package/dist/content/line-anchors.js.map +1 -0
  22. package/dist/content/read-projector.d.ts +7 -1
  23. package/dist/content/read-projector.d.ts.map +1 -1
  24. package/dist/content/read-projector.js +46 -10
  25. package/dist/content/read-projector.js.map +1 -1
  26. package/dist/content/read-resolve.js +3 -3
  27. package/dist/content/read-resolve.js.map +1 -1
  28. package/dist/core/BudgetReadout.d.ts.map +1 -1
  29. package/dist/core/BudgetReadout.js +9 -2
  30. package/dist/core/BudgetReadout.js.map +1 -1
  31. package/dist/core/ChannelWrite.js +1 -1
  32. package/dist/core/ChannelWrite.js.map +1 -1
  33. package/dist/core/ChannelWrite.sql +3 -3
  34. package/dist/core/CoreSchemeServices.d.ts +1 -1
  35. package/dist/core/CoreSchemeServices.d.ts.map +1 -1
  36. package/dist/core/CoreSchemeServices.js +1 -1
  37. package/dist/core/CoreSchemeServices.js.map +1 -1
  38. package/dist/core/Dispatcher.d.ts +15 -5
  39. package/dist/core/Dispatcher.d.ts.map +1 -1
  40. package/dist/core/Dispatcher.js +220 -978
  41. package/dist/core/Dispatcher.js.map +1 -1
  42. package/dist/core/DurableStatement.d.ts.map +1 -1
  43. package/dist/core/DurableStatement.js +2 -0
  44. package/dist/core/DurableStatement.js.map +1 -1
  45. package/dist/core/Engine.d.ts +18 -43
  46. package/dist/core/Engine.d.ts.map +1 -1
  47. package/dist/core/Engine.js +90 -1554
  48. package/dist/core/Engine.js.map +1 -1
  49. package/dist/core/Engine.sql +192 -112
  50. package/dist/core/ExecutableTools.d.ts +10 -0
  51. package/dist/core/ExecutableTools.d.ts.map +1 -0
  52. package/dist/core/ExecutableTools.js +41 -0
  53. package/dist/core/ExecutableTools.js.map +1 -0
  54. package/dist/core/ExecutorRegistry.d.ts +3 -3
  55. package/dist/core/ExecutorRegistry.d.ts.map +1 -1
  56. package/dist/core/ExecutorRegistry.js +2 -2
  57. package/dist/core/ExecutorRegistry.js.map +1 -1
  58. package/dist/core/LogBody.d.ts +3 -0
  59. package/dist/core/LogBody.d.ts.map +1 -1
  60. package/dist/core/LogBody.js +21 -5
  61. package/dist/core/LogBody.js.map +1 -1
  62. package/dist/core/LogEntryProjection.d.ts +14 -0
  63. package/dist/core/LogEntryProjection.d.ts.map +1 -0
  64. package/dist/core/LogEntryProjection.js +38 -0
  65. package/dist/core/LogEntryProjection.js.map +1 -0
  66. package/dist/core/ModelCall.d.ts +27 -0
  67. package/dist/core/ModelCall.d.ts.map +1 -0
  68. package/dist/core/ModelCall.js +127 -0
  69. package/dist/core/ModelCall.js.map +1 -0
  70. package/dist/core/PacketBuilder.d.ts +14 -25
  71. package/dist/core/PacketBuilder.d.ts.map +1 -1
  72. package/dist/core/PacketBuilder.js +170 -244
  73. package/dist/core/PacketBuilder.js.map +1 -1
  74. package/dist/core/ProblemLog.d.ts +1 -1
  75. package/dist/core/ProblemLog.d.ts.map +1 -1
  76. package/dist/core/ProblemLog.js +15 -3
  77. package/dist/core/ProblemLog.js.map +1 -1
  78. package/dist/core/ProposalLifecycle.d.ts +2 -2
  79. package/dist/core/ProposalLifecycle.d.ts.map +1 -1
  80. package/dist/core/ProposalLifecycle.js +17 -9
  81. package/dist/core/ProposalLifecycle.js.map +1 -1
  82. package/dist/core/ProviderInstantiate.d.ts.map +1 -1
  83. package/dist/core/ProviderInstantiate.js +25 -8
  84. package/dist/core/ProviderInstantiate.js.map +1 -1
  85. package/dist/core/ResourceMutations.d.ts +73 -0
  86. package/dist/core/ResourceMutations.d.ts.map +1 -0
  87. package/dist/core/ResourceMutations.js +1142 -0
  88. package/dist/core/ResourceMutations.js.map +1 -0
  89. package/dist/core/SchemeRegistry.js +10 -10
  90. package/dist/core/SchemeRegistry.js.map +1 -1
  91. package/dist/core/StoredPacket.d.ts +2 -2
  92. package/dist/core/StoredPacket.js +7 -7
  93. package/dist/core/StrikeRail.d.ts +0 -1
  94. package/dist/core/StrikeRail.d.ts.map +1 -1
  95. package/dist/core/StrikeRail.js +6 -4
  96. package/dist/core/StrikeRail.js.map +1 -1
  97. package/dist/core/TurnRunner.d.ts +72 -0
  98. package/dist/core/TurnRunner.d.ts.map +1 -0
  99. package/dist/core/TurnRunner.js +1730 -0
  100. package/dist/core/TurnRunner.js.map +1 -0
  101. package/dist/core/caps/CapsResolve.js +1 -1
  102. package/dist/core/caps/CapsResolve.js.map +1 -1
  103. package/dist/core/caps/DbChannelCaps.js +4 -4
  104. package/dist/core/caps/DbChannelCaps.js.map +1 -1
  105. package/dist/core/caps/DbEntryCaps.d.ts +2 -1
  106. package/dist/core/caps/DbEntryCaps.d.ts.map +1 -1
  107. package/dist/core/caps/DbEntryCaps.js +8 -5
  108. package/dist/core/caps/DbEntryCaps.js.map +1 -1
  109. package/dist/core/caps/DbProjectionCaps.d.ts +1 -0
  110. package/dist/core/caps/DbProjectionCaps.d.ts.map +1 -1
  111. package/dist/core/caps/DbProjectionCaps.js +29 -0
  112. package/dist/core/caps/DbProjectionCaps.js.map +1 -1
  113. package/dist/core/caps/SchemeCtxImpl.d.ts +5 -2
  114. package/dist/core/caps/SchemeCtxImpl.d.ts.map +1 -1
  115. package/dist/core/caps/SchemeCtxImpl.js +6 -4
  116. package/dist/core/caps/SchemeCtxImpl.js.map +1 -1
  117. package/dist/core/content-hash.js +2 -2
  118. package/dist/core/content-hash.js.map +1 -1
  119. package/dist/core/content-weight.d.ts +2 -0
  120. package/dist/core/content-weight.d.ts.map +1 -0
  121. package/dist/core/content-weight.js +4 -0
  122. package/dist/core/content-weight.js.map +1 -0
  123. package/dist/core/content_weight.d.ts +4 -0
  124. package/dist/core/content_weight.d.ts.map +1 -0
  125. package/dist/core/content_weight.js +6 -0
  126. package/dist/core/content_weight.js.map +1 -0
  127. package/dist/core/fork.d.ts.map +1 -1
  128. package/dist/core/fork.js +23 -7
  129. package/dist/core/fork.js.map +1 -1
  130. package/dist/core/fork.sql +38 -17
  131. package/dist/core/git-membership.d.ts.map +1 -1
  132. package/dist/core/git-membership.js +1 -2
  133. package/dist/core/git-membership.js.map +1 -1
  134. package/dist/core/git-state.js +1 -1
  135. package/dist/core/git-state.js.map +1 -1
  136. package/dist/core/packet-wire.d.ts +6 -3
  137. package/dist/core/packet-wire.d.ts.map +1 -1
  138. package/dist/core/packet-wire.js +257 -99
  139. package/dist/core/packet-wire.js.map +1 -1
  140. package/dist/core/provider-accounting.d.ts +24 -0
  141. package/dist/core/provider-accounting.d.ts.map +1 -0
  142. package/dist/core/provider-accounting.js +76 -0
  143. package/dist/core/provider-accounting.js.map +1 -0
  144. package/dist/core/scheme-types.d.ts +1 -1
  145. package/dist/core/scheme-types.d.ts.map +1 -1
  146. package/dist/core/search-gate.d.ts +2 -2
  147. package/dist/core/search-gate.d.ts.map +1 -1
  148. package/dist/core/search-gate.js +8 -8
  149. package/dist/core/search-gate.js.map +1 -1
  150. package/dist/core/turn-scheduler.js +1 -1
  151. package/dist/core/turn-scheduler.js.map +1 -1
  152. package/dist/digest/Digest.d.ts.map +1 -1
  153. package/dist/digest/Digest.js +221 -110
  154. package/dist/digest/Digest.js.map +1 -1
  155. package/dist/digest/digest.sql +40 -18
  156. package/dist/schemes/Exec.d.ts.map +1 -1
  157. package/dist/schemes/Exec.js +136 -110
  158. package/dist/schemes/Exec.js.map +1 -1
  159. package/dist/schemes/ExecOutputScheme.js +1 -1
  160. package/dist/schemes/ExecOutputScheme.js.map +1 -1
  161. package/dist/schemes/File.d.ts +5 -4
  162. package/dist/schemes/File.d.ts.map +1 -1
  163. package/dist/schemes/File.js +25 -23
  164. package/dist/schemes/File.js.map +1 -1
  165. package/dist/schemes/Log.d.ts +11 -0
  166. package/dist/schemes/Log.d.ts.map +1 -1
  167. package/dist/schemes/Log.js +172 -107
  168. package/dist/schemes/Log.js.map +1 -1
  169. package/dist/schemes/Log.sql +48 -32
  170. package/dist/schemes/Prompt.d.ts +3 -3
  171. package/dist/schemes/Prompt.d.ts.map +1 -1
  172. package/dist/schemes/Prompt.js +4 -1
  173. package/dist/schemes/Prompt.js.map +1 -1
  174. package/dist/schemes/Skill.d.ts +4 -4
  175. package/dist/schemes/Skill.d.ts.map +1 -1
  176. package/dist/schemes/Skill.js +3 -0
  177. package/dist/schemes/Skill.js.map +1 -1
  178. package/dist/schemes/Worker.d.ts +4 -4
  179. package/dist/schemes/Worker.d.ts.map +1 -1
  180. package/dist/schemes/Worker.js +11 -7
  181. package/dist/schemes/Worker.js.map +1 -1
  182. package/dist/schemes/_entry-crud.d.ts +0 -1
  183. package/dist/schemes/_entry-crud.d.ts.map +1 -1
  184. package/dist/schemes/_entry-crud.js +19 -13
  185. package/dist/schemes/_entry-crud.js.map +1 -1
  186. package/dist/schemes/_entry-crud.sql +4 -16
  187. package/dist/schemes/_entry-find.d.ts +17 -16
  188. package/dist/schemes/_entry-find.d.ts.map +1 -1
  189. package/dist/schemes/_entry-find.js +58 -52
  190. package/dist/schemes/_entry-find.js.map +1 -1
  191. package/dist/schemes/_entry-find.sql +2 -21
  192. package/dist/schemes/_entry-graph.sql +1 -1
  193. package/dist/schemes/_entry-manifest.d.ts +5 -17
  194. package/dist/schemes/_entry-manifest.d.ts.map +1 -1
  195. package/dist/schemes/_entry-manifest.js +32 -28
  196. package/dist/schemes/_entry-manifest.js.map +1 -1
  197. package/dist/schemes/_entry-ops.d.ts +5 -4
  198. package/dist/schemes/_entry-ops.d.ts.map +1 -1
  199. package/dist/schemes/_entry-ops.js +89 -49
  200. package/dist/schemes/_entry-ops.js.map +1 -1
  201. package/dist/schemes/_entry-ops.sql +30 -4
  202. package/dist/schemes/_entry-send.js +4 -4
  203. package/dist/schemes/_entry-send.js.map +1 -1
  204. package/dist/schemes/_search-index.d.ts.map +1 -1
  205. package/dist/schemes/_search-index.js +10 -2
  206. package/dist/schemes/_search-index.js.map +1 -1
  207. package/dist/schemes/exec-abort.js +2 -2
  208. package/dist/schemes/exec-abort.js.map +1 -1
  209. package/dist/server/Daemon.d.ts +7 -42
  210. package/dist/server/Daemon.d.ts.map +1 -1
  211. package/dist/server/Daemon.js +139 -900
  212. package/dist/server/Daemon.js.map +1 -1
  213. package/dist/server/DrainSupervisor.d.ts +117 -0
  214. package/dist/server/DrainSupervisor.d.ts.map +1 -0
  215. package/dist/server/DrainSupervisor.js +802 -0
  216. package/dist/server/DrainSupervisor.js.map +1 -0
  217. package/dist/server/drain.sql +2 -2
  218. package/dist/server/envelope.d.ts +0 -2
  219. package/dist/server/envelope.d.ts.map +1 -1
  220. package/dist/server/envelope.js.map +1 -1
  221. package/dist/server/envelope.sql +2 -2
  222. package/dist/server/lifecycle-recovery.sql +28 -15
  223. package/dist/server/logEntry.d.ts +2 -1
  224. package/dist/server/logEntry.d.ts.map +1 -1
  225. package/dist/server/logEntry.js +14 -2
  226. package/dist/server/logEntry.js.map +1 -1
  227. package/dist/server/logEntry.sql +5 -1
  228. package/dist/server/seam-entry-read.sql +2 -2
  229. package/dist/service.js +1 -1
  230. package/dist/service.js.map +1 -1
  231. package/migrations/001_schema.sql +622 -216
  232. package/package.json +39 -34
  233. package/dist/core/BudgetOverflow.d.ts +0 -20
  234. package/dist/core/BudgetOverflow.d.ts.map +0 -1
  235. package/dist/core/BudgetOverflow.js +0 -49
  236. package/dist/core/BudgetOverflow.js.map +0 -1
  237. package/dist/core/caps/DbTagCaps.d.ts +0 -10
  238. package/dist/core/caps/DbTagCaps.d.ts.map +0 -1
  239. package/dist/core/caps/DbTagCaps.js +0 -46
  240. package/dist/core/caps/DbTagCaps.js.map +0 -1
  241. package/dist/core/ruler_count.d.ts +0 -4
  242. package/dist/core/ruler_count.d.ts.map +0 -1
  243. package/dist/core/ruler_count.js +0 -7
  244. package/dist/core/ruler_count.js.map +0 -1
  245. package/dist/core/token-ruler.d.ts +0 -2
  246. package/dist/core/token-ruler.d.ts.map +0 -1
  247. package/dist/core/token-ruler.js +0 -5
  248. package/dist/core/token-ruler.js.map +0 -1
@@ -2,14 +2,13 @@
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";
6
- import { execPollBackoffMs } from "./exec-poll-backoff.js";
7
- import ChannelWrite from "../core/ChannelWrite.js";
8
- import { Paths } from "../index.js";
5
+ import Paths from "../Paths.js";
9
6
  import Engine from "../core/Engine.js";
10
7
  import ExecutorRegistry from "../core/ExecutorRegistry.js";
11
8
  import SchemeRegistry from "../core/SchemeRegistry.js";
12
9
  import { Mimetypes } from "@plurnk/plurnk-mimetypes";
10
+ import { RuntimeDeclaration } from "@plurnk/plurnk-execs";
11
+ import DrainSupervisor from "./DrainSupervisor.js";
13
12
  import { parsePath, Validator, } from "@plurnk/plurnk-contracts";
14
13
  import LogEntry from "./logEntry.js";
15
14
  import Envelope from "./envelope.js";
@@ -21,19 +20,15 @@ import Fork from "../core/fork.js";
21
20
  import WorkerName from "../core/WorkerName.js";
22
21
  import LoopLifecycle from "../core/LoopLifecycle.js";
23
22
  import { promptLoopPrefix } from "../core/plurnk-uri.js";
24
- import { rulerCount } from "../core/token-ruler.js";
23
+ import { contentWeight } from "../core/content-weight.js";
25
24
  import { parseAliasesFromEnv, resolveActiveAlias } from "@plurnk/plurnk-providers";
26
25
  import ProviderInstantiate from "../core/ProviderInstantiate.js";
27
26
  import { resolveLoopAlias } from "./loop-model.js";
28
- import { DEFAULT_LOOP_FLAGS } from "../core/scheme-types.js";
29
27
  import LoopFlagsReader from "../core/LoopFlagsReader.js";
30
28
  import Results, { OperationFailureError } from "../core/results.js";
31
29
  import WorkspaceGate from "../core/WorkspaceGate.js";
32
30
  import BranchBatches from "./BranchBatches.js";
33
- import ErrorDetail from "../core/ErrorDetail.js";
34
31
  import { observed, observedSync } from "../observe/spans.js";
35
- import { LOOP_TERMINALS, recordCounter } from "../observe/metrics.js";
36
- import { readOptimisticSettlementMs } from "../core/optimistic-settlement.js";
37
32
  const clientActionFailure = (error) => {
38
33
  if (error instanceof OperationFailureError)
39
34
  return error.result;
@@ -51,6 +46,7 @@ export default class Daemon {
51
46
  #workspaceGate;
52
47
  #branchBatches;
53
48
  #lifecycle;
49
+ #drains;
54
50
  #schemes;
55
51
  #mimetypes;
56
52
  #ownsMimetypes;
@@ -66,40 +62,6 @@ export default class Daemon {
66
62
  // module (plurnk-agui) subscribes and fans out to its OWN clients; core emits, never owns
67
63
  // client transport or connection state.
68
64
  #eventSubscribers = new Set();
69
- // Worker-level drain registry. At most one drain per worker. The stored object
70
- // is the drain's identity handle: start/exit compare it by reference so a
71
- // drain exiting never clobbers a successor that raced in, and a loop
72
- // enqueued during teardown is never stranded. A drain is a pure queue
73
- // consumer (claim → run → exit on empty queue); streams live independently
74
- // (subscriptions + Exec.idle), and a concluding stream routes through
75
- // inject() like any other loop source.
76
- #activeDrains = new Map();
77
- #drainExitTasks = new Set();
78
- // Per-worker cancellation scope. Loops AND the streams they spawn (execs)
79
- // share this signal, so loop.cancel / shutdown abort it once and every
80
- // in-flight subscription tears down — even a spawn that registers AFTER the
81
- // cancel self-aborts against the already-aborted signal (no race). Outlives
82
- // any single (ephemeral) drain; replaced with a fresh controller once
83
- // aborted so a later runLoop request isn't born cancelled.
84
- #workerAborts = new Map();
85
- // grammar 0.74.20 EXEC `<T,P>` — per-worker hibernation poll-wake timer. When a loop parks at
86
- // a park with a polled stream, a timer fires every P seconds to resume it ({§exec-poll}). One
87
- // per worker (the tightest cadence); cleared/replaced on each park and on cancel.
88
- #parkTimers = new Map();
89
- #pollTimers = new Map();
90
- #pollBackoff = new Map(); // {§exec-poll} — backoff step per worker
91
- // Per-worker drain-transition lock — see #withDrainLock (R4 / {§worker-lifecycle-single-drain}).
92
- #drainLocks = new Map();
93
- // {§worker-lifecycle-child-wake} — workers owed a wake: a child/stream conclusion fired while the worker was
94
- // mid-turn (not yet slept), so #wakeParkedWorker could not resume it. A child-worker conclusion is a
95
- // BOUNDED, lossless wake (a worker always concludes), so a hibernation awaiting one MUST return —
96
- // never deadlock. The drain honors the owed wake at the worker's next park, closing the conclude-
97
- // before-park race. (Only a live exec stream, unbounded absent a timeout, may hold a park open.)
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();
103
65
  constructor({ db, schemes, mimetypes, provider, nodeModulesPath, }) {
104
66
  this.#db = db;
105
67
  this.#lifecycle = new LoopLifecycle(db);
@@ -147,7 +109,7 @@ export default class Daemon {
147
109
  }))?.id;
148
110
  if (workerId === undefined)
149
111
  throw new Error("Branch worker insert returned no row");
150
- const loopId = await this.#enqueueFreshLoop({
112
+ const loopId = await this.#drains.enqueueFreshLoop({
151
113
  workerId,
152
114
  prompt,
153
115
  providerSpec,
@@ -158,7 +120,7 @@ export default class Daemon {
158
120
  },
159
121
  startChild: async (workspaceId, workerId, loopId) => {
160
122
  const systemPrompt = await readFile(Paths.instructionsSystem, "utf8");
161
- const started = await this.#ensureDrain({ workspaceId, workerId, systemPrompt });
123
+ const started = await this.#drains.ensureDrain({ workspaceId, workerId, systemPrompt });
162
124
  if (started === null)
163
125
  throw new Error(`Branch worker ${workerId} already has a live drain`);
164
126
  const result = await started.firstLoopPromise;
@@ -169,7 +131,7 @@ export default class Daemon {
169
131
  },
170
132
  wakeParent: async (workspaceId, workerId) => {
171
133
  const systemPrompt = await readFile(Paths.instructionsSystem, "utf8");
172
- await this.#settleCompletionWake(workspaceId, workerId, systemPrompt, false);
134
+ await this.#drains.settleCompletionWake(workspaceId, workerId, systemPrompt, false);
173
135
  },
174
136
  notify: (workspaceId, payload) => {
175
137
  this.#broadcast({ workspaceId }, "workspace/branch-batch", payload);
@@ -177,13 +139,12 @@ export default class Daemon {
177
139
  });
178
140
  this.#engine = new Engine({
179
141
  db, schemes: this.#schemes, mimetypes: this.#mimetypes,
180
- // {§tokenomics-agnostic-ruler} — the ONE model-facing token ruler (chars/2), NOT the
181
- // boot provider: token accounting is workspace-wide across many concurrent models, so
182
- // the write-time + catalog counts must be model-independent. Exact per-model counting
183
- // lives only at the packet-materialization fit-gate.
184
- tokenize: rulerCount,
142
+ // {§tokenomics-agnostic-ruler} — stored and catalog curation weights
143
+ // are workspace-wide across concurrent models, so they remain
144
+ // model-independent. Request-shaped token facts stay provider-owned.
145
+ weigh: contentWeight,
185
146
  streamEventNotify: (workspaceId, event) => this.notifyStreamEvent(workspaceId, event),
186
- wakeWorkerNotify: (payload) => { void this.#handleWakeWorker(payload); },
147
+ wakeWorkerNotify: (payload) => this.#drains.notifyWakeWorker(payload),
187
148
  // worker:// loop-start primitive — spawn/fork/irc deliver through
188
149
  // Daemon.inject (active sister → fold; idle → enqueue + drain). The
189
150
  // daemon owns provider + the law-file system prompt; the worker scheme
@@ -216,10 +177,58 @@ export default class Daemon {
216
177
  workspaceTurnCompleted: async ({ turnId }) => this.#branchBatches.sealTurn(turnId),
217
178
  // worker:// KILL (terminate) — cancel the addressed worker subtree and
218
179
  // tear down its held streams before the operation completes.
219
- cancelWorker: async (workerId, reason) => this.#cancelWorkerTree(workerId, reason),
220
- cancelDescendants: async (workerId, reason) => this.#cancelTree(workerId, reason, false),
180
+ cancelWorker: async (workerId, reason) => this.#drains.cancelWorkerTree(workerId, reason),
181
+ cancelDescendants: async (workerId, reason) => this.#drains.cancelDescendants(workerId, reason),
221
182
  noticeNotify: (workspaceId, payload) => this.notifyNotice(workspaceId, payload),
222
183
  });
184
+ this.#drains = new DrainSupervisor({
185
+ db,
186
+ lifecycle: this.#lifecycle,
187
+ injectPrompt: (workerId, prompt, openPaths) => this.#engine.inject(workerId, prompt, openPaths),
188
+ assertInjectionCompatibility: async ({ workerId, loopId, providerSpec, childProviderSpec, turnCeiling, flags, }) => {
189
+ await this.#assertFoldPosture(workerId, flags, loopId);
190
+ await this.#assertLoopProvider(loopId, providerSpec);
191
+ if (childProviderSpec !== undefined) {
192
+ await this.#assertLoopChildProvider(loopId, childProviderSpec);
193
+ }
194
+ await this.#assertLoopMaxTurns(loopId, turnCeiling?.source === "explicit" ? turnCeiling.effective : undefined);
195
+ },
196
+ reconcilePrompts: (workerId, endedLoopId) => this.#reconcileOrphanedPrompts(workerId, endedLoopId),
197
+ runLoop: async ({ workspaceId, workerId, loopId, maxTurns, prompt, systemPrompt, signal, onDispatch, }) => {
198
+ const { provider, childProvider } = await this.#providersForLoop(loopId);
199
+ return this.#engine.runLoop({
200
+ provider,
201
+ childProvider,
202
+ workspaceId,
203
+ workerId,
204
+ loopId,
205
+ maxTurns,
206
+ messages: [
207
+ { role: "system", content: systemPrompt },
208
+ { role: "user", content: prompt },
209
+ ],
210
+ origin: "model",
211
+ signal,
212
+ onDispatch,
213
+ });
214
+ },
215
+ loopUsage: (loopId) => this.#engine.loopUsage(loopId),
216
+ loopAttributions: (loopId) => this.#engine.loopAttributions(loopId),
217
+ takeParkDeadline: (loopId) => {
218
+ const deadline = this.#engine.parkDeadlines.get(loopId);
219
+ this.#engine.parkDeadlines.delete(loopId);
220
+ return deadline;
221
+ },
222
+ settleSearch: (target, status) => this.#engine.searchGate.settle(target, status),
223
+ cancelSubscription: (subscriptionId) => this.#engine.cancelSubscription(subscriptionId),
224
+ hasActiveStreams: (workerId) => this.#workerHasActiveStreams(workerId),
225
+ readSystemPrompt: () => readFile(Paths.instructionsSystem, "utf8"),
226
+ emitLogEntry: async (workspaceId, logEntryId) => {
227
+ const entry = await LogEntry.fetchLogEntry(this.#db, logEntryId);
228
+ this.#broadcast({ workspaceId }, "log/entry", { entry });
229
+ },
230
+ emit: (workspaceId, method, params) => this.#broadcast({ workspaceId }, method, params),
231
+ });
223
232
  // Wire proposal-pending events to the loop/proposal WS notification.
224
233
  // Sessionid scopes the broadcast to clients on the same workspace.
225
234
  this.#engine.onProposalPending((event) => {
@@ -398,8 +407,13 @@ export default class Daemon {
398
407
  async #providerSpecForLoop(loopId) {
399
408
  return (await this.#providerPolicyForLoop(loopId)).providerSpec;
400
409
  }
401
- async #providerForLoop(loopId) {
402
- return ProviderInstantiate.instantiateProvider(await this.#providerSpecForLoop(loopId));
410
+ async #providersForLoop(loopId) {
411
+ const policy = await this.#providerPolicyForLoop(loopId);
412
+ const provider = await ProviderInstantiate.instantiateProvider(policy.providerSpec);
413
+ const childProvider = policy.childProviderSpec === null
414
+ ? provider
415
+ : await ProviderInstantiate.instantiateProvider(policy.childProviderSpec);
416
+ return { provider, childProvider };
403
417
  }
404
418
  async #assertLoopProvider(loopId, requested) {
405
419
  const selected = await this.#providerSpecForLoop(loopId);
@@ -608,9 +622,7 @@ export default class Daemon {
608
622
  const isActive = active !== null && active.alias === a.alias;
609
623
  return {
610
624
  alias: a.alias, provider: a.provider, model: a.model, active: isActive,
611
- // The same effective model-facing budget loop usage reports, including
612
- // optional virtual pressure; known for the active alias, null elsewhere.
613
- promptBudget: isActive && this.#provider !== null ? this.#engine.promptBudgetFor(this.#provider) : null,
625
+ inputCapacity: isActive && this.#provider !== null ? this.#provider.inputCapacity : null,
614
626
  };
615
627
  }),
616
628
  };
@@ -853,18 +865,16 @@ export default class Daemon {
853
865
  contentOffset: c.contentOffset,
854
866
  contentLength: c.contentLength,
855
867
  mimetype: c.mimetype,
856
- tokens: c.tokens,
868
+ weight: c.weight,
857
869
  state: c.state,
858
870
  };
859
871
  }
860
- const tagRows = await this.#db.crud_read_tags.all({ entry_id: row.id });
861
872
  return entryReadResult({
862
873
  status: 200,
863
874
  entry: {
864
875
  entryId: row.id,
865
876
  target: location.target,
866
877
  channels,
867
- tags: tagRows.map((tag) => tag.tag),
868
878
  },
869
879
  });
870
880
  }
@@ -922,12 +932,13 @@ export default class Daemon {
922
932
  if (typeof namespaceOwner !== "string" || namespaceOwner.trim().length === 0) {
923
933
  throw new Error("registerRuntime: namespaceOwner must be a non-empty string");
924
934
  }
925
- this.#engine.registerRuntime(decl.name, {
935
+ const runtime = RuntimeDeclaration.assert(decl, namespaceOwner);
936
+ this.#engine.registerRuntime(runtime.name, {
926
937
  executor,
927
938
  namespaceOwner: { kind: "module", name: namespaceOwner },
928
- glyph: decl.glyph ?? "",
929
- example: decl.example ?? "",
930
- documentation: decl.documentation ?? "",
939
+ glyph: runtime.glyph ?? "",
940
+ invocation: runtime.invocation,
941
+ documentation: runtime.documentation ?? "",
931
942
  available: availability.available,
932
943
  detail: availability.detail,
933
944
  }, scheme);
@@ -975,6 +986,7 @@ export default class Daemon {
975
986
  if (this.#started)
976
987
  throw new Error("daemon already started");
977
988
  this.#started = true;
989
+ this.#drains.start();
978
990
  // Mimetypes owns its own discovery scan over @plurnk/plurnk-mimetypes-*
979
991
  // packages; pre-warm it so first index render doesn't pay the cost.
980
992
  await this.#mimetypes.ready();
@@ -1019,7 +1031,8 @@ export default class Daemon {
1019
1031
  }
1020
1032
  async #recoverLifecycle() {
1021
1033
  await this.#db.recovery_fail_active_loops.run({});
1022
- await this.#db.recovery_fail_open_provider_attempts.run({});
1034
+ await this.#db.recovery_settle_open_provider_requests.run({});
1035
+ await this.#db.recovery_fail_open_model_calls.run({});
1023
1036
  await this.#db.recovery_fail_ownerless_proposals.run({});
1024
1037
  await this.#db.recovery_error_orphan_subscription_channels.run({});
1025
1038
  await this.#db.recovery_fail_orphan_subscriptions.run({});
@@ -1027,12 +1040,12 @@ export default class Daemon {
1027
1040
  await this.#branchBatches.recover();
1028
1041
  const orphanSources = await this.#db.recovery_orphan_prompt_sources.all({});
1029
1042
  for (const source of orphanSources) {
1030
- await this.#reconcileOrphanedPrompts(source.worker_id, source.loop_id);
1043
+ await this.#drains.reconcileOrphanedPrompts(source.worker_id, source.loop_id);
1031
1044
  }
1032
1045
  const systemPrompt = await readFile(Paths.instructionsSystem, "utf8");
1033
1046
  const queued = await this.#db.recovery_queued_workers.all({});
1034
1047
  for (const row of queued) {
1035
- const started = await this.#ensureDrain({
1048
+ const started = await this.#drains.ensureDrain({
1036
1049
  workspaceId: row.workspace_id,
1037
1050
  workerId: row.worker_id,
1038
1051
  systemPrompt,
@@ -1043,7 +1056,7 @@ export default class Daemon {
1043
1056
  }
1044
1057
  const parked = await this.#db.recovery_parked_workers.all({});
1045
1058
  for (const row of parked) {
1046
- await this.#schedulePollWake(row.workspace_id, row.worker_id, systemPrompt);
1059
+ await this.#drains.schedulePollWake(row.workspace_id, row.worker_id, systemPrompt);
1047
1060
  }
1048
1061
  }
1049
1062
  async stop() {
@@ -1057,8 +1070,8 @@ export default class Daemon {
1057
1070
  .toReversed()
1058
1071
  .map((module) => Promise.resolve().then(() => module.close())));
1059
1072
  this.#moduleClosers = [];
1060
- // Drain order: (1) abort in-flight loops via #activeDrains so
1061
- // strike paths don't keep going, (2) await each drain's promise
1073
+ // Drain order: (1) tell the supervisor to abort worker scopes so
1074
+ // strike paths don't keep going, (2) await its active drains
1062
1075
  // to completion, (3) drain streaming schemes' background work
1063
1076
  // (exec spawn cleanup, channel writes). Only THEN close the DB
1064
1077
  // upstream — drain queries hit the DB right up until they exit.
@@ -1073,24 +1086,9 @@ export default class Daemon {
1073
1086
  this.#engine.cancelAllProposals("daemon_stopping");
1074
1087
  this.#engine.cancelDerivations(derivationAbort);
1075
1088
  this.#branchBatches.beginStop();
1076
- for (const scope of this.#workerAborts.values()) {
1077
- if (!scope.signal.aborted)
1078
- scope.abort("daemon_stopping");
1079
- }
1080
- for (const t of this.#pollTimers.values())
1081
- clearTimeout(t); // drop pending hibernation poll-wakes
1082
- this.#pollBackoff.clear();
1083
- this.#pollTimers.clear();
1084
- // Cancel park-deadline timers before DB close; otherwise a late #wakeParkedWorker would run after
1085
- // stop/db-close if left pending — an unhandled rejection (SqlRite closed) that abnormally
1086
- // exits the worker under load. Symmetric with the poll-wakes above; both must be reaped.
1087
- for (const t of this.#parkTimers.values())
1088
- clearTimeout(t);
1089
- this.#parkTimers.clear();
1089
+ this.#drains.beginStop("daemon_stopping");
1090
1090
  await this.#branchBatches.idle();
1091
- const drainPromises = [...this.#activeDrains.values()].map((d) => d.promise);
1092
- await Promise.allSettled(drainPromises);
1093
- await Promise.allSettled([...this.#drainExitTasks]);
1091
+ const [drainResult] = await Promise.allSettled([this.#drains.idle()]);
1094
1092
  const closeResults = await moduleClose;
1095
1093
  const [streamingResult] = await Promise.allSettled([this.#drainStreamingSchemes()]);
1096
1094
  const [derivationResult] = await Promise.allSettled([
@@ -1100,7 +1098,19 @@ export default class Daemon {
1100
1098
  ? await Promise.allSettled([this.#mimetypes.dispose()])
1101
1099
  : [];
1102
1100
  const [schemeResult] = await Promise.allSettled([this.#schemes.close()]);
1103
- const closeErrors = [...closeResults, streamingResult, derivationResult, ...mimetypeResults, schemeResult]
1101
+ // Streaming and scheme closure are the last producers of synchronous
1102
+ // conclusion notifications. Join the supervisor-owned async tails only
1103
+ // after those producers settle, before the caller may close SQLite.
1104
+ const [wakeResult] = await Promise.allSettled([this.#drains.idle()]);
1105
+ const closeErrors = [
1106
+ ...closeResults,
1107
+ drainResult,
1108
+ streamingResult,
1109
+ derivationResult,
1110
+ ...mimetypeResults,
1111
+ schemeResult,
1112
+ wakeResult,
1113
+ ]
1104
1114
  .filter((result) => result.status === "rejected")
1105
1115
  .flatMap((result) => result.reason instanceof AggregateError
1106
1116
  ? [...result.reason.errors]
@@ -1129,18 +1139,6 @@ export default class Daemon {
1129
1139
  notifyNotice(workspaceId, payload) {
1130
1140
  this.#broadcast({ workspaceId }, "notice/event", payload);
1131
1141
  }
1132
- /**
1133
- * Inject a prompt into a worker. Two paths:
1134
- * - Active drain: writes the next prompt:///<loop>/<N> entry via
1135
- * Engine.inject. The current loop publishes it at its next
1136
- * turn. Returns immediately with {action: "injected_next_turn"}.
1137
- * - No active drain: enqueues a fresh loop with the prompt at
1138
- * status=100, starts a drain. Returns the drain promise so the
1139
- * caller can await full completion.
1140
- *
1141
- * Both `runLoop` and wake-on-completion go through this method
1142
- * ({§actor-boundary-passive-wake}).
1143
- */
1144
1142
  // {§methods-loop-run-fold-consistency} — a folded prompt cannot reconfigure its loop.
1145
1143
  async #assertFoldPosture(workerId, flags, loopId) {
1146
1144
  if (flags === undefined || Object.keys(flags).length === 0)
@@ -1161,815 +1159,56 @@ export default class Daemon {
1161
1159
  });
1162
1160
  }
1163
1161
  }
1164
- async inject(args) {
1165
- const { workspaceId, workerId, prompt } = args;
1166
- // Active loop (status=102)? Fold the wake/prompt into its next turn.
1167
- // engine.inject returns null when no loop is currently executing, so
1168
- // we enqueue a fresh loop below and ensure a drain claims it.
1169
- if (this.#activeDrains.has(workerId)) {
1170
- const active = await this.#db.drain_current_loop_for_worker.get({ worker_id: workerId });
1171
- if (active !== undefined) {
1172
- await this.#assertFoldPosture(workerId, args.flags, active.id); // compare with the exact durable loop
1173
- await this.#assertLoopProvider(active.id, args.providerSpec);
1174
- if (args.childProviderSpec !== undefined)
1175
- await this.#assertLoopChildProvider(active.id, args.childProviderSpec);
1176
- await this.#assertLoopMaxTurns(active.id, args.turnCeiling?.source === "explicit" ? args.turnCeiling.effective : undefined);
1177
- }
1178
- const result = await this.#engine.inject(workerId, prompt, args.openPaths ?? []);
1179
- if (result !== null) {
1180
- return { action: "injected_next_turn", loopId: result.loopId, turnSeq: result.turnSeq };
1181
- }
1182
- }
1183
- // {§worker-lifecycle-wake-requeue-not-terminal} — a worker parked at 202 resumes that loop in place:
1184
- // is a wake edge like a stream/child conclusion, not a fresh loop that orphans the parked one
1185
- // (which would leave the worker non-quiescent forever). engine.inject writes the message as the
1186
- // slept loop's next-turn prompt (the directed message — distinct from the env door, which
1187
- // resumes promptless); then re-queue + drain it. {§worker-lifecycle-wake-liveness}.
1188
- if (!this.#activeDrains.has(workerId)) {
1189
- const slept = await this.#db.drain_find_slept_loop.get({ worker_id: workerId });
1190
- if (slept !== undefined) {
1191
- await this.#assertFoldPosture(workerId, args.flags, slept.id); // resume drops nothing silently
1192
- await this.#assertLoopProvider(slept.id, args.providerSpec);
1193
- if (args.childProviderSpec !== undefined)
1194
- await this.#assertLoopChildProvider(slept.id, args.childProviderSpec);
1195
- await this.#assertLoopMaxTurns(slept.id, args.turnCeiling?.source === "explicit" ? args.turnCeiling.effective : undefined);
1196
- const injected = await this.#engine.inject(workerId, prompt, args.openPaths ?? []);
1197
- await this.#lifecycle.wake(slept.id);
1198
- const started = await this.#ensureDrain({
1199
- workspaceId, workerId, systemPrompt: args.systemPrompt,
1200
- });
1201
- return { action: "injected_next_turn", loopId: slept.id, ...(injected?.turnSeq !== undefined ? { turnSeq: injected.turnSeq } : {}), ...(started ?? {}) };
1202
- }
1203
- }
1204
- const loopId = await this.#enqueueFreshLoop({
1205
- workerId,
1206
- prompt,
1207
- providerSpec: args.providerSpec,
1208
- childProviderSpec: args.childProviderSpec ?? null,
1209
- maxTurns: args.turnCeiling?.effective,
1210
- flags: args.flags,
1211
- openPaths: args.openPaths,
1212
- });
1213
- // Guarantee a drain claims the loop we just enqueued. #ensureDrain runs its
1214
- // check-and-start UNDER the per-worker drain lock ({§worker-lifecycle-single-drain}),
1215
- // serialized against a draining sibling's teardown relinquish so the two can't
1216
- // both register a drain (R4). A live drain re-claims the loop in its own
1217
- // iteration or its lock-held exit re-claim, so it's never stranded.
1218
- // firstLoopPromise is present only when THIS call started the drain — runLoop
1219
- // keys its fast-path response on that.
1220
- const started = await this.#ensureDrain({
1221
- workspaceId, workerId, systemPrompt: args.systemPrompt,
1222
- });
1223
- return { action: "enqueued_new_loop", loopId, ...(started ?? {}) };
1224
- }
1225
- async #enqueueFreshLoop(args) {
1226
- // {§worker-lifecycle-single-drain}: sequence allocation and insertion
1227
- // are one queue mutation; another accepted prompt cannot claim the gap.
1228
- return this.#withDrainLock(args.workerId, async () => {
1229
- const seqRow = await this.#db.loop_run_next_sequence.get({
1230
- worker_id: args.workerId,
1231
- });
1232
- if (seqRow === undefined)
1233
- throw new Error("enqueueFreshLoop: next-sequence query returned no row");
1234
- const loopRow = await this.#db.drain_enqueue_loop.get({
1235
- worker_id: args.workerId,
1236
- sequence: seqRow.next,
1237
- prompt: args.prompt,
1238
- provider_spec: JSON.stringify(args.providerSpec),
1239
- child_provider_spec: JSON.stringify(args.childProviderSpec),
1240
- max_turns: args.maxTurns ?? Number(process.env.PLURNK_SERVICE_MAX_TURNS ?? "50"),
1241
- });
1242
- if (loopRow === undefined)
1243
- throw new Error("enqueueFreshLoop: loop enqueue returned no row");
1244
- if (args.flags !== undefined) {
1245
- await this.#db.engine_set_loop_flags.run({
1246
- loop_id: loopRow.id,
1247
- flags: JSON.stringify({ ...DEFAULT_LOOP_FLAGS, ...args.flags }),
1248
- });
1249
- }
1250
- if (args.openPaths !== undefined && args.openPaths.length > 0) {
1251
- await this.#db.engine_set_loop_open_paths.run({
1252
- loop_id: loopRow.id,
1253
- open_paths: JSON.stringify(args.openPaths),
1254
- });
1255
- }
1256
- return loopRow.id;
1257
- });
1162
+ inject(args) {
1163
+ return this.#drains.inject(args);
1258
1164
  }
1259
- /**
1260
- * Start a drain for the given worker. The drain claims queued loops via
1261
- * drain_claim_next_loop (atomic 100→102 flip), executes each via
1262
- * Engine.runLoop, and re-checks. Stream-aware: when the queue is empty
1263
- * but the worker has active subscriptions, the drain parks on a
1264
- * #drainPokes signal — wake-on-completion → inject() wakes it. Drain
1265
- * exits when queue is empty AND no active subscriptions remain.
1266
- *
1267
- * Returns both `firstLoopPromise` (resolves once the first loop the
1268
- * drain processes completes — used by runLoop to give the caller a
1269
- * fast response containing their loop's result) and `drainPromise`
1270
- * (resolves only when the whole drain finishes, queue+subs settled).
1271
- */
1272
- #startDrain(opts) {
1273
- const { workspaceId, workerId, systemPrompt } = opts;
1274
- // The drain runs under the worker's cancellation scope (shared with the
1275
- // execs its loops spawn), so loop.cancel/shutdown abort it as a unit.
1276
- const controller = this.#workerSignal(workerId);
1277
- const handle = {
1278
- controller, promise: Promise.resolve(),
1279
- };
1280
- let resolveFirst = () => { };
1281
- let rejectFirst = () => { };
1282
- const firstLoopPromise = new Promise((res, rej) => {
1283
- resolveFirst = res;
1284
- rejectFirst = rej;
1285
- });
1286
- let firstSettled = false;
1287
- const claim = () => this.#db.drain_claim_next_loop.get({ worker_id: workerId });
1288
- const drainPromise = (async () => {
1289
- let loopsDrained = 0;
1290
- let lastResult = null;
1291
- let currentLoopId = null; // the loop being drained — for abort→499 settlement
1292
- try {
1293
- while (true) {
1294
- controller.signal.throwIfAborted();
1295
- let loopRow = await claim();
1296
- if (loopRow === undefined) {
1297
- // Queue empty → teardown UNDER the per-worker drain lock (R4 / I1),
1298
- // serialized against #ensureDrain so a concurrent inject can't
1299
- // start a 2nd drain in the gap. Re-claim while holding the lock;
1300
- // relinquish the registry slot only if it's empty too. A loop
1301
- // that raced in is returned and run — we stay registered, so
1302
- // there's no transient delete for #ensureDrain to catch.
1303
- loopRow = await this.#withDrainLock(workerId, async () => {
1304
- const claimed = await claim();
1305
- if (claimed === undefined && this.#activeDrains.get(workerId) === handle) {
1306
- this.#activeDrains.delete(workerId);
1307
- }
1308
- return claimed;
1309
- });
1310
- if (loopRow === undefined)
1311
- break;
1312
- }
1313
- currentLoopId = loopRow.id;
1314
- // {§methods-loop-run-model} — provider identity belongs to the claimed loop, not the
1315
- // drain that happened to claim it. A drain can consume multiple
1316
- // queued loops; resolve each durable selection at this boundary.
1317
- const provider = await this.#providerForLoop(loopRow.id);
1318
- const onDispatch = (logEntryId) => {
1319
- // {§methods-event-subscribe} — a log-broadcast failure must never crash the drain.
1320
- void (async () => {
1321
- const entry = await LogEntry.fetchLogEntry(this.#db, logEntryId);
1322
- this.#broadcast({ workspaceId }, "log/entry", { entry });
1323
- })().catch((e) => console.error("log/entry broadcast failed:", e instanceof Error ? e.message : String(e)));
1324
- };
1325
- const result = await observed(// {§observability-boundary}
1326
- "loop.run", { workspaceId, workerId, "loop.id": loopRow.id }, async (span) => {
1327
- const loopResult = await this.#engine.runLoop({
1328
- provider, workspaceId, workerId, loopId: loopRow.id, maxTurns: loopRow.max_turns,
1329
- messages: [
1330
- { role: "system", content: systemPrompt },
1331
- { role: "user", content: loopRow.prompt },
1332
- ],
1333
- origin: "model",
1334
- onDispatch,
1335
- signal: controller.signal,
1336
- });
1337
- span.setAttribute("status", loopResult.result.status);
1338
- recordCounter(LOOP_TERMINALS, { status: loopResult.result.status });
1339
- return loopResult;
1340
- });
1341
- if (result.result.status === 202) {
1342
- // The loop slept via SEND[202] — suspended, not terminated. Leave it at 202
1343
- // (resumable); no loop/terminated, no orphan-reconcile. A stream conclusion
1344
- // (#handleWakeWorker) re-queues it; and if it holds a polled stream, a poll timer
1345
- // wakes it every P to inspect ({§exec-poll}). {§worker-lifecycle-wake-liveness}.
1346
- void this.#schedulePollWake(workspaceId, workerId, systemPrompt).catch((err) => console.error("poll-wake scheduling failed:", err instanceof Error ? err.message : String(err)));
1347
- // {§send-premature-terminate}/SEND[202]<T> — the park deadline:
1348
- // dispatcher recorded the marker's seconds; a bounded park is woken at T
1349
- // regardless of arrivals, so a park always has a next turn. -1 (indefinite:
1350
- // the butler, a [300] ask) schedules nothing — irc/inject/conclusions wake it.
1351
- // In-memory: a daemon restart drops pending deadlines.
1352
- if (currentLoopId !== null) {
1353
- const deadline = this.#engine.parkDeadlines.get(currentLoopId);
1354
- this.#engine.parkDeadlines.delete(currentLoopId);
1355
- const prior = this.#parkTimers.get(workerId);
1356
- if (prior !== undefined) {
1357
- clearTimeout(prior);
1358
- this.#parkTimers.delete(workerId);
1359
- }
1360
- if (deadline !== undefined && deadline > 0) {
1361
- const t = setTimeout(() => {
1362
- this.#parkTimers.delete(workerId);
1363
- void this.#wakeParkedWorker(workspaceId, workerId, systemPrompt).catch((err) => console.error("park-deadline wake failed:", err instanceof Error ? err.message : String(err)));
1364
- }, deadline * 1000);
1365
- t.unref();
1366
- this.#parkTimers.set(workerId, t);
1367
- }
1368
- }
1369
- // Honor an OWED wake ({§worker-lifecycle-child-wake}): a child/stream concluded while
1370
- // this worker was mid-turn, before it slept — resume in place rather than park blind,
1371
- // so a worker hibernation always returns. The loop is 202 here; reset to
1372
- // claimable and the drain re-runs it on the next claim below.
1373
- if (this.#owedWakes.delete(workerId)) {
1374
- await this.#lifecycle.wake(loopRow.id);
1375
- currentLoopId = null;
1376
- continue;
1377
- }
1378
- // The loop is blocked at 202 on a live obligation ({§wait-obligation-matrix});
1379
- // that obligation's conclusion is its wake edge (the owed-wake above covers the
1380
- // conclude-before-block race). An idle wait never reaches here — it concluded at dispatch.
1381
- currentLoopId = null;
1382
- continue;
1383
- }
1384
- this.#owedWakes.delete(workerId); // the loop concluded (non-202) — no park to honor a held wake at
1385
- const [usage, attributions, turnIds] = await Promise.all([
1386
- this.#engine.loopUsage(loopRow.id),
1387
- this.#engine.loopAttributions(loopRow.id),
1388
- this.#lifecycle.turnIds(loopRow.id),
1389
- ]);
1390
- this.#broadcast({ workspaceId }, "loop/terminated", {
1391
- workerId,
1392
- loopId: loopRow.id,
1393
- result: result.result,
1394
- hitMaxTurns: result.hitMaxTurns,
1395
- turnIds,
1396
- usage,
1397
- attributions,
1398
- });
1399
- loopsDrained++;
1400
- const loopResult = {
1401
- loopId: loopRow.id,
1402
- turnIds,
1403
- result: result.result,
1404
- hitMaxTurns: result.hitMaxTurns,
1405
- usage,
1406
- attributions,
1407
- };
1408
- lastResult = loopResult;
1409
- if (!firstSettled) {
1410
- firstSettled = true;
1411
- resolveFirst(loopResult);
1412
- }
1413
- // A next-turn prompt this loop ended before consuming (a
1414
- // wake conclusion or a runLoop-while-active prompt) is promoted to
1415
- // a fresh queued loop so it's never silently dropped.
1416
- await this.#reconcileOrphanedPrompts(workerId, loopRow.id);
1417
- currentLoopId = null;
1418
- }
1419
- }
1420
- catch (err) {
1421
- if (controller.signal.aborted) {
1422
- // {§methods-loop-cancel} — loop.cancel / shutdown aborted the live drain. A cancellation
1423
- // is the loop's TERMINAL state (499), delivered via loop/terminated (runLoop no
1424
- // longer blocks to return it). A genuine error rejects firstLoopPromise.
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 = [];
1437
- const message = ErrorDetail.preview(controller.signal.reason ?? "user_cancelled")
1438
- || "no reason was supplied";
1439
- if (currentLoopId !== null) {
1440
- // {§methods-loop-cancel}/{§worker-lifecycle-terminal-result} —
1441
- // persist the exact 499 cancellation result before broadcasting it.
1442
- const cancelled = await this.#lifecycle.finish(currentLoopId, Results.failure("lifecycle:cancel", "loop-cancelled", 499, `The loop was cancelled: ${message}.`, {}, {
1443
- reason: message,
1444
- stage: "loop",
1445
- retryable: false,
1446
- }), { terminatedBy: "cancel" });
1447
- [usage, attributions] = await Promise.all([
1448
- this.#engine.loopUsage(currentLoopId),
1449
- this.#engine.loopAttributions(currentLoopId),
1450
- ]);
1451
- if (cancelled !== null) {
1452
- this.#broadcast({ workspaceId }, "loop/terminated", {
1453
- workerId,
1454
- loopId: currentLoopId,
1455
- result: cancelled,
1456
- hitMaxTurns: false,
1457
- turnIds: await this.#lifecycle.turnIds(currentLoopId),
1458
- usage,
1459
- attributions,
1460
- });
1461
- }
1462
- }
1463
- if (!firstSettled) {
1464
- firstSettled = true;
1465
- resolveFirst({
1466
- loopId: currentLoopId ?? 0,
1467
- turnIds: [],
1468
- result: currentLoopId === null
1469
- ? Results.failure("lifecycle:cancel", "loop-cancelled", 499, `The loop was cancelled: ${message}.`, {}, {
1470
- reason: message,
1471
- stage: "loop",
1472
- retryable: false,
1473
- })
1474
- : await this.#lifecycle.result(currentLoopId)
1475
- ?? Results.failure("lifecycle:cancel", "loop-cancelled", 499, `The loop was cancelled: ${message}.`, {}, {
1476
- reason: message,
1477
- stage: "loop",
1478
- retryable: false,
1479
- }),
1480
- hitMaxTurns: false,
1481
- usage,
1482
- });
1483
- }
1484
- }
1485
- else {
1486
- // {§worker-lifecycle-terminal-result} — a non-abort drain
1487
- // failure becomes an exact durable 500 and terminal notification;
1488
- // daemon diagnostics retain the complete caught error.
1489
- console.error(`drain error (workspace ${workspaceId}, worker ${workerId}, loop ${currentLoopId ?? "?"}):`, err);
1490
- if (currentLoopId !== null) {
1491
- const failure = err instanceof OperationFailureError
1492
- ? err.result
1493
- : Results.failure("daemon:drain", "loop-threw", 500, "The loop failed outside its operation result contract.", {}, {
1494
- stage: "loop",
1495
- retryable: false,
1496
- });
1497
- const settled = await this.#lifecycle.finish(currentLoopId, failure)
1498
- ?? await this.#lifecycle.result(currentLoopId);
1499
- if (settled === null) {
1500
- throw new Error(`drain could not settle loop ${currentLoopId}`, { cause: err });
1501
- }
1502
- const [usage, attributions] = await Promise.all([
1503
- this.#engine.loopUsage(currentLoopId),
1504
- this.#engine.loopAttributions(currentLoopId),
1505
- ]);
1506
- this.#broadcast({ workspaceId }, "loop/terminated", {
1507
- workerId,
1508
- loopId: currentLoopId,
1509
- result: settled,
1510
- hitMaxTurns: false,
1511
- turnIds: await this.#lifecycle.turnIds(currentLoopId),
1512
- usage,
1513
- attributions,
1514
- });
1515
- }
1516
- if (!firstSettled) {
1517
- firstSettled = true;
1518
- rejectFirst(err);
1519
- }
1520
- }
1521
- throw err;
1522
- }
1523
- finally {
1524
- if (!firstSettled) {
1525
- firstSettled = true;
1526
- rejectFirst(new Error("drain exited without producing a result"));
1527
- }
1528
- if (this.#activeDrains.get(workerId) === handle)
1529
- this.#activeDrains.delete(workerId);
1530
- }
1531
- return { loopsDrained, lastResult };
1532
- })();
1533
- handle.promise = drainPromise;
1534
- this.#activeDrains.set(workerId, handle);
1535
- // Topology join ({§worker-loop-lifecycle}): when this drain exits having CONCLUDED the worker, wake its parent
1536
- // if parked. Runs after the drain fully tears down (settled promise) so the quiescence check sees
1537
- // final state; speculative (#onDrainExit no-ops unless the worker concluded AND the parent is parked).
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) => {
1541
- console.error(`parent wake after worker ${workerId} settlement failed:`, err);
1542
- }).finally(() => {
1543
- this.#drainExitTasks.delete(drainExitTask);
1544
- });
1545
- // Swallow unhandled rejections (drain aborts with no awaiter); the
1546
- // error already surfaced via firstLoopPromise or was logged inside.
1547
- drainPromise.catch(() => { });
1548
- firstLoopPromise.catch(() => { });
1549
- return { firstLoopPromise, drainPromise };
1550
- }
1551
- // Per-worker drain-transition lock (R4 / {§worker-lifecycle-single-drain}). #ensureDrain's
1552
- // start and a drain's teardown relinquish both run under it, serialized, so the two
1553
- // can't interleave and register two drains for one worker. The critical section is the
1554
- // registry decision only (never a loop's work) — a sub-ms hop at drain boundaries.
1555
- // A promise-chain mutex: each caller awaits the prior holder; the tail self-prunes
1556
- // when idle so the Map stays bounded to workers mid-transition.
1557
- #withDrainLock(workerId, fn) {
1558
- const prev = this.#drainLocks.get(workerId) ?? Promise.resolve();
1559
- const run = prev.then(fn, fn);
1560
- const tail = run.catch(() => { });
1561
- this.#drainLocks.set(workerId, tail);
1562
- void tail.then(() => { if (this.#drainLocks.get(workerId) === tail)
1563
- this.#drainLocks.delete(workerId); });
1564
- return run;
1565
- }
1566
- // The drain guarantee, serialized per worker via #withDrainLock so it can't race a
1567
- // sibling drain's teardown relinquish into a double-drain (R4). A live drain
1568
- // (registered, NOT aborting) will claim the just-enqueued loop in its own iteration
1569
- // or its lock-held exit re-claim → return null. A registered-but-ABORTING drain is
1570
- // in teardown and won't claim, so we don't defer to it — start fresh, or the loop
1571
- // strands on a cancel/resume race (I6 no-lost-loop). Otherwise start one.
1572
- #ensureDrain(opts) {
1573
- return this.#withDrainLock(opts.workerId, async () => {
1574
- const existing = this.#activeDrains.get(opts.workerId);
1575
- if (existing !== undefined && !existing.controller.signal.aborted)
1576
- return null;
1577
- return this.#startDrain(opts);
1578
- });
1579
- }
1580
- // After a loop terminates, promote every next-turn frame it never consumed
1581
- // into one source-keyed queued loop. The first frame occupies the loop seed;
1582
- // later frames retain separate prompt entries and publish in the same turn.
1583
- // Re-entry and boot recovery complete that same queued identity.
1165
+ // Durable prompt promotion remains daemon policy; DrainSupervisor invokes
1166
+ // it under the same worker lock as enqueue and drain teardown.
1584
1167
  async #reconcileOrphanedPrompts(workerId, endedLoopId) {
1585
- await this.#withDrainLock(workerId, async () => {
1586
- const endedSeq = (await this.#db.engine_loop_sequence.get({ loop_id: endedLoopId }))?.sequence ?? endedLoopId;
1587
- const prefix = promptLoopPrefix(endedSeq);
1588
- const frames = await this.#db.drain_orphaned_prompts_for_loop.all({ loop_id: endedLoopId, owner_id: workerId, pattern: `${prefix}%`, prefix_len: prefix.length });
1589
- const first = frames[0];
1590
- if (first === undefined)
1591
- return;
1592
- const seqRow = await this.#db.loop_run_next_sequence.get({ worker_id: workerId });
1593
- if (seqRow === undefined)
1594
- throw new Error("reconcileOrphanedPrompts: next-sequence query returned no row");
1595
- const recovery = await this.#db.drain_enqueue_orphan_recovery_loop.get({
1596
- worker_id: workerId,
1597
- sequence: seqRow.next,
1598
- prompt: first.body,
1599
- flags: first.flags,
1600
- provider_spec: first.provider_spec,
1601
- child_provider_spec: first.child_provider_spec,
1602
- max_turns: first.max_turns,
1603
- open_paths: first.open_paths ?? "[]",
1604
- orphan_source_loop_id: endedLoopId,
1605
- });
1606
- if (recovery === undefined)
1607
- throw new Error("reconcileOrphanedPrompts: enqueue returned no row");
1608
- if (recovery.status !== 100)
1609
- return;
1610
- const moved = await this.#db.drain_rehome_orphaned_prompt_frames.all({
1611
- owner_id: workerId,
1612
- source_loop_id: endedLoopId,
1613
- source_pattern: `${prefix}%`,
1614
- source_prefix_len: prefix.length,
1615
- target_prefix: promptLoopPrefix(recovery.sequence),
1616
- });
1617
- if (moved.length !== frames.length) {
1618
- throw new Error(`reconcileOrphanedPrompts: expected to re-home ${frames.length} frames, moved ${moved.length}`);
1619
- }
1168
+ const endedSeq = (await this.#db.engine_loop_sequence.get({ loop_id: endedLoopId }))?.sequence ?? endedLoopId;
1169
+ const prefix = promptLoopPrefix(endedSeq);
1170
+ const frames = await this.#db.drain_orphaned_prompts_for_loop.all({ loop_id: endedLoopId, owner_id: workerId, pattern: `${prefix}%`, prefix_len: prefix.length });
1171
+ const first = frames[0];
1172
+ if (first === undefined)
1173
+ return;
1174
+ const seqRow = await this.#db.loop_run_next_sequence.get({ worker_id: workerId });
1175
+ if (seqRow === undefined)
1176
+ throw new Error("reconcileOrphanedPrompts: next-sequence query returned no row");
1177
+ const recovery = await this.#db.drain_enqueue_orphan_recovery_loop.get({
1178
+ worker_id: workerId,
1179
+ sequence: seqRow.next,
1180
+ prompt: first.body,
1181
+ flags: first.flags,
1182
+ provider_spec: first.provider_spec,
1183
+ child_provider_spec: first.child_provider_spec,
1184
+ max_turns: first.max_turns,
1185
+ open_paths: first.open_paths ?? "[]",
1186
+ orphan_source_loop_id: endedLoopId,
1620
1187
  });
1621
- }
1622
- // The worker's cancellation scope — lazily created, and replaced once aborted
1623
- // so a later runLoop gets a live signal. The drain and the execs its loops
1624
- // spawn all run under it.
1625
- #workerSignal(workerId) {
1626
- const existing = this.#workerAborts.get(workerId);
1627
- if (existing !== undefined && !existing.signal.aborted)
1628
- return existing;
1629
- const fresh = new AbortController();
1630
- this.#workerAborts.set(workerId, fresh);
1631
- return fresh;
1632
- }
1633
- async #cancelTree(workerId, reason, includeRoot) {
1634
- const cancelled = await this.#lifecycle.cancelTree(workerId, reason, includeRoot);
1635
- for (const targetWorkerId of cancelled.workerIds) {
1636
- const pollTimer = this.#pollTimers.get(targetWorkerId);
1637
- if (pollTimer !== undefined) {
1638
- clearTimeout(pollTimer);
1639
- this.#pollTimers.delete(targetWorkerId);
1640
- }
1641
- const parkTimer = this.#parkTimers.get(targetWorkerId);
1642
- if (parkTimer !== undefined) {
1643
- clearTimeout(parkTimer);
1644
- this.#parkTimers.delete(targetWorkerId);
1645
- }
1646
- this.#pollBackoff.delete(targetWorkerId);
1647
- this.#owedWakes.delete(targetWorkerId);
1648
- const scope = this.#workerAborts.get(targetWorkerId);
1649
- if (scope !== undefined && !scope.signal.aborted)
1650
- scope.abort(reason);
1651
- }
1652
- await Promise.all(cancelled.workerIds.map(async (targetWorkerId) => this.#reapWorkerStreams(targetWorkerId)));
1653
- for (const { loopId, workerId: targetWorkerId, result } of cancelled.loops) {
1654
- const row = await this.#db.drain_get_worker_workspace.get({ worker_id: targetWorkerId });
1655
- if (row === undefined)
1656
- continue;
1657
- const [usage, attributions] = await Promise.all([
1658
- this.#engine.loopUsage(loopId),
1659
- this.#engine.loopAttributions(loopId),
1660
- ]);
1661
- this.#broadcast({ workspaceId: row.workspace_id }, "loop/terminated", {
1662
- workerId: targetWorkerId,
1663
- loopId,
1664
- result,
1665
- hitMaxTurns: false,
1666
- turnIds: await this.#lifecycle.turnIds(loopId),
1667
- usage,
1668
- attributions,
1669
- });
1188
+ if (recovery === undefined)
1189
+ throw new Error("reconcileOrphanedPrompts: enqueue returned no row");
1190
+ if (recovery.status !== 100)
1191
+ return;
1192
+ const moved = await this.#db.drain_rehome_orphaned_prompt_frames.all({
1193
+ owner_id: workerId,
1194
+ source_loop_id: endedLoopId,
1195
+ source_pattern: `${prefix}%`,
1196
+ source_prefix_len: prefix.length,
1197
+ target_prefix: promptLoopPrefix(recovery.sequence),
1198
+ });
1199
+ if (moved.length !== frames.length) {
1200
+ throw new Error(`reconcileOrphanedPrompts: expected to re-home ${frames.length} frames, moved ${moved.length}`);
1670
1201
  }
1671
1202
  }
1672
- async #cancelWorkerTree(workerId, reason) {
1673
- await this.#cancelTree(workerId, reason, true);
1674
- }
1675
- /**
1676
- * Cancel the worker's in-flight work (loop.cancel). One abort, one scope: the
1677
- * worker signal stops the running loop's turn generation AND tears down every
1678
- * stream linked to it — a background exec that outlived its loop, or even a
1679
- * spawn that registers after this abort (it self-aborts against the aborted
1680
- * signal). Returns cancelled iff there was process-local work; durable
1681
- * unresolved loops in the worker tree are terminalized independently.
1682
- */
1683
1203
  cancelDrain(workerId, reason = "user_cancelled") {
1684
- const hadDrain = this.#activeDrains.has(workerId);
1685
- const hadWork = hadDrain || this.#workerHasActiveStreams(workerId);
1686
- // A cancel is deliberate — kill any pending hibernation poll-wake so it can't resurrect the worker.
1687
- const pollTimer = this.#pollTimers.get(workerId);
1688
- if (pollTimer !== undefined) {
1689
- clearTimeout(pollTimer);
1690
- this.#pollTimers.delete(workerId);
1691
- }
1692
- // Stop the active drain's turn-generation (its loop closes 499). The worker
1693
- // signal is the optimization path — the fast, listener-driven reap.
1694
- // Durable structured cancellation: one recursive transition claims the
1695
- // worker and every unresolved descendant, then reaps each process-local scope.
1696
- void this.#cancelWorkerTree(workerId, reason).catch((err) => {
1697
- console.error(`cancelTree(${workerId}) failed:`, err);
1698
- });
1699
- return hadWork;
1204
+ return this.#drains.cancel(workerId, reason);
1700
1205
  }
1701
- // Does the worker have an in-flight stream (a background exec)? Used only for
1702
- // loop.cancel's cancelled=true/false answer; the teardown itself rides the
1703
- // worker signal. Duck-typed like #drainStreamingSchemes.
1206
+ // Process-local stream activity is supplied to the drain owner through the
1207
+ // scheme registry rather than exposing that registry across the boundary.
1704
1208
  #workerHasActiveStreams(workerId) {
1705
1209
  const exec = this.#schemes.get("exec");
1706
1210
  return exec?.hasActiveSpawns?.(workerId) ?? false;
1707
1211
  }
1708
- // The contract-routed reap ({§worker-lifecycle-total-reap}): durable rows enumerate
1709
- // every open subscription; the live registry invokes its exact callable owner.
1710
- // The worker signal is only the fast path. An exec mid-spawn or a background exec
1711
- // from a past loop is caught regardless of listener timing. Idempotent — a stream
1712
- // the signal already reaped shares the same registry cancellation.
1713
- async #reapWorkerStreams(workerId) {
1714
- const open = await ChannelWrite.findOpenSubscriptionsForWorker(this.#db, workerId);
1715
- await Promise.all(open.map(({ id }) => this.#engine.cancelSubscription(id)));
1716
- }
1717
- /**
1718
- * Wake-on-completion handler. Streaming schemes call this when a
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.
1722
- *
1723
- * Skipped on result.status=499 (aborted): the model already knows about
1724
- * its own SEND[499], and a forcefully-cancelled loop's spawn-abort must
1725
- * not resurrect the worker.
1726
- */
1727
- async #handleWakeWorker(payload) {
1728
- const { entryOwnerId, ...wake } = payload;
1729
- const conclusion = { ...wake, workerId: entryOwnerId };
1730
- // {§search-gate} — settle the dedup registration: promote on a 200 conclusion, drop on
1731
- // failure (a dead search must never serve as a duplicate). No-op for non-search streams.
1732
- this.#engine.searchGate.settle(payload.target.replace(/^[a-z+.-]+:\/\//, "/").replace(/^\/+/, "/"), payload.result.status);
1733
- // Aborted streams don't wake — the abort was deliberate.
1734
- if (payload.result.status === 499) {
1735
- this.#broadcast({ workspaceId: payload.workspaceId }, "stream/concluded", {
1736
- ...conclusion, wakeAction: "skipped-aborted",
1737
- });
1738
- return;
1739
- }
1740
- // No resurrection ({§worker-lifecycle-no-resurrection}): a non-499 completion whose
1741
- // worker was cancelled (idle + its scope aborted) must not start a fresh drain —
1742
- // the cancel was deliberate. The deliverable is already in the channel/log and
1743
- // surfaces as a `collect` environment delta ({§env-delta}) if the worker is read or
1744
- // resumed; we just don't inject a turn. (An active worker folds the wake into its
1745
- // next turn via inject below; a resumed worker is active, never aborted, so it is
1746
- // unaffected.)
1747
- const scope = this.#workerAborts.get(payload.workerId);
1748
- if (scope?.signal.aborted === true && !this.#activeDrains.has(payload.workerId)) {
1749
- this.#broadcast({ workspaceId: payload.workspaceId }, "stream/concluded", {
1750
- ...conclusion, wakeAction: "skipped-cancelled",
1751
- });
1752
- return;
1753
- }
1754
- try {
1755
- const systemPrompt = await readFile(Paths.instructionsSystem, "utf8");
1756
- // A slept (202) loop means the worker parked via SEND[202] → resume it in place: re-queue
1757
- // it (202→100) so the drain re-claims and CONTINUES it (seq>1 → no re-foist). Checked
1758
- // FIRST: the slept status is the worker's true disposition regardless of a draining
1759
- // sibling mid-teardown (the #ensureDrain lock serializes the re-claim). No fresh loop,
1760
- // no summary-as-prompt — the resumed loop reads the concluded stream's own state from
1761
- // the manifest. {§worker-lifecycle-wake-liveness}.
1762
- const slept = await this.#db.drain_find_slept_loop.get({ worker_id: payload.workerId });
1763
- if (slept !== undefined) {
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));
1769
- });
1770
- this.#broadcast({ workspaceId: payload.workspaceId }, "stream/concluded", {
1771
- ...conclusion, wakeAction: "resumed-loop", wakeLoopId: slept.id,
1772
- });
1773
- return;
1774
- }
1775
- // No slept loop. A live loop surfaces the concluded stream ambiently via the
1776
- // environment-observation injector ({§exec-stream}) on its next turn — there is no prompt
1777
- // to inject and NO task to overwrite. The obsolete "automated environment update"
1778
- // synthesis (which clobbered the model's actual goal) is retired; just tell the client.
1779
- if (this.#activeDrains.has(payload.workerId)) {
1780
- this.#broadcast({ workspaceId: payload.workspaceId }, "stream/concluded", {
1781
- ...conclusion, wakeAction: "no-op-active-loop",
1782
- });
1783
- return;
1784
- }
1785
- // No slept loop, no active drain — nothing to resume (e.g. a SEND[200]-done worker whose
1786
- // streams were swept). Surface the conclusion without opening a loop.
1787
- this.#broadcast({ workspaceId: payload.workspaceId }, "stream/concluded", {
1788
- ...conclusion, wakeAction: "no-loop",
1789
- });
1790
- }
1791
- catch (err) {
1792
- console.error("wake-on-completion setup failed:", err instanceof Error ? err.message : String(err));
1793
- }
1794
- }
1795
- /**
1796
- * grammar 0.74.20 EXEC `<T,P>` — schedule a hibernation poll-wake. Called when a loop parks at
1797
- * a park; if the worker holds an open polled stream, arm a timer for its tightest cadence P that
1798
- * resumes the slept loop so the model inspects progress. While the loop is ACTIVE there is no
1799
- * poll work — ambient folded stream deltas already surface progress ({§exec-stream}); the wake
1800
- * matters only across hibernation. A wake-edge-less 202 (no polled stream) gets no timer. {§exec-poll}
1801
- */
1802
- async #schedulePollWake(workspaceId, workerId, systemPrompt) {
1803
- const existing = this.#pollTimers.get(workerId);
1804
- if (existing !== undefined) {
1805
- clearTimeout(existing);
1806
- this.#pollTimers.delete(workerId);
1807
- }
1808
- const row = await this.#db.drain_worker_min_poll.get({ worker_id: workerId });
1809
- if ((row?.open_count ?? 0) === 0) {
1810
- this.#pollBackoff.delete(workerId);
1811
- return;
1812
- }
1813
- const pollSec = row?.poll_seconds ?? null;
1814
- // {§exec-poll} — a positive explicit cadence wins, zero opts out,
1815
- // and an absent cadence uses the worker's exponential-backoff step.
1816
- let delayMs;
1817
- if (pollSec !== null && pollSec > 0) {
1818
- this.#pollBackoff.delete(workerId);
1819
- delayMs = pollSec * 1000;
1820
- }
1821
- else if (pollSec === 0) {
1822
- this.#pollBackoff.delete(workerId);
1823
- return; // explicit opt-out
1824
- }
1825
- else {
1826
- // An open stream without an explicit cadence uses the stream polling floor.
1827
- // Child joins never enter this branch: durable child settlement is their only wake edge.
1828
- const base = Number(process.env.PLURNK_SERVICE_EXEC_POLL_SEC ?? "60");
1829
- const turns = Number(process.env.PLURNK_SERVICE_EXEC_POLL_TURNS ?? "8");
1830
- const step = this.#pollBackoff.get(workerId) ?? 0;
1831
- delayMs = execPollBackoffMs(step, base, turns);
1832
- this.#pollBackoff.set(workerId, step + 1);
1833
- }
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();
1837
- const timer = setTimeout(() => {
1838
- this.#pollTimers.delete(workerId);
1839
- void this.#wakeParkedWorker(workspaceId, workerId, systemPrompt);
1840
- }, Math.max(delayMs, optimisticWaitMs));
1841
- timer.unref();
1842
- this.#pollTimers.set(workerId, timer);
1843
- }
1844
- /** Resume `workerId`'s slept (202) loop in place — the same 202→100 resume #handleWakeWorker uses, minus a
1845
- * wake payload. The shared wake primitive: a poll cadence ({§exec-poll}), a watched stream concluding,
1846
- * or a child worker finishing ({§worker-loop-lifecycle} topology join) all call this. A no-op if the worker was
1847
- * cancelled or isn't actually parked (no slept loop) — so calling it speculatively is safe. */
1848
- async #wakeParkedWorker(workspaceId, workerId, systemPrompt, oweIfActive = true) {
1849
- if (!this.#started)
1850
- return;
1851
- const scope = this.#workerAborts.get(workerId);
1852
- if (scope?.signal.aborted === true && !this.#activeDrains.has(workerId))
1853
- return; // cancelled — no resurrection
1854
- const slept = await this.#db.drain_find_slept_loop.get({ worker_id: workerId });
1855
- if (slept === undefined) {
1856
- // Not parked. If a drain is still ACTIVE, the worker is mid-turn and about to park — the
1857
- // conclusion that fired this wake arrived before the 202 committed (the conclude-before-park
1858
- // race). Owe the wake: the drain honors it at park so a worker hibernation never deadlocks.
1859
- // (No active drain → already concluded/running; nothing to wake.)
1860
- if (this.#activeDrains.has(workerId)) {
1861
- if (oweIfActive)
1862
- this.#owedWakes.add(workerId);
1863
- }
1864
- return;
1865
- }
1866
- const woke = await this.#lifecycle.wake(slept.id);
1867
- if (!woke)
1868
- return;
1869
- const started = await this.#ensureDrain({
1870
- workspaceId, workerId, systemPrompt,
1871
- });
1872
- started?.drainPromise?.catch((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
- }
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
- }
1954
- }
1955
- /** A worker's drain exited. If the worker truly CONCLUDED — no 202-blocked loop, no open stream — then
1956
- * wake its PARENT in place if the parent is blocked on the join (the structured-concurrency join — a
1957
- * child finishing is the wake edge for a parent that waited on it, {§worker-lifecycle-child-wake}). A worker
1958
- * blocked at 202, or still holding a stream, is NOT concluded — its own wake edges drive it, not this.
1959
- * The parent reads the child's deliverable from its own log (the {§worker-scheme-collect} delta) on
1960
- * resume — control edge here, never an injected prompt. Recurses up via the parent's own drain-exit. */
1961
- async #onDrainExit(workspaceId, workerId, systemPrompt) {
1962
- const slept = await this.#db.drain_find_slept_loop.get({ worker_id: workerId });
1963
- if (slept !== undefined)
1964
- return; // parked at 202 — not concluded, the worker is still alive
1965
- const openSubs = await this.#db.find_open_subscriptions_for_worker.all({ worker_id: workerId });
1966
- if (openSubs.length > 0)
1967
- return; // a stream still runs — its conclusion re-evaluates, not this exit
1968
- const parent = await this.#db.worker_parent_id.get({ worker_id: workerId });
1969
- if (parent?.parent_worker_id == null)
1970
- return; // a root worker — nobody to wake
1971
- await this.#settleCompletionWake(workspaceId, parent.parent_worker_id, systemPrompt);
1972
- }
1973
1212
  // {§methods-event-subscribe} — subscriber failures are transport-local:
1974
1213
  // log them at this boundary and never re-enter engine control flow.
1975
1214
  #emitTo(workspaceId, method, params) {