@plurnk/plurnk-service 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/.env.defaults +7 -21
  2. package/INSTALL.md +18 -17
  3. package/README.md +1 -1
  4. package/SPEC.md +209 -136
  5. package/dist/build-info.json +1 -1
  6. package/dist/core/ChannelWrite.sql +3 -3
  7. package/dist/core/ClientInteractions.d.ts +19 -0
  8. package/dist/core/ClientInteractions.d.ts.map +1 -0
  9. package/dist/core/ClientInteractions.js +124 -0
  10. package/dist/core/ClientInteractions.js.map +1 -0
  11. package/dist/core/ClientInteractions.sql +34 -0
  12. package/dist/core/CoreSchemeServices.d.ts +9 -3
  13. package/dist/core/CoreSchemeServices.d.ts.map +1 -1
  14. package/dist/core/CoreSchemeServices.js +8 -2
  15. package/dist/core/CoreSchemeServices.js.map +1 -1
  16. package/dist/core/Dispatcher.d.ts +4 -2
  17. package/dist/core/Dispatcher.d.ts.map +1 -1
  18. package/dist/core/Dispatcher.js +116 -1149
  19. package/dist/core/Dispatcher.js.map +1 -1
  20. package/dist/core/Engine.d.ts +24 -34
  21. package/dist/core/Engine.d.ts.map +1 -1
  22. package/dist/core/Engine.js +137 -1672
  23. package/dist/core/Engine.js.map +1 -1
  24. package/dist/core/Engine.sql +46 -32
  25. package/dist/core/ExecutableTools.d.ts +1 -0
  26. package/dist/core/ExecutableTools.d.ts.map +1 -1
  27. package/dist/core/ExecutableTools.js +17 -9
  28. package/dist/core/ExecutableTools.js.map +1 -1
  29. package/dist/core/ExecutorRegistry.d.ts +11 -3
  30. package/dist/core/ExecutorRegistry.d.ts.map +1 -1
  31. package/dist/core/ExecutorRegistry.js +139 -16
  32. package/dist/core/ExecutorRegistry.js.map +1 -1
  33. package/dist/core/LogBody.d.ts +1 -0
  34. package/dist/core/LogBody.d.ts.map +1 -1
  35. package/dist/core/LogBody.js +4 -0
  36. package/dist/core/LogBody.js.map +1 -1
  37. package/dist/core/ModelCall.d.ts +2 -2
  38. package/dist/core/ModelCall.d.ts.map +1 -1
  39. package/dist/core/ModelCall.js +4 -2
  40. package/dist/core/ModelCall.js.map +1 -1
  41. package/dist/core/PacketBuilder.d.ts +11 -22
  42. package/dist/core/PacketBuilder.d.ts.map +1 -1
  43. package/dist/core/PacketBuilder.js +90 -180
  44. package/dist/core/PacketBuilder.js.map +1 -1
  45. package/dist/core/ProblemLog.d.ts +1 -1
  46. package/dist/core/ProblemLog.d.ts.map +1 -1
  47. package/dist/core/ProblemLog.js +14 -3
  48. package/dist/core/ProblemLog.js.map +1 -1
  49. package/dist/core/ProposalLifecycle.d.ts +4 -2
  50. package/dist/core/ProposalLifecycle.d.ts.map +1 -1
  51. package/dist/core/ProposalLifecycle.js +19 -7
  52. package/dist/core/ProposalLifecycle.js.map +1 -1
  53. package/dist/core/ProviderInstantiate.js +2 -2
  54. package/dist/core/ProviderInstantiate.js.map +1 -1
  55. package/dist/core/ResourceMutations.d.ts +73 -0
  56. package/dist/core/ResourceMutations.d.ts.map +1 -0
  57. package/dist/core/ResourceMutations.js +1142 -0
  58. package/dist/core/ResourceMutations.js.map +1 -0
  59. package/dist/core/SchemeRegistry.d.ts +18 -10
  60. package/dist/core/SchemeRegistry.d.ts.map +1 -1
  61. package/dist/core/SchemeRegistry.js +195 -43
  62. package/dist/core/SchemeRegistry.js.map +1 -1
  63. package/dist/core/StoredPacket.d.ts +2 -2
  64. package/dist/core/StoredPacket.js +7 -7
  65. package/dist/core/TurnRunner.d.ts +74 -0
  66. package/dist/core/TurnRunner.d.ts.map +1 -0
  67. package/dist/core/TurnRunner.js +1733 -0
  68. package/dist/core/TurnRunner.js.map +1 -0
  69. package/dist/core/WorkspaceGate.d.ts +1 -0
  70. package/dist/core/WorkspaceGate.d.ts.map +1 -1
  71. package/dist/core/WorkspaceGate.js +39 -0
  72. package/dist/core/WorkspaceGate.js.map +1 -1
  73. package/dist/core/caps/CoreInteractionCaps.d.ts +9 -0
  74. package/dist/core/caps/CoreInteractionCaps.d.ts.map +1 -0
  75. package/dist/core/caps/CoreInteractionCaps.js +14 -0
  76. package/dist/core/caps/CoreInteractionCaps.js.map +1 -0
  77. package/dist/core/caps/DbChannelCaps.js +4 -4
  78. package/dist/core/caps/DbChannelCaps.js.map +1 -1
  79. package/dist/core/caps/SchemeCtxImpl.d.ts +2 -1
  80. package/dist/core/caps/SchemeCtxImpl.d.ts.map +1 -1
  81. package/dist/core/caps/SchemeCtxImpl.js +3 -0
  82. package/dist/core/caps/SchemeCtxImpl.js.map +1 -1
  83. package/dist/core/content-hash.js +2 -2
  84. package/dist/core/content-hash.js.map +1 -1
  85. package/dist/core/content-weight.d.ts +2 -0
  86. package/dist/core/content-weight.d.ts.map +1 -0
  87. package/dist/core/content-weight.js +4 -0
  88. package/dist/core/content-weight.js.map +1 -0
  89. package/dist/core/content_weight.d.ts +4 -0
  90. package/dist/core/content_weight.d.ts.map +1 -0
  91. package/dist/core/content_weight.js +6 -0
  92. package/dist/core/content_weight.js.map +1 -0
  93. package/dist/core/fork.sql +5 -5
  94. package/dist/core/packet-wire.d.ts +4 -4
  95. package/dist/core/packet-wire.d.ts.map +1 -1
  96. package/dist/core/packet-wire.js +21 -21
  97. package/dist/core/packet-wire.js.map +1 -1
  98. package/dist/core/scheme-types.d.ts +3 -2
  99. package/dist/core/scheme-types.d.ts.map +1 -1
  100. package/dist/core/scheme-types.js.map +1 -1
  101. package/dist/digest/Digest.js +1 -1
  102. package/dist/digest/Digest.js.map +1 -1
  103. package/dist/schemes/Exec.d.ts.map +1 -1
  104. package/dist/schemes/Exec.js +53 -15
  105. package/dist/schemes/Exec.js.map +1 -1
  106. package/dist/schemes/ExecOutputScheme.js +1 -1
  107. package/dist/schemes/ExecOutputScheme.js.map +1 -1
  108. package/dist/schemes/File.d.ts.map +1 -1
  109. package/dist/schemes/File.js +1 -1
  110. package/dist/schemes/File.js.map +1 -1
  111. package/dist/schemes/Log.d.ts.map +1 -1
  112. package/dist/schemes/Log.js +2 -2
  113. package/dist/schemes/Log.sql +2 -2
  114. package/dist/schemes/Worker.js +2 -2
  115. package/dist/schemes/Worker.js.map +1 -1
  116. package/dist/schemes/_entry-crud.js +4 -4
  117. package/dist/schemes/_entry-crud.js.map +1 -1
  118. package/dist/schemes/_entry-crud.sql +2 -2
  119. package/dist/schemes/_entry-find.d.ts +5 -5
  120. package/dist/schemes/_entry-find.d.ts.map +1 -1
  121. package/dist/schemes/_entry-find.js +29 -28
  122. package/dist/schemes/_entry-find.js.map +1 -1
  123. package/dist/schemes/_entry-manifest.js +4 -4
  124. package/dist/schemes/_entry-manifest.js.map +1 -1
  125. package/dist/schemes/_entry-ops.d.ts.map +1 -1
  126. package/dist/schemes/_entry-ops.js +3 -3
  127. package/dist/schemes/_entry-ops.js.map +1 -1
  128. package/dist/schemes/_entry-ops.sql +3 -3
  129. package/dist/server/Daemon.d.ts +20 -49
  130. package/dist/server/Daemon.d.ts.map +1 -1
  131. package/dist/server/Daemon.js +311 -908
  132. package/dist/server/Daemon.js.map +1 -1
  133. package/dist/server/DaemonModule.d.ts +31 -3
  134. package/dist/server/DaemonModule.d.ts.map +1 -1
  135. package/dist/server/DrainSupervisor.d.ts +117 -0
  136. package/dist/server/DrainSupervisor.d.ts.map +1 -0
  137. package/dist/server/DrainSupervisor.js +802 -0
  138. package/dist/server/DrainSupervisor.js.map +1 -0
  139. package/dist/server/client-input.d.ts +2 -0
  140. package/dist/server/client-input.d.ts.map +1 -1
  141. package/dist/server/client-input.js +12 -0
  142. package/dist/server/client-input.js.map +1 -1
  143. package/dist/server/lifecycle-recovery.sql +9 -0
  144. package/dist/server/logEntry.d.ts +1 -1
  145. package/dist/server/logEntry.js +1 -1
  146. package/dist/server/loopDocs.d.ts +1 -0
  147. package/dist/server/loopDocs.d.ts.map +1 -1
  148. package/dist/server/loopDocs.js +37 -8
  149. package/dist/server/loopDocs.js.map +1 -1
  150. package/dist/server/loopDocs.sql +10 -0
  151. package/dist/server/seam-entry-read.sql +2 -2
  152. package/dist/server/workspace-capabilities.sql +19 -0
  153. package/dist/service.js +1 -1
  154. package/dist/service.js.map +1 -1
  155. package/migrations/001_schema.sql +51 -10
  156. package/package.json +34 -33
  157. package/dist/core/ruler_count.d.ts +0 -4
  158. package/dist/core/ruler_count.d.ts.map +0 -1
  159. package/dist/core/ruler_count.js +0 -7
  160. package/dist/core/ruler_count.js.map +0 -1
  161. package/dist/core/token-ruler.d.ts +0 -2
  162. package/dist/core/token-ruler.d.ts.map +0 -1
  163. package/dist/core/token-ruler.js +0 -5
  164. package/dist/core/token-ruler.js.map +0 -1
@@ -1,99 +1,29 @@
1
- var _a;
2
- import { PlurnkParser, PlurnkParseError, UNKNOWN_POSITION } from "@plurnk/plurnk-contracts";
3
1
  import { RuntimeInvocation, RuntimeTag } from "@plurnk/plurnk-execs";
4
- import Owner from "./Owner.js";
5
- const TERMINAL_SEND_SIGNALS = new Set([102, 200, 202, 300, 499]);
6
- const comparePosition = (a, b) => a.line - b.line || a.column - b.column;
7
2
  import { Mimetypes, emptyRegistry } from "@plurnk/plurnk-mimetypes";
8
3
  import Meta from "@plurnk/plurnk-meta";
9
4
  import EntryCrud from "../schemes/_entry-crud.js";
10
5
  import SearchIndex from "../schemes/_search-index.js";
11
6
  import GitMembership from "./git-membership.js";
12
- import GitState from "./git-state.js";
13
- import WorkspaceSettings from "./workspace-settings.js";
14
- import { editedSpan } from "../content/index.js";
15
- import { promptPathname, promptLoopPrefix, renderTarget } from "./plurnk-uri.js";
16
- import { rulerCount } from "./token-ruler.js";
7
+ import { promptPathname, promptLoopPrefix } from "./plurnk-uri.js";
8
+ import { contentWeight } from "./content-weight.js";
17
9
  import SearchGate from "./search-gate.js";
18
10
  import LiveSubscriptions from "./LiveSubscriptions.js";
19
11
  import LoopLifecycle from "./LoopLifecycle.js";
20
- import { readFile } from "node:fs/promises";
21
- import { fileURLToPath } from "node:url";
22
12
  import { setTimeout as delay } from "node:timers/promises";
23
- import { randomUUID } from "node:crypto";
24
- // Shared module imported by both Engine and bin/digest.ts, so wire
25
- // projection and digest projection are structurally one function — no
26
- // drift between wire and digest possible.
27
- import PacketWire from "./packet-wire.js";
28
- import Results, { OperationFailureError } from "./results.js";
29
- import BranchReceipt from "./BranchReceipt.js";
30
- import TerminalResult from "./TerminalResult.js";
31
- import WorkerControlAddress from "./WorkerControlAddress.js";
32
- import JournalTurn from "./JournalTurn.js";
13
+ import Results from "./results.js";
33
14
  // The engine's collaborators — each owns one machine; Engine owns the loop/turn
34
15
  // lifecycle and wires them together as the public facade.
35
16
  import NoticeChannel from "./NoticeChannel.js";
36
17
  import ProblemLog from "./ProblemLog.js";
37
18
  import StrikeRail from "./StrikeRail.js";
38
19
  import PacketBuilder from "./PacketBuilder.js";
39
- import StoredPacket from "./StoredPacket.js";
40
20
  import ProposalLifecycle from "./ProposalLifecycle.js";
21
+ import ClientInteractions from "./ClientInteractions.js";
41
22
  import Dispatcher from "./Dispatcher.js";
42
- import { observed, observedSync } from "../observe/spans.js";
43
- import { OPS_DISPATCHED, PROVIDER_CALLS, recordCounter } from "../observe/metrics.js";
44
- import { scheduleTurnOps } from "./turn-scheduler.js";
45
- import { readOptimisticSettlementMs } from "./optimistic-settlement.js";
23
+ import TurnRunner, { LOOP_TIMEOUT_REASON } from "./TurnRunner.js";
24
+ import { observed } from "../observe/spans.js";
46
25
  import { providerRequestFromStorageRow, } from "./provider-accounting.js";
47
- import ModelCall, { ModelCallPersistenceError, ProviderAccountingIntegrityError, } from "./ModelCall.js";
48
26
  const DEFAULT_MAX_STRIKES = 6;
49
- const RECORD_STREAM_MIMETYPES = new Set(["application/jsonl", "application/x-ndjson"]);
50
- const baseMimetype = (mimetype) => mimetype.split(";", 1)[0].trim().toLowerCase();
51
- // {§exec-stream} Active streams publish only independently meaningful units.
52
- // Atomic documents wait for close; JSONL stops after its last complete record.
53
- const streamPublicationEnd = (content, mimetype, cursor, closed) => {
54
- const type = baseMimetype(mimetype);
55
- if (closed || type.startsWith("text/"))
56
- return content.length;
57
- if (!RECORD_STREAM_MIMETYPES.has(type))
58
- return cursor;
59
- return Math.max(cursor, content.lastIndexOf("\n") + 1);
60
- };
61
- const ENGINE_PROBLEMS = Object.freeze({
62
- max_commands_exceeded: {
63
- status: 429,
64
- code: "max-commands-exceeded",
65
- detail: "Later operations were not executed because the turn exceeded its operation limit.",
66
- },
67
- idle_turn: {
68
- status: 409,
69
- code: "idle-turn",
70
- detail: "`## SEND0 [102]` was emitted without an operation to continue from.",
71
- },
72
- });
73
- // The prompt entry target - prompt:///<loop>/<N>, self-only ({§prompt-self-only}):
74
- // the owner rides the owner_id column, the address carries only the loop coordinate.
75
- const promptTarget = (loopSeq, turnSeq) => {
76
- const storage = promptPathname(loopSeq, turnSeq);
77
- return {
78
- kind: "url", raw: `prompt://${storage}`,
79
- scheme: "prompt", username: null, password: null,
80
- hostname: null, port: null,
81
- pathname: storage, query: null, fragment: null,
82
- };
83
- };
84
- const assertOpenPaths = (value, source) => {
85
- if (!Array.isArray(value) || value.some((path) => typeof path !== "string" || path.length === 0)) {
86
- throw new TypeError(`${source}: expected an array of non-empty strings`);
87
- }
88
- return value;
89
- };
90
- const parsePromptAttributes = (encoded, source) => {
91
- const attributes = JSON.parse(encoded);
92
- if (attributes === null || typeof attributes !== "object" || Array.isArray(attributes)) {
93
- throw new TypeError(`${source}: expected a JSON object`);
94
- }
95
- return attributes;
96
- };
97
27
  const readMaxStrikes = () => {
98
28
  const raw = process.env.PLURNK_SERVICE_MAX_STRIKES;
99
29
  if (raw === undefined || raw.length === 0)
@@ -103,78 +33,7 @@ const readMaxStrikes = () => {
103
33
  return DEFAULT_MAX_STRIKES;
104
34
  return n;
105
35
  };
106
- // Per-emission action ceiling — OFF by default. `-1` (or unset/non-positive) = no cap:
107
- // every generated op dispatches. Runaway degeneration is a sampler concern (repetition penalty),
108
- // not grounds to drop already-generated work. A positive value is an operator ceiling a
109
- // workspace's maxCommands may tighten (min wins), never widen
110
- // ({§operator-config-workspace-max-commands}).
111
- const readMaxCommands = () => {
112
- const raw = process.env.PLURNK_SERVICE_MAX_COMMANDS;
113
- if (raw === undefined || raw.length === 0)
114
- return Number.POSITIVE_INFINITY;
115
- const n = Number.parseInt(raw, 10);
116
- if (!Number.isFinite(n) || n < 1)
117
- return Number.POSITIVE_INFINITY;
118
- return n;
119
- };
120
- // PLURNK_SERVICE_FILES_ITEMS — the turn-0 catalog preview. null = off;
121
- // -1 = the ordinary markerless page; positive N explicitly caps file rows. 0 / unset = off.
122
- const normalizeFilesItems = (n) => (!Number.isFinite(n) || n === 0 ? null : n < 0 ? -1 : n);
123
- const readFilesItems = () => {
124
- const raw = process.env.PLURNK_SERVICE_FILES_ITEMS;
125
- if (raw === undefined || raw.length === 0)
126
- return null;
127
- return normalizeFilesItems(Number.parseInt(raw, 10));
128
- };
129
- import { aggregateProviderAccounting, ProviderError, resolveActiveAlias, scopeEnvToAlias, } from "@plurnk/plurnk-providers";
130
- import { validateGbnf } from "@plurnk/gbnf";
131
- import ProviderInstantiate from "./ProviderInstantiate.js";
132
- // A generated rail may constrain only the sampled continuation while the chat
133
- // template contributes a prefix that llama-server preserves in its response.
134
- // The artifact names the alternate root that composes those bytes for evidence
135
- // grading; arbitrary grammars without the declaration retain their transport root.
136
- const grammarConstraint = (grammar) => {
137
- const declarations = [...grammar.matchAll(/^# @plurnk-response-root ([A-Za-z][A-Za-z0-9-]*)$/gm)];
138
- if (declarations.length === 0)
139
- return { transport: grammar, response: grammar };
140
- if (declarations.length !== 1)
141
- throw new Error("GBNF constraint declares multiple @plurnk-response-root values");
142
- const responseRoot = declarations[0][1];
143
- if (!grammar.split("\n").some((line) => line.startsWith(`${responseRoot} ::=`))) {
144
- throw new Error(`GBNF constraint declares missing response root ${responseRoot}`);
145
- }
146
- const roots = [...grammar.matchAll(/^root ::= ([A-Za-z][A-Za-z0-9-]*)$/gm)];
147
- if (roots.length !== 1)
148
- throw new Error("GBNF constraint must declare exactly one simple root");
149
- return {
150
- transport: grammar,
151
- response: grammar.replace(roots[0][0], `root ::= ${responseRoot}`),
152
- };
153
- };
154
- const TOKEN_BUDGET_OVERFLOW_DETAIL = "Token Budget Overflow: Token Usage exceeded Token Ceiling. Newest log items were automatically FOLDed to fit within token budget. Curate the log and/or perform more conservatively scoped or chunked retrieval operations to recover.";
155
- const tokenBudgetOverflowFailure = (pressure) => Results.failure("engine:context", "token-budget-overflow", 413, TOKEN_BUDGET_OVERFLOW_DETAIL, {}, { ...pressure });
156
- const budgetPressure = (usage, ceiling) => {
157
- if (usage <= ceiling)
158
- throw new Error("context-envelope admission requires positive ruler debt");
159
- return { usage, ceiling, deficit: usage - ceiling };
160
- };
161
- const contextEnvelopeFailure = (pressure, admission) => Results.failure("engine:context", "context-envelope-admission-failed", 413, `The configured context envelope cannot admit this request: ${admission.detail}.`, {}, {
162
- ...pressure,
163
- stage: "context-envelope-admission",
164
- contextAdmission: admission.reason,
165
- contextCapacity: admission.capacity,
166
- ...(admission.measurement === undefined ? {} : {
167
- promptTokens: admission.measurement.tokens,
168
- tokenKind: admission.measurement.kind,
169
- tokenSource: admission.measurement.source,
170
- }),
171
- retryable: false,
172
- });
173
- // Runtime normalization for a disposition the engine refuses or resolves as a
174
- // continue after dispatch ({§send}). Every admitted emission itself ends in an
175
- // explicit disposition SEND ({§emission-admission}).
176
- const TURN_STATUS_IMPLICIT_CONTINUE = 102;
177
- const INVALID_EMISSION_RECOVERY_MESSAGE = "Your previous response contained an unrecoverable syntax error. No operations were performed. Try again.";
36
+ import { aggregateProviderAccounting } from "@plurnk/plurnk-providers";
178
37
  const DEFAULT_MIN_CYCLES = 3;
179
38
  const DEFAULT_MAX_CYCLE_PERIOD = 4;
180
39
  const readPositiveInt = (envVar, fallback) => {
@@ -186,20 +45,11 @@ const readPositiveInt = (envVar, fallback) => {
186
45
  return fallback;
187
46
  return n;
188
47
  };
189
- const readEmissionAttempts = () => {
190
- const raw = process.env.PLURNK_SERVICE_EMISSION_ATTEMPTS;
191
- const value = Number.parseInt(raw ?? "", 10);
192
- if (!Number.isInteger(value) || value < 1) {
193
- throw new Error(`PLURNK_SERVICE_EMISSION_ATTEMPTS must be a positive integer; got ${raw}`);
194
- }
195
- return value;
196
- };
197
48
  // {§operator-config-loop-timeout} — the loop's wall-clock budget (PLURNK_SERVICE_LOOP_TIMEOUT).
198
49
  const DEFAULT_LOOP_TIMEOUT_MS = 86400000;
199
50
  const readLoopTimeoutMs = () => readPositiveInt("PLURNK_SERVICE_LOOP_TIMEOUT", DEFAULT_LOOP_TIMEOUT_MS);
200
51
  // The wall's abort reason — runLoop branches a mid-turn teardown to the 504 terminal on it.
201
- const LOOP_TIMEOUT_REASON = "loop_timeout";
202
- class Engine {
52
+ export default class Engine {
203
53
  static fingerprintTurn(ops) {
204
54
  return StrikeRail.fingerprintTurn(ops);
205
55
  }
@@ -212,7 +62,7 @@ class Engine {
212
62
  #mimetypes;
213
63
  // {§tokenomics-agnostic-ruler} — the stable model-independent ruler used
214
64
  // for write-time, catalog, receipt, and packet weights.
215
- #tokenize;
65
+ #weighContent;
216
66
  // Boot-discovered runtime executors. Daemon builds + sets via
217
67
  // setExecutors at start(); undefined until then (and in bare tests).
218
68
  #executors;
@@ -231,7 +81,9 @@ class Engine {
231
81
  #packets;
232
82
  searchGate = new SearchGate();
233
83
  #proposals;
84
+ #interactions;
234
85
  #dispatcher;
86
+ #turnRunner;
235
87
  #liveSubscriptions = new LiveSubscriptions();
236
88
  // Per-loop AbortController for cancellation propagation into scheme
237
89
  // ctx.signal. runLoop creates one at entry, cleans up at end. Engine
@@ -370,33 +222,7 @@ class Engine {
370
222
  #wakeWorkerNotify;
371
223
  #acquireWorkspaceTurn;
372
224
  #workspaceTurnCompleted;
373
- // Configured grammar text is cached by variant after its first load.
374
- #gbnfCache = new Map();
375
- // {§rail-truth-engine-verdict} — the verify GAP (a configured grammar @plurnk/gbnf can't
376
- // parse): warn once per message, never per turn; the turn records railsVerdict "unverifiable".
377
- static #railGapWarned = new Set();
378
- static #warnRailVerdictGapOnce(message) {
379
- if (_a.#railGapWarned.has(message))
380
- return;
381
- _a.#railGapWarned.add(message);
382
- process.stderr.write(`plurnk-engine: rail verdict unavailable — the configured grammar did not parse in @plurnk/gbnf (${message})\n`);
383
- }
384
- static #requireGrammarEvidence(response) {
385
- const evidence = response.grammarEvidence;
386
- if (evidence === undefined) {
387
- throw new Error("provider contract violation: configured GBNF response omitted grammar evidence");
388
- }
389
- const input = [...evidence.input];
390
- if (!Number.isInteger(evidence.contentStart)
391
- || evidence.contentStart < 0
392
- || evidence.contentStart > input.length
393
- || typeof evidence.transported !== "boolean"
394
- || input.slice(evidence.contentStart).join("") !== response.assistant.content) {
395
- throw new Error("provider contract violation: grammar evidence does not map exactly to assistant.content");
396
- }
397
- return evidence;
398
- }
399
- constructor({ db, schemes, mimetypes, streamEventNotify, wakeWorkerNotify, injectWorker, branchWorker, branchCompletionGate, cancelWorker, cancelDescendants, acquireWorkspaceTurn, workspaceTurnCompleted, noticeNotify, tokenize }) {
225
+ constructor({ db, schemes, mimetypes, streamEventNotify, wakeWorkerNotify, injectWorker, branchWorker, branchCompletionGate, cancelWorker, cancelDescendants, acquireWorkspaceTurn, workspaceTurnCompleted, noticeNotify, weigh }) {
400
226
  this.#db = db;
401
227
  this.#lifecycle = new LoopLifecycle(db);
402
228
  this.#schemes = schemes;
@@ -412,46 +238,70 @@ class Engine {
412
238
  discovery: { registry: emptyRegistry(), handlers: new Map(), skipped: [] },
413
239
  });
414
240
  // {§tokenomics-agnostic-ruler} — standalone construction and the daemon
415
- // use the same default; provider counting is confined to hard
416
- // context-envelope admission.
417
- this.#tokenize = tokenize ?? rulerCount;
241
+ // use the same default; provider request counting remains confined to
242
+ // provider-owned capacity assessment.
243
+ this.#weighContent = weigh ?? contentWeight;
418
244
  const executors = () => this.#executors;
419
245
  const loopSignal = (loopId) => this.#loopAborts.get(loopId)?.signal;
420
246
  this.#notices = new NoticeChannel({ notify: noticeNotify });
421
- this.#problems = new ProblemLog(db);
247
+ this.#problems = new ProblemLog(db, this.#weighContent);
422
248
  this.#strikes = new StrikeRail();
423
249
  this.#packets = new PacketBuilder({
424
250
  db,
425
251
  schemes,
426
252
  executors,
427
253
  });
254
+ this.#interactions = new ClientInteractions(db);
428
255
  this.#proposals = new ProposalLifecycle({
429
256
  db, schemes, notices: this.#notices,
430
257
  streamEventNotify, wakeWorkerNotify,
431
- tokenize: this.#tokenize, mimetypes: this.#mimetypes, executors, loopSignal,
258
+ weigh: this.#weighContent, mimetypes: this.#mimetypes, executors, loopSignal,
432
259
  liveSubscriptions: this.#liveSubscriptions,
260
+ interactions: this.#interactions,
433
261
  });
434
262
  this.#dispatcher = new Dispatcher({ searchGate: this.searchGate,
435
263
  db, schemes, mimetypes: this.#mimetypes,
436
- tokenize: this.#tokenize,
264
+ weigh: this.#weighContent,
437
265
  notices: this.#notices, proposals: this.#proposals,
266
+ interactions: this.#interactions,
438
267
  executors, loopSignal,
439
268
  streamEventNotify, wakeWorkerNotify, injectWorker, branchWorker, branchCompletionGate, cancelWorker, cancelDescendants,
440
269
  parkDeadlines: this.parkDeadlines,
441
270
  joinTargets: this.joinTargets,
442
271
  liveSubscriptions: this.#liveSubscriptions,
443
272
  });
273
+ this.#turnRunner = new TurnRunner({
274
+ db,
275
+ schemes,
276
+ mimetypes: this.#mimetypes,
277
+ weigh: this.#weighContent,
278
+ notices: this.#notices,
279
+ problems: this.#problems,
280
+ strikes: this.#strikes,
281
+ packets: this.#packets,
282
+ dispatcher: this.#dispatcher,
283
+ liveSubscriptions: this.#liveSubscriptions,
284
+ streamEventNotify,
285
+ wakeWorkerNotify,
286
+ executors,
287
+ loopSignal,
288
+ interactions: this.#interactions,
289
+ warmWorkspace: (context, invalidate, materialize) => this.#queueWorkspaceWarm(context, invalidate, materialize),
290
+ dispatch: (context) => this.dispatch(context),
291
+ resolveWorkerProviderIdentity: (workerId) => this.resolveWorkerProviderIdentity(workerId),
292
+ });
444
293
  schemes.bindCore({
445
294
  db,
446
295
  mimetypes: this.#mimetypes,
447
296
  executors,
448
- tokenize: this.#tokenize,
297
+ weigh: this.#weighContent,
449
298
  streamEventNotify,
450
299
  wakeWorkerNotify,
451
300
  injectWorker,
452
301
  pushNotice: (workspaceId, loopId, notice) => this.#notices.push(workspaceId, loopId, notice),
453
- defaultChannelFor: (scheme) => schemes.defaultChannelFor(scheme),
302
+ defaultChannelFor: (scheme, workspaceId) => schemes.defaultChannelFor(scheme, workspaceId),
454
303
  readExecSource: (statement, ctx) => this.#dispatcher.readExecSource(statement, ctx),
304
+ requestInteraction: (request, ids, signal) => this.#interactions.request(request, ids, signal),
455
305
  liveSubscriptions: this.#liveSubscriptions,
456
306
  });
457
307
  }
@@ -460,50 +310,70 @@ class Engine {
460
310
  setExecutors(executors) {
461
311
  this.#executors = executors;
462
312
  }
463
- // Register a module-owned runtime on the same two registries as boot discovery.
464
- // An optional same-name scheme handler lets one capability own both execution
465
- // and addressable state without teaching core its protocol.
466
- registerRuntime(tag, entry, scheme) {
313
+ // Register a complete module-owned runtime set on the same two registries
314
+ // as boot discovery. Both owners prepare the complete set before either
315
+ // publishes a name. {§plugin-namespace-arbitration}
316
+ registerRuntimes(registrations) {
467
317
  if (this.#executors === undefined)
468
- throw new Error("registerRuntime: executor registry not wired yet");
469
- RuntimeTag.assert(tag, "module runtime");
470
- const normalized = {
471
- ...entry,
472
- invocation: RuntimeInvocation.assert(entry.invocation, entry.namespaceOwner.name, tag),
473
- };
474
- // Preflight both owners before either write; synchronous registration
475
- // then cannot leave a half-claimed namespace. {§plugin-namespace-arbitration}
476
- this.#executors.assertCanRegister(tag, normalized.namespaceOwner);
477
- this.#schemes.assertRuntimeClaim(tag, normalized.namespaceOwner);
478
- this.#schemes.registerRuntimeScheme(tag, normalized.executor, normalized.namespaceOwner, scheme);
479
- this.#executors.register(tag, normalized);
318
+ throw new Error("registerRuntimes: executor registry not wired yet");
319
+ const normalized = registrations.map(({ tag, entry, scheme }) => {
320
+ RuntimeTag.assert(tag, "module runtime");
321
+ return {
322
+ tag,
323
+ entry: {
324
+ ...entry,
325
+ invocation: RuntimeInvocation.assert(entry.invocation, entry.namespaceOwner.name, tag),
326
+ },
327
+ scheme,
328
+ };
329
+ });
330
+ const commitExecutors = this.#executors.prepareRegistrations(normalized);
331
+ const commitSchemes = this.#schemes.prepareRuntimeSchemes(normalized.map(({ tag, entry, scheme }) => ({
332
+ tag,
333
+ executor: entry.executor,
334
+ owner: entry.namespaceOwner,
335
+ facet: scheme,
336
+ })));
337
+ commitSchemes();
338
+ commitExecutors();
339
+ }
340
+ registerRuntime(tag, entry, scheme) {
341
+ this.registerRuntimes([{ tag, entry, scheme }]);
480
342
  }
481
- // Supply an explicitly configured local constraint; ANTLR remains the
482
- // language authority. {§grammar-enforcement-verified-at-boot}
483
- async #grammarConstraint(provider) {
484
- // Resolve through the registered or active alias; ambiguity and load
485
- // failures never degrade to unconstrained generation.
486
- // {§grammar-enforcement-verified-at-boot}
487
- const registered = ProviderInstantiate.aliasOf(provider);
488
- const fallback = registered === undefined ? resolveActiveAlias(process.env)?.alias : undefined;
489
- if (registered === undefined && fallback === undefined && Object.keys(process.env).some((k) => k.startsWith("PLURNK_PROVIDERS_GBNF_"))) {
490
- throw new Error("GBNF constraint: provider has no registered alias and no active alias resolves, while per-alias PLURNK_PROVIDERS_GBNF_* constraints are configured");
343
+ async prepareWorkspaceRuntimes(workspaceId, namespaceOwner, registrations) {
344
+ if (this.#executors === undefined) {
345
+ throw new Error("prepareWorkspaceRuntimes: executor registry not wired yet");
491
346
  }
492
- const alias = registered ?? fallback ?? "";
493
- const variant = scopeEnvToAlias(process.env, alias, ["PLURNK_PROVIDERS_GBNF"]).PLURNK_PROVIDERS_GBNF;
494
- if (variant === undefined || variant === "" || variant === "0")
495
- return undefined;
496
- const hit = this.#gbnfCache.get(variant);
497
- if (hit !== undefined)
498
- return hit;
499
- const path = variant.startsWith("/") || variant.startsWith(".")
500
- ? variant
501
- : fileURLToPath(import.meta.resolve(`@plurnk/plurnk-contracts/${variant}`));
502
- const text = await readFile(path, "utf8"); // unresolvable/unreadable throws — a configured rail never silently degrades
503
- const constraint = grammarConstraint(text);
504
- this.#gbnfCache.set(variant, constraint);
505
- process.stderr.write(`plurnk-engine: GBNF constraint: ${alias || "(bare)"} → ${variant} (${text.length} chars)\n`);
506
- return constraint;
347
+ const normalized = registrations.map(({ tag, entry, scheme }) => {
348
+ RuntimeTag.assert(tag, "workspace module runtime");
349
+ return {
350
+ tag,
351
+ entry: {
352
+ ...entry,
353
+ invocation: RuntimeInvocation.assert(entry.invocation, entry.namespaceOwner.name, tag),
354
+ },
355
+ scheme,
356
+ };
357
+ });
358
+ const commitExecutors = this.#executors.prepareWorkspaceRegistrations(workspaceId, namespaceOwner, normalized);
359
+ const commitSchemes = await this.#schemes.prepareWorkspaceRuntimeSchemes(workspaceId, namespaceOwner, normalized.map(({ tag, entry, scheme }) => ({
360
+ tag,
361
+ executor: entry.executor,
362
+ owner: entry.namespaceOwner,
363
+ facet: scheme,
364
+ })));
365
+ return () => {
366
+ const rollbackSchemes = commitSchemes();
367
+ const rollbackExecutors = commitExecutors();
368
+ let pending = true;
369
+ return () => {
370
+ if (!pending)
371
+ return;
372
+ pending = false;
373
+ rollbackExecutors();
374
+ rollbackSchemes();
375
+ };
376
+ };
507
377
  }
508
378
  // A lineage's no-parent root; a root worker resolves to itself. Fail hard
509
379
  // when corruption leaves a worker without one. {§worker-primary}
@@ -523,115 +393,8 @@ class Engine {
523
393
  primaryWorkerId: identity.primary_worker_id,
524
394
  };
525
395
  }
526
- promptBudgetFor(provider) {
527
- return this.#packets.promptBudgetFor(provider);
528
- }
529
- async #attemptAttributions(provider, context) {
530
- const tags = Meta.composeAttributions(this.#schemes.attributions(context), this.#executors?.attributions(context) ?? [], await this.#mimetypes.attributions(context), provider.attributions?.(context) ?? []);
531
- return [...tags];
532
- }
533
- #providerAttributions(provider, context) {
534
- return [...Meta.composeAttributions(provider.attributions?.(context) ?? [])];
535
- }
536
- static #providerFailure(error, signal) {
537
- if (error instanceof ProviderError) {
538
- return { status: error.problem.status, problem: error.problem };
539
- }
540
- if (signal?.aborted === true) {
541
- const timedOut = signal.reason === LOOP_TIMEOUT_REASON;
542
- return Results.failure("engine:provider", timedOut ? "provider-call-timeout" : "provider-call-cancelled", timedOut ? 504 : 499, timedOut
543
- ? "The provider call was interrupted by the loop deadline."
544
- : "The provider call was interrupted by loop cancellation.", {}, { stage: "provider-request", retryable: false });
545
- }
546
- console.error("Provider failed outside its Problem Details contract:", error);
547
- return Results.failure("engine:provider", "provider-contract-violation", 502, "The provider failed without returning its required Problem Details.", {}, { stage: "provider-request", retryable: false });
548
- }
549
- async #runBareBatch({ statements, provider, turnId, modelCallSequenceStart, workspaceId, workerId, primaryWorkerId, loopSequence, turnSequence, signal, }) {
550
- const prepared = [];
551
- for (const [index, statement] of statements.entries()) {
552
- const providerWorkerId = randomUUID();
553
- const attributionContext = Object.freeze({
554
- workspaceId: String(workspaceId),
555
- workerId: providerWorkerId,
556
- primaryWorkerId,
557
- loop: loopSequence,
558
- turn: turnSequence,
559
- attempt: 1,
560
- });
561
- const attributions = this.#providerAttributions(provider, attributionContext);
562
- const modelCall = await ModelCall.open(this.#db, {
563
- turnId,
564
- sequence: modelCallSequenceStart + index,
565
- kind: "bare",
566
- attributions,
567
- model: provider.model,
568
- });
569
- prepared.push({ statement, modelCall, attributions, providerWorkerId });
570
- }
571
- const maxTokens = this.#packets.maxTokensFor(provider) ?? undefined;
572
- const settlements = await Promise.allSettled(prepared.map(async ({ statement, modelCall, attributions, providerWorkerId }) => {
573
- try {
574
- const response = await observed("provider.generate", { model: provider.model, attempt: 1, kind: "bare" }, async (span) => {
575
- try {
576
- const generated = await provider.generate({
577
- messages: [{ role: "user", content: statement.body }],
578
- workerId: providerWorkerId,
579
- primaryWorkerId,
580
- signal,
581
- maxTokens,
582
- attributions: attributions.length > 0 ? attributions : undefined,
583
- workspaceId: String(workspaceId),
584
- loop: loopSequence,
585
- turn: turnSequence,
586
- observeRequest: modelCall.observeRequest,
587
- callKind: "bare",
588
- });
589
- modelCall.assertAccounting(generated.accounting);
590
- recordCounter(PROVIDER_CALLS, {
591
- model: provider.model,
592
- attempt: 1,
593
- status: "resolved",
594
- });
595
- span.setAttribute("status", "resolved");
596
- return generated;
597
- }
598
- catch (error) {
599
- if (error instanceof ProviderError) {
600
- modelCall.assertAccounting(error.accounting);
601
- }
602
- throw error;
603
- }
604
- });
605
- await modelCall.observeResponse(response);
606
- return {
607
- statement,
608
- modelCallId: modelCall.id,
609
- result: Results.assert({
610
- status: 200,
611
- content: response.assistant.content,
612
- mimetype: "text/plain",
613
- }),
614
- };
615
- }
616
- catch (error) {
617
- if (error instanceof ModelCallPersistenceError || error instanceof ProviderAccountingIntegrityError) {
618
- throw error;
619
- }
620
- const failure = _a.#providerFailure(error, signal);
621
- if (error instanceof ProviderError && error.attempt !== undefined) {
622
- await modelCall.observeResponse(error.attempt, failure);
623
- }
624
- else {
625
- await modelCall.fail(failure);
626
- }
627
- return { statement, modelCallId: modelCall.id, result: failure };
628
- }
629
- }));
630
- const internalFailure = settlements.find((settlement) => settlement.status === "rejected");
631
- if (internalFailure !== undefined)
632
- throw internalFailure.reason;
633
- signal?.throwIfAborted();
634
- return settlements.map((settlement) => settlement.value);
396
+ curationBudgetFor(provider) {
397
+ return this.#packets.curationBudgetFor(provider);
635
398
  }
636
399
  // {§attribution} — reporting derives from exact provider-request evidence;
637
400
  // malformed durable tags fail here instead of being silently filtered.
@@ -655,34 +418,18 @@ class Engine {
655
418
  const requests = await this.#db.engine_loop_provider_requests.all({ loop_id: loopId });
656
419
  return {
657
420
  accounting: aggregateProviderAccounting(requests.map(providerRequestFromStorageRow)),
658
- // Latest physical request on the latest turn, not the billed total.
659
- contextTokens: row.context,
660
- // Latest effective packet allowance; null when uncapped or unknown.
661
- promptBudget: row.context_size,
421
+ // Latest assembled request's stable model-independent weight.
422
+ curationWeight: row.curation_weight,
423
+ // Latest provider-derived calibration used only for curation.
424
+ curationBudget: row.curation_budget,
425
+ // Latest emission call's last physical request, not the billed total.
426
+ contextTokens: row.context_tokens,
427
+ // Request-shaped physical capacity from that same latest emission call.
428
+ contextCapacity: row.context_capacity,
662
429
  // Latest turn's opaque provider metadata. {§meta-passthrough}
663
430
  meta: JSON.parse(row.meta ?? "{}"),
664
431
  };
665
432
  }
666
- // A mapped rail divergence is a CODE-POINT offset into the model's content;
667
- // the snippet/notices surface speaks the parser-point convention. {§parser-position}
668
- // Convert over code points (not UTF-16 units) so an astral char doesn't skew the line,
669
- // clamping out-of-range offsets to the content's end.
670
- #offsetToLineColumn(content, offset) {
671
- const cps = Array.from(content);
672
- const clamped = Math.max(0, Math.min(offset, cps.length));
673
- let line = 1;
674
- let column = 0;
675
- for (let i = 0; i < clamped; i++) {
676
- if (cps[i] === "\n") {
677
- line++;
678
- column = 0;
679
- }
680
- else {
681
- column++;
682
- }
683
- }
684
- return { line, column };
685
- }
686
433
  async runLoop({ provider, childProvider = provider, messages, requirements = "", workspaceId, workerId, loopId, maxTurns = 50, maxStrikes = readMaxStrikes(), minCycles = readPositiveInt("PLURNK_SERVICE_MIN_CYCLES", DEFAULT_MIN_CYCLES), maxCyclePeriod = readPositiveInt("PLURNK_SERVICE_MAX_CYCLE_PERIOD", DEFAULT_MAX_CYCLE_PERIOD), origin = "model", signal, onDispatch, }) {
687
434
  // A 202 park suspends this durable loop and a later wake re-enters runLoop.
688
435
  // Its ceiling therefore counts every prior turn, not merely this process-local
@@ -849,16 +596,16 @@ class Engine {
849
596
  return { turnIds, result, hitMaxTurns: false, reason: "invalid_emission" };
850
597
  }
851
598
  invalidEmissionRecoveryEntryId = null;
852
- // SPEC {§grinder}: the effective context envelope rejected the request.
853
- if (turn.budgetHardStop) {
854
- if (turn.budgetFailure === undefined) {
855
- throw new Error("a context-envelope hard-stop requires its exact failure");
599
+ // SPEC {§grinder}: the provider rejected the changed request for capacity.
600
+ if (turn.capacityHardStop) {
601
+ if (turn.capacityFailure === undefined) {
602
+ throw new Error("a provider-capacity stop requires its exact failure");
856
603
  }
857
- const result = await this.#lifecycle.finish(loopId, turn.budgetFailure);
604
+ const result = await this.#lifecycle.finish(loopId, turn.capacityFailure);
858
605
  if (result === null)
859
- throw new Error(`loop ${loopId} became terminal before budget settlement`);
860
- cleanup("forceful", "context_envelope");
861
- return { turnIds, result, hitMaxTurns: false, reason: "context_envelope" };
606
+ throw new Error(`loop ${loopId} became terminal before provider-capacity settlement`);
607
+ cleanup("forceful", "provider_capacity");
608
+ return { turnIds, result, hitMaxTurns: false, reason: "provider_capacity" };
862
609
  }
863
610
  // {§engine-rails} — per-turn strike accounting (cycle detection,
864
611
  // steer coupling, hard operation outcomes). StrikeRail owns the
@@ -888,1130 +635,15 @@ class Engine {
888
635
  }
889
636
  }
890
637
  }
891
- async runTurn({ provider, childProvider = provider, messages, requirements = "", workspaceId, workerId, loopId, origin = "model", signal, onDispatch, turnNumber = 1, maxTurns = 50, invalidEmissionRecoveryEntryId, }) {
892
- const allowInvalidEmissionRecovery = invalidEmissionRecoveryEntryId === null;
893
- const transientOpenLogEntryId = typeof invalidEmissionRecoveryEntryId === "number"
894
- ? invalidEmissionRecoveryEntryId
895
- : null;
896
- // === Turn-as-container model ===
897
- //
898
- // Turn rows are created at runTurn OPEN (status=102, placeholder
899
- // packet) so things can be written into the turn before the model
900
- // is called: the user prompt on turn 1; later, system signals or
901
- // injected Notices on any turn. The turn is CLOSED at
902
- // the end with the final packet + status + usage stats.
903
- //
904
- // sequence is "ordinal of stuff in this turn." Pre-model
905
- // writes consume low indices; model ops continue from there.
906
- const seqRow = await this.#db.engine_next_turn_sequence.get({ loop_id: loopId });
907
- const seq = seqRow.next;
908
- // loops.sequence is the loop's ordinal within the worker. Turn-0 foists that belong to the
909
- // WORKER (manifest preview, AGENTS, operator docs) gate on the worker's first loop, not every loop's
910
- // first turn ({§actor-boundary-catalog-preview}); per-loop foists such as
911
- // {§prompt-entry} still fire each loop. Read once, turn-1 only.
912
- const loopRow = seq === 1
913
- ? await this.#db.engine_get_loop_prompt.get({ loop_id: loopId })
914
- : undefined;
915
- const workerFirstLoop = (loopRow?.sequence ?? 0) === 1;
916
- const openRow = await this.#db.engine_open_turn.get({
917
- loop_id: loopId, sequence: seq,
918
- });
919
- if (openRow === undefined)
920
- throw new Error("Engine.runTurn: turn open returned no row");
921
- const turnId = openRow.id;
922
- // {§env-delta-log-pull} — establish a fresh worker's observation
923
- // baseline immediately after its first turn opens. A fork already has
924
- // its parent's cursor, so the NULL-guarded statement leaves it intact.
925
- // Events committed after this statement belong to this or a later
926
- // closed pull window; pre-existing state is read through the ordinary
927
- // shared-world projections in this first packet.
928
- await this.#db.engine_initialize_ambient_cursor.get({ workspace_id: workspaceId, worker_id: workerId });
929
- // Threaded per turn, never engine state, so concurrent loops on
930
- // different providers each read their own honest tokenizer values.
931
- const systemCtx = {
932
- db: this.#db, workspaceId, workerId, loopId, turnId,
933
- writer: "plurnk",
934
- signal: this.#loopAborts.get(loopId)?.signal,
935
- streamEventNotify: this.#streamEventNotify,
936
- wakeWorkerNotify: this.#wakeWorkerNotify,
937
- tokenize: this.#tokenize,
938
- mimetypes: this.#mimetypes,
939
- defaultChannelFor: (s) => this.#schemes.defaultChannelFor(s),
940
- pushNotice: (notice) => this.#notices.push(workspaceId, loopId, notice),
941
- };
942
- // Pre-model writes. Each prompt the model has not seen yet becomes an
943
- // actionless `prompt` log row whose target is its durable prompt:// entry:
944
- // - Turn 1: loop.prompt is the initial user prompt.
945
- // - Turn N>1: only if Engine.inject (or wake-on-completion via
946
- // daemon.inject) wrote a prompt entry for this turn slot
947
- // between turn N-1 and N. Inject writes directly to entries;
948
- // we DON'T re-foist here for N>1.
949
- // Model ops dispatch after these pre-model rows.
950
- let nextActionIndex = 1;
951
- const turnOpenPaths = [];
952
- // {§worker-initialization-entry} — the worker's first turn opens with a kernel-authored initialization: a
953
- // worked turn PLAN → the environment FINDs the foist ACTUALLY dispatches → SEND signal 102. Built
954
- // from the real ops below (not a static print — we lean into the genuine echo paradigm) and
955
- // written at sequence 1, so it reads first with the foisted results following.
956
- const turnZeroMoves = [];
957
- if (seq === 1) {
958
- if (workerFirstLoop)
959
- nextActionIndex = 2; // reserve sequence 1 for initialization
960
- // Operator doc READs (PLURNK_SERVICE_MD_<ALIAS>, {§actor-boundary-doc-injection}). The docs were materialized
961
- // as worker://plurnk/<entry> entries by the plurnk worker (LoopDocs, via the
962
- // {§actor-boundary} keystone); foist a READ of each into THIS turn-0 so the model
963
- // reads them inline. It sees only the READ — the materializing EDIT
964
- // lives in the plurnk worker's log, never the model's.
965
- // {§operator-config-workspace-md-docs} — env docs union the workspace's client docs; foist a READ of
966
- // each materialized worker://plurnk/<alias>.md (LoopDocs materialized the same set).
967
- const { mdDocs } = await WorkspaceSettings.read(this.#db, workspaceId);
968
- // {§actor-boundary-doc-injection} — operator docs appear on the worker's first loop.
969
- for (const doc of workerFirstLoop ? await WorkspaceSettings.resolveDocs(mdDocs) : []) {
970
- const docTarget = {
971
- kind: "url", raw: `worker://plurnk/${doc.entryName}`, scheme: "worker",
972
- username: null, password: null, hostname: "plurnk", port: null,
973
- pathname: `/${doc.entryName}`, query: null, fragment: null,
974
- };
975
- const docRead = {
976
- op: "READ", suffix: "", signal: null, target: docTarget,
977
- lineMarker: null, body: null, position: UNKNOWN_POSITION,
978
- };
979
- await this.dispatch({
980
- statement: docRead, workspaceId, workerId, loopId, turnId,
981
- sequence: nextActionIndex, origin: "plurnk", onDispatch,
982
- });
983
- nextActionIndex++;
984
- }
985
- const promptRow = loopRow; // {§prompt-entry} — per-loop; fires every loop's turn 1
986
- if (promptRow !== undefined && typeof promptRow.prompt === "string" && promptRow.prompt.length > 0) {
987
- const openPaths = assertOpenPaths(JSON.parse(promptRow.open_paths), `Loop ${loopId} open_paths`);
988
- const promptLoopSeq = promptRow.sequence; // the loop's per-worker sequence — model-facing, matching log coordinates (owner: the db id read as prompt/2/1)
989
- const promptPath = promptTarget(promptLoopSeq, seq);
990
- const entry = {
991
- channels: { body: { content: promptRow.prompt, mimetype: "text/markdown" } },
992
- attributes: { openPaths },
993
- };
994
- await EntryCrud.writeEntry(promptPath.pathname, entry, systemCtx, "prompt", workerId);
995
- turnOpenPaths.push(...openPaths);
996
- const promptLogId = await this.#writePromptLog({
997
- workerId,
998
- loopId,
999
- turnId,
1000
- sequence: nextActionIndex,
1001
- target: promptPath,
1002
- content: promptRow.prompt,
1003
- });
1004
- onDispatch?.(promptLogId);
1005
- nextActionIndex++;
1006
- }
1007
- }
1008
- // {§prompt-loop-containment}: the loop contains every prompt that arrived
1009
- // while it ran. Publish each undelivered frame as a prompt row, oldest
1010
- // first, so rapid arrivals reach the model together exactly once.
1011
- {
1012
- const loopSeqRow = await this.#db.engine_loop_sequence.get({ loop_id: loopId });
1013
- const loopSeq = loopSeqRow?.sequence ?? loopId;
1014
- const prefix = promptLoopPrefix(loopSeq);
1015
- const undelivered = (await this.#db.drain_undelivered_prompts_for_loop.all({
1016
- owner_id: workerId,
1017
- pattern: `${prefix}%`,
1018
- prefix_len: prefix.length,
1019
- loop_id: loopId,
1020
- }))
1021
- .filter((r) => typeof r.content === "string" && r.content.length > 0);
1022
- for (const injectedRow of undelivered) {
1023
- const attributes = parsePromptAttributes(injectedRow.attributes, `Prompt ${injectedRow.pathname} attributes`);
1024
- const encodedOpenPaths = attributes.openPaths;
1025
- if (encodedOpenPaths !== undefined) {
1026
- turnOpenPaths.push(...assertOpenPaths(encodedOpenPaths, `Prompt ${injectedRow.pathname} openPaths`));
1027
- }
1028
- const ordinal = Number(injectedRow.pathname.split("/").filter(Boolean).at(-1));
1029
- const injTarget = promptTarget(loopSeq, ordinal);
1030
- const promptLogId = await this.#writePromptLog({
1031
- workerId,
1032
- loopId,
1033
- turnId,
1034
- sequence: nextActionIndex,
1035
- target: injTarget,
1036
- content: injectedRow.content,
1037
- });
1038
- onDispatch?.(promptLogId);
1039
- nextActionIndex++;
1040
- }
1041
- }
1042
- // The persistent search-index pass (_search-index.maintain) attaches
1043
- // every readable entry/log projection to complete graph/FTS/vector derivations.
1044
- // NOT an action: no log entry, no sequence slot,
1045
- // not dispatched. There is no materialized manifest entry — the catalog
1046
- // is served on demand by FIND: recursive when asked, shallow-mapped below.
1047
- // {§semantic-embed-dedup} — one pass-wide semantic plan binds every
1048
- // chunk counter to the derivation identity it produces.
1049
- // SPEC {§membership} D4/D5 — git-ls-files workspace membership, resolved at
1050
- // prompt-composition (EMI is eager + exhaustive — git is the only bound). When the
1051
- // workspace's project_root is a git working tree, tracked files are
1052
- // members without a client `add`; active members are materialized
1053
- // (disk → body channel) so they appear in the catalog. No-ops
1054
- // on headless / non-git workspaces. Runs BEFORE the derivation pump so
1055
- // this turn's packet reflects them.
1056
- // Workspace creation starts this eagerly. Joining here is the correctness
1057
- // boundary: the model never runs against partial graph/vector coverage.
1058
- await this.#queueWorkspaceWarm(systemCtx, false);
1059
- // The warm materialized membership before deriving. This second pass is the
1060
- // ordinary cheap change detector and captures any drift that landed meanwhile.
1061
- const fsDivergences = await GitMembership.indexGitMembership(systemCtx);
1062
- // {§packet-git-status} — one post-reconciliation snapshot supplies both
1063
- // the compact packet summary and each causal file event's exact XY state.
1064
- const gitStatus = await GitState.status(this.#db, workspaceId, this.#loopAborts.get(loopId)?.signal);
1065
- await this.#logFsFictions(workspaceId, fsDivergences, gitStatus);
1066
- // The refresh above may have changed bodies (including model/client edits
1067
- // since the startup warm). Re-derive to completion before packet/model
1068
- // construction. Membership is already current, so this pass does not
1069
- // consume the filesystem divergences a second time.
1070
- await this.#queueWorkspaceWarm(systemCtx, true, false);
1071
- // Turn-0 catalog preview (PLURNK_SERVICE_FILES_ITEMS, {§actor-boundary-catalog-preview}):
1072
- // Four FIND surveys foisted into the worker's first model turn establish the project,
1073
- // commons, private, and documentation surfaces in that order. Their `init`
1074
- // classification lets the model curate this opening survey as one log set.
1075
- if (seq === 1) {
1076
- // {§operator-config-workspace-files-items} — workspace filesItems replaces the env default.
1077
- const { filesItems: workspaceMI } = await WorkspaceSettings.read(this.#db, workspaceId);
1078
- const filesItems = workspaceMI !== null ? normalizeFilesItems(workspaceMI) : readFilesItems();
1079
- if (filesItems !== null && workerFirstLoop) { // {§actor-boundary-catalog-preview} — once per worker
1080
- const catalogSchemes = await this.#db.engine_scheme_catalog_summary.all({ workspace_id: workspaceId });
1081
- const fileItems = catalogSchemes.find(({ scheme }) => scheme === "file")?.shallow_items ?? 0;
1082
- const fileCap = filesItems > 0 && fileItems > 0 ? Math.min(filesItems, fileItems) : null;
1083
- await Owner.kernelId(this.#db, workspaceId);
1084
- const surveys = [
1085
- {
1086
- statement: {
1087
- op: "FIND", suffix: "", signal: ["+init"],
1088
- target: { kind: "local", raw: "*" },
1089
- body: null,
1090
- lineMarker: fileCap === null ? null : { marks: [1, fileCap] },
1091
- position: UNKNOWN_POSITION,
1092
- },
1093
- exemplar: `## FIND0 [+init] (*)${fileCap === null ? "" : ` <1,${fileCap}>`}`,
1094
- },
1095
- {
1096
- statement: {
1097
- op: "FIND", suffix: "", signal: ["+init"],
1098
- target: { kind: "url", raw: "worker:///*", scheme: "worker", username: null, password: null, hostname: null, port: null, pathname: "/*", query: null, fragment: null },
1099
- body: null, lineMarker: null, position: UNKNOWN_POSITION,
1100
- },
1101
- exemplar: "## FIND0 [+init] (worker:///*)",
1102
- },
1103
- {
1104
- statement: {
1105
- op: "FIND", suffix: "", signal: ["+init"],
1106
- target: { kind: "url", raw: "worker://~/*", scheme: "worker", username: null, password: null, hostname: "~", port: null, pathname: "/*", query: null, fragment: null },
1107
- body: null, lineMarker: null, position: UNKNOWN_POSITION,
1108
- },
1109
- exemplar: "## FIND0 [+init] (worker://~/*)",
1110
- },
1111
- {
1112
- statement: {
1113
- op: "FIND", suffix: "", signal: ["+init", "+docs"],
1114
- target: { kind: "url", raw: "worker://plurnk/docs/**", scheme: "worker", username: null, password: null, hostname: "plurnk", port: null, pathname: "/docs/**", query: null, fragment: null },
1115
- body: null, lineMarker: { marks: [1, -1] }, position: UNKNOWN_POSITION,
1116
- },
1117
- exemplar: "## FIND0 [+init,+docs] (worker://plurnk/docs/**) <1,-1>",
1118
- },
1119
- ];
1120
- for (const { statement, exemplar } of surveys) {
1121
- await this.dispatch({ statement, workspaceId, workerId, loopId, turnId, sequence: nextActionIndex, origin: "plurnk", onDispatch });
1122
- nextActionIndex++;
1123
- turnZeroMoves.push(exemplar);
1124
- }
1125
- }
1126
- // {§worker-initialization-entry} — write the kernel's turn-0 initialization OPEN at sequence 1: PLAN → the FINDs actually
1127
- // foisted above (real, their results already in the log) → SEND signal 102. Dynamic — it reflects
1128
- // the true survey, never a frozen print — and OPEN: the worked example the model orients on,
1129
- // so the grammar can stay thin.
1130
- if (workerFirstLoop) {
1131
- const initialization = [
1132
- "# PLAN0\n* Initialization complete.\n* Next: address the prompt.",
1133
- ...turnZeroMoves,
1134
- "## SEND0 [102]\nNext, address the prompt.",
1135
- ].join("\n\n");
1136
- await this.#dispatcher.writeInitializationEntry({ verbatim: initialization, workerId, loopId, turnId, sequence: 1 });
1137
- }
1138
- }
1139
- // {§methods-loop-run-open-paths}: selected workspace paths belong to
1140
- // the prompt frame. Publish the frame, then dispatch ordinary core READs
1141
- // in that same turn; missing/non-member paths retain their normal 4xx.
1142
- for (const raw of turnOpenPaths) {
1143
- const pathname = raw.startsWith("/") ? raw : `/${raw}`;
1144
- const fileRead = {
1145
- op: "READ", suffix: "", signal: null, lineMarker: null,
1146
- target: {
1147
- kind: "url", raw: `file://${pathname}`, scheme: "file",
1148
- username: null, password: null, hostname: null, port: null,
1149
- pathname, query: null, fragment: null,
1150
- },
1151
- body: null, position: UNKNOWN_POSITION,
1152
- };
1153
- await this.dispatch({
1154
- statement: fileRead, workspaceId, workerId, loopId, turnId,
1155
- sequence: nextActionIndex, origin: "plurnk", onDispatch,
1156
- });
1157
- nextActionIndex++;
1158
- }
1159
- // {§env-delta-log-pull} — materialize ambient observations before packet
1160
- // composition and reserve their action indices. {§exec-stream} owns the
1161
- // distinct byte-cursor path for this worker's streams.
1162
- // {§exec-poll} — EXEC `<0>` is turn-scoped: reap the worker's open turn-scoped streams (necessarily
1163
- // from a prior turn — this runs before the turn's own spawns) so a `<0>` never survives into
1164
- // the subsequent turn. The terminal output then surfaces born-OPEN via the stream-delta path.
1165
- await this.#reapTurnScopedStreams(workerId);
1166
- nextActionIndex += await this.#materializeEnvironmentDeltas({ workspaceId, workerId, loopId, turnId, fromSequence: nextActionIndex });
1167
- nextActionIndex += await this.#materializeStreamDeltas({ workerId, loopId, turnId, fromSequence: nextActionIndex });
1168
- // The post-reconciliation Git snapshot above is threaded into the packet
1169
- // and every budget rebuild; overflow never shells again.
1170
- // Notices are non-terminal observations, never operation-failure truth.
1171
- // Drain once and thread the same set through every grinder rebuild.
1172
- const notices = this.#notices.drain(loopId)
1173
- .filter((event) => event.level !== "info");
1174
- // Build the model request packet ({§packet-stored-shape}). The log build
1175
- // queries log_entries scoped to the worker — the prompt entry just
1176
- // written (if turn 1) is part of that query result.
1177
- let requestPacket = await this.#packets.buildRequestPacket({
1178
- initialMessages: messages, requirements, workspaceId, workerId, loopId,
1179
- currentTurnSeq: seq, provider, gitStatus, notices, transientOpenLogEntryId,
1180
- });
1181
- // SPEC {§grinder} — budget grinder, pre-LLM: reclaim window on actual overflow.
1182
- const enforced = await this.#packets.enforceBudget({
1183
- packet: requestPacket, provider, loopId, turnId,
1184
- recordOverflow: async (pressure) => {
1185
- await this.#problems.record({
1186
- workerId,
1187
- loopId,
1188
- turnId,
1189
- sequence: nextActionIndex++,
1190
- origin: "plurnk",
1191
- source: "engine",
1192
- result: tokenBudgetOverflowFailure(pressure),
1193
- });
1194
- },
1195
- rebuild: () => this.#packets.buildRequestPacket({
1196
- initialMessages: messages, requirements, workspaceId, workerId, loopId,
1197
- currentTurnSeq: seq, provider, gitStatus, notices, transientOpenLogEntryId,
1198
- }),
1199
- });
1200
- requestPacket = enforced.packet;
1201
- if (!enforced.fit) {
1202
- // {§tokenomics-context-envelope-admission}: the overflow Problem is
1203
- // already durable. Remaining ruler debt may still be admitted; only
1204
- // the effective total context envelope can terminally reject the turn.
1205
- const contextAdmission = await this.#packets.contextEnvelopeAdmission(requestPacket, provider, this.#loopAborts.get(loopId)?.signal);
1206
- if (!contextAdmission.admitted) {
1207
- const ceiling = this.#packets.ceilingFor(provider);
1208
- if (ceiling === null) {
1209
- throw new Error("an unbounded prompt budget cannot enter context-envelope admission");
1210
- }
1211
- const failure = contextEnvelopeFailure(budgetPressure(requestPacket.tokens, ceiling), contextAdmission);
1212
- await this.#problems.record({
1213
- workerId,
1214
- loopId,
1215
- turnId,
1216
- sequence: nextActionIndex++,
1217
- origin: "plurnk",
1218
- source: "engine",
1219
- result: failure,
1220
- });
1221
- // Preserve the exact terminal evidence in the stored request. It is
1222
- // never sent because admission has already failed.
1223
- requestPacket = await this.#packets.buildRequestPacket({
1224
- initialMessages: messages, requirements, workspaceId, workerId, loopId,
1225
- currentTurnSeq: seq, provider, gitStatus, notices, transientOpenLogEntryId,
1226
- });
1227
- await this.#db.engine_close_turn.run({
1228
- id: turnId, status: 413, packet: StoredPacket.stringify(requestPacket),
1229
- // The attempted turn retains its effective allowance even
1230
- // when no provider exchange completed. {§tokenomics-client-gauge}
1231
- usage_prompt_budget: this.#packets.promptBudgetFor(provider),
1232
- finish_reason: "budget_hard_stop", model: provider.model, meta: "{}",
1233
- });
1234
- return {
1235
- turnId,
1236
- status: 413,
1237
- outcomes: [],
1238
- fingerprint: "",
1239
- budgetHardStop: true,
1240
- steerStruck: false,
1241
- emissionAttempts: 0,
1242
- emissionExhausted: false,
1243
- budgetFailure: failure,
1244
- };
1245
- }
1246
- }
1247
- const modelMessages = PacketWire.packetToWireMessages(requestPacket);
1248
- // Packet pressure and provider generation are independent. The grinder governs
1249
- // only the request packet; maxTokens comes only from the provider envelope and
1250
- // never shrinks as the virtual prompt budget fills.
1251
- let response;
1252
- let splitResponse;
1253
- let railGrammar;
1254
- let railResponseGrammar;
1255
- let railEvidence;
1256
- let emissionAttempts = 0;
1257
- let providerCallInFlight = false;
1258
- let providerAttemptSequence = 0;
1259
- let providerAttemptId = null;
1260
- let providerModelCall = null;
1261
- let providerAttemptAttributions = [];
1262
- const providerSignal = this.#loopAborts.get(loopId)?.signal ?? signal;
1263
- // {§client-metadata}
1264
- const { client } = await WorkspaceSettings.read(this.#db, workspaceId);
1265
- const loopSeq = (await this.#db.engine_loop_sequence.get({ loop_id: loopId }))?.sequence ?? loopId;
1266
- const providerIdentity = await this.resolveWorkerProviderIdentity(workerId);
1267
- const { workerId: providerWorkerId, primaryWorkerId } = providerIdentity;
1268
- const classifyProviderAttempt = async (id, attemptSplit, sequence, accepted) => {
1269
- const result = await this.#db.engine_classify_turn_attempt_response.run({
1270
- id,
1271
- accepted: accepted ? 1 : 0,
1272
- parse_errors: JSON.stringify(attemptSplit.parseErrors),
1273
- });
1274
- if (result.changes !== 1) {
1275
- throw new Error(`emission attempt ${id} was not awaiting classification`);
1276
- }
1277
- emissionAttempts = sequence;
1278
- };
1279
- try {
1280
- // {§turn-lifecycle}: bracket the complete provider-attempt window with liveness notices.
1281
- if (!signal?.aborted)
1282
- this.#notices.push(workspaceId, loopId, { source: "engine:turn", kind: "turn_awaiting_model", level: "info", message: "awaiting model response" });
1283
- const railConstraint = await this.#grammarConstraint(provider);
1284
- railGrammar = railConstraint?.transport;
1285
- railResponseGrammar = railConstraint?.response;
1286
- const attemptLimit = readEmissionAttempts();
1287
- const maxTokens = this.#packets.maxTokensFor(provider) ?? undefined;
1288
- const strikeStreak = this.#strikes.streak(loopId);
1289
- for (let attempt = 1; attempt <= attemptLimit; attempt++) {
1290
- // Every attempt carries the exact same model packet, coordinates,
1291
- // limits, and engine-strike state. Plugin-authored tags are pulled
1292
- // for the attempt and do not alter the model messages. No failed
1293
- // emission is appended and no new engine turn opens between calls.
1294
- providerAttemptSequence = attempt;
1295
- const attributionContext = Object.freeze({
1296
- workspaceId: String(workspaceId),
1297
- workerId: providerWorkerId,
1298
- primaryWorkerId,
1299
- loop: loopSeq,
1300
- turn: seq,
1301
- attempt,
1302
- });
1303
- providerAttemptAttributions = await this.#attemptAttributions(provider, attributionContext);
1304
- requestPacket = { ...requestPacket, attributions: providerAttemptAttributions };
1305
- providerModelCall = await ModelCall.open(this.#db, {
1306
- turnId,
1307
- sequence: attempt,
1308
- kind: "emission",
1309
- attributions: providerAttemptAttributions,
1310
- model: provider.model,
1311
- });
1312
- const attemptRow = await this.#db.engine_open_turn_attempt.get({
1313
- model_call_id: providerModelCall.id,
1314
- });
1315
- if (attemptRow === undefined) {
1316
- throw new Error(`Engine.runTurn: provider attempt ${attempt} did not open`);
1317
- }
1318
- providerAttemptId = attemptRow.id;
1319
- providerCallInFlight = true;
1320
- const currentModelCall = providerModelCall;
1321
- const completedResponse = await observed(// {§observability-boundary}
1322
- "provider.generate", { model: provider.model, attempt }, async (span) => {
1323
- try {
1324
- const generated = await provider.generate({
1325
- messages: modelMessages,
1326
- workerId: providerWorkerId,
1327
- primaryWorkerId,
1328
- signal: providerSignal,
1329
- grammar: railGrammar,
1330
- maxTokens,
1331
- strikes: strikeStreak,
1332
- attributions: providerAttemptAttributions.length > 0
1333
- ? providerAttemptAttributions
1334
- : undefined,
1335
- client: client ?? undefined,
1336
- workspaceId: String(workspaceId),
1337
- loop: loopSeq,
1338
- turn: seq,
1339
- observeRequest: currentModelCall.observeRequest,
1340
- callKind: "emission",
1341
- }); // {§provider-surface-generate} {§provider-guarantees-signal-wired} {§provider-guarantees-serial-attempts} {§attribution} {§client-metadata}
1342
- currentModelCall.assertAccounting(generated.accounting);
1343
- providerCallInFlight = false;
1344
- recordCounter(PROVIDER_CALLS, {
1345
- model: provider.model,
1346
- attempt,
1347
- status: "resolved",
1348
- });
1349
- span.setAttribute("status", "resolved");
1350
- return generated;
1351
- }
1352
- catch (error) {
1353
- if (error instanceof ProviderError) {
1354
- currentModelCall.assertAccounting(error.accounting);
1355
- }
1356
- throw error;
1357
- }
1358
- });
1359
- response = completedResponse;
1360
- await currentModelCall.observeResponse(completedResponse);
1361
- railEvidence = railGrammar === undefined
1362
- ? undefined
1363
- : _a.#requireGrammarEvidence(completedResponse);
1364
- splitResponse = this.#splitResponse(completedResponse);
1365
- await classifyProviderAttempt(attemptRow.id, splitResponse, attempt, splitResponse.emissionValid);
1366
- if (splitResponse.emissionValid)
1367
- break;
1368
- }
1369
- if (!signal?.aborted)
1370
- this.#notices.push(workspaceId, loopId, { source: "engine:turn", kind: "turn_generated", level: "info", message: "parsing model response" });
1371
- }
1372
- catch (err) {
1373
- // This handler owns only provider-call failures. Parser, cost, SQL,
1374
- // and engine-contract failures retain their original source.
1375
- if (err instanceof ModelCallPersistenceError || err instanceof ProviderAccountingIntegrityError)
1376
- throw err;
1377
- if (!providerCallInFlight)
1378
- throw err;
1379
- providerCallInFlight = false;
1380
- if (providerAttemptId === null || providerModelCall === null) {
1381
- throw new Error("provider call failed without durable model-call and attempt identities", { cause: err });
1382
- }
1383
- const failure = _a.#providerFailure(err, providerSignal);
1384
- // {§provider-interrupted-attempt} — a provider-declared interruption
1385
- // carries response evidence without becoming a completed exchange.
1386
- // Persist it as an unaccepted attempt before settling the failure.
1387
- if (err instanceof ProviderError && err.attempt !== undefined) {
1388
- response = err.attempt;
1389
- await providerModelCall.observeResponse(response, failure);
1390
- splitResponse = this.#splitResponse(response);
1391
- await classifyProviderAttempt(providerAttemptId, splitResponse, providerAttemptSequence, false);
1392
- }
1393
- else {
1394
- await providerModelCall.fail(failure);
1395
- emissionAttempts = providerAttemptSequence;
1396
- }
1397
- // {§turn-never-blank} — a ProviderError means no completed exchange exists.
1398
- // Persist its exact RFC 9457 result before propagating it. Grammar evidence
1399
- // and its engine-owned verdict exist only on completed responses
1400
- // ({§rail-truth-engine-verdict}). Cancellation is lifecycle truth, not a
1401
- // provider failure. Close the
1402
- // attempted turn without inventing an assistant response, then let
1403
- // runLoop/Daemon settle the exact 504/499 loop result.
1404
- if (providerSignal?.aborted) {
1405
- await this.#db.engine_close_turn.run({
1406
- id: turnId,
1407
- status: providerSignal.reason === LOOP_TIMEOUT_REASON ? 504 : 499,
1408
- packet: StoredPacket.stringify(requestPacket),
1409
- usage_prompt_budget: this.#packets.promptBudgetFor(provider),
1410
- finish_reason: splitResponse?.callMetadata.finishReason ?? null,
1411
- model: splitResponse?.callMetadata.model ?? provider.model,
1412
- meta: JSON.stringify(response?.meta ?? {}),
1413
- });
1414
- throw err;
1415
- }
1416
- const recorded = await this.#problems.record({
1417
- workerId,
1418
- loopId,
1419
- turnId,
1420
- sequence: nextActionIndex,
1421
- origin: "plurnk",
1422
- source: "provider",
1423
- result: failure,
1424
- });
1425
- // The provider call was attempted, but no completed exchange exists.
1426
- // Persist the exact request half and failure status; omitting assistant
1427
- // is materially different from fabricating an empty model turn.
1428
- await this.#db.engine_close_turn.run({
1429
- id: turnId,
1430
- status: recorded.result.status,
1431
- packet: StoredPacket.stringify(requestPacket),
1432
- usage_prompt_budget: this.#packets.promptBudgetFor(provider),
1433
- finish_reason: splitResponse?.callMetadata.finishReason ?? null,
1434
- model: splitResponse?.callMetadata.model ?? provider.model,
1435
- meta: JSON.stringify(response?.meta ?? {}),
1436
- });
1437
- throw new OperationFailureError(recorded.result, { cause: err });
1438
- }
1439
- if (response === undefined || splitResponse === undefined || providerModelCall === null) {
1440
- throw new Error("provider attempt loop completed without a response");
1441
- }
1442
- const emissionModelCallId = providerModelCall.id;
1443
- if (!splitResponse.emissionValid) {
1444
- // {§invalid-emission-attempts} The first consecutive exhaustion
1445
- // publishes only the raw final response and generic recovery fact.
1446
- let rejectedModelEntryId;
1447
- if (allowInvalidEmissionRecovery) {
1448
- rejectedModelEntryId = await this.#dispatcher.writeModelEntry({
1449
- verbatim: splitResponse.packetAssistant.content,
1450
- workerId,
1451
- loopId,
1452
- turnId,
1453
- sequence: nextActionIndex,
1454
- folded: true,
1455
- modelCallId: emissionModelCallId,
1456
- admission: "rejected",
1457
- });
1458
- this.#notices.push(workspaceId, loopId, {
1459
- source: "engine:grammar",
1460
- kind: "invalid_emission",
1461
- level: "error",
1462
- message: INVALID_EMISSION_RECOVERY_MESSAGE,
1463
- });
1464
- }
1465
- await this.#db.engine_close_turn.run({
1466
- id: turnId,
1467
- status: allowInvalidEmissionRecovery ? TURN_STATUS_IMPLICIT_CONTINUE : 500,
1468
- packet: StoredPacket.stringify(requestPacket),
1469
- usage_prompt_budget: this.#packets.promptBudgetFor(provider),
1470
- finish_reason: splitResponse.callMetadata.finishReason,
1471
- model: splitResponse.callMetadata.model,
1472
- meta: JSON.stringify(response.meta ?? {}),
1473
- });
1474
- return {
1475
- turnId,
1476
- status: allowInvalidEmissionRecovery ? TURN_STATUS_IMPLICIT_CONTINUE : 500,
1477
- outcomes: [],
1478
- fingerprint: "",
1479
- budgetHardStop: false,
1480
- steerStruck: false,
1481
- emissionAttempts,
1482
- emissionExhausted: true,
1483
- ...(rejectedModelEntryId === undefined ? {} : { rejectedModelEntryId }),
1484
- };
1485
- }
1486
- // {§packet-stored-shape} — admitted emission data extends the packet;
1487
- // provider-call metadata remains on the Turn row.
1488
- const { packetAssistant, callMetadata, parseNotices, recoverableParseErrors, } = splitResponse; // raw assistant content is opaque — split, never interpreted — {§provider-guarantees-assistantraw-opaque}
1489
- for (const notice of parseNotices) {
1490
- this.#notices.push(workspaceId, loopId, notice);
1491
- }
1492
- // Non-fatal provider transport notices on an accepted turn. Forward each
1493
- // Notice with a content-offset `line:col`;
1494
- // the model resolves it against its own emission — READ the folded model-emission row at the
1495
- // cited lines ({§model-entry}) — not an embedded snippet that would duplicate the emission.
1496
- for (const notice of response.notices ?? []) {
1497
- const located = typeof notice.position === "number"
1498
- ? this.#offsetToLineColumn(packetAssistant.content, notice.position)
1499
- : null;
1500
- this.#notices.push(workspaceId, loopId, {
1501
- source: notice.source,
1502
- kind: notice.kind,
1503
- message: notice.message ?? "",
1504
- level: notice.level,
1505
- ...(located !== null
1506
- ? { position: { type: "content-offset", line: located.line, column: located.column } }
1507
- : {}),
1508
- });
1509
- }
1510
- // Grade configured local evidence independently. Endpoint-owned
1511
- // constraints remain provider observations. {§rail-truth-engine-verdict}
1512
- let railKeys;
1513
- if (railGrammar !== undefined) {
1514
- if (railEvidence === undefined)
1515
- throw new Error("configured GBNF response has no final grammar evidence");
1516
- if (railResponseGrammar === undefined)
1517
- throw new Error("configured GBNF has no response grammar");
1518
- let verdict = null;
1519
- try {
1520
- verdict = validateGbnf(railResponseGrammar, railEvidence.input);
1521
- }
1522
- catch (cause) {
1523
- _a.#warnRailVerdictGapOnce(cause.message);
1524
- }
1525
- railKeys = {
1526
- railsAttached: railEvidence.transported ? "client" : "withheld",
1527
- railsVerdict: verdict?.status ?? "unverifiable",
1528
- };
1529
- if (verdict !== null && verdict.status !== "accept") {
1530
- const contentPosition = verdict.pos >= railEvidence.contentStart
1531
- ? verdict.pos - railEvidence.contentStart
1532
- : null;
1533
- const located = contentPosition === null
1534
- ? null
1535
- : this.#offsetToLineColumn(packetAssistant.content, contentPosition);
1536
- this.#notices.push(workspaceId, loopId, {
1537
- source: "engine:rails",
1538
- kind: "grammar_unenforced",
1539
- message: verdict.status === "reject"
1540
- ? `emission rejects the grammar at raw code point ${verdict.pos}`
1541
- : `emission is an incomplete grammar sentence (ends at raw code point ${verdict.pos})`,
1542
- level: "warn",
1543
- ...(located === null
1544
- ? {}
1545
- : { position: { type: "content-offset", line: located.line, column: located.column } }),
1546
- });
1547
- }
1548
- }
1549
- const opsCount = packetAssistant.ops.length;
1550
- const finalOp = packetAssistant.ops.at(-1);
1551
- if (finalOp?.op !== "SEND") {
1552
- // Text emissions cannot reach this point without a disposition;
1553
- // this fail-hard guard also keeps Mock's trusted pre-parsed seam
1554
- // from creating runtime states that production admission forbids.
1555
- throw new Error("an admitted emission must end in a disposition SEND");
1556
- }
1557
- const dispositionSignal = finalOp.signal;
1558
- if (typeof dispositionSignal !== "number" || !TERMINAL_SEND_SIGNALS.has(dispositionSignal)) {
1559
- throw new Error("an admitted emission must end in a disposition SEND");
1560
- }
1561
- const sendOp = finalOp;
1562
- // {§send} the terminal contract — engine error states verify a terminal claim against loop
1563
- // state, never trusting the model's code. They strike via turn.steerStruck
1564
- // ({§engine-rails}): the loop continues, the model sees the steering hint not the strike
1565
- // count, and a non-resolver spins out to the engine's 500.
1566
- let steerStruck = false;
1567
- // Engine errors raised this turn, minted as op='error' log rows after dispatch (they share the
1568
- // post-dispatch sequence counter). {§operation-result-uniform-error-channel}
1569
- const pendingEngineErrors = [];
1570
- // Terminal adjudication moved to the DISPATCHER ({§send-premature-terminate}, the unified
1571
- // pending set): the terminal SEND is judged AT ITS OWN DISPATCH — after the emission's
1572
- // earlier ops executed — so a same-turn KILL+[200] repairs in one turn and a same-turn
1573
- // WORK+[200] is caught. A refused final disposition (409) strikes via
1574
- // the dispatch-loop check below.
1575
- let turnStatus = dispositionSignal;
1576
- // Idle turn: an implicit-continue (102) that did no WORK — its ops are only PLAN/SEND, no mid op.
1577
- // The model continued with nothing to do.
1578
- const midOpsCount = packetAssistant.ops.filter((op) => op.op !== "PLAN" && op.op !== "SEND").length
1579
- + recoverableParseErrors.length;
1580
- if (!steerStruck && turnStatus === TURN_STATUS_IMPLICIT_CONTINUE && midOpsCount === 0) {
1581
- steerStruck = true;
1582
- pendingEngineErrors.push("idle_turn");
1583
- }
1584
- // Close the turn with the final packet, status, and usage stats.
1585
- const packet = StoredPacket.admit(requestPacket, packetAssistant, response.assistantRaw);
1586
- await this.#db.engine_close_turn.run({
1587
- id: turnId,
1588
- status: turnStatus,
1589
- packet: StoredPacket.stringify(packet),
1590
- usage_prompt_budget: this.#packets.promptBudgetFor(provider), // {§tokenomics-client-gauge}
1591
- finish_reason: callMetadata.finishReason,
1592
- model: callMetadata.model,
1593
- // Opaque provider metadata plus engine-authored rail keys.
1594
- // {§meta-passthrough}, {§rail-truth-engine-verdict}
1595
- meta: JSON.stringify({ ...(response.meta ?? {}), ...(railKeys ?? {}) }),
1596
- });
1597
- // Dispatch model ops starting at nextActionIndex (continues the
1598
- // turn's running counter after any pre-model writes).
1599
- //
1600
- // Max-commands ceiling: OFF by default (unlimited) — every generated op dispatches.
1601
- // A degenerate op-loop is a sampler failure guarded at generation, not by dropping
1602
- // already-generated work post-hoc. The ceiling is an OPT-IN operator/client bound:
1603
- // when set, overflow ops drop without per-op log entries (no forensics flood) and the
1604
- // model gets one notices signal next packet.
1605
- // {§operator-config-workspace-max-commands} — workspace maxCommands min()s the env ceiling.
1606
- const maxCommands = Math.min(readMaxCommands(), (await WorkspaceSettings.read(this.#db, workspaceId)).maxCommands ?? Number.POSITIVE_INFINITY);
1607
- // PLAN (intended goals) and the final disposition SEND are not actions —
1608
- // they always dispatch and never count against the cap. maxCommands
1609
- // bounds real actions only; maxCommands:0 still admits a plan and a disposition
1610
- // (the PLAN/SEND ops, zero actions), which is its only coherent meaning.
1611
- let realCommands = 0;
1612
- const admittedOps = packetAssistant.ops.filter((op) => {
1613
- return op.op === "PLAN"
1614
- || op === sendOp
1615
- || realCommands++ < maxCommands;
1616
- });
1617
- const opsToDispatch = scheduleTurnOps(admittedOps);
1618
- await this.#dispatcher.prepareEditBatches(opsToDispatch.filter((statement) => statement.op === "EDIT"), {
1619
- workspaceId, workerId, loopId, turnId,
1620
- origin, onDispatch,
1621
- });
1622
- const droppedCount = opsCount - opsToDispatch.length;
1623
- const bareStatements = opsToDispatch.filter((statement) => statement.op === "BARE");
1624
- let bareResults = null;
1625
- const outcomes = [];
1626
- // Running counter — a multi-file READ writes N rows from one statement (rowsWritten),
1627
- // so the next op's sequence picks up after them. Collapses to nextActionIndex+i when
1628
- // every op writes one row (the common case).
1629
- let rowSeq = nextActionIndex;
1630
- let parseErrorsRecorded = false;
1631
- const recordRecoverableParseErrors = async () => {
1632
- if (parseErrorsRecorded)
1633
- return;
1634
- parseErrorsRecorded = true;
1635
- for (const error of recoverableParseErrors) {
1636
- const recorded = await this.#problems.record({
1637
- workerId,
1638
- loopId,
1639
- turnId,
1640
- sequence: rowSeq++,
1641
- origin: "model",
1642
- source: "grammar",
1643
- result: Results.failure("grammar:parser", "invalid-operation-syntax", 400, error.message, {}, {
1644
- line: error.line,
1645
- column: error.column,
1646
- source: error.source,
1647
- stage: "parse",
1648
- recovery: "Correct only the failed operation; sibling operations were retained.",
1649
- retryable: false,
1650
- }),
1651
- });
1652
- outcomes.push({ op: null, status: recorded.result.status });
1653
- onDispatch?.(recorded.id);
1654
- }
1655
- };
1656
- for (const statement of opsToDispatch) {
1657
- if (statement.op === "SEND"
1658
- && typeof statement.signal === "number"
1659
- && TERMINAL_SEND_SIGNALS.has(statement.signal)) {
1660
- await recordRecoverableParseErrors();
1661
- // {§worker-optimistic-settlement} — SEND judges the refreshed
1662
- // lifecycle after this turn's own fast streams receive one
1663
- // bounded opportunity to conclude. This is not a sleep and
1664
- // does not delay a later turn for older monitored streams.
1665
- const execHandler = this.#schemes.get("exec");
1666
- await execHandler?.settleTurnSpawns?.(workerId, turnId, readOptimisticSettlementMs(), providerSignal);
1667
- }
1668
- const result = await observed(// {§observability-boundary}
1669
- "op.dispatch", { op: statement.op }, async (span) => {
1670
- let dispatchResult;
1671
- if (statement.op === "BARE") {
1672
- if (bareResults === null) {
1673
- const batch = await this.#runBareBatch({
1674
- statements: bareStatements,
1675
- provider: childProvider,
1676
- turnId,
1677
- modelCallSequenceStart: providerAttemptSequence + 1,
1678
- workspaceId,
1679
- workerId,
1680
- primaryWorkerId,
1681
- loopSequence: loopSeq,
1682
- turnSequence: seq,
1683
- signal: providerSignal,
1684
- });
1685
- bareResults = new Map(batch.map((item) => [item.statement, item]));
1686
- }
1687
- const bare = bareResults.get(statement);
1688
- if (bare === undefined) {
1689
- throw new Error("BARE statement reached dispatch without its batch result");
1690
- }
1691
- dispatchResult = await this.#dispatcher.recordBareResult({
1692
- statement,
1693
- workspaceId,
1694
- workerId,
1695
- loopId,
1696
- turnId,
1697
- sequence: rowSeq,
1698
- origin,
1699
- onDispatch,
1700
- }, bare.result, bare.modelCallId);
1701
- }
1702
- else {
1703
- dispatchResult = await this.#dispatcher.dispatch({
1704
- statement, workspaceId, workerId, loopId, turnId,
1705
- sequence: rowSeq,
1706
- origin, onDispatch,
1707
- });
1708
- }
1709
- span.setAttribute("status", dispatchResult.status);
1710
- recordCounter(OPS_DISPATCHED, { op: statement.op, status: dispatchResult.status });
1711
- return dispatchResult;
1712
- });
1713
- outcomes.push({ op: statement.op, status: result.status });
1714
- for (const normalization of result.scopeNormalizations ?? []) {
1715
- this.#notices.push(workspaceId, loopId, {
1716
- source: "engine:slicer",
1717
- kind: "scope_normalized",
1718
- level: "warn",
1719
- message: `Scope <${normalization.requested.join(",")}> was normalized to <${normalization.canonical.join(",")}>.`,
1720
- });
1721
- }
1722
- // {§engine-rails} — a refused final disposition leaves both loop
1723
- // and turn continuing, and its 409 steering ruling strikes once.
1724
- if (statement === sendOp && result.status === 409) {
1725
- steerStruck = true;
1726
- turnStatus = TURN_STATUS_IMPLICIT_CONTINUE;
1727
- await this.#db.engine_reconcile_turn_status.run({ id: turnId, status: turnStatus });
1728
- }
1729
- // {§send-300-choices}: a question resolves through the proposal system; whatever the
1730
- // resolution (answer/reject/timeout), the LOOP continues to the turn where the model
1731
- // reads it; the turn record is a continue, never a 300 terminal.
1732
- if (statement === sendOp && sendOp.signal === 300 && result.status !== 409) {
1733
- turnStatus = TURN_STATUS_IMPLICIT_CONTINUE;
1734
- await this.#db.engine_reconcile_turn_status.run({ id: turnId, status: turnStatus });
1735
- }
1736
- // A broadcast [202] is a conditional wait, not an unconditional turn status:
1737
- // live work parks at 202; completed-but-unobserved work continues at 102; an
1738
- // empty join completes at 200. Persist and return the dispatcher's actual ruling.
1739
- if (statement === sendOp
1740
- && result.status !== 409
1741
- && sendOp.target === null
1742
- && sendOp.signal === 202
1743
- && result.status !== 202) {
1744
- turnStatus = result.status;
1745
- await this.#db.engine_reconcile_turn_status.run({ id: turnId, status: turnStatus });
1746
- }
1747
- rowSeq += result.rowsWritten ?? 1;
1748
- }
1749
- await recordRecoverableParseErrors();
1750
- // Engine rail failures mint as op='error' log rows at the turn's next
1751
- // free sequence. Bounded syntax failures were recorded in their
1752
- // authored turn before its terminal disposition.
1753
- let errSeq = rowSeq;
1754
- // max_commands_exceeded IS model-facing: dropped ops the model emitted that didn't run.
1755
- if (droppedCount > 0)
1756
- pendingEngineErrors.push("max_commands_exceeded");
1757
- for (const kind of pendingEngineErrors) {
1758
- const problem = ENGINE_PROBLEMS[kind];
1759
- const extensions = kind === "max_commands_exceeded"
1760
- ? {
1761
- operationLimit: maxCommands,
1762
- omittedOperations: droppedCount,
1763
- stage: "dispatch-admission",
1764
- recovery: "Continue with no more than the configured operation limit.",
1765
- retryable: false,
1766
- }
1767
- : {
1768
- stage: "turn",
1769
- recovery: "Perform an operation before continuing with `## SEND0 [102]`.",
1770
- retryable: false,
1771
- };
1772
- await this.#problems.record({
1773
- workerId,
1774
- loopId,
1775
- turnId,
1776
- sequence: errSeq++,
1777
- origin: "plurnk",
1778
- source: "rail",
1779
- result: Results.failure("engine:rail", problem.code, problem.status, problem.detail, {}, extensions),
1780
- });
1781
- }
1782
- // {§log-row-self-explains} — model-operation failures remain on their
1783
- // own rows; genuine engine faults fail hard and mint no substitute row.
1784
- // {§model-entry} — mirror this turn's verbatim emission back as a `model` row, so the NEXT
1785
- // packet shows the model exactly what it last produced. ALWAYS born FOLDED — the old
1786
- // born-OPEN-on-error auto-trigger was conditional helpfulness that bred its own hazards
1787
- // (a 24k-char ramble mirrored open re-injects itself into the next packet: cost,
1788
- // contamination, pressure feedback).
1789
- // {§encrypted-reasoning-carrier} — core relays every provider-normalized
1790
- // item unchanged.
1791
- const reasoningItems = response.assistant.reasoningEncrypted?.length
1792
- ? response.assistant.reasoningEncrypted
1793
- : undefined;
1794
- if (packetAssistant.content.trim().length > 0 || reasoningItems !== undefined) {
1795
- await this.#dispatcher.writeModelEntry({
1796
- verbatim: packetAssistant.content,
1797
- workerId,
1798
- loopId,
1799
- turnId,
1800
- sequence: errSeq++,
1801
- folded: true,
1802
- modelCallId: emissionModelCallId,
1803
- ...(reasoningItems !== undefined ? { reasoningItems } : {}),
1804
- });
1805
- }
1806
- return {
1807
- turnId,
1808
- status: turnStatus,
1809
- outcomes,
1810
- fingerprint: StrikeRail.fingerprintTurn(packetAssistant.ops),
1811
- budgetHardStop: false,
1812
- steerStruck,
1813
- emissionAttempts,
1814
- emissionExhausted: false,
1815
- };
638
+ runTurn(args) {
639
+ return this.#turnRunner.runTurn(args);
1816
640
  }
1817
- // Split the wire-level ProviderResponse into the two destinations:
1818
- // packet.assistant gets the model's emission (content, ops, reasoning);
1819
- // Turn columns get accepted-call metadata (finishReason, model). Physical
1820
- // request usage and cost have their own cardinal persistence path.
1821
- // {§provider-surface} Text-fragment scraping policy lives
1822
- // here — engine owns the parse and the scraping rule, providers stay
1823
- // grammar-unaware.
1824
- //
1825
- // Test-fixture escape hatch: the Mock provider may pre-supply `ops` on
1826
- // its assistant payload to skip the parse roundtrip. The wire Provider
1827
- // contract has no `ops` field; only Mock exposes one. Real providers
1828
- // always take the parse path because their `assistant.ops` is undefined.
1829
- #splitResponse(response) {
1830
- const { assistant } = response;
1831
- const preParsedOps = assistant.ops;
1832
- const ops = [];
1833
- // PLAN is an ordinary op — emitted by the model, dispatched, and passed to the
1834
- // client as a log entry. No special hoisting into the reasoning field. Only
1835
- // structured operations are executable; interstitial text is not an operation.
1836
- // Full PlurnkParseError context is preserved on rejected attempt evidence;
1837
- // warnings remain admissible Notices. {§parse-diagnostics}
1838
- const parseErrors = [];
1839
- let hasUnparsedTail = false;
1840
- const parseNotices = [];
1841
- if (preParsedOps !== undefined) {
1842
- ops.push(...preParsedOps);
1843
- }
1844
- else {
1845
- // {§observability-boundary} — the parse is observed without its input;
1846
- // only the resulting statement count is attributable.
1847
- const parsed = observedSync("contracts.parse", {}, (span) => {
1848
- const result = PlurnkParser.parse(assistant.content);
1849
- span.setAttribute("statements", result.items.filter((item) => item.kind === "statement").length);
1850
- return result;
1851
- });
1852
- for (const item of parsed.items) {
1853
- if (item.kind === "statement") {
1854
- ops.push(item.statement);
1855
- }
1856
- else if (item.kind === "error") {
1857
- const err = item.error;
1858
- if (err instanceof PlurnkParseError) {
1859
- if (err.severity === "warning") {
1860
- parseNotices.push({
1861
- source: "grammar",
1862
- kind: "parse_advisory",
1863
- level: "warn",
1864
- message: err.message,
1865
- position: {
1866
- type: "content-offset",
1867
- line: err.line,
1868
- column: err.column,
1869
- },
1870
- parserSource: err.source,
1871
- });
1872
- }
1873
- else {
1874
- parseErrors.push({ message: err.message, line: err.line, column: err.column, source: err.source });
1875
- }
1876
- }
1877
- else {
1878
- const msg = err?.message ?? "parse error";
1879
- parseErrors.push({ message: msg, line: 0, column: 0, source: "parser" });
1880
- }
1881
- }
1882
- }
1883
- // Boundary loss is the parser's one public fact from `unparsedTail.from` onward;
1884
- // preserve it with the rejected forensic attempt. {§unparsed-tail-boundary}
1885
- const tail = parsed.unparsedTail;
1886
- if (tail !== undefined) {
1887
- hasUnparsedTail = true;
1888
- parseErrors.push({ message: tail.reason, line: tail.from.line, column: tail.from.column, source: "grammar" });
1889
- }
1890
- }
1891
- const plan = ops[0]?.op === "PLAN" ? ops[0] : undefined;
1892
- const finalOp = ops.at(-1);
1893
- const terminalSend = finalOp?.op === "SEND"
1894
- && typeof finalOp.signal === "number"
1895
- && TERMINAL_SEND_SIGNALS.has(finalOp.signal)
1896
- ? finalOp
1897
- : undefined;
1898
- const recoverableParseErrors = plan !== undefined && terminalSend !== undefined && !hasUnparsedTail
1899
- ? parseErrors.filter((error) => comparePosition(error, plan.position) > 0
1900
- && comparePosition(error, terminalSend.position) < 0).toSorted(comparePosition)
1901
- : [];
1902
- const emissionValid = preParsedOps !== undefined
1903
- || (plan !== undefined
1904
- && terminalSend !== undefined
1905
- && !hasUnparsedTail
1906
- && recoverableParseErrors.length === parseErrors.length);
1907
- const reasoning = assistant.reasoning ?? null;
1908
- return {
1909
- packetAssistant: { content: assistant.content, ops, reasoning },
1910
- callMetadata: { finishReason: assistant.finishReason, model: assistant.model },
1911
- parseErrors,
1912
- recoverableParseErrors: emissionValid ? recoverableParseErrors : [],
1913
- parseNotices,
1914
- // The ANTLR model-turn parser is authoritative. A trustworthy
1915
- // PLAN...SEND frame admits bounded interior statement failures so
1916
- // they become durable operation results. Missing boundaries,
1917
- // errors outside the frame, and an unparsed tail reject wholesale.
1918
- // Pre-parsed ops are Mock's trusted test seam.
1919
- emissionValid,
1920
- };
1921
- }
1922
- // #note12 — the plugin-provided reference docs (schemes' + execs' `documentation`),
1923
- // materialized at worker://plurnk/docs/<name>.md by LoopDocs (like operator docs).
1924
641
  docEntries(workspaceId) {
1925
642
  return this.#packets.docEntries(workspaceId);
1926
643
  }
1927
644
  // {§env-delta-log-pull} — materialize one closed interval of the ambient
1928
645
  // occurrence journal into this worker's self-contained log. #67 owns only
1929
646
  // the remaining model-facing actor-name projection.
1930
- async #materializeEnvironmentDeltas(args) {
1931
- const { workspaceId, workerId, loopId, turnId, fromSequence } = args;
1932
- const rows = await this.#db.engine_pull_ambient_events.all({ workspace_id: workspaceId, worker_id: workerId });
1933
- const window = rows[0];
1934
- if (window === undefined)
1935
- throw new Error(`ambient pull: worker ${workerId} has no observation window`);
1936
- let written = 0;
1937
- for (const r of rows) {
1938
- if (r.event_id === null || r.producer_worker_id === null || r.producer_worker_name === null || r.kind === null
1939
- || r.op === null || r.status_rx === null)
1940
- continue;
1941
- const termination = r.kind === "loop_termination";
1942
- if (r.rx === null)
1943
- throw new Error(`ambient event ${r.event_id} has no materializable result`);
1944
- const terminal = termination
1945
- ? TerminalResult.parse(r.rx, `ambient loop-termination event ${r.event_id}`)
1946
- : null;
1947
- if (terminal !== null && terminal.status !== r.status_rx) {
1948
- throw new Error(`ambient loop-termination event ${r.event_id} status ${r.status_rx} does not match its terminal result status ${terminal.status}`);
1949
- }
1950
- let attrs = r.attrs ?? "{}";
1951
- const parsedTags = JSON.parse(r.tags ?? "[]");
1952
- if (!Array.isArray(parsedTags) || !parsedTags.every((tag) => typeof tag === "string" && tag.length > 0)) {
1953
- throw new TypeError(`ambient event ${r.event_id} tags must be an array of nonempty strings`);
1954
- }
1955
- const tags = [...new Set(parsedTags)].toSorted();
1956
- if (tags.length !== parsedTags.length || tags.some((tag, index) => tag !== parsedTags[index])) {
1957
- throw new TypeError(`ambient event ${r.event_id} tags must be unique and sorted`);
1958
- }
1959
- if (terminal !== null) {
1960
- const inherited = JSON.parse(attrs);
1961
- if (inherited === null || typeof inherited !== "object" || Array.isArray(inherited)) {
1962
- throw new TypeError(`ambient loop-termination event ${r.event_id} attrs must be an object`);
1963
- }
1964
- const receipt = await BranchReceipt.render(this.#db, r.producer_worker_id);
1965
- attrs = JSON.stringify({
1966
- ...inherited,
1967
- kind: "loop_termination",
1968
- ...(r.terminated_by === null ? {} : { terminatedBy: r.terminated_by }),
1969
- ...(receipt === null ? {} : { receipt }),
1970
- });
1971
- }
1972
- const inserted = await this.#db.engine_insert_ambient_delta.get({
1973
- worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
1974
- event_id: r.event_id,
1975
- source: r.source ?? WorkerControlAddress.render(r.producer_worker_name),
1976
- op: r.op,
1977
- scheme: r.scheme,
1978
- hostname: r.hostname,
1979
- pathname: r.pathname,
1980
- rx: r.rx,
1981
- mimetype_rx: "application/json",
1982
- status: r.status_rx,
1983
- expanded: terminal !== null && terminal.status >= 200 && terminal.status < 300 ? 1 : 0,
1984
- attrs,
1985
- });
1986
- const materialized = inserted ?? await this.#db.engine_ambient_delta_id.get({
1987
- worker_id: workerId,
1988
- event_id: r.event_id,
1989
- });
1990
- if (materialized === undefined)
1991
- throw new Error(`ambient event ${r.event_id} has no observer log row after materialization`);
1992
- for (const tag of tags) {
1993
- await this.#db.log_write_tag.run({ log_entry_id: materialized.id, tag });
1994
- }
1995
- if (inserted !== undefined)
1996
- written++;
1997
- }
1998
- await this.#db.engine_advance_ambient_cursor.get({
1999
- workspace_id: workspaceId,
2000
- worker_id: workerId,
2001
- cursor: window.cursor,
2002
- boundary: window.boundary,
2003
- });
2004
- return written;
2005
- }
2006
- // {§exec-poll} — EXEC `<0>` is turn-scoped: abort the worker's open turn-scoped streams via their
2007
- // owning scheme (the same registry-routed abort the total reap uses). Called at each pre-turn
2008
- // before the turn's own spawns, so every open turn-scoped sub here is from a prior turn — it
2009
- // never survives into the subsequent turn. Fire-and-forget: the spawn finalizes async and its
2010
- // terminal output surfaces born-OPEN through the stream-delta path ({§exec-stream}).
2011
- async #reapTurnScopedStreams(workerId) {
2012
- const open = await this.#db.find_open_turn_scoped_subscriptions_for_worker.all({ worker_id: workerId });
2013
- await Promise.all(open.map(({ id }) => this.#liveSubscriptions.cancel(id)));
2014
- }
2015
647
  cancelSubscription(subscriptionId) {
2016
648
  return this.#liveSubscriptions.cancel(subscriptionId);
2017
649
  }
@@ -2020,142 +652,6 @@ class Engine {
2020
652
  // 200 while the channel streams and preserves the exact terminal result when closed. Ongoing
2021
653
  // observations fold; the terminal observation auto-OPENs. The cursor is the streamEnd recorded
2022
654
  // on the channel's prior observation. {§exec-stream}
2023
- async #materializeStreamDeltas(args) {
2024
- const { workerId, loopId, turnId, fromSequence } = args;
2025
- const channels = await this.#db.engine_worker_stream_channels.all({ worker_id: workerId });
2026
- let written = 0;
2027
- for (const ch of channels) {
2028
- // Default channels are an implementation detail. Preserve the
2029
- // channel internally on the entry/subscription, but present the
2030
- // ordinary address to the model; only an explicitly non-default
2031
- // channel earns a fragment in the log.
2032
- const visibleFragment = ch.published_channel !== null
2033
- && ch.channel === this.#schemes.defaultChannelFor(ch.runtime)
2034
- ? null
2035
- : ch.channel;
2036
- const prior = await this.#db.engine_stream_cursor.get({
2037
- worker_id: workerId, scheme: ch.runtime, pathname: ch.coord, fragment: visibleFragment,
2038
- });
2039
- const priorAttrs = prior !== undefined ? JSON.parse(prior.attrs) : {};
2040
- const cursor = priorAttrs.streamEnd ?? 0;
2041
- const closed = ch.state === "closed" || ch.state === "errored";
2042
- const terminal = closed
2043
- ? Results.assert(JSON.parse(ch.close_result ?? "null"))
2044
- : null;
2045
- const terminalResult = async (fields, sequence) => {
2046
- if (terminal === null)
2047
- throw new Error(`closed subscription ${ch.subscription_id} has no terminal result`);
2048
- const result = Results.assert({
2049
- ...terminal,
2050
- ...(terminal.problem === undefined ? {} : { problem: { ...terminal.problem } }),
2051
- ...fields,
2052
- });
2053
- if (result.problem !== undefined) {
2054
- const seqs = await this.#db.engine_loop_turn_seqs.get({
2055
- loop_id: loopId,
2056
- turn_id: turnId,
2057
- });
2058
- if (seqs === undefined)
2059
- throw new Error(`stream delta has no log coordinate for loop=${loopId} turn=${turnId}`);
2060
- Results.attachInstance(result, `log:///${seqs.loop_seq}/${seqs.turn_seq}/${sequence}/READ`);
2061
- }
2062
- return result;
2063
- };
2064
- const publishEnd = streamPublicationEnd(ch.content, ch.mimetype, cursor, closed);
2065
- if (publishEnd <= cursor) {
2066
- // The cursor-terminal race: a channel written in one final
2067
- // burst gets fully shown FOLDED while still active; the close then has zero new
2068
- // content and the auto-OPEN terminal observation never fired — the model was never shown
2069
- // the conclusion of a stream whose result it already holds folded. The same
2070
- // observation is required when the channel produced no publishable content:
2071
- // completion is information independently of payload. Text streams retain their
2072
- // terse marker; structured channels emit a bodyless typed conclusion.
2073
- if (closed && priorAttrs.terminal !== true) {
2074
- const streamTarget = renderTarget({
2075
- scheme: ch.runtime,
2076
- pathname: ch.coord,
2077
- fragment: visibleFragment,
2078
- });
2079
- if (streamTarget === null)
2080
- throw new Error(`stream ${ch.subscription_id} has no renderable address`);
2081
- const pointer = cursor > 0
2082
- ? `full output already delivered above; READ ${streamTarget} to revisit`
2083
- : "stream produced no output";
2084
- const sequence = fromSequence + written;
2085
- const content = baseMimetype(ch.mimetype).startsWith("text/")
2086
- ? `[ stream closed (${ch.close_status ?? 200}) - ${pointer} ]`
2087
- : "";
2088
- await this.#db.engine_insert_stream_delta.run({
2089
- worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence,
2090
- scheme: ch.runtime, pathname: ch.coord, fragment: visibleFragment,
2091
- rx: JSON.stringify(await terminalResult({
2092
- content,
2093
- mimetype: ch.mimetype,
2094
- }, sequence)),
2095
- status: terminal?.status ?? 200,
2096
- attrs: JSON.stringify({ streamEnd: ch.content.length, terminal: true }),
2097
- expanded: 1,
2098
- });
2099
- written++;
2100
- }
2101
- continue;
2102
- }
2103
- // startLine continues the line count across turns: a multi-turn stream's deltas number
2104
- // into one sequence (lines N..M, then M+1..), not N independent "1:" restarts. {§exec-stream}
2105
- const startLine = (ch.content.slice(0, cursor).match(/\n/g)?.length ?? 0) + 1;
2106
- const sequence = fromSequence + written;
2107
- const content = ch.content.slice(cursor, publishEnd);
2108
- const terminalDelivery = closed && publishEnd === ch.content.length;
2109
- const fields = { content, mimetype: ch.mimetype, startLine };
2110
- const result = terminalDelivery
2111
- ? await terminalResult(fields, sequence)
2112
- : { status: 200, ...fields };
2113
- await this.#db.engine_insert_stream_delta.run({
2114
- worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence,
2115
- scheme: ch.runtime, pathname: ch.coord, fragment: visibleFragment,
2116
- rx: JSON.stringify(result),
2117
- status: result.status,
2118
- attrs: JSON.stringify({ streamEnd: publishEnd, terminal: terminalDelivery }),
2119
- expanded: terminalDelivery ? 1 : 0, // {§exec-stream} — terminal observation auto-OPENs; ongoing folds
2120
- });
2121
- written++;
2122
- }
2123
- return written;
2124
- }
2125
- // {§env-delta-filesystem-narration} {§membership-emi-divergence-signal}
2126
- // — journal project-file divergence once through the reserved actor.
2127
- async #logFsFictions(workspaceId, divergences, gitStatus) {
2128
- if (divergences.length === 0)
2129
- return;
2130
- const gitByPath = new Map(gitStatus?.files.map(({ path, status }) => [path, status]) ?? []);
2131
- const worker = await this.#db.envelope_get_worker_by_name.get({ workspace_id: workspaceId, name: "plurnk" })
2132
- ?? await this.#db.envelope_insert_worker.get({ workspace_id: workspaceId, name: "plurnk", origin: "plurnk" });
2133
- if (worker === undefined)
2134
- throw new Error("logFsFictions: plurnk worker resolution returned no row");
2135
- const loop = await this.#db.envelope_insert_client_loop.get({ worker_id: worker.id });
2136
- if (loop === undefined)
2137
- throw new Error("logFsFictions: loop insert returned no row");
2138
- const turn = await JournalTurn.insert(this.#db, loop.id);
2139
- let sequence = 1;
2140
- for (const d of divergences) {
2141
- const span = editedSpan(d.before, d.after);
2142
- await this.#db.engine_insert_log_entry.get({
2143
- worker_id: worker.id, loop_id: loop.id, turn_id: turn.id, sequence: sequence++,
2144
- origin: "plurnk", source: "file", model_call_id: null,
2145
- op: "EDIT", suffix: "", signal: null,
2146
- // Match Dispatcher.#extractTarget: a bare file address has NULL scheme
2147
- // only in log target metadata; its entry identity remains `file`.
2148
- scheme: null, username: null, password: null, hostname: null, port: null,
2149
- pathname: d.pathname, query: null, fragment: null, lineMarker: null,
2150
- tx: "", mimetype_tx: "text/plain",
2151
- rx: JSON.stringify({ status: 200, entryId: d.entryId, channel: d.channel, span }), mimetype_rx: "application/json",
2152
- status_rx: 200, tokens: 0, state: "resolved", outcome: null,
2153
- attrs: gitByPath.has(d.pathname)
2154
- ? JSON.stringify({ git: gitByPath.get(d.pathname) })
2155
- : "{}",
2156
- });
2157
- }
2158
- }
2159
655
  async dispatch(context) {
2160
656
  return observed(// {§observability-boundary}
2161
657
  "op.dispatch", { op: context.statement.op }, async (span) => {
@@ -2175,44 +671,6 @@ class Engine {
2175
671
  async resolveEntryAddress(context) {
2176
672
  return this.#dispatcher.resolveEntryAddress(context);
2177
673
  }
2178
- async #writePromptLog({ workerId, loopId, turnId, sequence, target, content, }) {
2179
- const row = await this.#db.engine_insert_log_entry.get({
2180
- worker_id: workerId,
2181
- loop_id: loopId,
2182
- turn_id: turnId,
2183
- sequence,
2184
- origin: "plurnk",
2185
- source: null,
2186
- model_call_id: null,
2187
- op: "prompt",
2188
- suffix: "",
2189
- signal: null,
2190
- scheme: target.scheme,
2191
- username: target.username,
2192
- password: target.password,
2193
- hostname: target.hostname,
2194
- port: target.port,
2195
- pathname: target.pathname,
2196
- query: target.query,
2197
- fragment: target.fragment,
2198
- lineMarker: null,
2199
- tx: "",
2200
- mimetype_tx: "text/plain",
2201
- rx: JSON.stringify({ content, mimetype: "text/markdown" }),
2202
- mimetype_rx: "application/json",
2203
- status_rx: 200,
2204
- tokens: this.#tokenize(content),
2205
- state: "resolved",
2206
- outcome: null,
2207
- attrs: "{}",
2208
- });
2209
- if (row === undefined)
2210
- throw new Error("Engine.#writePromptLog: INSERT ... RETURNING produced no row");
2211
- return row.id;
2212
- }
2213
- // External API to feed a resolution into a pending proposal — the client-interface
2214
- // seam, core-owned disposition, or the timeout watcher.
2215
- // {§worker-lifecycle-total-reap}: release every stopped-world waiter before joining drains.
2216
674
  cancelAllProposals(outcome) {
2217
675
  this.#proposals.cancelAll(outcome);
2218
676
  }
@@ -2231,6 +689,15 @@ class Engine {
2231
689
  async pendingProposals(workspaceId) {
2232
690
  return this.#proposals.list(workspaceId);
2233
691
  }
692
+ onClientInteractionPending(listener) {
693
+ this.#interactions.onPending(listener);
694
+ }
695
+ async pendingClientInteractions(workspaceId) {
696
+ return this.#interactions.list(workspaceId);
697
+ }
698
+ async resolveClientInteraction(interactionId, resolution) {
699
+ await this.#interactions.resolve(interactionId, resolution);
700
+ }
2234
701
  // Used by wake-on-completion (daemon side): "is there any loop in this
2235
702
  // worker still accepting turns?" If yes, skip the wake — the active loop
2236
703
  // will pick up the channel transition at its next turn boundary. If no,
@@ -2250,9 +717,9 @@ class Engine {
2250
717
  signal: undefined,
2251
718
  streamEventNotify: this.#streamEventNotify,
2252
719
  wakeWorkerNotify: this.#wakeWorkerNotify,
2253
- tokenize: this.#tokenize,
720
+ weigh: this.#weighContent,
2254
721
  mimetypes: this.#mimetypes,
2255
- defaultChannelFor: (s) => this.#schemes.defaultChannelFor(s),
722
+ defaultChannelFor: (s) => this.#schemes.defaultChannelFor(s, workspaceId),
2256
723
  pushNotice: (notice) => this.#notices.notify(workspaceId, 0, notice),
2257
724
  };
2258
725
  await this.#queueWorkspaceWarm(ctx); // materialize first; overlapping requests coalesce and rescan
@@ -2305,7 +772,7 @@ class Engine {
2305
772
  signal: this.#loopAborts.get(loopId)?.signal,
2306
773
  streamEventNotify: this.#streamEventNotify,
2307
774
  wakeWorkerNotify: this.#wakeWorkerNotify,
2308
- tokenize: this.#tokenize,
775
+ weigh: this.#weighContent,
2309
776
  pushNotice: (notice) => this.#notices.push(workspaceRow.workspace_id, loopId, notice),
2310
777
  };
2311
778
  const entry = {
@@ -2316,6 +783,4 @@ class Engine {
2316
783
  return { loopId, turnSeq };
2317
784
  }
2318
785
  }
2319
- _a = Engine;
2320
- export default Engine;
2321
786
  //# sourceMappingURL=Engine.js.map