@plurnk/plurnk-service 1.17.0 → 1.18.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 (206) hide show
  1. package/.env.defaults +19 -1
  2. package/README.md +1 -1
  3. package/SPEC.md +1025 -946
  4. package/dist/build-info.json +1 -1
  5. package/dist/content/read-projector.js +2 -2
  6. package/dist/content/read-projector.js.map +1 -1
  7. package/dist/content/read-resolve.d.ts +1 -0
  8. package/dist/content/read-resolve.d.ts.map +1 -1
  9. package/dist/content/read-resolve.js +13 -10
  10. package/dist/content/read-resolve.js.map +1 -1
  11. package/dist/core/AdmittedTurnExecutor.d.ts +1 -2
  12. package/dist/core/AdmittedTurnExecutor.d.ts.map +1 -1
  13. package/dist/core/AdmittedTurnExecutor.js +12 -12
  14. package/dist/core/AdmittedTurnExecutor.js.map +1 -1
  15. package/dist/core/CapabilityResolver.d.ts.map +1 -1
  16. package/dist/core/CapabilityResolver.js +40 -33
  17. package/dist/core/CapabilityResolver.js.map +1 -1
  18. package/dist/core/ChannelWrite.d.ts +1 -0
  19. package/dist/core/ChannelWrite.d.ts.map +1 -1
  20. package/dist/core/ChannelWrite.js +1 -1
  21. package/dist/core/ChannelWrite.js.map +1 -1
  22. package/dist/core/DataStatementRunner.d.ts.map +1 -1
  23. package/dist/core/DataStatementRunner.js +194 -144
  24. package/dist/core/DataStatementRunner.js.map +1 -1
  25. package/dist/core/Dispatcher.d.ts +6 -1
  26. package/dist/core/Dispatcher.d.ts.map +1 -1
  27. package/dist/core/Dispatcher.js +25 -26
  28. package/dist/core/Dispatcher.js.map +1 -1
  29. package/dist/core/Engine.js +1 -1
  30. package/dist/core/Engine.js.map +1 -1
  31. package/dist/core/ExecutionOutputs.sql +8 -0
  32. package/dist/core/ExecutorRegistry.d.ts +4 -0
  33. package/dist/core/ExecutorRegistry.d.ts.map +1 -1
  34. package/dist/core/ExecutorRegistry.js +1 -0
  35. package/dist/core/ExecutorRegistry.js.map +1 -1
  36. package/dist/core/LogBody.d.ts.map +1 -1
  37. package/dist/core/LogBody.js +2 -1
  38. package/dist/core/LogBody.js.map +1 -1
  39. package/dist/core/LogEntryProjection.d.ts.map +1 -1
  40. package/dist/core/LogEntryProjection.js +4 -10
  41. package/dist/core/LogEntryProjection.js.map +1 -1
  42. package/dist/core/LogWriter.d.ts.map +1 -1
  43. package/dist/core/LogWriter.js +9 -7
  44. package/dist/core/LogWriter.js.map +1 -1
  45. package/dist/core/LoopDriver.d.ts.map +1 -1
  46. package/dist/core/LoopDriver.js +1 -3
  47. package/dist/core/LoopDriver.js.map +1 -1
  48. package/dist/core/ModelCall.d.ts.map +1 -1
  49. package/dist/core/ModelCall.js +2 -6
  50. package/dist/core/ModelCall.js.map +1 -1
  51. package/dist/core/ModelCall.sql +7 -36
  52. package/dist/core/ProposalLifecycle.d.ts.map +1 -1
  53. package/dist/core/ProposalLifecycle.js +8 -6
  54. package/dist/core/ProposalLifecycle.js.map +1 -1
  55. package/dist/core/RuntimeWorker.d.ts.map +1 -1
  56. package/dist/core/RuntimeWorker.js +6 -1
  57. package/dist/core/RuntimeWorker.js.map +1 -1
  58. package/dist/core/RuntimeWorker.sql +7 -1
  59. package/dist/core/SchemeRegistry.js +1 -1
  60. package/dist/core/SchemeRegistry.js.map +1 -1
  61. package/dist/core/StrikeRail.d.ts +2 -2
  62. package/dist/core/StrikeRail.d.ts.map +1 -1
  63. package/dist/core/StrikeRail.js +7 -5
  64. package/dist/core/StrikeRail.js.map +1 -1
  65. package/dist/core/ToolResources.d.ts.map +1 -1
  66. package/dist/core/ToolResources.js +5 -3
  67. package/dist/core/ToolResources.js.map +1 -1
  68. package/dist/core/TurnDispositionHandler.d.ts +4 -6
  69. package/dist/core/TurnDispositionHandler.d.ts.map +1 -1
  70. package/dist/core/TurnDispositionHandler.js +88 -80
  71. package/dist/core/TurnDispositionHandler.js.map +1 -1
  72. package/dist/core/TurnMaterialization.d.ts.map +1 -1
  73. package/dist/core/TurnMaterialization.js +7 -9
  74. package/dist/core/TurnMaterialization.js.map +1 -1
  75. package/dist/core/TurnOps.d.ts.map +1 -1
  76. package/dist/core/TurnOps.js +1 -1
  77. package/dist/core/TurnOps.js.map +1 -1
  78. package/dist/core/TurnRunner.d.ts +1 -5
  79. package/dist/core/TurnRunner.d.ts.map +1 -1
  80. package/dist/core/TurnRunner.js +1096 -1062
  81. package/dist/core/TurnRunner.js.map +1 -1
  82. package/dist/core/WorkerControlHandler.d.ts.map +1 -1
  83. package/dist/core/WorkerControlHandler.js +23 -0
  84. package/dist/core/WorkerControlHandler.js.map +1 -1
  85. package/dist/core/WorkerName.d.ts +3 -6
  86. package/dist/core/WorkerName.d.ts.map +1 -1
  87. package/dist/core/WorkerName.js +9 -25
  88. package/dist/core/WorkerName.js.map +1 -1
  89. package/dist/core/WorkspaceGate.js +4 -4
  90. package/dist/core/WorkspaceGate.js.map +1 -1
  91. package/dist/core/env-catalog.d.ts +28 -0
  92. package/dist/core/env-catalog.d.ts.map +1 -0
  93. package/dist/core/env-catalog.js +99 -0
  94. package/dist/core/env-catalog.js.map +1 -0
  95. package/dist/core/file-materialization.js +1 -1
  96. package/dist/core/file-materialization.js.map +1 -1
  97. package/dist/core/git-state.js +1 -1
  98. package/dist/core/git-state.js.map +1 -1
  99. package/dist/core/operation-target-groups.d.ts.map +1 -1
  100. package/dist/core/operation-target-groups.js +2 -1
  101. package/dist/core/operation-target-groups.js.map +1 -1
  102. package/dist/core/packet-wire.d.ts.map +1 -1
  103. package/dist/core/packet-wire.js +320 -295
  104. package/dist/core/packet-wire.js.map +1 -1
  105. package/dist/digest/Digest.d.ts.map +1 -1
  106. package/dist/digest/Digest.js +3 -1
  107. package/dist/digest/Digest.js.map +1 -1
  108. package/dist/digest/DigestRender.d.ts +1 -0
  109. package/dist/digest/DigestRender.d.ts.map +1 -1
  110. package/dist/digest/DigestRender.js +44 -8
  111. package/dist/digest/DigestRender.js.map +1 -1
  112. package/dist/digest/digest-rows.d.ts +6 -0
  113. package/dist/digest/digest-rows.d.ts.map +1 -1
  114. package/dist/digest/digest.sql +11 -2
  115. package/dist/schemes/Exec.d.ts.map +1 -1
  116. package/dist/schemes/Exec.js +68 -33
  117. package/dist/schemes/Exec.js.map +1 -1
  118. package/dist/schemes/ExecScheduler.js +2 -2
  119. package/dist/schemes/ExecScheduler.js.map +1 -1
  120. package/dist/schemes/File.d.ts.map +1 -1
  121. package/dist/schemes/File.js +2 -1
  122. package/dist/schemes/File.js.map +1 -1
  123. package/dist/schemes/Log.js +1 -1
  124. package/dist/schemes/Log.js.map +1 -1
  125. package/dist/schemes/QuestionTool.js +1 -1
  126. package/dist/schemes/QuestionTool.js.map +1 -1
  127. package/dist/schemes/TurnSource.d.ts.map +1 -1
  128. package/dist/schemes/TurnSource.js +7 -6
  129. package/dist/schemes/TurnSource.js.map +1 -1
  130. package/dist/schemes/_entry-crud.sql +18 -0
  131. package/dist/schemes/_entry-manifest.d.ts.map +1 -1
  132. package/dist/schemes/_entry-manifest.js +5 -14
  133. package/dist/schemes/_entry-manifest.js.map +1 -1
  134. package/dist/schemes/_entry-manifest.sql +1 -1
  135. package/dist/schemes/_search-index.d.ts +5 -1
  136. package/dist/schemes/_search-index.d.ts.map +1 -1
  137. package/dist/schemes/_search-index.js +96 -56
  138. package/dist/schemes/_search-index.js.map +1 -1
  139. package/dist/schemes/_search-index.sql +17 -0
  140. package/dist/schemes/exec-abort.js +1 -1
  141. package/dist/schemes/exec-abort.js.map +1 -1
  142. package/dist/schemes/exec-env.d.ts +3 -0
  143. package/dist/schemes/exec-env.d.ts.map +1 -1
  144. package/dist/schemes/exec-env.js +66 -15
  145. package/dist/schemes/exec-env.js.map +1 -1
  146. package/dist/schemes/exec-runtime.d.ts +1 -1
  147. package/dist/schemes/exec-runtime.d.ts.map +1 -1
  148. package/dist/schemes/exec-runtime.js +1 -1
  149. package/dist/schemes/exec-runtime.js.map +1 -1
  150. package/dist/server/ClientReads.d.ts.map +1 -1
  151. package/dist/server/ClientReads.js +2 -1
  152. package/dist/server/ClientReads.js.map +1 -1
  153. package/dist/server/Daemon.d.ts +1 -0
  154. package/dist/server/Daemon.d.ts.map +1 -1
  155. package/dist/server/Daemon.js +32 -14
  156. package/dist/server/Daemon.js.map +1 -1
  157. package/dist/server/DaemonModule.d.ts +7 -2
  158. package/dist/server/DaemonModule.d.ts.map +1 -1
  159. package/dist/server/EnvFunctionality.d.ts +43 -0
  160. package/dist/server/EnvFunctionality.d.ts.map +1 -0
  161. package/dist/server/EnvFunctionality.js +194 -0
  162. package/dist/server/EnvFunctionality.js.map +1 -0
  163. package/dist/server/Functionality.d.ts +4 -2
  164. package/dist/server/Functionality.d.ts.map +1 -1
  165. package/dist/server/Functionality.js +188 -79
  166. package/dist/server/Functionality.js.map +1 -1
  167. package/dist/server/FunctionalityManager.d.ts +2 -0
  168. package/dist/server/FunctionalityManager.d.ts.map +1 -1
  169. package/dist/server/FunctionalityManager.js +13 -3
  170. package/dist/server/FunctionalityManager.js.map +1 -1
  171. package/dist/server/MembersFunctionality.d.ts.map +1 -1
  172. package/dist/server/MembersFunctionality.js +0 -1
  173. package/dist/server/MembersFunctionality.js.map +1 -1
  174. package/dist/server/Retention.d.ts +3 -0
  175. package/dist/server/Retention.d.ts.map +1 -1
  176. package/dist/server/Retention.js +7 -4
  177. package/dist/server/Retention.js.map +1 -1
  178. package/dist/server/Retention.sql +23 -0
  179. package/dist/server/SkillsFunctionality.d.ts.map +1 -1
  180. package/dist/server/SkillsFunctionality.js +5 -2
  181. package/dist/server/SkillsFunctionality.js.map +1 -1
  182. package/dist/server/WorkspaceResidency.d.ts +2 -0
  183. package/dist/server/WorkspaceResidency.d.ts.map +1 -1
  184. package/dist/server/WorkspaceResidency.js +27 -0
  185. package/dist/server/WorkspaceResidency.js.map +1 -1
  186. package/dist/server/client-input.js +1 -1
  187. package/dist/server/client-input.js.map +1 -1
  188. package/dist/server/drain.sql +1 -1
  189. package/dist/server/envelope.d.ts +0 -1
  190. package/dist/server/envelope.d.ts.map +1 -1
  191. package/dist/server/envelope.js +0 -12
  192. package/dist/server/envelope.js.map +1 -1
  193. package/dist/server/lifecycle-recovery.sql +4 -3
  194. package/dist/server/workspace-capabilities.sql +45 -0
  195. package/dist/service.d.ts.map +1 -1
  196. package/dist/service.js +16 -1
  197. package/dist/service.js.map +1 -1
  198. package/docs/env.md +55 -0
  199. package/docs/members.md +4 -4
  200. package/docs/skills.md +1 -1
  201. package/migrations/002_workers.sql +15 -0
  202. package/migrations/004_inference.sql +53 -12
  203. package/migrations/005_entries.sql +8 -0
  204. package/migrations/006_log.sql +18 -15
  205. package/migrations/007_subscriptions.sql +2 -2
  206. package/package.json +42 -32
@@ -1,6 +1,8 @@
1
+ var _a;
1
2
  import { TurnDisposition } from "@plurnk/plurnk-contracts";
2
3
  import NativeContent from "./NativeContent.js";
3
- import { PathSyntax, PlurnkParser, PlurnkParseError, UNKNOWN_POSITION } from "@plurnk/plurnk-contracts";
4
+ import { PlurnkParser } from "@plurnk/plurnk-parser";
5
+ import { PathSyntax, PlurnkParseError, UNKNOWN_POSITION } from "@plurnk/plurnk-contracts";
4
6
  import { setTimeout as delay } from "node:timers/promises";
5
7
  import { aggregateProviderAccounting } from "@plurnk/plurnk-providers";
6
8
  const comparePosition = (a, b) => a.line - b.line || a.column - b.column;
@@ -154,7 +156,23 @@ const readProviderRecoveryBackoff = () => readMilliseconds("PLURNK_SERVICE_PROVI
154
156
  const RECOVERABLE_PROVIDER_FAILURES = new Set(["rate_limit", "network_failure", "deadline_exceeded", "resource_interrupted"]);
155
157
  // The wall's abort reason — runLoop branches a mid-turn teardown to the 504 terminal on it.
156
158
  export const LOOP_TIMEOUT_REASON = "loop_timeout";
157
- export default class TurnRunner {
159
+ // Every turn result carries the same shape; a diversion names only what differs.
160
+ const turnResult = ({ createdTurnIds, turnId }, status, facts = {}) => ({
161
+ createdTurnIds,
162
+ turnId,
163
+ producer: "model",
164
+ kind: "inference",
165
+ status,
166
+ outcomes: [],
167
+ fingerprint: "",
168
+ capacityHardStop: false,
169
+ providerParked: false,
170
+ emptyTurn: false,
171
+ emissionAttempts: 0,
172
+ emissionExhausted: false,
173
+ ...facts,
174
+ });
175
+ class TurnRunner {
158
176
  #db;
159
177
  #schemes;
160
178
  #mimetypes;
@@ -204,7 +222,7 @@ export default class TurnRunner {
204
222
  this.#dispatch = dispatch;
205
223
  this.#resolveWorkerProviderIdentity = resolveWorkerProviderIdentity;
206
224
  this.#materialization = new TurnMaterialization({ db: this.#db, weighContent: this.#weighContent });
207
- this.#bareBatch = new BareBatchRunner({ db: this.#db, providerAttributions: this.#providerAttributions.bind(this), providerFailure: TurnRunner.#providerFailure });
225
+ this.#bareBatch = new BareBatchRunner({ db: this.#db, providerAttributions: this.#providerAttributions.bind(this), providerFailure: _a.#providerFailure });
208
226
  this.#admitted = new AdmittedTurnExecutor({ db: this.#db, schemes: this.#schemes, notices: this.#notices, problems: this.#problems, dispatcher: this.#dispatcher, bareBatch: this.#bareBatch });
209
227
  }
210
228
  async #recordInference(args) {
@@ -300,16 +318,63 @@ export default class TurnRunner {
300
318
  }, (kind) => accepted.includes(kind));
301
319
  return { messages, nativeInputs: [...nativeInputs] };
302
320
  }
303
- async runTurn({ provider, childProvider = provider, messages, recap = "", workspaceId, workerId, loopId, signal, onDispatch, onSettled, turnNumber = 1, maxTurns = 50, invalidEmissionRecoveryEntryId, allowUnobservedRetrievalCompletion = false, }) {
321
+ // One inference cycle: the six phases in order, over the records declared above.
322
+ // A diversion (curation overflow, a provider failure, an exhausted emission)
323
+ // completes the turn and returns; anything else that throws fails every turn
324
+ // this cycle opened.
325
+ async runTurn({ provider, childProvider = provider, messages, recap = "", workspaceId, workerId, loopId, signal, onDispatch, onSettled, turnNumber = 1, invalidEmissionRecoveryEntryId, }) {
326
+ const args = {
327
+ provider, childProvider, messages, recap, workspaceId, workerId, loopId, signal, onDispatch, onSettled,
328
+ turnNumber, invalidEmissionRecoveryEntryId,
329
+ };
330
+ const createdTurnIds = [];
331
+ try {
332
+ const container = await this.#openTurnContainer(args, createdTurnIds);
333
+ const gitStatus = await this.#deriveWorkspace(args, container.systemCtx);
334
+ if (container.initializationTurn !== null)
335
+ await this.#runInitializationTurn(args, container, container.initializationTurn);
336
+ const request = await this.#composeRequest(args, container, gitStatus);
337
+ const overflow = this.#packets.curationOverflow(request.packet);
338
+ if (overflow !== null)
339
+ return await this.#failCuration(request, overflow);
340
+ const attempts = await this.#prepareProviderAttempts(args, request);
341
+ let emission;
342
+ try {
343
+ emission = await this.#attemptProvider(args, request, attempts);
344
+ }
345
+ catch (error) {
346
+ return await this.#settleProviderFailure(error, args, request, attempts);
347
+ }
348
+ if (!emission.split.emissionValid)
349
+ return await this.#rejectExhaustedEmission(args, request, emission);
350
+ await this.#recordAdmittedEmission(args, request, emission);
351
+ return await this.#settleAdmittedTurn(args, request, emission);
352
+ }
353
+ catch (cause) {
354
+ const completionFailures = [];
355
+ for (const createdTurnId of createdTurnIds) {
356
+ try {
357
+ await Turn.failOpen(this.#db, createdTurnId);
358
+ }
359
+ catch (completionCause) {
360
+ completionFailures.push(completionCause);
361
+ }
362
+ }
363
+ if (completionFailures.length > 0) {
364
+ throw new AggregateError([cause, ...completionFailures], "turn execution failed and its open turn containers could not all be completed");
365
+ }
366
+ throw cause;
367
+ }
368
+ }
369
+ // Phase 1 — the producer-neutral turn container. Every producer opens the same
370
+ // durable turn and completes it only after its ordered operations settle; model
371
+ // packets and provider metadata are optional inference evidence, not turn identity.
372
+ async #openTurnContainer(args, createdTurnIds) {
373
+ const { workspaceId, workerId, loopId, turnNumber, invalidEmissionRecoveryEntryId } = args;
304
374
  const workerName = await WorkerName.forId(this.#db, workerId);
305
375
  const transientOpenLogEntryId = typeof invalidEmissionRecoveryEntryId === "number"
306
376
  ? invalidEmissionRecoveryEntryId
307
377
  : null;
308
- // === Producer-neutral turn container ===
309
- //
310
- // Every producer opens the same durable turn and completes it only
311
- // after its ordered operations settle. Model packets and provider
312
- // metadata are optional inference evidence, not turn identity.
313
378
  const initialSequence = await this.#db.engine_next_turn_sequence.get({ loop_id: loopId });
314
379
  if (initialSequence === undefined)
315
380
  throw new Error("Engine.runTurn: next turn sequence is unavailable");
@@ -325,1085 +390,1052 @@ export default class TurnRunner {
325
390
  worker_id: workerId,
326
391
  });
327
392
  const workerFirstInference = priorInference?.present === 0;
328
- const createdTurnIds = [];
329
- try {
330
- // {§worker-initialization-entry} — turn zero is an ordinary `_plurnk`
331
- // operation turn. Its model-facing zero is a phase label; durable turn
332
- // coordinates remain one-based.
333
- const initializationTurn = initialSequence.next === 1 && workerFirstInference
334
- ? await Turn.open(this.#db, { loopId, producer: "_plurnk", kind: "initialization" })
335
- : null;
336
- if (initializationTurn !== null)
337
- createdTurnIds.push(initializationTurn.id);
338
- const initializationPolicies = initializationTurn === null
339
- ? []
340
- : (await CapabilityPolicies.layers(this.#db, workspaceId)).map((layer) => layer.policy);
341
- const initializationAdmits = (statement) => this.#capabilities.allowsAcross(statement, workspaceId, initializationPolicies);
342
- let modelTurn = initializationTurn === null
343
- ? await Turn.open(this.#db, { loopId, producer: "model", kind: "inference" })
344
- : null;
345
- if (modelTurn !== null)
346
- createdTurnIds.push(modelTurn.id);
347
- // {§env-delta-log-pull} — creation atomically owns the ordinary worker
348
- // baseline or fork snapshot. Packet assembly consumes, never invents,
349
- // that durable observation boundary.
350
- const ambientCursor = await this.#db.engine_initialize_ambient_cursor.get({ workspace_id: workspaceId, worker_id: workerId });
351
- if (ambientCursor?.ambient_event_cursor === null || ambientCursor === undefined) {
352
- throw new Error(`worker ${workerId} has no durable ambient observation boundary`);
353
- }
354
- // Threaded per turn, never engine state, so concurrent loops on
355
- // different providers each read their own honest tokenizer values.
356
- const systemContext = (contextTurnId) => ({
357
- db: this.#db, workspaceId, workerId, loopId, turnId: contextTurnId,
358
- writer: "_plurnk",
359
- signal: this.#loopSignal(loopId),
360
- streamEventNotify: this.#streamEventNotify,
361
- wakeWorkerNotify: this.#wakeWorkerNotify,
362
- weigh: this.#weighContent,
363
- mimetypes: this.#mimetypes,
364
- defaultChannelFor: (s) => this.#schemes.defaultChannelFor(s, workspaceId),
365
- pushNotice: (notice) => this.#notices.push(workspaceId, workerId, loopId, notice),
366
- requestInteraction: (request, signal = this.#loopSignal(loopId)) => this.#interactions.request(request, { workspaceId, workerId, loopId, turnId: contextTurnId }, signal),
367
- });
368
- let systemCtx = systemContext(initializationTurn?.id ?? modelTurn.id);
369
- // {§prompt-entry} — the prompt entry exists before any turn of the loop
370
- // runs; its `prompt` log row is published to the model turn below (one
371
- // durable publication per loop, decided by that row).
372
- const promptPublication = turnNumber === 1 && loopRow?.prompt_published === 0
373
- && typeof loopRow.prompt === "string" && loopRow.prompt.length > 0
374
- ? {
375
- content: loopRow.prompt,
376
- source: loopRow.prompt_source,
377
- path: promptTarget(workerName, await PromptFrames.write(systemCtx, {
378
- loopSequence: loopRow.sequence, ordinal: 1, content: loopRow.prompt,
379
- source: loopRow.prompt_source, pathname: loopRow.prompt_pathname,
380
- openPaths: assertOpenPaths(JSON.parse(loopRow.open_paths), `Loop ${loopId} open_paths`),
381
- })),
393
+ // {§worker-initialization-entry} — turn zero is an ordinary `_plurnk`
394
+ // operation turn. Its model-facing zero is a phase label; durable turn
395
+ // coordinates remain one-based.
396
+ const initializationTurn = initialSequence.next === 1 && workerFirstInference
397
+ ? await Turn.open(this.#db, { loopId, producer: "_plurnk", kind: "initialization" })
398
+ : null;
399
+ if (initializationTurn !== null)
400
+ createdTurnIds.push(initializationTurn.id);
401
+ const initializationPolicies = initializationTurn === null
402
+ ? []
403
+ : (await CapabilityPolicies.layers(this.#db, workspaceId)).map((layer) => layer.policy);
404
+ const modelTurn = initializationTurn === null
405
+ ? await Turn.open(this.#db, { loopId, producer: "model", kind: "inference" })
406
+ : null;
407
+ if (modelTurn !== null)
408
+ createdTurnIds.push(modelTurn.id);
409
+ // {§env-delta-log-pull} — creation atomically owns the ordinary worker
410
+ // baseline or fork snapshot. Packet assembly consumes, never invents,
411
+ // that durable observation boundary.
412
+ const ambientCursor = await this.#db.engine_initialize_ambient_cursor.get({ workspace_id: workspaceId, worker_id: workerId });
413
+ if (ambientCursor?.ambient_event_cursor === null || ambientCursor === undefined) {
414
+ throw new Error(`worker ${workerId} has no durable ambient observation boundary`);
415
+ }
416
+ const systemCtx = this.#schemeContext(args, initializationTurn?.id ?? modelTurn.id);
417
+ // {§prompt-entry} — the prompt entry exists before any turn of the loop
418
+ // runs; its `prompt` log row is published to the model turn below (one
419
+ // durable publication per loop, decided by that row).
420
+ const promptPublication = turnNumber === 1 && loopRow?.prompt_published === 0
421
+ && typeof loopRow.prompt === "string" && loopRow.prompt.length > 0
422
+ ? {
423
+ content: loopRow.prompt,
424
+ source: loopRow.prompt_source,
425
+ path: promptTarget(workerName, await PromptFrames.write(systemCtx, {
426
+ loopSequence: loopRow.sequence, ordinal: 1, content: loopRow.prompt,
427
+ source: loopRow.prompt_source, pathname: loopRow.prompt_pathname,
382
428
  openPaths: assertOpenPaths(JSON.parse(loopRow.open_paths), `Loop ${loopId} open_paths`),
383
- }
384
- : null;
385
- const initializationStatements = [];
386
- // {§worker-initialization-entry} — the worker's first turn is the worked
387
- // example itself: the actual orienting operations and an ordinary TASK.
388
- if (initializationTurn !== null) {
389
- // {§turn0-agents-stunt} — the project AGENTS.md (materialized by LoopDocs as
390
- // worker:///_plurnk/agents.md) gets one foisted READ on the worker's first
391
- // loop, so local repo guidance is visible turn-0 content. Global policy
392
- // stays in the system prompt; nothing else is force-read.
393
- if (workerFirstInference) {
394
- const agentsEntry = await this.#db.crud_find_workspace_entry.get({
395
- workspace_id: workspaceId,
396
- scheme: "worker",
397
- authority: "",
398
- pathname: generatedPathname("/agents.md"),
399
- });
400
- if (agentsEntry !== undefined) {
401
- const agentsTarget = {
402
- kind: "url", raw: "worker:///_plurnk/agents.md", scheme: "worker",
403
- username: null, password: null, hostname: null, port: null,
404
- pathname: generatedPathname("/agents.md"), query: null, fragment: null,
405
- };
406
- const agentsRead = {
407
- op: "READ", aside: null, target: agentsTarget,
408
- metadata: null, lineMarker: null, matcher: null, body: null, position: UNKNOWN_POSITION,
409
- };
410
- initializationStatements.push(agentsRead);
411
- }
412
- }
429
+ })),
430
+ openPaths: assertOpenPaths(JSON.parse(loopRow.open_paths), `Loop ${loopId} open_paths`),
413
431
  }
414
- // The persistent search-index pass (_search-index.maintain) attaches
415
- // every readable entry/log projection to complete graph/FTS derivations.
416
- // NOT an action: no log entry, no sequence slot,
417
- // not dispatched. There is no materialized manifest entry — the catalog
418
- // is served on demand by FIND: recursive when asked, shallow-mapped below.
419
- // SPEC {§membership} D4/D5 — git-ls-files workspace membership, resolved at
420
- // prompt-composition (EMI is eager + exhaustive — git is the only bound). When the
421
- // workspace's project_root is a git working tree, tracked files are
422
- // members without a client `add`; active members are materialized
423
- // (disk → body channel) so they appear in the catalog. No-ops
424
- // on headless / non-git workspaces. Runs BEFORE the derivation pump so
425
- // this turn's packet reflects them.
426
- // Joining here is the correctness boundary: passive workspace creation and
427
- // attachment spend nothing, while the model never runs against partial
428
- // graph/vector coverage.
429
- await this.#warmWorkspace(systemCtx, false);
430
- // The warm materialized membership before deriving. This second pass is the
431
- // ordinary cheap change detector and captures any drift that landed meanwhile.
432
- const fsDivergences = await GitMembership.indexGitMembership(systemCtx);
433
- // {§packet-git-status} — one post-reconciliation snapshot supplies both
434
- // the compact packet summary and each causal file event's exact XY state.
435
- const gitStatus = await GitState.status(this.#db, workspaceId, this.#loopSignal(loopId));
436
- await this.#materialization.logFsFictions(workspaceId, fsDivergences, gitStatus);
437
- // The refresh above may have changed bodies (including model/client edits
438
- // since the startup warm). Re-derive to completion before packet/model
439
- // construction. Membership is already current, so this pass does not
440
- // consume the filesystem divergences a second time.
441
- await this.#warmWorkspace(systemCtx, true, false);
442
- // Turn-0 catalog preview (PLURNK_SERVICE_FILES_ITEMS, {§actor-boundary-catalog-preview}):
443
- // Eight bodyless FIND surveys in the worker's packetless initialization turn establish the Agent
444
- // Skills, the plurnk references, the enabled tools, agents, and members, then the project, commons,
445
- // and named scratch, in that order.
446
- // Their `init` classification lets the model curate this opening survey as one log set.
447
- if (initializationTurn !== null) {
448
- // {§operator-config-workspace-files-items} — workspace filesItems replaces the env default.
449
- const { filesItems: workspaceMI } = await WorkspaceSettings.read(this.#db, workspaceId);
450
- const filesItems = workspaceMI !== null ? normalizeFilesItems(workspaceMI) : readFilesItems();
451
- if (filesItems !== null && workerFirstInference) { // {§actor-boundary-catalog-preview} — once per worker
452
- const catalogSchemes = await this.#db.engine_scheme_catalog_summary.all({ workspace_id: workspaceId });
453
- const fileItems = catalogSchemes.find(({ scheme }) => scheme === "file")?.shallow_items ?? 0;
454
- const fileCap = filesItems > 0 && fileItems > 0 ? Math.min(filesItems, fileItems) : null;
455
- // {§tools-resource-materialization} — expanded families project complete
456
- // invocation blocks, paged through ordinary FIND result ranges.
457
- const registry = this.#executors();
458
- const plurnkCatalog = workerCatalogTarget("plurnk");
459
- const toolsCatalog = workerCatalogTarget("tools");
460
- const toolExpansions = [];
461
- for (const tag of registry?.availableRuntimes(workspaceId) ?? []) {
462
- const entry = registry?.entry(tag, workspaceId);
463
- if (entry?.resourcesPath !== "/tools" || entry.expandTools !== true)
464
- continue;
465
- const tools = registry?.toolRegistry(tag, workspaceId);
466
- const admittedTools = tools?.tools.filter((tool) => this.#capabilities.allowsRuntimeAcross(tag, tool.target, workspaceId, initializationPolicies)) ?? [];
467
- if (admittedTools.length === 0)
468
- continue;
469
- const targetFilter = tools !== null && tools !== undefined && admittedTools.length !== tools.tools.length
470
- ? " \\((?:" + admittedTools.map((tool) => regexLiteral(PathSyntax.escapeTarget(tool.target))).join("|") + ")\\)"
471
- : " ";
472
- const pattern = "^(\x60{3,})" + regexLiteral(tag) + targetFilter + "[^\\n]*?(?:\\1|\\n[\\s\\S]*?\\n\\1)$";
473
- toolExpansions.push({
474
- statement: {
475
- op: "FIND", aside: null,
476
- target: { kind: "url", raw: `worker:///_plurnk/tools/${tag}.md`, scheme: "worker", username: null, password: null, hostname: null, port: null, pathname: generatedPathname(`/tools/${tag}.md`), query: null, fragment: null },
477
- metadata: null,
478
- matcher: { dialect: "regex", raw: `/${pattern.replaceAll("/", "\\/")}/m`, pattern, flags: "m" },
479
- body: null, lineMarker: null, position: UNKNOWN_POSITION,
480
- },
481
- });
482
- }
483
- const surveys = [
484
- {
485
- statement: {
486
- op: "FIND", aside: null,
487
- target: { kind: "url", raw: "skill://*/SKILL.md", scheme: "skill", username: null, password: null, hostname: "*", port: null, pathname: "/SKILL.md", query: null, fragment: null },
488
- metadata: null,
489
- matcher: null, body: null, lineMarker: { marks: [1, -1] }, position: UNKNOWN_POSITION,
490
- },
491
- },
492
- ...(plurnkCatalog === null ? [] : [{
493
- statement: {
494
- op: "FIND", aside: null,
495
- target: plurnkCatalog,
496
- metadata: null,
497
- matcher: null, body: null, lineMarker: { marks: [1, -1] }, position: UNKNOWN_POSITION,
498
- },
499
- }]),
500
- ...(toolsCatalog === null ? [] : [{
501
- // {§tools-resource-materialization} — enabled tool families
502
- // (MCP servers) survey at family level; each row's summary is
503
- // the server one-liner or its flagship invocation form, so the
504
- // discovery row itself orients. Servers named in
505
- // PLURNK_MCP_EXPANDED add a second survey of their complete
506
- // tool tree.
507
- statement: {
508
- op: "FIND", aside: null,
509
- target: toolsCatalog,
510
- metadata: null,
511
- matcher: null, body: null, lineMarker: { marks: [1, -1] }, position: UNKNOWN_POSITION,
512
- },
513
- }]),
514
- ...toolExpansions,
515
- {
516
- // {§a2a-agents-catalog} — enabled outbound agents survey at
517
- // alias level; each row's summary is the agent's identity line,
518
- // the exact card stays pullable through READ a2a://<alias>.
519
- statement: {
520
- op: "FIND", aside: null,
521
- target: { kind: "url", raw: "worker:///_plurnk/agents/*.md", scheme: "worker", username: null, password: null, hostname: null, port: null, pathname: generatedPathname("/agents/*.md"), query: null, fragment: null },
522
- metadata: null,
523
- matcher: null, body: null, lineMarker: { marks: [1, -1] }, position: UNKNOWN_POSITION,
524
- },
525
- },
526
- {
527
- // {§members-projection} — enabled members definitions survey at alias
528
- // level; each row's summary is what its glob resolved to.
529
- statement: {
530
- op: "FIND", aside: null,
531
- target: { kind: "url", raw: "worker:///_plurnk/members/*.md", scheme: "worker", username: null, password: null, hostname: null, port: null, pathname: generatedPathname("/members/*.md"), query: null, fragment: null },
532
- metadata: null,
533
- matcher: null, body: null, lineMarker: { marks: [1, -1] }, position: UNKNOWN_POSITION,
534
- },
535
- },
536
- {
537
- statement: {
538
- op: "FIND", aside: "project root member files",
539
- target: { kind: "local", raw: "*" },
540
- metadata: null,
541
- matcher: null,
542
- body: null,
543
- lineMarker: fileCap === null ? null : { marks: [1, fileCap] },
544
- position: UNKNOWN_POSITION,
545
- },
546
- },
547
- {
548
- statement: {
549
- op: "FIND", aside: "shared worker Extended Context",
550
- target: { kind: "url", raw: "worker:///*", scheme: "worker", username: null, password: null, hostname: null, port: null, pathname: "/*", query: null, fragment: null },
551
- metadata: null,
552
- matcher: null, body: null, lineMarker: null, position: UNKNOWN_POSITION,
553
- },
554
- },
555
- {
556
- statement: {
557
- op: "FIND", aside: "private worker Extended Context",
558
- target: { kind: "url", raw: `worker://${workerName}/*`, scheme: "worker", username: null, password: null, hostname: workerName, port: null, pathname: "/*", query: null, fragment: null },
559
- metadata: null,
560
- matcher: null, body: null, lineMarker: null, position: UNKNOWN_POSITION,
561
- },
562
- },
563
- ];
564
- initializationStatements.push(...surveys.map(({ statement }) => statement).filter(({ target }) => this.#schemes.get(target?.kind === "url" ? target.scheme : "file", workspaceId) !== undefined));
565
- }
566
- const task = {
567
- op: "TASK", aside: null, target: null, metadata: null, lineMarker: null,
568
- body: [{ content: "Address the prompt.", status: "in_progress" }],
569
- position: UNKNOWN_POSITION,
432
+ : null;
433
+ const initializationStatements = [];
434
+ // {§worker-initialization-entry} — the worker's first turn is the worked
435
+ // example itself: the actual orienting operations and an ordinary TASK.
436
+ // {§turn0-agents-stunt} — the project AGENTS.md (materialized by LoopDocs as
437
+ // worker:///_plurnk/agents.md) gets one foisted READ on the worker's first
438
+ // loop, so local repo guidance is visible turn-0 content. Global policy
439
+ // stays in the system prompt; nothing else is force-read.
440
+ if (initializationTurn !== null) {
441
+ const agentsEntry = await this.#db.crud_find_workspace_entry.get({
442
+ workspace_id: workspaceId,
443
+ scheme: "worker",
444
+ authority: "",
445
+ pathname: generatedPathname("/agents.md"),
446
+ });
447
+ if (agentsEntry !== undefined) {
448
+ const agentsTarget = {
449
+ kind: "url", raw: "worker:///_plurnk/agents.md", scheme: "worker",
450
+ username: null, password: null, hostname: null, port: null,
451
+ pathname: generatedPathname("/agents.md"), query: null, fragment: null,
570
452
  };
571
- const pathname = `/${loopRow.sequence}/${initializationTurn.sequence}`;
572
- await Turn.recordSource(this.#db, initializationTurn.id, "reasoning", ReasoningView.initialSource());
573
- // {§reasoning-initial-read} — the pattern READ needs a text/plain projection to run the regex.
574
- const pluck = await this.#mimetypes.getHandler("text/plain") !== null;
575
- const reasoningRead = ReasoningView.initialRead(provider, loopRow.sequence, initializationTurn.sequence, pluck);
576
- if (reasoningRead !== null)
577
- initializationStatements.push(reasoningRead);
578
- initializationStatements.push({
579
- op: "READ", aside: "inspect this turn's emission", matcher: null, body: null, metadata: null,
580
- target: {
581
- kind: "url", raw: `ops://${pathname}`, scheme: "ops", pathname,
582
- username: null, password: null, hostname: null, port: null, query: null, fragment: null,
583
- },
584
- lineMarker: { marks: [1, -1] }, position: UNKNOWN_POSITION,
585
- });
586
- // {§prompt-entry} — the prompt reaches the model as its `prompt` row in the first
587
- // model turn; initialization does not READ it a second time.
588
- initializationStatements.push(task);
589
- const admittedInitializationStatements = initializationStatements.filter(initializationAdmits);
590
- const source = TurnOps.renderInternal(admittedInitializationStatements);
591
- const admitted = TurnOps.parseInternal(source);
592
- const result = await this.executeAdmittedTurn({
593
- statements: admitted,
594
- source,
595
- origin: "_plurnk",
596
- workspaceId,
597
- workerId,
598
- loopId,
599
- turnId: initializationTurn.id,
600
- fromSequence: 1,
601
- failOnOperationError: true,
602
- signal: this.#loopSignal(loopId),
603
- onDispatch,
604
- onSettled,
605
- });
606
- if (result.status !== TURN_STATUS_IMPLICIT_CONTINUE) {
607
- throw new Error(`initialization TASK returned ${result.status}; expected ${TURN_STATUS_IMPLICIT_CONTINUE}`);
608
- }
609
- }
610
- // Initialization is a complete preceding turn, not a set of rows
611
- // interleaved with the model boundary. The engine may now acquire the
612
- // inference turn inside the same warmed workspace cycle.
613
- if (modelTurn === null) {
614
- modelTurn = await Turn.open(this.#db, { loopId, producer: "model", kind: "inference" });
615
- createdTurnIds.push(modelTurn.id);
616
- }
617
- const seq = modelTurn.sequence;
618
- const turnId = modelTurn.id;
619
- systemCtx = systemContext(turnId);
620
- // Pre-model writes. Each prompt the model has not seen yet becomes a
621
- // `prompt` operation row whose target is its durable prompt:// entry.
622
- // Model operations continue the same turn sequence after these rows.
623
- let nextActionIndex = 1;
624
- const turnOpenPaths = [];
625
- if (promptPublication !== null) { // {§prompt-entry} — one durable publication per loop
626
- turnOpenPaths.push(...promptPublication.openPaths);
627
- const promptLogId = await this.#materialization.writePromptLog({
628
- workerId,
629
- loopId,
630
- turnId,
631
- sequence: nextActionIndex++,
632
- target: promptPublication.path,
633
- content: promptPublication.content,
634
- source: promptPublication.source,
635
- });
636
- onDispatch?.(promptLogId);
637
- await onSettled?.(promptLogId);
638
- }
639
- // {§prompt-loop-containment}: the loop contains every prompt that arrived
640
- // while it ran. Publish each undelivered frame oldest-first exactly once.
641
- {
642
- const loopSeqRow = await this.#db.engine_loop_sequence.get({ loop_id: loopId });
643
- const loopSeq = loopSeqRow?.sequence ?? loopId;
644
- const prefix = promptLoopPrefix(loopSeq);
645
- const undelivered = (await this.#db.drain_undelivered_prompts_for_loop.all({
646
- worker_id: workerId,
647
- pattern: `${prefix}%`,
648
- prefix_len: prefix.length,
649
- loop_id: loopId,
650
- }))
651
- .filter((row) => typeof row.content === "string" && row.content.length > 0);
652
- for (const injectedRow of undelivered) {
653
- const attributes = parsePromptAttributes(injectedRow.attributes, `Prompt ${injectedRow.pathname} attributes`);
654
- if (attributes.openPaths !== undefined) {
655
- turnOpenPaths.push(...assertOpenPaths(attributes.openPaths, `Prompt ${injectedRow.pathname} openPaths`));
656
- }
657
- const promptLogId = await this.#materialization.writePromptLog({
658
- workerId,
659
- loopId,
660
- turnId,
661
- sequence: nextActionIndex++,
662
- target: promptTarget(workerName, injectedRow.pathname),
663
- content: injectedRow.content,
664
- source: promptSourceFromAttributes(attributes, `Prompt ${injectedRow.pathname} attributes`),
665
- });
666
- onDispatch?.(promptLogId);
667
- await onSettled?.(promptLogId);
668
- }
669
- }
670
- // {§methods-loop-run-open-paths}: selected workspace paths belong to
671
- // the prompt frame. Publish the frame, then dispatch ordinary core READs
672
- // in that same turn; missing/non-member paths retain their normal 4xx.
673
- for (const raw of turnOpenPaths) {
674
- const pathname = raw.startsWith("/") ? raw : `/${raw}`;
675
- const fileRead = {
676
- op: "READ", aside: null, lineMarker: null, matcher: null,
677
- target: {
678
- kind: "url", raw: `file://${pathname}`, scheme: "file",
679
- username: null, password: null, hostname: null, port: null,
680
- pathname, query: null, fragment: null,
681
- },
682
- metadata: null,
683
- body: null, position: UNKNOWN_POSITION,
453
+ const agentsRead = {
454
+ op: "READ", aside: null, target: agentsTarget,
455
+ metadata: null, lineMarker: null, matcher: null, body: null, position: UNKNOWN_POSITION,
684
456
  };
685
- await this.#dispatch({
686
- statement: fileRead, workspaceId, workerId, loopId, turnId,
687
- sequence: nextActionIndex, origin: "_plurnk", onDispatch, onSettled,
688
- });
689
- nextActionIndex++;
457
+ initializationStatements.push(agentsRead);
690
458
  }
691
- // {§env-delta-log-pull} — materialize ambient observations before packet
692
- // composition and reserve their action indices. {§exec-stream} owns the
693
- // distinct byte-cursor path for this worker's streams.
694
- // {§exec-poll} — EXEC `<0>` is turn-scoped: reap the worker's open turn-scoped streams (necessarily
695
- // from a prior turn — this runs before the turn's own spawns) so a `<0>` never survives into
696
- // the subsequent turn. The terminal output then surfaces initially visible via the stream-delta path.
697
- await this.#reapTurnScopedStreams(workerId);
698
- nextActionIndex += await this.#materialization.materializeEnvironmentDeltas({ workspaceId, workerId, loopId, turnId, fromSequence: nextActionIndex });
699
- nextActionIndex += await this.#materialization.materializeStreamDeltas({ workspaceId, workerId, loopId, turnId, fromSequence: nextActionIndex });
700
- // The post-reconciliation Git snapshot above is threaded into the packet
701
- // and every budget rebuild; overflow never shells again.
702
- // Notices are non-terminal observations, never operation-failure truth.
703
- // Drain once and thread the same set through every overflow rebuild.
704
- const notices = this.#notices.drain(loopId)
705
- .filter((event) => event.level !== "info");
706
- // Build the model request packet ({§packet-stored-shape}). The log build
707
- // queries log_entries scoped to the worker — the prompt entry just
708
- // written (if turn 1) is part of that query result.
709
- let promptProjection = "automatic";
710
- const loopSeq = (await this.#db.engine_loop_sequence.get({ loop_id: loopId }))?.sequence ?? loopId;
711
- const buildPacket = () => this.#packets.buildRequestPacket({
712
- initialMessages: messages,
713
- recap,
714
- workspaceId,
459
+ }
460
+ return {
461
+ workerName, transientOpenLogEntryId, loopRow, createdTurnIds,
462
+ initializationTurn, initializationPolicies, initializationStatements,
463
+ modelTurn, systemCtx, promptPublication,
464
+ };
465
+ }
466
+ // The `_plurnk` scheme context for one turn. Threaded per turn, never engine
467
+ // state, so concurrent loops on different providers each read their own honest
468
+ // tokenizer values.
469
+ #schemeContext({ workspaceId, workerId, loopId }, turnId) {
470
+ return {
471
+ db: this.#db, workspaceId, workerId, loopId, turnId,
472
+ writer: "_plurnk",
473
+ signal: this.#loopSignal(loopId),
474
+ streamEventNotify: this.#streamEventNotify,
475
+ wakeWorkerNotify: this.#wakeWorkerNotify,
476
+ weigh: this.#weighContent,
477
+ mimetypes: this.#mimetypes,
478
+ defaultChannelFor: (s) => this.#schemes.defaultChannelFor(s, workspaceId),
479
+ pushNotice: (notice) => this.#notices.push(workspaceId, workerId, loopId, notice),
480
+ requestInteraction: (request, signal = this.#loopSignal(loopId)) => this.#interactions.request(request, { workspaceId, workerId, loopId, turnId }, signal),
481
+ };
482
+ }
483
+ // Phase 2 — membership and derivation to completion, then the one git snapshot
484
+ // every packet rebuild reads.
485
+ async #deriveWorkspace({ workspaceId, loopId }, systemCtx) {
486
+ // The persistent search-index pass (_search-index.maintain) attaches
487
+ // every readable entry/log projection to complete graph/FTS derivations.
488
+ // NOT an action: no log entry, no sequence slot,
489
+ // not dispatched. There is no materialized manifest entry — the catalog
490
+ // is served on demand by FIND: recursive when asked, shallow-mapped below.
491
+ // SPEC {§membership} D4/D5 — git-ls-files workspace membership, resolved at
492
+ // prompt-composition (EMI is eager + exhaustive — git is the only bound). When the
493
+ // workspace's project_root is a git working tree, tracked files are
494
+ // members without a client `add`; active members are materialized
495
+ // (disk → body channel) so they appear in the catalog. No-ops
496
+ // on headless / non-git workspaces. Runs BEFORE the derivation pump so
497
+ // this turn's packet reflects them.
498
+ // Joining here is the correctness boundary: passive workspace creation and
499
+ // attachment spend nothing, while the model never runs against partial
500
+ // graph/vector coverage.
501
+ await this.#warmWorkspace(systemCtx, false);
502
+ // The warm materialized membership before deriving. This second pass is the
503
+ // ordinary cheap change detector and captures any drift that landed meanwhile.
504
+ const fsDivergences = await GitMembership.indexGitMembership(systemCtx);
505
+ // {§packet-git-status} — one post-reconciliation snapshot supplies both
506
+ // the compact packet summary and each causal file event's exact XY state.
507
+ const gitStatus = await GitState.status(this.#db, workspaceId, this.#loopSignal(loopId));
508
+ await this.#materialization.logFsFictions(workspaceId, fsDivergences, gitStatus);
509
+ // The refresh above may have changed bodies (including model/client edits
510
+ // since the startup warm). Re-derive to completion before packet/model
511
+ // construction. Membership is already current, so this pass does not
512
+ // consume the filesystem divergences a second time.
513
+ await this.#warmWorkspace(systemCtx, true, false);
514
+ return gitStatus;
515
+ }
516
+ // {§worker-initialization-entry} — the worker's first turn is the worked example
517
+ // itself: the actual orienting operations and an ordinary TASK, executed as a
518
+ // complete turn before the model boundary.
519
+ async #runInitializationTurn(args, container, initializationTurn) {
520
+ const { provider, workspaceId, workerId, loopId, onDispatch, onSettled } = args;
521
+ const { loopRow, initializationStatements, initializationPolicies } = container;
522
+ // Turn-0 catalog preview (PLURNK_SERVICE_FILES_ITEMS, {§actor-boundary-catalog-preview}):
523
+ // Eight bodyless FIND surveys in the worker's packetless initialization turn establish the Agent
524
+ // Skills, the plurnk references, the enabled tools, agents, and members, then the project, commons,
525
+ // and named scratch, in that order.
526
+ // Their `init` classification lets the model curate this opening survey as one log set.
527
+ // {§operator-config-workspace-files-items} — workspace filesItems replaces the env default.
528
+ const { filesItems: workspaceMI } = await WorkspaceSettings.read(this.#db, workspaceId);
529
+ const filesItems = workspaceMI !== null ? normalizeFilesItems(workspaceMI) : readFilesItems();
530
+ if (filesItems !== null) { // {§actor-boundary-catalog-preview} — once per worker
531
+ initializationStatements.push(...await this.#catalogSurveys(args, container, filesItems));
532
+ }
533
+ const task = {
534
+ op: "TASK", aside: null, target: null, metadata: null, lineMarker: null,
535
+ body: [{ content: "Address the prompt.", status: "in_progress" }],
536
+ position: UNKNOWN_POSITION,
537
+ };
538
+ const pathname = `/${loopRow.sequence}/${initializationTurn.sequence}`;
539
+ await Turn.recordSource(this.#db, initializationTurn.id, "reasoning", ReasoningView.initialSource());
540
+ // {§reasoning-initial-read} — the pattern READ needs a text/plain projection to run the regex.
541
+ const pluck = await this.#mimetypes.getHandler("text/plain") !== null;
542
+ const reasoningRead = ReasoningView.initialRead(provider, loopRow.sequence, initializationTurn.sequence, pluck);
543
+ if (reasoningRead !== null)
544
+ initializationStatements.push(reasoningRead);
545
+ initializationStatements.push({
546
+ op: "READ", aside: "inspect this turn's emission", matcher: null, body: null, metadata: null,
547
+ target: {
548
+ kind: "url", raw: `ops://${pathname}`, scheme: "ops", pathname,
549
+ username: null, password: null, hostname: null, port: null, query: null, fragment: null,
550
+ },
551
+ lineMarker: { marks: [1, -1] }, position: UNKNOWN_POSITION,
552
+ });
553
+ // {§prompt-entry} — the prompt reaches the model as its `prompt` row in the first
554
+ // model turn; initialization does not READ it a second time.
555
+ initializationStatements.push(task);
556
+ const admittedInitializationStatements = initializationStatements.filter((statement) => this.#capabilities.allowsAcross(statement, workspaceId, initializationPolicies));
557
+ const source = TurnOps.renderInternal(admittedInitializationStatements);
558
+ const admitted = TurnOps.parseInternal(source);
559
+ const result = await this.executeAdmittedTurn({
560
+ statements: admitted,
561
+ source,
562
+ origin: "_plurnk",
563
+ workspaceId,
564
+ workerId,
565
+ loopId,
566
+ turnId: initializationTurn.id,
567
+ fromSequence: 1,
568
+ failOnOperationError: true,
569
+ signal: this.#loopSignal(loopId),
570
+ onDispatch,
571
+ onSettled,
572
+ });
573
+ if (result.status !== TURN_STATUS_IMPLICIT_CONTINUE) {
574
+ throw new Error(`initialization TASK returned ${result.status}; expected ${TURN_STATUS_IMPLICIT_CONTINUE}`);
575
+ }
576
+ }
577
+ // {§actor-boundary-catalog-preview} — the opening surveys, each admitted only when
578
+ // its scheme is registered for the workspace. A positive filesItems caps the
579
+ // project rows.
580
+ async #catalogSurveys({ workspaceId }, { workerName, initializationPolicies }, filesItems) {
581
+ const catalogSchemes = await this.#db.engine_scheme_catalog_summary.all({ workspace_id: workspaceId });
582
+ const fileItems = catalogSchemes.find(({ scheme }) => scheme === "file")?.shallow_items ?? 0;
583
+ const fileCap = filesItems > 0 && fileItems > 0 ? Math.min(filesItems, fileItems) : null;
584
+ const surveys = [
585
+ {
586
+ op: "FIND", aside: null,
587
+ target: { kind: "url", raw: "skill://*/SKILL.md", scheme: "skill", username: null, password: null, hostname: "*", port: null, pathname: "/SKILL.md", query: null, fragment: null },
588
+ metadata: null,
589
+ matcher: null, body: null, lineMarker: { marks: [1, -1] }, position: UNKNOWN_POSITION,
590
+ },
591
+ {
592
+ op: "FIND", aside: null,
593
+ target: workerCatalogTarget("plurnk"),
594
+ metadata: null,
595
+ matcher: null, body: null, lineMarker: { marks: [1, -1] }, position: UNKNOWN_POSITION,
596
+ },
597
+ {
598
+ // {§tools-resource-materialization} — enabled tool families
599
+ // (MCP servers) survey at family level; each row's summary is
600
+ // the server one-liner or its flagship invocation form, so the
601
+ // discovery row itself orients. Servers named in
602
+ // PLURNK_MCP_EXPANDED add a second survey of their complete
603
+ // tool tree.
604
+ op: "FIND", aside: null,
605
+ target: workerCatalogTarget("tools"),
606
+ metadata: null,
607
+ matcher: null, body: null, lineMarker: { marks: [1, -1] }, position: UNKNOWN_POSITION,
608
+ },
609
+ ...this.#toolExpansions(workspaceId, initializationPolicies),
610
+ {
611
+ // {§a2a-agents-catalog} — enabled outbound agents survey at
612
+ // alias level; each row's summary is the agent's identity line,
613
+ // the exact card stays pullable through READ a2a://<alias>.
614
+ op: "FIND", aside: null,
615
+ target: { kind: "url", raw: "worker:///_plurnk/agents/*.md", scheme: "worker", username: null, password: null, hostname: null, port: null, pathname: generatedPathname("/agents/*.md"), query: null, fragment: null },
616
+ metadata: null,
617
+ matcher: null, body: null, lineMarker: { marks: [1, -1] }, position: UNKNOWN_POSITION,
618
+ },
619
+ {
620
+ // {§members-projection} — enabled members definitions survey at alias
621
+ // level; each row's summary is what its glob resolved to.
622
+ op: "FIND", aside: null,
623
+ target: { kind: "url", raw: "worker:///_plurnk/members/*.md", scheme: "worker", username: null, password: null, hostname: null, port: null, pathname: generatedPathname("/members/*.md"), query: null, fragment: null },
624
+ metadata: null,
625
+ matcher: null, body: null, lineMarker: { marks: [1, -1] }, position: UNKNOWN_POSITION,
626
+ },
627
+ {
628
+ op: "FIND", aside: "project root member files",
629
+ target: { kind: "local", raw: "*" },
630
+ metadata: null,
631
+ matcher: null,
632
+ body: null,
633
+ lineMarker: fileCap === null ? null : { marks: [1, fileCap] },
634
+ position: UNKNOWN_POSITION,
635
+ },
636
+ {
637
+ op: "FIND", aside: "shared worker Extended Context",
638
+ target: { kind: "url", raw: "worker:///*", scheme: "worker", username: null, password: null, hostname: null, port: null, pathname: "/*", query: null, fragment: null },
639
+ metadata: null,
640
+ matcher: null, body: null, lineMarker: null, position: UNKNOWN_POSITION,
641
+ },
642
+ {
643
+ op: "FIND", aside: "private worker Extended Context",
644
+ target: { kind: "url", raw: `worker://${workerName}/*`, scheme: "worker", username: null, password: null, hostname: workerName, port: null, pathname: "/*", query: null, fragment: null },
645
+ metadata: null,
646
+ matcher: null, body: null, lineMarker: null, position: UNKNOWN_POSITION,
647
+ },
648
+ ];
649
+ return surveys.filter(({ target }) => this.#schemes.get(target?.kind === "url" ? target.scheme : "file", workspaceId) !== undefined);
650
+ }
651
+ // {§tools-resource-materialization} — expanded families project complete
652
+ // invocation blocks, paged through ordinary FIND result ranges: one pattern FIND
653
+ // per family, naming the admitted tools when the policy narrows them.
654
+ #toolExpansions(workspaceId, policies) {
655
+ const registry = this.#executors();
656
+ const expansions = [];
657
+ for (const tag of registry?.availableRuntimes(workspaceId) ?? []) {
658
+ const entry = registry?.entry(tag, workspaceId);
659
+ if (entry?.resourcesPath !== "/tools" || entry.expandTools !== true)
660
+ continue;
661
+ const tools = registry?.toolRegistry(tag, workspaceId);
662
+ const admittedTools = tools?.tools.filter((tool) => this.#capabilities.allowsRuntimeAcross(tag, tool.target, workspaceId, policies)) ?? [];
663
+ if (admittedTools.length === 0)
664
+ continue;
665
+ const targetFilter = tools !== null && tools !== undefined && admittedTools.length !== tools.tools.length
666
+ ? " \\((?:" + admittedTools.map((tool) => regexLiteral(PathSyntax.escapeTarget(tool.target))).join("|") + ")\\)"
667
+ : " ";
668
+ const pattern = "^(\x60{3,})" + regexLiteral(tag) + targetFilter + "[^\\n]*?(?:\\1|\\n[\\s\\S]*?\\n\\1)$";
669
+ expansions.push({
670
+ op: "FIND", aside: null,
671
+ target: { kind: "url", raw: `worker:///_plurnk/tools/${tag}.md`, scheme: "worker", username: null, password: null, hostname: null, port: null, pathname: generatedPathname(`/tools/${tag}.md`), query: null, fragment: null },
672
+ metadata: null,
673
+ matcher: { dialect: "regex", raw: `/${pattern.replaceAll("/", "\\/")}/m`, pattern, flags: "m" },
674
+ body: null, lineMarker: null, position: UNKNOWN_POSITION,
675
+ });
676
+ }
677
+ return expansions;
678
+ }
679
+ // Phase 3 — the inference turn and its request: the prompts the model has not
680
+ // seen, their open-path READs, the ambient and stream deltas, then the packet
681
+ // ({§packet-stored-shape}).
682
+ async #composeRequest(args, container, gitStatus) {
683
+ const { workspaceId, workerId, loopId } = args;
684
+ // Initialization is a complete preceding turn, not a set of rows
685
+ // interleaved with the model boundary. The engine may now acquire the
686
+ // inference turn inside the same warmed workspace cycle.
687
+ const modelTurn = container.modelTurn ?? await Turn.open(this.#db, { loopId, producer: "model", kind: "inference" });
688
+ if (container.modelTurn === null)
689
+ container.createdTurnIds.push(modelTurn.id);
690
+ const { id: turnId, sequence: seq } = modelTurn;
691
+ const systemCtx = this.#schemeContext(args, turnId);
692
+ const loopSeq = (await this.#db.engine_loop_sequence.get({ loop_id: loopId }))?.sequence ?? loopId;
693
+ const prompts = await this.#publishPrompts(args, container, turnId, loopSeq);
694
+ let nextActionIndex = await this.#readOpenPaths(args, turnId, prompts.openPaths, prompts.nextActionIndex);
695
+ // {§env-delta-log-pull} — materialize ambient observations before packet
696
+ // composition and reserve their action indices. {§exec-stream} owns the
697
+ // distinct byte-cursor path for this worker's streams.
698
+ // {§exec-poll} — an execution `<0>` is turn-scoped: reap the worker's open turn-scoped streams (necessarily
699
+ // from a prior turn — this runs before the turn's own spawns) so a `<0>` never survives into
700
+ // the subsequent turn. The terminal output then surfaces initially visible via the stream-delta path.
701
+ await this.#reapTurnScopedStreams(workerId);
702
+ nextActionIndex += await this.#materialization.materializeEnvironmentDeltas({ workspaceId, workerId, loopId, turnId, fromSequence: nextActionIndex });
703
+ nextActionIndex += await this.#materialization.materializeStreamDeltas({ workspaceId, workerId, loopId, turnId, fromSequence: nextActionIndex });
704
+ // The post-reconciliation Git snapshot above is threaded into the packet
705
+ // and every budget rebuild; overflow never shells again.
706
+ // Notices are non-terminal observations, never operation-failure truth.
707
+ // Drain once and thread the same set through every overflow rebuild.
708
+ const notices = this.#notices.drain(loopId)
709
+ .filter((event) => event.level !== "info");
710
+ // Build the model request packet ({§packet-stored-shape}). The log build
711
+ // queries log_entries scoped to the worker — the prompt entry just
712
+ // written (if turn 1) is part of that query result.
713
+ const facts = { turnId, seq, gitStatus, notices, transientOpenLogEntryId: container.transientOpenLogEntryId, promptProjection: "automatic" };
714
+ let packet = await this.#buildPacket(args, facts);
715
+ // {§context-output-admission} — output admission changes no operation
716
+ // outcome, authored inventory, or turn identity.
717
+ if (await this.#packets.admitOutput(packet, turnId))
718
+ packet = await this.#buildPacket(args, facts);
719
+ return { ...facts, createdTurnIds: container.createdTurnIds, loopSeq, systemCtx, nextActionIndex, packet };
720
+ }
721
+ // Pre-model writes. Each prompt the model has not seen yet becomes a `prompt`
722
+ // operation row whose target is its durable prompt:// entry; model operations
723
+ // continue the same turn sequence after these rows. Returns the next action index
724
+ // and the frames' open paths.
725
+ async #publishPrompts({ workerId, loopId, onDispatch, onSettled }, { workerName, promptPublication }, turnId, loopSeq) {
726
+ let nextActionIndex = 1;
727
+ const openPaths = [];
728
+ if (promptPublication !== null) { // {§prompt-entry} — one durable publication per loop
729
+ openPaths.push(...promptPublication.openPaths);
730
+ const promptLogId = await this.#materialization.writePromptLog({
715
731
  workerId,
716
732
  loopId,
717
- currentTurnSeq: seq,
718
- provider,
719
- gitStatus,
720
- notices,
721
- transientOpenLogEntryId,
722
- promptProjection,
723
733
  turnId,
734
+ sequence: nextActionIndex++,
735
+ target: promptPublication.path,
736
+ content: promptPublication.content,
737
+ source: promptPublication.source,
724
738
  });
725
- let requestPacket = await buildPacket();
726
- // {§context-output-admission} — output admission changes no operation
727
- // outcome, authored inventory, or turn identity.
728
- if (await this.#packets.admitOutput(requestPacket, turnId))
729
- requestPacket = await buildPacket();
730
- const remaining = this.#packets.curationOverflow(requestPacket);
731
- if (remaining !== null) {
732
- const curationFailure = curationOverflowFailure(remaining);
733
- await Turn.complete(this.#db, turnId, curationFailure.status);
734
- return {
735
- createdTurnIds,
736
- turnId,
737
- producer: "model",
738
- kind: "inference",
739
- status: curationFailure.status,
740
- outcomes: [],
741
- fingerprint: "",
742
- capacityHardStop: false,
743
- providerParked: false,
744
- steerStruck: false,
745
- emptyTurn: false,
746
- emissionAttempts: 0,
747
- emissionExhausted: false,
748
- curationFailure,
749
- };
739
+ onDispatch?.(promptLogId);
740
+ await onSettled?.(promptLogId);
741
+ }
742
+ // {§prompt-loop-containment}: the loop contains every prompt that arrived
743
+ // while it ran. Publish each undelivered frame oldest-first exactly once.
744
+ const prefix = promptLoopPrefix(loopSeq);
745
+ const undelivered = (await this.#db.drain_undelivered_prompts_for_loop.all({
746
+ worker_id: workerId,
747
+ pattern: `${prefix}%`,
748
+ prefix_len: prefix.length,
749
+ loop_id: loopId,
750
+ }))
751
+ .filter((row) => typeof row.content === "string" && row.content.length > 0);
752
+ for (const injectedRow of undelivered) {
753
+ const attributes = parsePromptAttributes(injectedRow.attributes, `Prompt ${injectedRow.pathname} attributes`);
754
+ if (attributes.openPaths !== undefined) {
755
+ openPaths.push(...assertOpenPaths(attributes.openPaths, `Prompt ${injectedRow.pathname} openPaths`));
750
756
  }
751
- let materializedRequest = await this.#wireMessages(requestPacket, systemCtx, provider);
752
- let modelMessages = materializedRequest.messages;
753
- let nativeInputs = materializedRequest.nativeInputs;
754
- // Curation pressure and provider generation are independent. The
755
- // provider owns its configured total output envelope.
756
- let response;
757
- let splitResponse;
758
- let railGrammar;
759
- let railEvidence;
760
- let emissionAttempts = 0;
761
- let providerCallInFlight = false;
762
- let modelCallSequence = 0;
763
- let currentEmissionAttempt = 0;
764
- let providerAttemptId = null;
765
- let providerModelCall = null;
766
- // {§provider-recovery} — this turn's recovery clock: the first recoverable provider
767
- // failure starts it; the budget and backoff are the operator's.
768
- let recoveryStartedAt = null;
769
- let recoveryFailures = 0;
770
- let providerParked = false;
771
- const recoveryBudget = readProviderRecovery();
772
- const recoveryBackoff = readProviderRecoveryBackoff();
773
- let providerAttemptAttributions = [];
774
- const providerSignal = this.#loopSignal(loopId) ?? signal;
775
- // {§client-metadata}
776
- const { client } = await WorkspaceSettings.read(this.#db, workspaceId);
777
- const providerIdentity = await this.#resolveWorkerProviderIdentity(workerId);
778
- const { workerId: providerWorkerId, primaryWorkerId } = providerIdentity;
779
- const classifyProviderAttempt = async (id, attemptSplit, emissionAttempt, accepted) => {
780
- const result = await this.#db.engine_classify_turn_attempt_response.run({
781
- id,
782
- accepted: accepted ? 1 : 0,
783
- parse_errors: JSON.stringify(attemptSplit.parseErrors),
784
- });
785
- if (result.changes !== 1) {
786
- throw new Error(`emission attempt ${id} was not awaiting classification`);
787
- }
788
- emissionAttempts = emissionAttempt;
789
- };
790
- const recoverCapacityPacket = async () => {
791
- let baselineMessages = modelMessages;
792
- if (promptProjection === "automatic") {
793
- promptProjection = "withheld";
794
- const candidate = await buildPacket();
795
- const candidateRequest = await this.#wireMessages(candidate, systemCtx, provider);
796
- if (JSON.stringify(candidateRequest.messages) !== JSON.stringify(baselineMessages)) {
797
- requestPacket = candidate;
798
- materializedRequest = candidateRequest;
799
- modelMessages = materializedRequest.messages;
800
- nativeInputs = materializedRequest.nativeInputs;
801
- return true;
802
- }
803
- baselineMessages = candidateRequest.messages;
804
- }
805
- return false;
757
+ const promptLogId = await this.#materialization.writePromptLog({
758
+ workerId,
759
+ loopId,
760
+ turnId,
761
+ sequence: nextActionIndex++,
762
+ target: promptTarget(workerName, injectedRow.pathname),
763
+ content: injectedRow.content,
764
+ source: promptSourceFromAttributes(attributes, `Prompt ${injectedRow.pathname} attributes`),
765
+ });
766
+ onDispatch?.(promptLogId);
767
+ await onSettled?.(promptLogId);
768
+ }
769
+ return { nextActionIndex, openPaths };
770
+ }
771
+ // {§methods-loop-run-open-paths}: selected workspace paths belong to the prompt
772
+ // frame. Publish the frame, then dispatch ordinary core READs in that same turn;
773
+ // missing/non-member paths retain their normal 4xx. Returns the next action index.
774
+ async #readOpenPaths({ workspaceId, workerId, loopId, onDispatch, onSettled }, turnId, openPaths, fromSequence) {
775
+ let nextActionIndex = fromSequence;
776
+ for (const raw of openPaths) {
777
+ const pathname = raw.startsWith("/") ? raw : `/${raw}`;
778
+ const fileRead = {
779
+ op: "READ", aside: null, lineMarker: null, matcher: null,
780
+ target: {
781
+ kind: "url", raw: `file://${pathname}`, scheme: "file",
782
+ username: null, password: null, hostname: null, port: null,
783
+ pathname, query: null, fragment: null,
784
+ },
785
+ metadata: null,
786
+ body: null, position: UNKNOWN_POSITION,
806
787
  };
807
- try {
808
- // {§turn-lifecycle}: bracket the complete provider-attempt window with liveness notices.
809
- if (!signal?.aborted)
810
- this.#notices.push(workspaceId, workerId, loopId, { source: "engine:turn", kind: "turn_awaiting_model", level: "info", message: "awaiting model response" });
811
- railGrammar = await this.#operatorGrammar(provider);
812
- const attemptLimit = readEmissionAttempts();
813
- const strikeStreak = await this.#strikes.streak(loopId);
814
- // {§turn-accounting-notice} (#465) — every physical exchange this turn pays
815
- // for, successes and failed calls alike, so the completion notice carries
816
- // the exact settled wire spend.
817
- const turnWireAccounting = [];
818
- for (let attempt = 1; attempt <= attemptLimit;) {
819
- // Capacity recovery may rebuild and resend the request without
820
- // consuming a grammar-emission attempt. Every logical provider
821
- // call still receives its own durable sequence and accounting.
822
- currentEmissionAttempt = attempt;
823
- modelCallSequence++;
824
- const attributionContext = Object.freeze({
825
- workspaceId: String(workspaceId),
826
- workerId: providerWorkerId,
827
- primaryWorkerId,
828
- loop: loopSeq,
829
- turn: seq,
830
- attempt: modelCallSequence,
831
- });
832
- providerAttemptAttributions = await this.#attemptAttributions(provider, attributionContext);
833
- requestPacket = { ...requestPacket, attributions: providerAttemptAttributions };
834
- providerModelCall = await ModelCall.open(this.#db, {
835
- turnId,
836
- kind: "emission",
837
- attributions: providerAttemptAttributions,
838
- model: provider.model,
839
- });
840
- const attemptRow = await this.#db.engine_open_turn_attempt.get({
841
- model_call_id: providerModelCall.id,
842
- });
843
- if (attemptRow === undefined) {
844
- throw new Error(`Engine.runTurn: provider call ${modelCallSequence} did not open`);
845
- }
846
- providerAttemptId = attemptRow.id;
847
- providerCallInFlight = true;
848
- const currentModelCall = providerModelCall;
849
- let reasoningStarted = false;
850
- let reasoningRequestSequence = 0;
851
- const endReasoning = () => {
852
- if (!reasoningStarted)
853
- return;
854
- this.#reasoningEventNotify(workspaceId, {
855
- workerId,
856
- loopId,
857
- turnId,
858
- modelCallId: currentModelCall.id,
859
- requestSequence: reasoningRequestSequence,
860
- phase: "end",
861
- });
862
- reasoningStarted = false;
863
- };
864
- const observeRequest = async (...args) => {
865
- endReasoning();
866
- const settle = await currentModelCall.observeRequest(...args);
867
- reasoningRequestSequence = currentModelCall.requestSequence;
868
- return settle;
869
- };
870
- // {§notifications-reasoning-event} Only the parent emission is
871
- // conversational; BARE has no observer on its isolated calls.
872
- const observeReasoning = this.#reasoningEventNotify === undefined
873
- ? undefined
874
- : (delta) => {
875
- if (reasoningRequestSequence === 0) {
876
- throw new Error("provider emitted reasoning before opening its physical request");
877
- }
878
- if (!reasoningStarted) {
879
- reasoningStarted = true;
880
- this.#reasoningEventNotify(workspaceId, {
881
- workerId,
882
- loopId,
883
- turnId,
884
- modelCallId: currentModelCall.id,
885
- requestSequence: reasoningRequestSequence,
886
- phase: "start",
887
- });
888
- }
889
- this.#reasoningEventNotify(workspaceId, {
890
- workerId,
891
- loopId,
892
- turnId,
893
- modelCallId: currentModelCall.id,
894
- requestSequence: reasoningRequestSequence,
895
- phase: "content",
896
- delta,
897
- });
898
- };
899
- let completedResponse;
900
- try {
901
- completedResponse = await observed(// {§observability-boundary}
902
- GEN_AI_REQUEST_SPAN, { model: provider.model, attempt: modelCallSequence }, async (span) => {
903
- try {
904
- await this.#packets.recordObservations(requestPacket);
905
- const generated = await provider.generate({
906
- messages: modelMessages,
907
- workerId: providerWorkerId,
908
- primaryWorkerId,
909
- signal: providerSignal,
910
- grammar: railGrammar,
911
- strikes: strikeStreak,
912
- attributions: providerAttemptAttributions.length > 0
913
- ? providerAttemptAttributions
914
- : undefined,
915
- client: client ?? undefined,
916
- workspaceId: String(workspaceId),
917
- loop: loopSeq,
918
- turn: seq,
919
- observeRequest,
920
- observeReasoning,
921
- callKind: "emission",
922
- }); // {§provider-surface-generate} {§provider-guarantees-signal-wired} {§provider-guarantees-serial-attempts} {§attribution} {§client-metadata}
923
- currentModelCall.assertAccounting(generated.accounting);
924
- providerCallInFlight = false;
925
- recordCounter(PROVIDER_CALLS, {
926
- model: provider.model,
927
- attempt: modelCallSequence,
928
- status: "resolved",
929
- });
930
- span.setAttribute("status", "resolved");
931
- settleGenAiResponse(span, generated);
932
- return generated;
933
- }
934
- catch (error) {
935
- if (error instanceof ProviderError) {
936
- currentModelCall.assertAccounting(error.accounting);
937
- turnWireAccounting.push(...error.accounting);
938
- }
939
- throw error;
940
- }
941
- }, genAiRequestOptions(ProviderInstantiate.aliasOf(provider) ?? "plurnk", provider.model));
942
- }
943
- catch (error) {
944
- if (error instanceof ProviderError
945
- && RECOVERABLE_PROVIDER_FAILURES.has(error.kind)
946
- && providerSignal?.aborted !== true) {
947
- // {§provider-recovery} — a transient provider failure never ends the loop:
948
- // record it, wait, and re-issue the same turn; once the budget is spent the
949
- // outer handler parks the loop instead of failing it.
950
- recoveryStartedAt ??= Date.now();
951
- const elapsed = Date.now() - recoveryStartedAt;
952
- if (elapsed >= recoveryBudget) {
953
- providerParked = true;
954
- throw error;
955
- }
956
- recoveryFailures += 1;
957
- const failure = TurnRunner.#providerFailure(error, providerSignal);
958
- if (error.attempt !== undefined) {
959
- // {§provider-interrupted-attempt} — the interrupted response stays durable
960
- // as an unaccepted attempt; it is never admitted or replayed.
961
- await currentModelCall.observeResponse(error.attempt, failure, nativeInputs);
962
- await classifyProviderAttempt(providerAttemptId, this.#splitResponse(error.attempt, this.#executors()?.availableRuntimes(workspaceId) ?? []), currentEmissionAttempt, false);
963
- }
964
- else {
965
- await currentModelCall.fail(failure, error.capacity ?? null);
966
- }
967
- providerCallInFlight = false;
968
- await this.#problems.record({
969
- workerId,
970
- loopId,
971
- turnId,
972
- sequence: nextActionIndex++,
973
- origin: "_plurnk",
974
- source: "provider",
975
- result: failure,
976
- });
977
- const wait = Math.min(recoveryBackoff * 2 ** (recoveryFailures - 1), recoveryBackoff * 12);
978
- this.#notices.push(workspaceId, workerId, loopId, {
979
- source: "engine:provider",
980
- kind: "provider_unavailable",
981
- level: "warn",
982
- message: `${failure.problem?.title ?? "Provider failure"}: retrying in ${Math.round(wait / 1000)}s (${Math.round(elapsed / 1000)}s of the ${Math.round(recoveryBudget / 1000)}s recovery budget spent).`,
983
- });
984
- // An abort during the wait re-enters generate, which refuses on the aborted signal.
985
- await delay(wait, undefined, { signal: providerSignal }).catch(() => undefined);
986
- // The response is still owed for this exact input. Failure rows remain
987
- // durable, but recursively materializing them would mutate and cache-bust
988
- // the request being recovered. {§provider-recovery}
989
- continue;
990
- }
991
- if (!(error instanceof ProviderError)
992
- || error.kind !== "capacity_exceeded"
993
- || providerSignal?.aborted === true
994
- || !await recoverCapacityPacket()) {
995
- throw error;
996
- }
997
- const failure = TurnRunner.#providerFailure(error, providerSignal);
998
- await currentModelCall.fail(failure, error.capacity ?? null);
999
- providerCallInFlight = false;
1000
- await this.#problems.record({
1001
- workerId,
1002
- loopId,
1003
- turnId,
1004
- sequence: nextActionIndex++,
1005
- origin: "_plurnk",
1006
- source: "provider",
1007
- result: failure,
1008
- });
1009
- // Include the durable recovery signal in the replacement
1010
- // request while preserving the selected recovery posture.
1011
- requestPacket = await buildPacket();
1012
- materializedRequest = await this.#wireMessages(requestPacket, systemCtx, provider);
1013
- modelMessages = materializedRequest.messages;
1014
- nativeInputs = materializedRequest.nativeInputs;
1015
- continue;
1016
- }
1017
- finally {
1018
- endReasoning();
1019
- }
1020
- if (recoveryFailures > 0) {
1021
- this.#notices.push(workspaceId, workerId, loopId, {
1022
- source: "engine:provider",
1023
- kind: "provider_recovered",
1024
- level: "info",
1025
- message: `Provider recovered after ${recoveryFailures} failed call${recoveryFailures === 1 ? "" : "s"}.`,
1026
- });
1027
- recoveryFailures = 0;
1028
- recoveryStartedAt = null;
1029
- }
1030
- response = completedResponse;
1031
- turnWireAccounting.push(...completedResponse.accounting);
1032
- await currentModelCall.observeResponse(completedResponse, null, nativeInputs);
1033
- railEvidence = railGrammar === undefined ? undefined : completedResponse.grammarEvidence;
1034
- splitResponse = this.#splitResponse(completedResponse, this.#executors()?.availableRuntimes(workspaceId) ?? []);
1035
- await classifyProviderAttempt(attemptRow.id, splitResponse, attempt, splitResponse.emissionValid);
1036
- if (splitResponse.emissionValid)
1037
- break;
1038
- attempt++;
1039
- }
1040
- if (!signal?.aborted) {
1041
- const wire = aggregateProviderAccounting(turnWireAccounting);
1042
- this.#notices.push(workspaceId, workerId, loopId, {
1043
- source: "engine:turn",
1044
- kind: "turn_generated",
1045
- level: "info",
1046
- message: "parsing model response",
1047
- // {§turn-accounting-notice} (#465) — the exact settled derivation,
1048
- // never a second stored fact: a live watcher accrues loop cost per turn.
1049
- accounting: {
1050
- requests: turnWireAccounting.length,
1051
- costUsd: wire.costUsd,
1052
- inputTokens: wire.usage?.inputTokens ?? null,
1053
- outputTokens: wire.usage?.outputTokens ?? null,
1054
- reasoningTokens: wire.usage?.outputTokenDetails?.reasoningTokens ?? null,
1055
- cacheReadTokens: wire.usage?.inputTokenDetails?.cacheReadTokens ?? null,
1056
- },
1057
- });
1058
- }
788
+ await this.#dispatch({
789
+ statement: fileRead, workspaceId, workerId, loopId, turnId,
790
+ sequence: nextActionIndex, origin: "_plurnk", onDispatch, onSettled,
791
+ });
792
+ nextActionIndex++;
793
+ }
794
+ return nextActionIndex;
795
+ }
796
+ #buildPacket({ messages, recap, workspaceId, workerId, loopId, provider }, facts) {
797
+ return this.#packets.buildRequestPacket({
798
+ initialMessages: messages,
799
+ recap,
800
+ workspaceId,
801
+ workerId,
802
+ loopId,
803
+ currentTurnSeq: facts.seq,
804
+ provider,
805
+ gitStatus: facts.gitStatus,
806
+ notices: facts.notices,
807
+ transientOpenLogEntryId: facts.transientOpenLogEntryId,
808
+ promptProjection: facts.promptProjection,
809
+ turnId: facts.turnId,
810
+ });
811
+ }
812
+ // Retained context cannot fit: the turn completes on the curation failure and
813
+ // the loop rules the terminal.
814
+ async #failCuration(request, overflow) {
815
+ const curationFailure = curationOverflowFailure(overflow);
816
+ await Turn.complete(this.#db, request.turnId, curationFailure.status);
817
+ return turnResult(request, curationFailure.status, { curationFailure });
818
+ }
819
+ // Phase 4's bookkeeping: the wire request, the recovery knobs, the signal the
820
+ // provider sees, the client id and the worker's provider identity.
821
+ async #prepareProviderAttempts({ provider, workspaceId, workerId, loopId, signal }, request) {
822
+ const wire = await this.#wireMessages(request.packet, request.systemCtx, provider);
823
+ // {§provider-recovery} — this turn's recovery clock: the first recoverable provider
824
+ // failure starts it; the budget and backoff are the operator's.
825
+ const recoveryBudget = readProviderRecovery();
826
+ const recoveryBackoff = readProviderRecoveryBackoff();
827
+ const providerSignal = this.#loopSignal(loopId) ?? signal;
828
+ // {§client-metadata}
829
+ const { client } = await WorkspaceSettings.read(this.#db, workspaceId);
830
+ const { workerId: providerWorkerId, primaryWorkerId } = await this.#resolveWorkerProviderIdentity(workerId);
831
+ return {
832
+ wire,
833
+ response: undefined,
834
+ split: undefined,
835
+ railGrammar: undefined,
836
+ railEvidence: undefined,
837
+ emissionAttempts: 0,
838
+ callInFlight: false,
839
+ modelCallSequence: 0,
840
+ currentEmissionAttempt: 0,
841
+ attemptId: null,
842
+ modelCall: null,
843
+ recoveryStartedAt: null,
844
+ recoveryFailures: 0,
845
+ parked: false,
846
+ attributions: [],
847
+ recoveryBudget,
848
+ recoveryBackoff,
849
+ signal: providerSignal,
850
+ client,
851
+ providerWorkerId,
852
+ primaryWorkerId,
853
+ turnWireAccounting: [],
854
+ };
855
+ }
856
+ // Phase 4 — the provider attempt loop. Each iteration is one logical call: a
857
+ // recoverable failure or a capacity rebuild re-issues the same emission attempt,
858
+ // an invalid emission spends one, and a valid emission ends the loop
859
+ // ({§invalid-emission-attempts}).
860
+ async #attemptProvider(args, request, attempts) {
861
+ const { provider, workspaceId, workerId, loopId, signal } = args;
862
+ // {§turn-lifecycle}: bracket the complete provider-attempt window with liveness notices.
863
+ if (!signal?.aborted)
864
+ this.#notices.push(workspaceId, workerId, loopId, { source: "engine:turn", kind: "turn_awaiting_model", level: "info", message: "awaiting model response" });
865
+ attempts.railGrammar = await this.#operatorGrammar(provider);
866
+ const attemptLimit = readEmissionAttempts();
867
+ const strikeStreak = await this.#strikes.streak(loopId);
868
+ for (let attempt = 1; attempt <= attemptLimit;) {
869
+ const verdict = await this.#issueProviderCall(args, request, attempts, attempt, strikeStreak);
870
+ if (verdict === "admitted")
871
+ break;
872
+ if (verdict === "rejected")
873
+ attempt++;
874
+ }
875
+ if (!signal?.aborted) {
876
+ const wire = aggregateProviderAccounting(attempts.turnWireAccounting);
877
+ this.#notices.push(workspaceId, workerId, loopId, {
878
+ source: "engine:turn",
879
+ kind: "turn_generated",
880
+ level: "info",
881
+ message: "parsing model response",
882
+ // {§turn-accounting-notice} (#465) — the exact settled derivation,
883
+ // never a second stored fact: a live watcher accrues loop cost per turn.
884
+ accounting: {
885
+ requests: attempts.turnWireAccounting.length,
886
+ costUsd: wire.costUsd,
887
+ inputTokens: wire.usage?.inputTokens ?? null,
888
+ outputTokens: wire.usage?.outputTokens ?? null,
889
+ reasoningTokens: wire.usage?.outputTokenDetails?.reasoningTokens ?? null,
890
+ cacheReadTokens: wire.usage?.inputTokenDetails?.cacheReadTokens ?? null,
891
+ },
892
+ });
893
+ }
894
+ if (attempts.response === undefined || attempts.split === undefined || attempts.modelCall === null) {
895
+ throw new Error("provider attempt loop completed without a response");
896
+ }
897
+ const emission = {
898
+ response: attempts.response,
899
+ split: attempts.split,
900
+ modelCallId: attempts.modelCall.id,
901
+ railGrammar: attempts.railGrammar,
902
+ railEvidence: attempts.railEvidence,
903
+ emissionAttempts: attempts.emissionAttempts,
904
+ signal: attempts.signal,
905
+ primaryWorkerId: attempts.primaryWorkerId,
906
+ };
907
+ if (emission.split.packetAssistant.reasoning?.length) {
908
+ await Turn.recordSource(this.#db, request.turnId, "reasoning", emission.split.packetAssistant.reasoning, { modelCallId: emission.modelCallId });
909
+ }
910
+ return emission;
911
+ }
912
+ // One logical provider call: its durable model call and attempt row, the exchange
913
+ // under the reasoning observer, and its classification. Capacity recovery and
914
+ // {§provider-recovery} re-issue the same emission attempt; the parser's admission
915
+ // is the one verdict on a completed exchange.
916
+ async #issueProviderCall(args, request, attempts, attempt, strikeStreak) {
917
+ const { provider, workspaceId, workerId, loopId } = args;
918
+ // Capacity recovery may rebuild and resend the request without
919
+ // consuming a grammar-emission attempt. Every logical provider
920
+ // call still receives its own durable sequence and accounting.
921
+ attempts.currentEmissionAttempt = attempt;
922
+ attempts.modelCallSequence++;
923
+ const attributionContext = Object.freeze({
924
+ workspaceId: String(workspaceId),
925
+ workerId: attempts.providerWorkerId,
926
+ primaryWorkerId: attempts.primaryWorkerId,
927
+ loop: request.loopSeq,
928
+ turn: request.seq,
929
+ attempt: attempts.modelCallSequence,
930
+ });
931
+ attempts.attributions = await this.#attemptAttributions(provider, attributionContext);
932
+ request.packet = { ...request.packet, attributions: attempts.attributions };
933
+ const modelCall = await ModelCall.open(this.#db, {
934
+ turnId: request.turnId,
935
+ kind: "emission",
936
+ attributions: attempts.attributions,
937
+ model: provider.model,
938
+ });
939
+ attempts.modelCall = modelCall;
940
+ const attemptRow = await this.#db.engine_open_turn_attempt.get({
941
+ model_call_id: modelCall.id,
942
+ });
943
+ if (attemptRow === undefined) {
944
+ throw new Error(`Engine.runTurn: provider call ${attempts.modelCallSequence} did not open`);
945
+ }
946
+ attempts.attemptId = attemptRow.id;
947
+ attempts.callInFlight = true;
948
+ const reasoning = this.#reasoningObserver(args, request.turnId, modelCall);
949
+ let completedResponse;
950
+ try {
951
+ completedResponse = await this.#generate(args, request, attempts, modelCall, reasoning, strikeStreak);
952
+ }
953
+ catch (error) {
954
+ if (error instanceof ProviderError
955
+ && RECOVERABLE_PROVIDER_FAILURES.has(error.kind)
956
+ && attempts.signal?.aborted !== true) {
957
+ await this.#recoverProviderFailure(args, request, attempts, modelCall, attemptRow.id, error);
958
+ return "reissued";
1059
959
  }
1060
- catch (err) {
1061
- // This handler owns only provider-call failures. Parser, cost, SQL,
1062
- // and engine-contract failures retain their original source.
1063
- if (err instanceof ModelCallPersistenceError || err instanceof ProviderAccountingIntegrityError)
1064
- throw err;
1065
- if (!providerCallInFlight)
1066
- throw err;
1067
- providerCallInFlight = false;
1068
- if (providerAttemptId === null || providerModelCall === null) {
1069
- throw new Error("provider call failed without durable model-call and attempt identities", { cause: err });
1070
- }
1071
- const failure = TurnRunner.#providerFailure(err, providerSignal);
1072
- const capacityFailure = err instanceof ProviderError && err.kind === "capacity_exceeded";
1073
- // {§provider-interrupted-attempt} — a provider-declared interruption
1074
- // carries response evidence without becoming a completed exchange.
1075
- // Persist it as an unaccepted attempt before settling the failure.
1076
- if (err instanceof ProviderError && err.attempt !== undefined) {
1077
- response = err.attempt;
1078
- await providerModelCall.observeResponse(response, failure, nativeInputs);
1079
- splitResponse = this.#splitResponse(response, this.#executors()?.availableRuntimes(workspaceId) ?? []);
1080
- await classifyProviderAttempt(providerAttemptId, splitResponse, currentEmissionAttempt, false);
1081
- }
1082
- else {
1083
- await providerModelCall.fail(failure, err instanceof ProviderError ? err.capacity ?? null : null);
1084
- if (!capacityFailure)
1085
- emissionAttempts = currentEmissionAttempt;
960
+ if (!(error instanceof ProviderError)
961
+ || error.kind !== "capacity_exceeded"
962
+ || attempts.signal?.aborted === true
963
+ || !await this.#recoverCapacityPacket(args, request, attempts)) {
964
+ throw error;
965
+ }
966
+ await this.#resendForCapacity(args, request, attempts, modelCall, error);
967
+ return "reissued";
968
+ }
969
+ finally {
970
+ reasoning.end();
971
+ }
972
+ if (attempts.recoveryFailures > 0) {
973
+ this.#notices.push(workspaceId, workerId, loopId, {
974
+ source: "engine:provider",
975
+ kind: "provider_recovered",
976
+ level: "info",
977
+ message: `Provider recovered after ${attempts.recoveryFailures} failed call${attempts.recoveryFailures === 1 ? "" : "s"}.`,
978
+ });
979
+ attempts.recoveryFailures = 0;
980
+ attempts.recoveryStartedAt = null;
981
+ }
982
+ attempts.response = completedResponse;
983
+ attempts.turnWireAccounting.push(...completedResponse.accounting);
984
+ await modelCall.observeResponse(completedResponse, null, attempts.wire.nativeInputs);
985
+ attempts.railEvidence = attempts.railGrammar === undefined ? undefined : completedResponse.grammarEvidence;
986
+ const split = this.#splitResponse(completedResponse, this.#executors()?.availableRuntimes(workspaceId) ?? []);
987
+ attempts.split = split;
988
+ await this.#classifyProviderAttempt(attempts, attemptRow.id, split, attempt, split.emissionValid);
989
+ return split.emissionValid ? "admitted" : "rejected";
990
+ }
991
+ // {§notifications-reasoning-event} — only the parent emission is conversational;
992
+ // BARE has no observer on its isolated calls. Reasoning deltas cite the physical
993
+ // request they stream in; a new request or the call's end closes the open span.
994
+ #reasoningObserver({ workspaceId, workerId, loopId }, turnId, modelCall) {
995
+ let reasoningStarted = false;
996
+ let reasoningRequestSequence = 0;
997
+ const end = () => {
998
+ if (!reasoningStarted)
999
+ return;
1000
+ this.#reasoningEventNotify(workspaceId, {
1001
+ workerId,
1002
+ loopId,
1003
+ turnId,
1004
+ modelCallId: modelCall.id,
1005
+ requestSequence: reasoningRequestSequence,
1006
+ phase: "end",
1007
+ });
1008
+ reasoningStarted = false;
1009
+ };
1010
+ const observeRequest = async (...args) => {
1011
+ end();
1012
+ const settle = await modelCall.observeRequest(...args);
1013
+ reasoningRequestSequence = modelCall.requestSequence;
1014
+ return settle;
1015
+ };
1016
+ const observeReasoning = this.#reasoningEventNotify === undefined
1017
+ ? undefined
1018
+ : (delta) => {
1019
+ if (reasoningRequestSequence === 0) {
1020
+ throw new Error("provider emitted reasoning before opening its physical request");
1086
1021
  }
1087
- // {§turn-never-blank} — a ProviderError means no completed exchange exists.
1088
- // Persist its exact RFC 9457 result before propagating it. Grammar transport
1089
- // evidence exists only on completed responses ({§operator-grammar}).
1090
- // Cancellation is lifecycle truth, not a provider failure. Close the
1091
- // attempted turn without inventing an assistant response, then let
1092
- // runLoop/Daemon settle the exact 504/499 loop result.
1093
- if (providerSignal?.aborted) {
1094
- const status = providerSignal.reason === LOOP_TIMEOUT_REASON ? 504 : 499;
1095
- await this.#recordInference({
1096
- workspaceId, workerId, loopId, turnId,
1097
- evidence: {
1098
- packet: StoredPacket.stringify(requestPacket),
1099
- sections: StoredPacket.sections(requestPacket),
1100
- usageCurationBudget: this.#packets.curationBudgetFor(requestPacket),
1101
- finishReason: splitResponse?.callMetadata.finishReason ?? null,
1102
- model: splitResponse?.callMetadata.model ?? provider.model,
1103
- meta: JSON.stringify(response?.meta ?? {}),
1104
- },
1022
+ if (!reasoningStarted) {
1023
+ reasoningStarted = true;
1024
+ this.#reasoningEventNotify(workspaceId, {
1025
+ workerId,
1026
+ loopId,
1027
+ turnId,
1028
+ modelCallId: modelCall.id,
1029
+ requestSequence: reasoningRequestSequence,
1030
+ phase: "start",
1105
1031
  });
1106
- await Turn.complete(this.#db, turnId, status);
1107
- throw err;
1108
1032
  }
1109
- const recorded = await this.#problems.record({
1033
+ this.#reasoningEventNotify(workspaceId, {
1110
1034
  workerId,
1111
1035
  loopId,
1112
1036
  turnId,
1113
- sequence: nextActionIndex,
1114
- origin: "_plurnk",
1115
- source: "provider",
1116
- result: failure,
1037
+ modelCallId: modelCall.id,
1038
+ requestSequence: reasoningRequestSequence,
1039
+ phase: "content",
1040
+ delta,
1117
1041
  });
1118
- // The provider call was attempted, but no completed exchange exists.
1119
- // Persist the exact request half and failure status; omitting assistant
1120
- // is materially different from fabricating an empty model turn.
1121
- await this.#recordInference({
1122
- workspaceId, workerId, loopId, turnId,
1123
- evidence: {
1124
- packet: StoredPacket.stringify(requestPacket),
1125
- sections: StoredPacket.sections(requestPacket),
1126
- usageCurationBudget: this.#packets.curationBudgetFor(requestPacket),
1127
- finishReason: splitResponse?.callMetadata.finishReason ?? null,
1128
- model: splitResponse?.callMetadata.model ?? provider.model,
1129
- meta: JSON.stringify(response?.meta ?? {}),
1130
- },
1131
- });
1132
- await Turn.complete(this.#db, turnId, providerParked ? 202 : recorded.result.status);
1133
- if (providerParked) {
1134
- // {§provider-recovery} — the recovery budget is spent: the loop parks exactly like a
1135
- // [202] wait and resumes on the next prompt or wake; the failure stays durable.
1136
- this.#notices.push(workspaceId, workerId, loopId, {
1137
- source: "engine:provider",
1138
- kind: "provider_unavailable",
1139
- level: "error",
1140
- message: `${recorded.result.problem?.title ?? "Provider failure"}: the ${Math.round(recoveryBudget / 1000)}s recovery budget is spent; the loop is parked and resumes on the next prompt or wake.`,
1141
- });
1142
- return {
1143
- createdTurnIds,
1144
- turnId,
1145
- producer: "model",
1146
- kind: "inference",
1147
- status: 202,
1148
- outcomes: [],
1149
- fingerprint: "",
1150
- capacityHardStop: false, providerParked: true,
1151
- providerFailure: recorded.result,
1152
- steerStruck: false,
1153
- emptyTurn: false,
1154
- emissionAttempts,
1155
- emissionExhausted: false,
1156
- };
1157
- }
1158
- if (capacityFailure) {
1159
- return {
1160
- createdTurnIds,
1161
- turnId,
1162
- producer: "model",
1163
- kind: "inference",
1164
- status: recorded.result.status,
1165
- outcomes: [],
1166
- fingerprint: "",
1167
- capacityHardStop: true,
1168
- providerParked: false,
1169
- capacityFailure: recorded.result,
1170
- steerStruck: false,
1171
- emptyTurn: false,
1172
- emissionAttempts,
1173
- emissionExhausted: false,
1174
- };
1175
- }
1176
- if (err instanceof ProviderError
1177
- && err.kind === "invalid_response"
1178
- && providerSignal?.aborted !== true) {
1179
- // {§engine-rails} Contract Strikes: the provider violated its response
1180
- // contract. The failure is durable and the turn is complete; the strike
1181
- // rail — never an instant loop death — rules whether the streak ends it.
1182
- return {
1183
- createdTurnIds,
1184
- turnId,
1185
- producer: "model",
1186
- kind: "inference",
1187
- status: recorded.result.status,
1188
- outcomes: [{ op: null, status: recorded.result.status, problemType: recorded.result.problem?.type ?? null }],
1189
- fingerprint: `provider-contract-violation:${turnId}`,
1190
- capacityHardStop: false,
1191
- providerParked: false,
1192
- providerFailure: recorded.result,
1193
- steerStruck: false,
1194
- emptyTurn: false,
1195
- emissionAttempts,
1196
- emissionExhausted: false,
1197
- };
1198
- }
1199
- throw new OperationFailureError(recorded.result, { cause: err });
1200
- }
1201
- if (response === undefined || splitResponse === undefined || providerModelCall === null) {
1202
- throw new Error("provider attempt loop completed without a response");
1203
- }
1204
- const emissionModelCallId = providerModelCall.id;
1205
- if (splitResponse.packetAssistant.reasoning?.length) {
1206
- await Turn.recordSource(this.#db, turnId, "reasoning", splitResponse.packetAssistant.reasoning, { modelCallId: emissionModelCallId });
1207
- }
1208
- if (!splitResponse.emissionValid) {
1209
- // {§invalid-emission-attempts} Every exhaustion publishes the raw final
1210
- // response and its recovery fact; {§engine-rails} Contract Strikes rule
1211
- // how many consecutive frame-contract violations the loop survives.
1212
- let rejectedModelEntryId;
1213
- {
1214
- rejectedModelEntryId = await this.#dispatcher.writeEmissionAttempt({
1215
- verbatim: splitResponse.packetAssistant.content,
1216
- workerId,
1217
- loopId,
1218
- turnId,
1219
- sequence: nextActionIndex,
1220
- modelCallId: emissionModelCallId,
1221
- });
1222
- // {§invalid-emission-attempts} — the informed turn carries the parser's own
1223
- // diagnostic and position: the model sees WHY, not only that it was refused.
1224
- const diagnostic = splitResponse.parseErrors[0];
1225
- const cut = allowanceCutMessage(splitResponse.callMetadata.finishReason, response.capacity.responseMax ?? provider.outputBudget);
1226
- if (cut !== null) {
1227
- this.#notices.push(workspaceId, workerId, loopId, {
1228
- source: "engine:capacity",
1229
- kind: "output_truncated",
1230
- level: "error",
1231
- message: `${cut}; no operations were performed`,
1232
- });
1233
- }
1234
- else {
1235
- this.#notices.push(workspaceId, workerId, loopId, {
1236
- source: "engine:grammar",
1237
- kind: "invalid_emission",
1238
- level: "error",
1239
- message: diagnostic === undefined
1240
- ? INVALID_EMISSION_RECOVERY_MESSAGE
1241
- : `${INVALID_EMISSION_RECOVERY_MESSAGE} Parser: ${diagnostic.message}`,
1242
- ...(diagnostic !== undefined && diagnostic.line > 0
1243
- ? { position: { type: "content-offset", line: diagnostic.line, column: diagnostic.column } }
1244
- : {}),
1245
- });
1246
- }
1247
- }
1248
- const status = TURN_STATUS_IMPLICIT_CONTINUE;
1249
- await this.#recordInference({
1250
- workspaceId, workerId, loopId, turnId,
1251
- evidence: {
1252
- packet: StoredPacket.stringify(requestPacket),
1253
- sections: StoredPacket.sections(requestPacket),
1254
- usageCurationBudget: this.#packets.curationBudgetFor(requestPacket),
1255
- finishReason: splitResponse.callMetadata.finishReason,
1256
- model: splitResponse.callMetadata.model,
1257
- meta: JSON.stringify(response.meta ?? {}),
1258
- },
1042
+ };
1043
+ return { observeRequest, observeReasoning, end };
1044
+ }
1045
+ // The exchange under its GenAI span: the packet's observations are recorded, the
1046
+ // provider generates against the wire request, and the call's accounting is
1047
+ // asserted whether it resolved or failed.
1048
+ async #generate({ provider, workspaceId }, request, attempts, modelCall, reasoning, strikeStreak) {
1049
+ return await observed(// {§observability-boundary}
1050
+ GEN_AI_REQUEST_SPAN, { model: provider.model, attempt: attempts.modelCallSequence }, async (span) => {
1051
+ try {
1052
+ await this.#packets.recordObservations(request.packet);
1053
+ const generated = await provider.generate({
1054
+ messages: attempts.wire.messages,
1055
+ workerId: attempts.providerWorkerId,
1056
+ primaryWorkerId: attempts.primaryWorkerId,
1057
+ signal: attempts.signal,
1058
+ grammar: attempts.railGrammar,
1059
+ strikes: strikeStreak,
1060
+ attributions: attempts.attributions.length > 0
1061
+ ? attempts.attributions
1062
+ : undefined,
1063
+ client: attempts.client ?? undefined,
1064
+ workspaceId: String(workspaceId),
1065
+ loop: request.loopSeq,
1066
+ turn: request.seq,
1067
+ observeRequest: reasoning.observeRequest,
1068
+ observeReasoning: reasoning.observeReasoning,
1069
+ callKind: "emission",
1070
+ }); // {§provider-surface-generate} {§provider-guarantees-signal-wired} {§provider-guarantees-serial-attempts} {§attribution} {§client-metadata}
1071
+ modelCall.assertAccounting(generated.accounting);
1072
+ attempts.callInFlight = false;
1073
+ recordCounter(PROVIDER_CALLS, {
1074
+ model: provider.model,
1075
+ attempt: attempts.modelCallSequence,
1076
+ status: "resolved",
1259
1077
  });
1260
- await Turn.complete(this.#db, turnId, status);
1261
- return {
1262
- createdTurnIds,
1263
- turnId,
1264
- producer: "model",
1265
- kind: "inference",
1266
- status: TURN_STATUS_IMPLICIT_CONTINUE,
1267
- outcomes: [{ op: null, status: 500, problemType: "https://problems.plurnk.xyz/engine/invalid-emission-exhausted" }],
1268
- fingerprint: `frame-contract-violation:${turnId}`,
1269
- capacityHardStop: false,
1270
- providerParked: false,
1271
- steerStruck: false,
1272
- emptyTurn: false,
1273
- emissionAttempts,
1274
- emissionExhausted: true,
1275
- ...(rejectedModelEntryId === undefined ? {} : { rejectedModelEntryId }),
1276
- };
1277
- }
1278
- // {§packet-stored-shape} — admitted emission data extends the packet;
1279
- // provider-call metadata remains on the Turn row.
1280
- const { packetAssistant, callMetadata, parseNotices, recoverableParseErrors, } = splitResponse; // raw assistant content is opaque — split, never interpreted — {§provider-guarantees-assistantraw-opaque}
1281
- for (const notice of parseNotices) {
1282
- this.#notices.push(workspaceId, workerId, loopId, notice);
1078
+ span.setAttribute("status", "resolved");
1079
+ settleGenAiResponse(span, generated);
1080
+ return generated;
1283
1081
  }
1284
- // {§empty-turn} — a response cut at the output allowance with nothing admitted names the
1285
- // cut, so the model reads the ceiling, never a parser symptom (#478).
1286
- if (splitResponse.emptyTurn && callMetadata.finishReason === "length") {
1287
- const cut = allowanceCutMessage(callMetadata.finishReason, response.capacity.responseMax ?? provider.outputBudget);
1288
- if (cut !== null) {
1289
- this.#notices.push(workspaceId, workerId, loopId, {
1290
- source: "engine:turn",
1291
- kind: "output_truncated",
1292
- level: "warn",
1293
- message: `${cut}; no operations were performed`,
1294
- });
1082
+ catch (error) {
1083
+ if (error instanceof ProviderError) {
1084
+ modelCall.assertAccounting(error.accounting);
1085
+ attempts.turnWireAccounting.push(...error.accounting);
1295
1086
  }
1087
+ throw error;
1296
1088
  }
1297
- // Non-fatal provider transport notices on an accepted turn. Forward each
1298
- // Notice with a content-offset `line:col`;
1299
- // the model resolves it against its own emission — READ ops:///<loop>/<turn> at the
1300
- // cited lines ({§turn-ops-entry}) — not an embedded snippet that would duplicate the emission.
1301
- for (const notice of response.notices ?? []) {
1302
- const located = typeof notice.position === "number"
1303
- ? this.#offsetToLineColumn(packetAssistant.content, notice.position)
1304
- : null;
1305
- this.#notices.push(workspaceId, workerId, loopId, {
1306
- source: notice.source,
1307
- kind: notice.kind,
1308
- message: notice.message ?? "",
1309
- level: notice.level,
1310
- ...(located !== null
1311
- ? { position: { type: "content-offset", line: located.line, column: located.column } }
1312
- : {}),
1313
- });
1314
- }
1315
- const allowanceCut = allowanceCutMessage(splitResponse.callMetadata.finishReason, response.capacity.responseMax ?? provider.outputBudget);
1316
- if (allowanceCut !== null) {
1317
- this.#notices.push(workspaceId, workerId, loopId, {
1318
- source: "engine:capacity",
1319
- kind: "output_truncated",
1320
- message: allowanceCut,
1321
- level: "warn",
1322
- });
1323
- }
1324
- // {§operator-grammar} — transport evidence only: the turn records whether the operator's
1325
- // grammar reached the wire. Nothing grades the response against it; the parser's
1326
- // admission is the one verdict (#588).
1327
- const railKeys = railGrammar === undefined
1328
- ? undefined
1329
- : { railsAttached: railEvidence?.transported === true ? "client" : "withheld" };
1330
- // Attach the admitted inference evidence. The turn remains open until
1331
- // the producer-neutral admitted-turn executor settles every operation
1332
- // and its exact source artifact.
1333
- const packet = StoredPacket.admit(requestPacket, packetAssistant, response.assistantRaw);
1334
- await this.#recordInference({
1335
- workspaceId, workerId, loopId, turnId,
1336
- evidence: {
1337
- packet: StoredPacket.stringify(packet),
1338
- sections: StoredPacket.sections(packet),
1339
- usageCurationBudget: this.#packets.curationBudgetFor(requestPacket), // {§tokenomics-client-gauge}
1340
- finishReason: callMetadata.finishReason,
1341
- model: callMetadata.model,
1342
- // Opaque provider metadata plus the grammar transport key.
1343
- // {§meta-passthrough}, {§operator-grammar}
1344
- meta: JSON.stringify({ ...(response.meta ?? {}), ...(railKeys ?? {}) }),
1345
- },
1346
- });
1347
- // {§operator-config-workspace-max-commands} — workspace maxCommands
1348
- // narrows the operator ceiling before the admitted program reaches the
1349
- // shared executor.
1350
- const maxCommands = Math.min(readMaxCommands(), (await WorkspaceSettings.read(this.#db, workspaceId)).maxCommands ?? Number.POSITIVE_INFINITY);
1351
- const executed = await this.executeAdmittedTurn({
1352
- statements: packetAssistant.ops,
1353
- source: splitResponse.sourceBacked ? packetAssistant.content : null,
1354
- sourceModelCallId: emissionModelCallId,
1355
- origin: "model",
1356
- workspaceId,
1357
- workerId,
1358
- loopId,
1359
- turnId,
1360
- fromSequence: nextActionIndex,
1361
- maxCommands,
1362
- allowUnobservedRetrievalCompletion,
1363
- recoverableParseErrors,
1364
- emptyTurn: splitResponse.emptyTurn,
1365
- bare: {
1366
- provider: childProvider,
1367
- primaryWorkerId,
1368
- loopSequence: loopSeq,
1369
- turnSequence: seq,
1370
- signal: providerSignal,
1371
- },
1372
- signal: providerSignal,
1373
- onDispatch,
1374
- onSettled,
1089
+ }, genAiRequestOptions(ProviderInstantiate.aliasOf(provider) ?? "plurnk", provider.model));
1090
+ }
1091
+ // {§provider-recovery} — a transient provider failure never ends the loop: record
1092
+ // it, wait, and re-issue the same turn; once the budget is spent the failure
1093
+ // handler parks the loop instead of failing it.
1094
+ async #recoverProviderFailure({ workspaceId, workerId, loopId }, request, attempts, modelCall, attemptId, error) {
1095
+ attempts.recoveryStartedAt ??= Date.now();
1096
+ const elapsed = Date.now() - attempts.recoveryStartedAt;
1097
+ if (elapsed >= attempts.recoveryBudget) {
1098
+ attempts.parked = true;
1099
+ throw error;
1100
+ }
1101
+ attempts.recoveryFailures += 1;
1102
+ const failure = _a.#providerFailure(error, attempts.signal);
1103
+ if (error.attempt !== undefined) {
1104
+ // {§provider-interrupted-attempt} — the interrupted response stays durable
1105
+ // as an unaccepted attempt; it is never admitted or replayed.
1106
+ await modelCall.observeResponse(error.attempt, failure, attempts.wire.nativeInputs);
1107
+ await this.#classifyProviderAttempt(attempts, attemptId, this.#splitResponse(error.attempt, this.#executors()?.availableRuntimes(workspaceId) ?? []), attempts.currentEmissionAttempt, false);
1108
+ }
1109
+ else {
1110
+ await modelCall.fail(failure, error.capacity ?? null);
1111
+ }
1112
+ attempts.callInFlight = false;
1113
+ await this.#problems.record({
1114
+ workerId,
1115
+ loopId,
1116
+ turnId: request.turnId,
1117
+ sequence: request.nextActionIndex++,
1118
+ origin: "_plurnk",
1119
+ source: "provider",
1120
+ result: failure,
1121
+ });
1122
+ const wait = Math.min(attempts.recoveryBackoff * 2 ** (attempts.recoveryFailures - 1), attempts.recoveryBackoff * 12);
1123
+ this.#notices.push(workspaceId, workerId, loopId, {
1124
+ source: "engine:provider",
1125
+ kind: "provider_unavailable",
1126
+ level: "warn",
1127
+ message: `${failure.problem?.title ?? "Provider failure"}: retrying in ${Math.round(wait / 1000)}s (${Math.round(elapsed / 1000)}s of the ${Math.round(attempts.recoveryBudget / 1000)}s recovery budget spent).`,
1128
+ });
1129
+ // An abort during the wait re-enters generate, which refuses on the aborted signal.
1130
+ await delay(wait, undefined, { signal: attempts.signal }).catch(() => undefined);
1131
+ // The response is still owed for this exact input. Failure rows remain
1132
+ // durable, but recursively materializing them would mutate and cache-bust
1133
+ // the request being recovered. {§provider-recovery}
1134
+ }
1135
+ // The first capacity rejection withholds the automatic prompt projection; the
1136
+ // rebuilt request is resent only when it actually changed the wire messages.
1137
+ async #recoverCapacityPacket(args, request, attempts) {
1138
+ if (request.promptProjection !== "automatic")
1139
+ return false;
1140
+ request.promptProjection = "withheld";
1141
+ const candidate = await this.#buildPacket(args, request);
1142
+ const candidateRequest = await this.#wireMessages(candidate, request.systemCtx, args.provider);
1143
+ if (JSON.stringify(candidateRequest.messages) === JSON.stringify(attempts.wire.messages))
1144
+ return false;
1145
+ request.packet = candidate;
1146
+ attempts.wire = candidateRequest;
1147
+ return true;
1148
+ }
1149
+ // The capacity rejection is durable as a failed call and a problem row; the
1150
+ // replacement request is rebuilt to include that recovery signal while keeping
1151
+ // the selected projection.
1152
+ async #resendForCapacity(args, request, attempts, modelCall, error) {
1153
+ const { workerId, loopId, provider } = args;
1154
+ const failure = _a.#providerFailure(error, attempts.signal);
1155
+ await modelCall.fail(failure, error.capacity ?? null);
1156
+ attempts.callInFlight = false;
1157
+ await this.#problems.record({
1158
+ workerId,
1159
+ loopId,
1160
+ turnId: request.turnId,
1161
+ sequence: request.nextActionIndex++,
1162
+ origin: "_plurnk",
1163
+ source: "provider",
1164
+ result: failure,
1165
+ });
1166
+ // Include the durable recovery signal in the replacement
1167
+ // request while preserving the selected recovery posture.
1168
+ request.packet = await this.#buildPacket(args, request);
1169
+ attempts.wire = await this.#wireMessages(request.packet, request.systemCtx, provider);
1170
+ }
1171
+ // The attempt row's verdict: accepted, or rejected with the parser's errors. The
1172
+ // classified emission attempt is the turn's spent count.
1173
+ async #classifyProviderAttempt(attempts, id, split, emissionAttempt, accepted) {
1174
+ const result = await this.#db.engine_classify_turn_attempt_response.run({
1175
+ id,
1176
+ accepted: accepted ? 1 : 0,
1177
+ parse_errors: JSON.stringify(split.parseErrors),
1178
+ });
1179
+ if (result.changes !== 1) {
1180
+ throw new Error(`emission attempt ${id} was not awaiting classification`);
1181
+ }
1182
+ attempts.emissionAttempts = emissionAttempt;
1183
+ }
1184
+ // The provider-call failure handler. It owns only failures raised while a call
1185
+ // was in flight; parser, cost, SQL, and engine-contract failures retain their
1186
+ // original source.
1187
+ async #settleProviderFailure(err, args, request, attempts) {
1188
+ if (err instanceof ModelCallPersistenceError || err instanceof ProviderAccountingIntegrityError)
1189
+ throw err;
1190
+ if (!attempts.callInFlight)
1191
+ throw err;
1192
+ attempts.callInFlight = false;
1193
+ if (attempts.attemptId === null || attempts.modelCall === null) {
1194
+ throw new Error("provider call failed without durable model-call and attempt identities", { cause: err });
1195
+ }
1196
+ const { provider, workspaceId, workerId, loopId } = args;
1197
+ const { turnId } = request;
1198
+ const failure = _a.#providerFailure(err, attempts.signal);
1199
+ const capacityFailure = err instanceof ProviderError && err.kind === "capacity_exceeded";
1200
+ // {§provider-interrupted-attempt} — a provider-declared interruption
1201
+ // carries response evidence without becoming a completed exchange.
1202
+ // Persist it as an unaccepted attempt before settling the failure.
1203
+ if (err instanceof ProviderError && err.attempt !== undefined) {
1204
+ attempts.response = err.attempt;
1205
+ await attempts.modelCall.observeResponse(err.attempt, failure, attempts.wire.nativeInputs);
1206
+ attempts.split = this.#splitResponse(err.attempt, this.#executors()?.availableRuntimes(workspaceId) ?? []);
1207
+ await this.#classifyProviderAttempt(attempts, attempts.attemptId, attempts.split, attempts.currentEmissionAttempt, false);
1208
+ }
1209
+ else {
1210
+ await attempts.modelCall.fail(failure, err instanceof ProviderError ? err.capacity ?? null : null);
1211
+ if (!capacityFailure)
1212
+ attempts.emissionAttempts = attempts.currentEmissionAttempt;
1213
+ }
1214
+ const evidence = this.#requestEvidence(provider, request.packet, attempts.split, attempts.response);
1215
+ // {§turn-never-blank} — a ProviderError means no completed exchange exists.
1216
+ // Persist its exact RFC 9457 result before propagating it. Grammar transport
1217
+ // evidence exists only on completed responses ({§operator-grammar}).
1218
+ // Cancellation is lifecycle truth, not a provider failure. Close the
1219
+ // attempted turn without inventing an assistant response, then let
1220
+ // runLoop/Daemon settle the exact 504/499 loop result.
1221
+ if (attempts.signal?.aborted) {
1222
+ const status = attempts.signal.reason === LOOP_TIMEOUT_REASON ? 504 : 499;
1223
+ await this.#recordInference({ workspaceId, workerId, loopId, turnId, evidence });
1224
+ await Turn.complete(this.#db, turnId, status);
1225
+ throw err;
1226
+ }
1227
+ const recorded = await this.#problems.record({
1228
+ workerId,
1229
+ loopId,
1230
+ turnId,
1231
+ sequence: request.nextActionIndex,
1232
+ origin: "_plurnk",
1233
+ source: "provider",
1234
+ result: failure,
1235
+ });
1236
+ // The provider call was attempted, but no completed exchange exists.
1237
+ // Persist the exact request half and failure status; omitting assistant
1238
+ // is materially different from fabricating an empty model turn.
1239
+ await this.#recordInference({ workspaceId, workerId, loopId, turnId, evidence });
1240
+ await Turn.complete(this.#db, turnId, attempts.parked ? 202 : recorded.result.status);
1241
+ const { emissionAttempts } = attempts;
1242
+ if (attempts.parked) {
1243
+ // {§provider-recovery} — the recovery budget is spent: the loop parks exactly like a
1244
+ // [202] wait and resumes on the next prompt or wake; the failure stays durable.
1245
+ this.#notices.push(workspaceId, workerId, loopId, {
1246
+ source: "engine:provider",
1247
+ kind: "provider_unavailable",
1248
+ level: "error",
1249
+ message: `${recorded.result.problem?.title ?? "Provider failure"}: the ${Math.round(attempts.recoveryBudget / 1000)}s recovery budget is spent; the loop is parked and resumes on the next prompt or wake.`,
1375
1250
  });
1376
- return {
1377
- createdTurnIds,
1378
- turnId,
1379
- producer: "model",
1380
- kind: "inference",
1381
- status: executed.status,
1382
- outcomes: executed.outcomes,
1383
- fingerprint: executed.fingerprint,
1384
- capacityHardStop: false,
1385
- providerParked: false,
1386
- steerStruck: executed.steerStruck,
1387
- emptyTurn: executed.emptyTurn,
1251
+ return turnResult(request, 202, { providerParked: true, providerFailure: recorded.result, emissionAttempts });
1252
+ }
1253
+ if (capacityFailure) {
1254
+ return turnResult(request, recorded.result.status, { capacityHardStop: true, capacityFailure: recorded.result, emissionAttempts });
1255
+ }
1256
+ if (err instanceof ProviderError && err.kind === "invalid_response") {
1257
+ // {§engine-rails} Contract Strikes: the provider violated its response
1258
+ // contract. The failure is durable and the turn is complete; the strike
1259
+ // rail — never an instant loop death — rules whether the streak ends it.
1260
+ return turnResult(request, recorded.result.status, {
1261
+ outcomes: [{ op: null, status: recorded.result.status, problemType: recorded.result.problem?.type ?? null }],
1262
+ fingerprint: `provider-contract-violation:${turnId}`,
1263
+ providerFailure: recorded.result,
1388
1264
  emissionAttempts,
1389
- emissionExhausted: false,
1390
- };
1265
+ });
1391
1266
  }
1392
- catch (cause) {
1393
- const completionFailures = [];
1394
- for (const createdTurnId of createdTurnIds) {
1395
- try {
1396
- await Turn.failOpen(this.#db, createdTurnId);
1397
- }
1398
- catch (completionCause) {
1399
- completionFailures.push(completionCause);
1400
- }
1401
- }
1402
- if (completionFailures.length > 0) {
1403
- throw new AggregateError([cause, ...completionFailures], "turn execution failed and its open turn containers could not all be completed");
1404
- }
1405
- throw cause;
1267
+ throw new OperationFailureError(recorded.result, { cause: err });
1268
+ }
1269
+ // Inference evidence for a turn without an admitted exchange: the exact request
1270
+ // half, the last attempt's metadata when one exists, the provider's own model otherwise.
1271
+ #requestEvidence(provider, packet, split, response) {
1272
+ return {
1273
+ packet: StoredPacket.stringify(packet),
1274
+ sections: StoredPacket.sections(packet),
1275
+ usageCurationBudget: this.#packets.curationBudgetFor(packet),
1276
+ finishReason: split?.callMetadata.finishReason ?? null,
1277
+ model: split?.callMetadata.model ?? provider.model,
1278
+ meta: JSON.stringify(response?.meta ?? {}),
1279
+ };
1280
+ }
1281
+ // Phase 5, refused — {§invalid-emission-attempts}: every exhaustion publishes the
1282
+ // raw final response and its recovery fact; {§engine-rails} Contract Strikes rule
1283
+ // how many consecutive frame-contract violations the loop survives.
1284
+ async #rejectExhaustedEmission({ provider, workspaceId, workerId, loopId }, request, emission) {
1285
+ const { split, response } = emission;
1286
+ const { turnId } = request;
1287
+ const rejectedModelEntryId = await this.#dispatcher.writeEmissionAttempt({
1288
+ verbatim: split.packetAssistant.content,
1289
+ workerId,
1290
+ loopId,
1291
+ turnId,
1292
+ sequence: request.nextActionIndex,
1293
+ modelCallId: emission.modelCallId,
1294
+ });
1295
+ // {§invalid-emission-attempts} — the informed turn carries the parser's own
1296
+ // diagnostic and position: the model sees WHY, not only that it was refused.
1297
+ const diagnostic = split.parseErrors[0];
1298
+ const cut = allowanceCutMessage(split.callMetadata.finishReason, response.capacity.responseMax ?? provider.outputBudget);
1299
+ if (cut !== null) {
1300
+ this.#notices.push(workspaceId, workerId, loopId, {
1301
+ source: "engine:capacity",
1302
+ kind: "output_truncated",
1303
+ level: "error",
1304
+ message: `${cut}; no operations were performed`,
1305
+ });
1406
1306
  }
1307
+ else {
1308
+ this.#notices.push(workspaceId, workerId, loopId, {
1309
+ source: "engine:grammar",
1310
+ kind: "invalid_emission",
1311
+ level: "error",
1312
+ message: diagnostic === undefined
1313
+ ? INVALID_EMISSION_RECOVERY_MESSAGE
1314
+ : `${INVALID_EMISSION_RECOVERY_MESSAGE} Parser: ${diagnostic.message}`,
1315
+ ...(diagnostic !== undefined && diagnostic.line > 0
1316
+ ? { position: { type: "content-offset", line: diagnostic.line, column: diagnostic.column } }
1317
+ : {}),
1318
+ });
1319
+ }
1320
+ await this.#recordInference({ workspaceId, workerId, loopId, turnId, evidence: this.#requestEvidence(provider, request.packet, split, response) });
1321
+ await Turn.complete(this.#db, turnId, TURN_STATUS_IMPLICIT_CONTINUE);
1322
+ return turnResult(request, TURN_STATUS_IMPLICIT_CONTINUE, {
1323
+ outcomes: [{ op: null, status: 500, problemType: "https://problems.plurnk.xyz/engine/invalid-emission-exhausted" }],
1324
+ fingerprint: `frame-contract-violation:${turnId}`,
1325
+ emissionAttempts: emission.emissionAttempts,
1326
+ emissionExhausted: true,
1327
+ rejectedModelEntryId,
1328
+ });
1329
+ }
1330
+ // Phase 5, admitted — the emission's notices, then {§packet-stored-shape}: admitted
1331
+ // emission data extends the packet while provider-call metadata remains on the
1332
+ // Turn row.
1333
+ async #recordAdmittedEmission({ provider, workspaceId, workerId, loopId }, request, emission) {
1334
+ const { split, response } = emission;
1335
+ const { packetAssistant, callMetadata, parseNotices } = split; // raw assistant content is opaque — split, never interpreted — {§provider-guarantees-assistantraw-opaque}
1336
+ for (const notice of parseNotices) {
1337
+ this.#notices.push(workspaceId, workerId, loopId, notice);
1338
+ }
1339
+ const allowanceCut = allowanceCutMessage(callMetadata.finishReason, response.capacity.responseMax ?? provider.outputBudget);
1340
+ // {§empty-turn} — a response cut at the output allowance with nothing admitted names the
1341
+ // cut, so the model reads the ceiling, never a parser symptom (#478).
1342
+ if (split.emptyTurn && allowanceCut !== null) {
1343
+ this.#notices.push(workspaceId, workerId, loopId, {
1344
+ source: "engine:turn",
1345
+ kind: "output_truncated",
1346
+ level: "warn",
1347
+ message: `${allowanceCut}; no operations were performed`,
1348
+ });
1349
+ }
1350
+ // Non-fatal provider transport notices on an accepted turn. Forward each
1351
+ // Notice with a content-offset `line:col`;
1352
+ // the model resolves it against its own emission — READ ops:///<loop>/<turn> at the
1353
+ // cited lines ({§turn-ops-entry}) — not an embedded snippet that would duplicate the emission.
1354
+ for (const notice of response.notices ?? []) {
1355
+ const located = typeof notice.position === "number"
1356
+ ? this.#offsetToLineColumn(packetAssistant.content, notice.position)
1357
+ : null;
1358
+ this.#notices.push(workspaceId, workerId, loopId, {
1359
+ source: notice.source,
1360
+ kind: notice.kind,
1361
+ message: notice.message ?? "",
1362
+ level: notice.level,
1363
+ ...(located !== null
1364
+ ? { position: { type: "content-offset", line: located.line, column: located.column } }
1365
+ : {}),
1366
+ });
1367
+ }
1368
+ if (allowanceCut !== null) {
1369
+ this.#notices.push(workspaceId, workerId, loopId, {
1370
+ source: "engine:capacity",
1371
+ kind: "output_truncated",
1372
+ message: allowanceCut,
1373
+ level: "warn",
1374
+ });
1375
+ }
1376
+ // {§operator-grammar} — transport evidence only: the turn records whether the operator's
1377
+ // grammar reached the wire. Nothing grades the response against it; the parser's
1378
+ // admission is the one verdict (#588).
1379
+ const railKeys = emission.railGrammar === undefined
1380
+ ? undefined
1381
+ : { railsAttached: emission.railEvidence?.transported === true ? "client" : "withheld" };
1382
+ // Attach the admitted inference evidence. The turn remains open until
1383
+ // the producer-neutral admitted-turn executor settles every operation
1384
+ // and its exact source artifact.
1385
+ const packet = StoredPacket.admit(request.packet, packetAssistant, response.assistantRaw);
1386
+ await this.#recordInference({
1387
+ workspaceId, workerId, loopId, turnId: request.turnId,
1388
+ evidence: {
1389
+ packet: StoredPacket.stringify(packet),
1390
+ sections: StoredPacket.sections(packet),
1391
+ usageCurationBudget: this.#packets.curationBudgetFor(request.packet), // {§tokenomics-client-gauge}
1392
+ finishReason: callMetadata.finishReason,
1393
+ model: callMetadata.model,
1394
+ // Opaque provider metadata plus the grammar transport key.
1395
+ // {§meta-passthrough}, {§operator-grammar}
1396
+ meta: JSON.stringify({ ...(response.meta ?? {}), ...(railKeys ?? {}) }),
1397
+ },
1398
+ });
1399
+ }
1400
+ // Phase 6 — the admitted program runs under the workspace's command ceiling and
1401
+ // the turn settles on the executor's verdict.
1402
+ async #settleAdmittedTurn(args, request, emission) {
1403
+ const { childProvider, workspaceId, workerId, loopId, onDispatch, onSettled } = args;
1404
+ const { split } = emission;
1405
+ // {§operator-config-workspace-max-commands} — workspace maxCommands
1406
+ // narrows the operator ceiling before the admitted program reaches the
1407
+ // shared executor.
1408
+ const maxCommands = Math.min(readMaxCommands(), (await WorkspaceSettings.read(this.#db, workspaceId)).maxCommands ?? Number.POSITIVE_INFINITY);
1409
+ const executed = await this.executeAdmittedTurn({
1410
+ statements: split.packetAssistant.ops,
1411
+ source: split.sourceBacked ? split.packetAssistant.content : null,
1412
+ sourceModelCallId: emission.modelCallId,
1413
+ origin: "model",
1414
+ workspaceId,
1415
+ workerId,
1416
+ loopId,
1417
+ turnId: request.turnId,
1418
+ fromSequence: request.nextActionIndex,
1419
+ maxCommands,
1420
+ recoverableParseErrors: split.recoverableParseErrors,
1421
+ emptyTurn: split.emptyTurn,
1422
+ bare: {
1423
+ provider: childProvider,
1424
+ primaryWorkerId: emission.primaryWorkerId,
1425
+ loopSequence: request.loopSeq,
1426
+ turnSequence: request.seq,
1427
+ signal: emission.signal,
1428
+ },
1429
+ signal: emission.signal,
1430
+ onDispatch,
1431
+ onSettled,
1432
+ });
1433
+ return turnResult(request, executed.status, {
1434
+ outcomes: executed.outcomes,
1435
+ fingerprint: executed.fingerprint,
1436
+ emptyTurn: executed.emptyTurn,
1437
+ emissionAttempts: emission.emissionAttempts,
1438
+ });
1407
1439
  }
1408
1440
  // Split the wire-level ProviderResponse into the two destinations:
1409
1441
  // packet.assistant gets the model's emission (content, ops, reasoning);
@@ -1517,7 +1549,7 @@ export default class TurnRunner {
1517
1549
  }
1518
1550
  // #note12 — plugin reference docs are materialized beneath
1519
1551
  // worker:///_plurnk/plurnk/ by LoopDocs.
1520
- // {§exec-poll} — EXEC `<0>` is turn-scoped: abort the worker's open turn-scoped streams via their
1552
+ // {§exec-poll} — an execution `<0>` is turn-scoped: abort the worker's open turn-scoped streams via their
1521
1553
  // owning scheme (the same registry-routed abort the total reap uses). Called at each pre-turn
1522
1554
  // before the turn's own spawns, so every open turn-scoped sub here is from a prior turn — it
1523
1555
  // never survives into the subsequent turn. Fire-and-forget: the spawn finalizes async and its
@@ -1530,4 +1562,6 @@ export default class TurnRunner {
1530
1562
  return this.#admitted.executeAdmittedTurn(...args);
1531
1563
  }
1532
1564
  }
1565
+ _a = TurnRunner;
1566
+ export default TurnRunner;
1533
1567
  //# sourceMappingURL=TurnRunner.js.map