@plurnk/plurnk-service 1.0.4 → 1.0.6

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 (215) hide show
  1. package/.env.defaults +55 -31
  2. package/INSTALL.md +3 -3
  3. package/README.md +4 -4
  4. package/SPEC.md +205 -193
  5. package/dist/Paths.js +1 -1
  6. package/dist/Paths.js.map +1 -1
  7. package/dist/core/ChannelWrite.d.ts +18 -18
  8. package/dist/core/ChannelWrite.d.ts.map +1 -1
  9. package/dist/core/ChannelWrite.js +17 -17
  10. package/dist/core/ChannelWrite.js.map +1 -1
  11. package/dist/core/ChannelWrite.sql +11 -11
  12. package/dist/core/Dispatcher.d.ts +11 -16
  13. package/dist/core/Dispatcher.d.ts.map +1 -1
  14. package/dist/core/Dispatcher.js +122 -125
  15. package/dist/core/Dispatcher.js.map +1 -1
  16. package/dist/core/Engine.d.ts +20 -23
  17. package/dist/core/Engine.d.ts.map +1 -1
  18. package/dist/core/Engine.js +213 -210
  19. package/dist/core/Engine.js.map +1 -1
  20. package/dist/core/Engine.sql +108 -130
  21. package/dist/core/PacketBuilder.d.ts +10 -11
  22. package/dist/core/PacketBuilder.d.ts.map +1 -1
  23. package/dist/core/PacketBuilder.js +204 -128
  24. package/dist/core/PacketBuilder.js.map +1 -1
  25. package/dist/core/ProposalLifecycle.d.ts +8 -8
  26. package/dist/core/ProposalLifecycle.d.ts.map +1 -1
  27. package/dist/core/ProposalLifecycle.js +10 -10
  28. package/dist/core/ProposalLifecycle.js.map +1 -1
  29. package/dist/core/ProviderInstantiate.d.ts +1 -0
  30. package/dist/core/ProviderInstantiate.d.ts.map +1 -1
  31. package/dist/core/ProviderInstantiate.js +9 -3
  32. package/dist/core/ProviderInstantiate.js.map +1 -1
  33. package/dist/core/SchemeRegistry.d.ts +4 -1
  34. package/dist/core/SchemeRegistry.d.ts.map +1 -1
  35. package/dist/core/SchemeRegistry.js +18 -9
  36. package/dist/core/SchemeRegistry.js.map +1 -1
  37. package/dist/core/TelemetryChannel.d.ts +5 -5
  38. package/dist/core/TelemetryChannel.d.ts.map +1 -1
  39. package/dist/core/TelemetryChannel.js +9 -9
  40. package/dist/core/TelemetryChannel.js.map +1 -1
  41. package/dist/core/caps/CapsResolve.js +2 -2
  42. package/dist/core/caps/CapsResolve.js.map +1 -1
  43. package/dist/core/caps/DbNotifyCaps.js +2 -2
  44. package/dist/core/caps/DbNotifyCaps.js.map +1 -1
  45. package/dist/core/caps/DbSubscriptionCaps.js +7 -7
  46. package/dist/core/caps/DbSubscriptionCaps.js.map +1 -1
  47. package/dist/core/caps/SchemeCtxImpl.d.ts +2 -2
  48. package/dist/core/caps/SchemeCtxImpl.d.ts.map +1 -1
  49. package/dist/core/caps/SchemeCtxImpl.js +4 -4
  50. package/dist/core/caps/SchemeCtxImpl.js.map +1 -1
  51. package/dist/core/content-hash.d.ts +2 -0
  52. package/dist/core/content-hash.d.ts.map +1 -0
  53. package/dist/core/content-hash.js +6 -0
  54. package/dist/core/content-hash.js.map +1 -0
  55. package/dist/core/fork.d.ts +1 -1
  56. package/dist/core/fork.d.ts.map +1 -1
  57. package/dist/core/fork.js +31 -26
  58. package/dist/core/fork.js.map +1 -1
  59. package/dist/core/fork.sql +41 -32
  60. package/dist/core/git-env.d.ts.map +1 -1
  61. package/dist/core/git-env.js +23 -7
  62. package/dist/core/git-env.js.map +1 -1
  63. package/dist/core/git-iso.d.ts +9 -0
  64. package/dist/core/git-iso.d.ts.map +1 -0
  65. package/dist/core/git-iso.js +227 -0
  66. package/dist/core/git-iso.js.map +1 -0
  67. package/dist/core/git-membership.d.ts +2 -2
  68. package/dist/core/git-membership.d.ts.map +1 -1
  69. package/dist/core/git-membership.js +70 -50
  70. package/dist/core/git-membership.js.map +1 -1
  71. package/dist/core/git-state.d.ts +1 -1
  72. package/dist/core/git-state.d.ts.map +1 -1
  73. package/dist/core/git-state.js +21 -11
  74. package/dist/core/git-state.js.map +1 -1
  75. package/dist/core/packet-wire.d.ts.map +1 -1
  76. package/dist/core/packet-wire.js +81 -22
  77. package/dist/core/packet-wire.js.map +1 -1
  78. package/dist/core/plurnk-uri.d.ts +2 -2
  79. package/dist/core/plurnk-uri.d.ts.map +1 -1
  80. package/dist/core/plurnk-uri.js +19 -12
  81. package/dist/core/plurnk-uri.js.map +1 -1
  82. package/dist/core/run-ops.sql +26 -26
  83. package/dist/core/scheme-types.d.ts +6 -11
  84. package/dist/core/scheme-types.d.ts.map +1 -1
  85. package/dist/core/search-gate.js +0 -0
  86. package/dist/core/search-gate.js.map +1 -1
  87. package/dist/core/token-ruler.d.ts +2 -0
  88. package/dist/core/token-ruler.d.ts.map +1 -0
  89. package/dist/core/token-ruler.js +18 -0
  90. package/dist/core/token-ruler.js.map +1 -0
  91. package/dist/core/worker-cap.d.ts +8 -0
  92. package/dist/core/worker-cap.d.ts.map +1 -0
  93. package/dist/core/worker-cap.js +20 -0
  94. package/dist/core/worker-cap.js.map +1 -0
  95. package/dist/core/{session-settings.d.ts → workspace-settings.d.ts} +5 -5
  96. package/dist/core/workspace-settings.d.ts.map +1 -0
  97. package/dist/core/{session-settings.js → workspace-settings.js} +13 -13
  98. package/dist/core/workspace-settings.js.map +1 -0
  99. package/dist/digest/Digest.d.ts +3 -3
  100. package/dist/digest/Digest.d.ts.map +1 -1
  101. package/dist/digest/Digest.js +79 -73
  102. package/dist/digest/Digest.js.map +1 -1
  103. package/dist/digest/digest.sql +18 -18
  104. package/dist/schemes/Exec.d.ts +9 -2
  105. package/dist/schemes/Exec.d.ts.map +1 -1
  106. package/dist/schemes/Exec.js +121 -68
  107. package/dist/schemes/Exec.js.map +1 -1
  108. package/dist/schemes/ExecOutputScheme.js +3 -3
  109. package/dist/schemes/ExecOutputScheme.js.map +1 -1
  110. package/dist/schemes/File.js +23 -23
  111. package/dist/schemes/File.js.map +1 -1
  112. package/dist/schemes/Known.js +5 -5
  113. package/dist/schemes/Known.js.map +1 -1
  114. package/dist/schemes/Log.d.ts.map +1 -1
  115. package/dist/schemes/Log.js +63 -14
  116. package/dist/schemes/Log.js.map +1 -1
  117. package/dist/schemes/Log.sql +49 -8
  118. package/dist/schemes/Plurnk.js +5 -5
  119. package/dist/schemes/Plurnk.js.map +1 -1
  120. package/dist/schemes/Skill.js +5 -5
  121. package/dist/schemes/Skill.js.map +1 -1
  122. package/dist/schemes/Unknown.js +5 -5
  123. package/dist/schemes/Unknown.js.map +1 -1
  124. package/dist/schemes/{Run.d.ts → Worker.d.ts} +2 -2
  125. package/dist/schemes/Worker.d.ts.map +1 -0
  126. package/dist/schemes/{Run.js → Worker.js} +67 -67
  127. package/dist/schemes/Worker.js.map +1 -0
  128. package/dist/schemes/_entry-crud.js +11 -11
  129. package/dist/schemes/_entry-crud.js.map +1 -1
  130. package/dist/schemes/_entry-crud.sql +29 -29
  131. package/dist/schemes/_entry-find.d.ts +2 -1
  132. package/dist/schemes/_entry-find.d.ts.map +1 -1
  133. package/dist/schemes/_entry-find.js +31 -19
  134. package/dist/schemes/_entry-find.js.map +1 -1
  135. package/dist/schemes/_entry-find.sql +9 -9
  136. package/dist/schemes/_entry-graph.d.ts +2 -2
  137. package/dist/schemes/_entry-graph.d.ts.map +1 -1
  138. package/dist/schemes/_entry-graph.js +18 -18
  139. package/dist/schemes/_entry-graph.js.map +1 -1
  140. package/dist/schemes/_entry-graph.sql +8 -8
  141. package/dist/schemes/_entry-manifest.d.ts +1 -1
  142. package/dist/schemes/_entry-manifest.d.ts.map +1 -1
  143. package/dist/schemes/_entry-manifest.js +74 -50
  144. package/dist/schemes/_entry-manifest.js.map +1 -1
  145. package/dist/schemes/_entry-ops.d.ts +4 -4
  146. package/dist/schemes/_entry-ops.d.ts.map +1 -1
  147. package/dist/schemes/_entry-ops.js +25 -25
  148. package/dist/schemes/_entry-ops.js.map +1 -1
  149. package/dist/schemes/_entry-ops.sql +9 -9
  150. package/dist/schemes/_entry-semantic.d.ts +2 -1
  151. package/dist/schemes/_entry-semantic.d.ts.map +1 -1
  152. package/dist/schemes/_entry-semantic.js +23 -4
  153. package/dist/schemes/_entry-semantic.js.map +1 -1
  154. package/dist/schemes/_entry-semantic.sql +18 -4
  155. package/dist/schemes/_entry-send.d.ts +1 -1
  156. package/dist/schemes/_entry-send.d.ts.map +1 -1
  157. package/dist/schemes/_entry-send.js +6 -6
  158. package/dist/schemes/_entry-send.js.map +1 -1
  159. package/dist/schemes/exec-abort.js +1 -1
  160. package/dist/schemes/exec-abort.js.map +1 -1
  161. package/dist/server/Daemon.d.ts +50 -48
  162. package/dist/server/Daemon.d.ts.map +1 -1
  163. package/dist/server/Daemon.js +307 -297
  164. package/dist/server/Daemon.js.map +1 -1
  165. package/dist/server/client-input.js +20 -20
  166. package/dist/server/client-input.js.map +1 -1
  167. package/dist/server/dispatch-as-plurnk.d.ts +1 -1
  168. package/dist/server/dispatch-as-plurnk.d.ts.map +1 -1
  169. package/dist/server/dispatch-as-plurnk.js +7 -7
  170. package/dist/server/dispatch-as-plurnk.js.map +1 -1
  171. package/dist/server/drain.sql +27 -27
  172. package/dist/server/envelope.d.ts +20 -20
  173. package/dist/server/envelope.d.ts.map +1 -1
  174. package/dist/server/envelope.js +86 -86
  175. package/dist/server/envelope.js.map +1 -1
  176. package/dist/server/envelope.sql +39 -39
  177. package/dist/server/logEntry.d.ts +1 -1
  178. package/dist/server/logEntry.d.ts.map +1 -1
  179. package/dist/server/logEntry.js +1 -1
  180. package/dist/server/logEntry.js.map +1 -1
  181. package/dist/server/loop-model.d.ts +3 -0
  182. package/dist/server/loop-model.d.ts.map +1 -0
  183. package/dist/server/loop-model.js +26 -0
  184. package/dist/server/loop-model.js.map +1 -0
  185. package/dist/server/loopDocs.d.ts +1 -1
  186. package/dist/server/loopDocs.d.ts.map +1 -1
  187. package/dist/server/loopDocs.js +6 -6
  188. package/dist/server/loopDocs.js.map +1 -1
  189. package/dist/server/seam-entry-read.sql +2 -2
  190. package/dist/server/seam-log-read.sql +3 -3
  191. package/dist/server/seam-loop.sql +3 -3
  192. package/dist/server/seam-proposal-list.sql +4 -4
  193. package/dist/server/yolo.js +2 -2
  194. package/dist/server/yolo.js.map +1 -1
  195. package/dist/service.d.ts.map +1 -1
  196. package/dist/service.js +21 -6
  197. package/dist/service.js.map +1 -1
  198. package/migrations/0000-00-00.01_schema.sql +84 -85
  199. package/package.json +17 -14
  200. package/dist/core/TokenGauge.d.ts +0 -16
  201. package/dist/core/TokenGauge.d.ts.map +0 -1
  202. package/dist/core/TokenGauge.js +0 -58
  203. package/dist/core/TokenGauge.js.map +0 -1
  204. package/dist/core/run-cap.d.ts +0 -8
  205. package/dist/core/run-cap.d.ts.map +0 -1
  206. package/dist/core/run-cap.js +0 -20
  207. package/dist/core/run-cap.js.map +0 -1
  208. package/dist/core/search-prefetch.d.ts +0 -17
  209. package/dist/core/search-prefetch.d.ts.map +0 -1
  210. package/dist/core/search-prefetch.js +0 -164
  211. package/dist/core/search-prefetch.js.map +0 -1
  212. package/dist/core/session-settings.d.ts.map +0 -1
  213. package/dist/core/session-settings.js.map +0 -1
  214. package/dist/schemes/Run.d.ts.map +0 -1
  215. package/dist/schemes/Run.js.map +0 -1
@@ -2,14 +2,13 @@ import { PlurnkParser, PlurnkParseError } from "@plurnk/plurnk-grammar";
2
2
  import { Mimetypes, emptyRegistry } from "@plurnk/plurnk-mimetypes";
3
3
  import EntryCrud from "../schemes/_entry-crud.js";
4
4
  import EntryManifest from "../schemes/_entry-manifest.js";
5
- import { markTerminal } from "../schemes/Run.js";
6
- import TokenGauge from "./TokenGauge.js";
5
+ import { markTerminal } from "../schemes/Worker.js";
7
6
  import GitMembership from "./git-membership.js";
8
7
  import GitState from "./git-state.js";
9
- import SessionSettings from "./session-settings.js";
8
+ import WorkspaceSettings from "./workspace-settings.js";
10
9
  import { editedSpan } from "../content/index.js";
11
10
  import { promptPathname } from "./plurnk-uri.js";
12
- import SearchPrefetch from "./search-prefetch.js";
11
+ import { rulerCount } from "./token-ruler.js";
13
12
  import SearchGate from "./search-gate.js";
14
13
  import { readFile } from "node:fs/promises";
15
14
  import { fileURLToPath } from "node:url";
@@ -27,12 +26,11 @@ import PacketBuilder from "./PacketBuilder.js";
27
26
  import ProposalLifecycle from "./ProposalLifecycle.js";
28
27
  import Dispatcher from "./Dispatcher.js";
29
28
  const DEFAULT_MAX_STRIKES = 3;
30
- const DEFAULT_MAX_COMMANDS = 99;
31
- // The foisted prompt EDIT/READ target — run-qualified storage (§prompt-auto-read, #382 fault-1)
29
+ // The foisted prompt EDIT/READ target — worker-qualified storage (§prompt-auto-read, #382 fault-1)
32
30
  // rendered in the plurnk:// authority form (hostname carries the namespace, plurnk-uri folds it
33
31
  // back into the storage key on dispatch).
34
- const promptTarget = (runId, loopSeq, turnSeq) => {
35
- const storage = promptPathname(runId, loopSeq, turnSeq);
32
+ const promptTarget = (workerId, loopSeq, turnSeq) => {
33
+ const storage = promptPathname(workerId, loopSeq, turnSeq);
36
34
  return {
37
35
  kind: "url", raw: `plurnk://${storage.slice(1)}`,
38
36
  scheme: "plurnk", username: null, password: null,
@@ -49,13 +47,17 @@ const readMaxStrikes = () => {
49
47
  return DEFAULT_MAX_STRIKES;
50
48
  return n;
51
49
  };
50
+ // Per-emission op ceiling — OFF by default. `-1` (or unset/non-positive) = no cap: every
51
+ // generated op dispatches. Runaway degeneration is a sampler concern (repetition penalty),
52
+ // not grounds to drop already-generated work. A positive value is an operator ceiling a
53
+ // workspace's maxCommands may tighten (min wins), never widen (#232).
52
54
  const readMaxCommands = () => {
53
55
  const raw = process.env.PLURNK_SERVICE_MAX_COMMANDS;
54
56
  if (raw === undefined || raw.length === 0)
55
- return DEFAULT_MAX_COMMANDS;
57
+ return Number.POSITIVE_INFINITY;
56
58
  const n = Number.parseInt(raw, 10);
57
59
  if (!Number.isFinite(n) || n < 1)
58
- return DEFAULT_MAX_COMMANDS;
60
+ return Number.POSITIVE_INFINITY;
59
61
  return n;
60
62
  };
61
63
  // PLURNK_SERVICE_FILES_ITEMS — the turn-0 manifest preview. null = off (no foist);
@@ -115,7 +117,7 @@ export default class Engine {
115
117
  // marker's seconds; -1 = indefinite), consumed by the daemon's drain park-exit to schedule
116
118
  // the deadline wake. In-memory: a daemon restart drops pending deadlines (documented).
117
119
  parkDeadlines = new Map();
118
- // §join-blocking-collect (#354) — loops whose turn issued a READ(run://running-child): the
120
+ // §join-blocking-collect (#354) — loops whose turn issued a READ(worker://running-child): the
119
121
  // blocking join. The dispatcher arms this on the READ; the turn's bare SEND[102] parks on it
120
122
  // (indefinite) instead of continuing, and any SEND clears it. Twin of parkDeadlines.
121
123
  joinTargets = new Set();
@@ -128,7 +130,6 @@ export default class Engine {
128
130
  // fitting turn (the model curated; a LATER overflow earns a fresh recovery) and at loop cleanup.
129
131
  #hardOverflowRecovery = new Set();
130
132
  #packets;
131
- searchPrefetch;
132
133
  searchGate = new SearchGate();
133
134
  #proposals;
134
135
  #dispatcher;
@@ -138,20 +139,15 @@ export default class Engine {
138
139
  // Streaming schemes (exec) chain their per-spawn controllers off
139
140
  // ctx.signal so cancelled loops tear down their background spawns.
140
141
  #loopAborts = new Map();
141
- // §tokenomics-ceiling-calibrates-to-usage (#311) — the loop's observed real/measured token
142
- // ratio (provider usage.prompt ÷ our measured packet tokens), monotone max so the ceiling
143
- // only tightens. A chars/4 heuristic ruler undercounts escaped-JSON logs ~1.5×; calibrating
144
- // against the provider's own count makes a real context overflow unreachable past turn 1.
145
- #tokenRatios = new Map();
146
142
  // §send-premature-terminate — loops owed one idle-grace turn after a retrieval-only 409
147
143
  // (the steer's own advice is to wait; in-memory, fail-open on restart).
148
144
  #retrievalRefusalGrace = new Set();
149
145
  // §derivation-off-hot-path — the background derivation chain: the per-turn pump and the
150
- // session warm ride it instead of the turn (a 2-CPU container CPU-embedding a 335-entry
146
+ // workspace warm ride it instead of the turn (a 2-CPU container CPU-embedding a 335-entry
151
147
  // ingest starved every loop for ~28min, #316). Serialized (never two pumps interleaved),
152
148
  // drained at daemon stop (never racing db close), failures logged (never swallowed).
153
149
  // A turn never waits on an embedding; a ~query warms its own candidate slice inline
154
- // (§semantic-cold-query-full-fidelity), so cold sessions still get full-fidelity search.
150
+ // (§semantic-cold-query-full-fidelity), so cold workspaces still get full-fidelity search.
155
151
  #derivationChain = Promise.resolve();
156
152
  #queueDerivation(job) {
157
153
  const run = this.#derivationChain.then(job).catch((err) => {
@@ -165,14 +161,15 @@ export default class Engine {
165
161
  await this.#derivationChain;
166
162
  }
167
163
  #streamEventNotify;
168
- #wakeRunNotify;
164
+ #wakeWorkerNotify;
169
165
  // Cached plurnk GBNF — read once on the first constrained generate (#189).
170
166
  #gbnfCache = new Map(); // variant name -> GBNF text (per-alias selection, #353)
171
- constructor({ db, schemes, mimetypes, streamEventNotify, wakeRunNotify, injectRun, cancelRun, telemetryEventNotify, tokenize }) {
167
+ #railAnnounced = new Set(); // #488 — one rail-state line per alias, positive drill-visible signal
168
+ constructor({ db, schemes, mimetypes, streamEventNotify, wakeWorkerNotify, injectWorker, cancelWorker, telemetryEventNotify, tokenize }) {
172
169
  this.#db = db;
173
170
  this.#schemes = schemes;
174
171
  this.#streamEventNotify = streamEventNotify;
175
- this.#wakeRunNotify = wakeRunNotify;
172
+ this.#wakeWorkerNotify = wakeWorkerNotify;
176
173
  // Default to empty discovery — standalone Engine construction (in
177
174
  // tests) gets no handlers, and content flows through the framework's
178
175
  // raw-content fitContent fallback. Daemon-managed Engine receives a
@@ -183,18 +180,15 @@ export default class Engine {
183
180
  // Tripwire default matches the Mimetypes boot affordance (SPEC §mimetype-surface):
184
181
  // the divisor stands in only until the provider-backed tokenizer is
185
182
  // wired by the Daemon. Real counts come from provider.countTokens.
186
- this.#tokenize = tokenize ?? ((text) => Math.ceil(text.length / 4));
183
+ this.#tokenize = tokenize ?? rulerCount;
187
184
  const executors = () => this.#executors;
188
185
  const loopSignal = (loopId) => this.#loopAborts.get(loopId)?.signal;
189
186
  this.#telemetry = new TelemetryChannel({ db, notify: telemetryEventNotify });
190
187
  this.#strikes = new StrikeRail();
191
188
  this.#packets = new PacketBuilder({ db, schemes, telemetry: this.#telemetry, executors });
192
- // §search-prefetch — the web-member sync pass; the daemon's wake handler drives it,
193
- // the hold loop below honors it (holds() = a pass in flight for the run).
194
- this.searchPrefetch = new SearchPrefetch({ db: this.#db, schemes: this.#schemes, tokenize: this.#tokenize });
195
189
  this.#proposals = new ProposalLifecycle({
196
190
  db, schemes, telemetry: this.#telemetry,
197
- streamEventNotify, wakeRunNotify,
191
+ streamEventNotify, wakeWorkerNotify,
198
192
  tokenize: this.#tokenize, mimetypes: this.#mimetypes, executors, loopSignal,
199
193
  });
200
194
  this.#dispatcher = new Dispatcher({ searchGate: this.searchGate,
@@ -202,7 +196,7 @@ export default class Engine {
202
196
  tokenize: this.#tokenize,
203
197
  telemetry: this.#telemetry, proposals: this.#proposals,
204
198
  executors, loopSignal,
205
- streamEventNotify, wakeRunNotify, injectRun, cancelRun,
199
+ streamEventNotify, wakeWorkerNotify, injectWorker, cancelWorker,
206
200
  parkDeadlines: this.parkDeadlines,
207
201
  joinTargets: this.joinTargets,
208
202
  });
@@ -243,18 +237,36 @@ export default class Engine {
243
237
  // that constrain sampling (llama-server, response_format backends); a cloud model that IGNORES the grammar
244
238
  // gets a filter-mode divergence event every turn for nothing. So the bare default is OFF
245
239
  // and the GBNF-capable aliases opt IN via a PLURNK_PROVIDERS_GBNF_<alias> suffix.
246
- const alias = ProviderInstantiate.aliasOf(provider) ?? resolveActiveAlias(process.env)?.alias ?? "";
240
+ // #488 — the rail must be VERIFIABLE, never silently off. Two guards:
241
+ // (1) the alias fallback is only trusted when NO per-alias GBNF opt-ins exist: an
242
+ // unregistered provider in a process that configured suffixed rails could fall back
243
+ // to a DIFFERENT active alias, miss the suffix, and run unconstrained — the silent
244
+ // severance class run78 demonstrated (free decode, fabricated logs, CLEAN telemetry).
245
+ // (2) every resolution states itself ONCE on stderr — "grammar rail: <alias> → <variant>/OFF" —
246
+ // so any drill capture positively shows rail state; absence of the line is greppable.
247
+ const registered = ProviderInstantiate.aliasOf(provider);
248
+ const fallback = registered === undefined ? resolveActiveAlias(process.env)?.alias : undefined;
249
+ if (registered === undefined && fallback === undefined && Object.keys(process.env).some((k) => k.startsWith("PLURNK_PROVIDERS_GBNF_"))) {
250
+ throw new Error("grammar rail: provider has no registered alias and no active alias resolves, while per-alias PLURNK_PROVIDERS_GBNF_* rails are configured — refusing to run unconstrained (#488).");
251
+ }
252
+ const alias = registered ?? fallback ?? "";
247
253
  const variant = scopeEnvToAlias(process.env, alias, ["PLURNK_PROVIDERS_GBNF"]).PLURNK_PROVIDERS_GBNF;
248
- if (variant === undefined || variant === "" || variant === "0")
254
+ if (variant === undefined || variant === "" || variant === "0") {
255
+ if (!this.#railAnnounced.has(alias)) {
256
+ this.#railAnnounced.add(alias);
257
+ process.stderr.write(`plurnk-engine: grammar rail: ${alias || "(bare)"} → OFF (no PLURNK_PROVIDERS_GBNF for this alias)\n`);
258
+ }
249
259
  return undefined;
260
+ }
250
261
  const hit = this.#gbnfCache.get(variant);
251
262
  if (hit !== undefined)
252
263
  return hit;
253
264
  const path = variant.startsWith("/") || variant.startsWith(".")
254
265
  ? variant
255
266
  : fileURLToPath(import.meta.resolve(`@plurnk/plurnk-grammar/${variant}`));
256
- const text = await readFile(path, "utf8");
267
+ const text = await readFile(path, "utf8"); // unresolvable/unreadable throws — a configured rail never silently degrades
257
268
  this.#gbnfCache.set(variant, text);
269
+ process.stderr.write(`plurnk-engine: grammar rail: ${alias || "(bare)"} → ${variant} (${text.length} chars)\n`);
258
270
  return text;
259
271
  }
260
272
  // Per-loop usage totals (#197): SUM the loop's turns (usage is stored per
@@ -277,7 +289,7 @@ export default class Engine {
277
289
  // summed promptTokens above, which overcounts a context that grows across turns.
278
290
  contextTokens: row?.context ?? 0,
279
291
  // #274 — the last turn's model window (denominator); null when the provider reports none.
280
- contextSize: row?.context_size ?? null,
292
+ promptBudget: row?.context_size ?? null,
281
293
  // #252 — the latest turn's opaque provider blob, parsed for the wire. Empty {} when the
282
294
  // provider returned no meta. The service forwards it; it never reads a field within.
283
295
  meta: JSON.parse(row?.meta ?? "{}"),
@@ -303,7 +315,7 @@ export default class Engine {
303
315
  }
304
316
  return { line, column };
305
317
  }
306
- async runLoop({ provider, messages, requirements = "", sessionId, runId, 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, }) {
318
+ async runLoop({ 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, }) {
307
319
  const turnIds = [];
308
320
  const suddenDeathThreshold = maxTurns - maxStrikes;
309
321
  // Per-loop AbortController for scheme-side cancellation propagation.
@@ -345,7 +357,6 @@ export default class Engine {
345
357
  this.searchGate.cleanup(loopId);
346
358
  this.#hardOverflowRecovery.delete(loopId);
347
359
  this.#telemetry.delete(loopId);
348
- this.#tokenRatios.delete(loopId);
349
360
  };
350
361
  while (true) {
351
362
  // The wall fired between turns — rule 504 before anything else reads the loop.
@@ -358,7 +369,7 @@ export default class Engine {
358
369
  if (row.status === 100) {
359
370
  // NOT a terminal — a wake re-queued this loop while its own live drain was
360
371
  // between turns (a child concluded in the gap between our 202 write and this
361
- // check, §run-lifecycle-wake-requeue-not-terminal). The wake's intent is KEEP
372
+ // check, §worker-lifecycle-wake-requeue-not-terminal). The wake's intent is KEEP
362
373
  // RUNNING: re-claim atomically and continue — the injected prompt is already
363
374
  // this loop's next turn. Returning it as "external" broadcast a QUEUED loop
364
375
  // as loop/terminated {finalStatus: 100} — the delegation-flags flake.
@@ -396,19 +407,19 @@ export default class Engine {
396
407
  const holdCapMs = Number(process.env.PLURNK_SERVICE_EXEC_HOLD_MS ?? "300000");
397
408
  if (holdSet.size > 0 && holdCapMs > 0 && execHandler?.hasActiveHoldSpawns !== undefined) {
398
409
  const holdStart = Date.now();
399
- while ((execHandler.hasActiveHoldSpawns(runId, holdSet) || this.searchPrefetch.holds(runId)) && Date.now() - holdStart < holdCapMs) {
410
+ while (execHandler.hasActiveHoldSpawns(workerId, holdSet) && Date.now() - holdStart < holdCapMs) {
400
411
  await delay(150, undefined, { signal });
401
412
  }
402
413
  }
403
414
  const execWaitMs = Number(process.env.PLURNK_SERVICE_EXEC_WAIT_MS ?? "0");
404
415
  if (execWaitMs > 0) {
405
- if (execHandler?.hasActiveSpawns?.(runId) === true)
416
+ if (execHandler?.hasActiveSpawns?.(workerId) === true)
406
417
  await delay(execWaitMs, undefined, { signal });
407
418
  }
408
419
  let turn;
409
420
  try {
410
421
  turn = await this.runTurn({
411
- provider, messages, requirements, sessionId, runId, loopId, origin, signal, onDispatch,
422
+ provider, messages, requirements, workspaceId, workerId, loopId, origin, signal, onDispatch,
412
423
  turnNumber: turnIds.length + 1, maxTurns,
413
424
  });
414
425
  }
@@ -455,7 +466,7 @@ export default class Engine {
455
466
  }
456
467
  }
457
468
  }
458
- async runTurn({ provider, messages, requirements = "", sessionId, runId, loopId, origin = "model", signal, onDispatch, turnNumber = 1, maxTurns = 50, }) {
469
+ async runTurn({ provider, messages, requirements = "", workspaceId, workerId, loopId, origin = "model", signal, onDispatch, turnNumber = 1, maxTurns = 50, }) {
459
470
  // === Turn-as-container model ===
460
471
  //
461
472
  // Turn rows are created at runTurn OPEN (status=102, placeholder
@@ -468,8 +479,8 @@ export default class Engine {
468
479
  // writes consume low indices; model ops continue from there.
469
480
  const seqRow = await this.#db.engine_next_turn_sequence.get({ loop_id: loopId });
470
481
  const seq = seqRow.next;
471
- // #269 — loops.sequence is the loop's ordinal WITHIN the run. Turn-0 foists that belong to the
472
- // RUN (manifest preview, AGENTS, operator docs) gate on the run's FIRST loop, not every loop's
482
+ // #269 — loops.sequence is the loop's ordinal WITHIN the worker. Turn-0 foists that belong to the
483
+ // RUN (manifest preview, AGENTS, operator docs) gate on the worker's FIRST loop, not every loop's
473
484
  // first turn; per-loop foists (the prompt, @file) still fire each loop. Read once, turn-1 only.
474
485
  const loopRow = seq === 1
475
486
  ? await this.#db.engine_get_loop_prompt.get({ loop_id: loopId })
@@ -493,7 +504,7 @@ export default class Engine {
493
504
  // from sequence=2 onward on prompt-foisted turns; 1 onward
494
505
  // otherwise.
495
506
  let nextActionIndex = 1;
496
- // §model-entry — the run's first turn opens with the model's own turn-0, mirrored OPEN: a
507
+ // §model-entry — the worker's first turn opens with the model's own turn-0, mirrored OPEN: a
497
508
  // worked turn PLAN → the environment FINDs the foist ACTUALLY dispatches → SEND[102]. Built
498
509
  // from the real ops below (not a static print — we lean into the genuine echo paradigm) and
499
510
  // written at sequence 1, so it reads first as the emission with the foisted results following.
@@ -502,15 +513,15 @@ export default class Engine {
502
513
  if (runFirstLoop)
503
514
  nextActionIndex = 2; // reserve sequence 1 for the turn-0 echo
504
515
  // Operator doc READs (PLURNK_SERVICE_MD_<ALIAS>, §actor-boundary-doc-injection). The docs were materialized
505
- // as plurnk:///<entry> entries by the plurnk run (loop_run, via the
516
+ // as plurnk:///<entry> entries by the plurnk worker (loop_run, via the
506
517
  // §actor-boundary keystone); foist a READ of each into THIS turn-0 so the model
507
518
  // reads them inline. It sees only the READ — the materializing EDIT
508
- // lives in the plurnk run's log, never the model's.
509
- // #231 — env docs (PLURNK_SERVICE_MD_*) UNION the session's client docs; foist a READ of
519
+ // lives in the plurnk worker's log, never the model's.
520
+ // #231 — env docs (PLURNK_SERVICE_MD_*) UNION the workspace's client docs; foist a READ of
510
521
  // each materialized plurnk:///<alias>.md (loop_run materialized the same set).
511
- const { mdDocs } = await SessionSettings.read(this.#db, sessionId);
512
- // #269 — operator docs are run-once; foist them only on the run's first loop.
513
- for (const doc of runFirstLoop ? await SessionSettings.resolveDocs(mdDocs) : []) {
522
+ const { mdDocs } = await WorkspaceSettings.read(this.#db, workspaceId);
523
+ // #269 — operator docs are run-once; foist them only on the worker's first loop.
524
+ for (const doc of runFirstLoop ? await WorkspaceSettings.resolveDocs(mdDocs) : []) {
514
525
  const docTarget = {
515
526
  kind: "url", raw: `plurnk:///${doc.entryName}`, scheme: "plurnk",
516
527
  username: null, password: null, hostname: null, port: null,
@@ -521,7 +532,7 @@ export default class Engine {
521
532
  lineMarker: null, body: null, position: { line: 1, column: 1 },
522
533
  };
523
534
  await this.dispatch({
524
- statement: docRead, sessionId, runId, loopId, turnId,
535
+ statement: docRead, workspaceId, workerId, loopId, turnId,
525
536
  sequence: nextActionIndex, origin: "plurnk", onDispatch,
526
537
  });
527
538
  nextActionIndex++;
@@ -529,7 +540,7 @@ export default class Engine {
529
540
  const promptRow = loopRow; // #269 — already read above (per-loop; fires every loop's turn 1)
530
541
  if (promptRow !== undefined && typeof promptRow.prompt === "string" && promptRow.prompt.length > 0) {
531
542
  const promptLoopSeq = promptRow.sequence; // the loop's PER-RUN sequence — model-facing, matching log coordinates (owner: the db id read as prompt/2/1)
532
- const promptPath = promptTarget(runId, promptLoopSeq, seq);
543
+ const promptPath = promptTarget(workerId, promptLoopSeq, seq);
533
544
  const promptStmt = {
534
545
  op: "EDIT", suffix: "", signal: null,
535
546
  target: promptPath, lineMarker: null,
@@ -537,7 +548,7 @@ export default class Engine {
537
548
  };
538
549
  let promptLogId;
539
550
  await this.dispatch({
540
- statement: promptStmt, sessionId, runId, loopId, turnId,
551
+ statement: promptStmt, workspaceId, workerId, loopId, turnId,
541
552
  sequence: nextActionIndex, origin: "plurnk",
542
553
  onDispatch: (id) => { promptLogId = id; onDispatch?.(id); },
543
554
  });
@@ -551,14 +562,19 @@ export default class Engine {
551
562
  // when it runs fewer than 12 (whole-read form doubles as teaching). Prior prompts
552
563
  // stay listed by path in the system packet's User Prompts section — reachable,
553
564
  // never silently lost.
565
+ // §arrival-law (#499) — the preview bound is the knob (was a hardcoded 12). The
566
+ // line-slice is an EFFICIENCY (do not materialize hundreds of lines into rx); the
567
+ // RENDER is the law — packet-wire's arrival preview cuts the char dimension there,
568
+ // which a line marker structurally cannot (a 1-line slice of a 1-line bomb is 416).
569
+ const previewLines = Number(process.env.PLURNK_SERVICE_ARRIVAL_PREVIEW_LINES ?? "16");
554
570
  const promptLineCount = promptRow.prompt.split("\n").length;
555
571
  const promptRead = {
556
572
  op: "READ", suffix: "", signal: null, target: promptPath,
557
- lineMarker: { marks: promptLineCount >= 12 ? [1, 12] : [1, -1] },
573
+ lineMarker: { marks: promptLineCount >= previewLines ? [1, previewLines] : [1, -1] },
558
574
  body: null, position: { line: 1, column: 1 },
559
575
  };
560
576
  await this.dispatch({
561
- statement: promptRead, sessionId, runId, loopId, turnId,
577
+ statement: promptRead, workspaceId, workerId, loopId, turnId,
562
578
  sequence: nextActionIndex, origin: "plurnk", onDispatch,
563
579
  });
564
580
  nextActionIndex++;
@@ -570,18 +586,18 @@ export default class Engine {
570
586
  if (seq > 1) {
571
587
  const loopSeqRow = await this.#db.engine_loop_sequence.get({ loop_id: loopId });
572
588
  const loopSeq = loopSeqRow?.sequence ?? loopId;
573
- const injected = await this.#db.drain_get_all_prompt_bodies_for_loop.all({ pattern: promptPathname(runId, loopSeq, seq) });
589
+ const injected = await this.#db.drain_get_all_prompt_bodies_for_loop.all({ pattern: promptPathname(workerId, loopSeq, seq) });
574
590
  const injectedRow = injected.find((r) => typeof r.content === "string" && r.content.length > 0);
575
591
  if (injectedRow !== undefined) {
576
592
  const lineCount = injectedRow.content.split("\n").length;
577
- const injTarget = promptTarget(runId, loopSeq, seq);
593
+ const injTarget = promptTarget(workerId, loopSeq, seq);
578
594
  const injRead = {
579
595
  op: "READ", suffix: "", signal: null, target: injTarget,
580
596
  lineMarker: { marks: lineCount >= 12 ? [1, 12] : [1, -1] },
581
597
  body: null, position: { line: 1, column: 1 },
582
598
  };
583
599
  await this.dispatch({
584
- statement: injRead, sessionId, runId, loopId, turnId,
600
+ statement: injRead, workspaceId, workerId, loopId, turnId,
585
601
  sequence: nextActionIndex, origin: "plurnk", onDispatch,
586
602
  });
587
603
  nextActionIndex++;
@@ -591,48 +607,46 @@ export default class Engine {
591
607
  // every entry's deep channels (symbols/refs/embeddings/FTS, deep_hash-gated) so the
592
608
  // catalog and FIND read current data. NOT an action: no log entry, no sequence slot,
593
609
  // not dispatched. There is no plurnk:///manifest.json entry — the catalog is served
594
- // on demand by FIND(scheme:///**), foisted into the run's first turn below.
610
+ // on demand by FIND(scheme:///**), foisted into the worker's first turn below.
595
611
  // #312 — the turn's token gauge: the ACTIVE provider's tokenizer identity + exact counter
596
612
  // (mimetypes seam; provider upper bound surfaced as tokenizer_unavailable when inexact).
597
613
  // Threaded per turn — never engine state — so concurrent loops on different providers
598
614
  // each read their own honest numbers.
599
- const gauge = await TokenGauge.resolve(this.#mimetypes, provider, (event) => this.#telemetry.push(sessionId, loopId, event));
600
615
  const systemCtx = {
601
- db: this.#db, sessionId, runId, loopId, turnId,
616
+ db: this.#db, workspaceId, workerId, loopId, turnId,
602
617
  writer: "plurnk",
603
618
  signal: this.#loopAborts.get(loopId)?.signal,
604
619
  streamEventNotify: this.#streamEventNotify,
605
- wakeRunNotify: this.#wakeRunNotify,
620
+ wakeWorkerNotify: this.#wakeWorkerNotify,
606
621
  tokenize: this.#tokenize,
607
- gauge,
608
622
  mimetypes: this.#mimetypes,
609
623
  defaultChannelFor: (s) => this.#schemes.defaultChannelFor(s),
610
- pushTelemetry: (event) => this.#telemetry.push(sessionId, loopId, event),
624
+ pushTelemetry: (event) => this.#telemetry.push(workspaceId, loopId, event),
611
625
  };
612
626
  // SPEC §membership D4/D5 — git-ls-files workspace membership, resolved at
613
627
  // prompt-composition (EMI is eager + exhaustive — git is the only bound). When the
614
- // session's project_root is a git working tree, tracked files are
628
+ // workspace's project_root is a git working tree, tracked files are
615
629
  // members without a client `add`; active members are materialized
616
630
  // (disk → body channel) so they appear in the catalog. No-ops
617
- // on headless / non-git sessions. Runs BEFORE the derivation pump so
631
+ // on headless / non-git workspaces. Runs BEFORE the derivation pump so
618
632
  // this turn's packet reflects them.
619
633
  const fsDivergences = await GitMembership.indexGitMembership(systemCtx);
620
- await this.#logFsFictions(sessionId, fsDivergences);
634
+ await this.#logFsFictions(workspaceId, fsDivergences);
621
635
  this.#queueDerivation(() => EntryManifest.maintainDerivations(systemCtx)); // §derivation-off-hot-path — the turn proceeds; ~queries warm their own slice
622
636
  // Turn-0 catalog preview (PLURNK_SERVICE_FILES_ITEMS, §actor-boundary-catalog-preview):
623
- // one FIND(scheme:///**) per scheme that holds entries, foisted into the run's first
637
+ // one FIND(scheme:///**) per scheme that holds entries, foisted into the worker's first
624
638
  // model turn so it opens with its catalog (the per-scheme arrays that replaced the
625
639
  // single manifest.json). -1 → each scheme's whole catalog; N → its first N rows
626
640
  // (clamped to the scheme's count so FIND's strict <L> never 416s); off by default.
627
641
  if (seq === 1) {
628
- // #231 — a session's client-chosen filesItems REPLACES the env default outright.
629
- const { filesItems: sessionMI } = await SessionSettings.read(this.#db, sessionId);
630
- const filesItems = sessionMI !== null ? normalizeFilesItems(sessionMI) : readFilesItems();
642
+ // #231 — a workspace's client-chosen filesItems REPLACES the env default outright.
643
+ const { filesItems: workspaceMI } = await WorkspaceSettings.read(this.#db, workspaceId);
644
+ const filesItems = workspaceMI !== null ? normalizeFilesItems(workspaceMI) : readFilesItems();
631
645
  if (filesItems !== null && runFirstLoop) { // #269 — catalog preview is run-once
632
- // engine_scheme_catalog_summary is the scheme source: session-scoped, ordered,
646
+ // engine_scheme_catalog_summary is the scheme source: workspace-scoped, ordered,
633
647
  // one row per scheme that has entries (scheme=null → file). log:// is absent —
634
648
  // it lives in log_entries, not the catalog (present-mode, the # Log section).
635
- const catalogSchemes = await this.#db.engine_scheme_catalog_summary.all({ session_id: sessionId });
649
+ const catalogSchemes = await this.#db.engine_scheme_catalog_summary.all({ workspace_id: workspaceId });
636
650
  // known:/// + unknown:/// + file ALWAYS foist, even at zero entries — else the
637
651
  // model burns a turn running the FIND itself, assuming the catalog is merely
638
652
  // being withheld. An empty FIND(**) is orienting, not noise (owner): it tells
@@ -674,7 +688,7 @@ export default class Engine {
674
688
  position: { line: 1, column: 1 },
675
689
  };
676
690
  await this.dispatch({
677
- statement: catalogFind, sessionId, runId, loopId, turnId,
691
+ statement: catalogFind, workspaceId, workerId, loopId, turnId,
678
692
  sequence: nextActionIndex, origin: "plurnk", onDispatch,
679
693
  });
680
694
  nextActionIndex++;
@@ -682,21 +696,21 @@ export default class Engine {
682
696
  // own survey, mirrored OPEN). The <L> cap rides as `<1,N>`, exactly as the model would type it.
683
697
  turnZeroMoves.push(`<<FIND(${isPlurnk ? "plurnk://docs/**" : isFile ? "**" : `${schemeName}:///**`})${cap === null ? "" : `<1,${cap}>`}::FIND`);
684
698
  }
685
- // §run-scheme — Manifest(run) = session-scope ∪ THIS run's run-scope. Foist the
686
- // building run's OWN scratch (run://self/**, uncapped — a run needs the full view to
699
+ // §worker-scheme — Manifest(run) = workspace-scope ∪ THIS worker's worker-scope. Foist the
700
+ // building worker's OWN scratch (worker://self/**, uncapped — a worker needs the full view to
687
701
  // manage its private workspace) so it's catalogued in ITS perspective alone; other
688
- // runs reach it only via explicit FIND(run://<name>/**). A run with no scratch foists nothing.
689
- const selfRun = await this.#db.run_name_by_id.get({ run_id: runId });
690
- const scratch = selfRun === undefined ? 0 : (await this.#db.engine_run_scratch_count.get({ session_id: sessionId, owner_prefix: `/${selfRun.name}/*` }))?.entries ?? 0;
702
+ // runs reach it only via explicit FIND(worker://<name>/**). A worker with no scratch foists nothing.
703
+ const selfWorker = await this.#db.worker_name_by_id.get({ worker_id: workerId });
704
+ const scratch = selfWorker === undefined ? 0 : (await this.#db.engine_worker_scratch_count.get({ workspace_id: workspaceId, owner_prefix: `/${selfWorker.name}/*` }))?.entries ?? 0;
691
705
  if (scratch > 0) {
692
706
  const runFind = {
693
707
  op: "FIND", suffix: "", signal: null,
694
- target: { kind: "url", raw: "run://self/**", scheme: "run", username: null, password: null, hostname: "self", port: null, pathname: "/**", params: {}, fragment: null },
708
+ target: { kind: "url", raw: "worker://self/**", scheme: "worker", username: null, password: null, hostname: "self", port: null, pathname: "/**", params: {}, fragment: null },
695
709
  body: null, lineMarker: null, position: { line: 1, column: 1 },
696
710
  };
697
- await this.dispatch({ statement: runFind, sessionId, runId, loopId, turnId, sequence: nextActionIndex, origin: "plurnk", onDispatch });
711
+ await this.dispatch({ statement: runFind, workspaceId, workerId, loopId, turnId, sequence: nextActionIndex, origin: "plurnk", onDispatch });
698
712
  nextActionIndex++;
699
- turnZeroMoves.push("<<FIND(run://self/**)::FIND"); // §model-entry — the run-scope survey, into the turn-0 echo
713
+ turnZeroMoves.push("<<FIND(worker://self/**)::FIND"); // §model-entry — the worker-scope survey, into the turn-0 echo
700
714
  }
701
715
  }
702
716
  // #260 — foist a turn-0 READ of each client-passed @file path so its content sits in front
@@ -715,7 +729,7 @@ export default class Engine {
715
729
  body: null, position: { line: 1, column: 1 },
716
730
  };
717
731
  await this.dispatch({
718
- statement: fileRead, sessionId, runId, loopId, turnId,
732
+ statement: fileRead, workspaceId, workerId, loopId, turnId,
719
733
  sequence: nextActionIndex, origin: "plurnk", onDispatch,
720
734
  });
721
735
  nextActionIndex++;
@@ -726,35 +740,34 @@ export default class Engine {
726
740
  // so the grammar can stay thin. Subsequent turns mirror the model's real output, folded.
727
741
  if (runFirstLoop) {
728
742
  const emission = ["<<PLAN:Initialize:PLAN", ...turnZeroMoves, "<<SEND[102]:Initialized:SEND"].join("\n");
729
- await this.#dispatcher.writeModelEntry({ verbatim: emission, runId, loopId, turnId, sequence: 1, folded: false, origin: "plurnk" });
743
+ await this.#dispatcher.writeModelEntry({ verbatim: emission, workerId, loopId, turnId, sequence: 1, folded: false, origin: "plurnk" });
730
744
  }
731
745
  }
732
- // §env-delta — pre-seed the run's ambient observations (what changed since
746
+ // §env-delta — pre-seed the worker's ambient observations (what changed since
733
747
  // it last looked) as foisted rows before the packet composes; advance the action index
734
748
  // past them so model ops continue after. Two instances of one machine: env-delta (sibling
735
749
  // edits · timestamp cursor · always folded) and exec streams (channel bytes · byte cursor ·
736
750
  // terminal delta opens). §env-delta §exec-stream
737
- // §exec-poll — EXEC `<0>` is turn-scoped: reap the run's open turn-scoped streams (necessarily
751
+ // §exec-poll — EXEC `<0>` is turn-scoped: reap the worker's open turn-scoped streams (necessarily
738
752
  // from a prior turn — this runs before the turn's own spawns) so a `<0>` never survives into
739
753
  // the subsequent turn. The terminal output then surfaces born-OPEN via the stream-delta path.
740
- await this.#reapTurnScopedStreams(runId);
741
- nextActionIndex += await this.#materializeEnvironmentDeltas({ sessionId, runId, loopId, turnId, fromSequence: nextActionIndex });
742
- nextActionIndex += await this.#materializeStreamDeltas({ runId, loopId, turnId, fromSequence: nextActionIndex });
754
+ await this.#reapTurnScopedStreams(workerId);
755
+ nextActionIndex += await this.#materializeEnvironmentDeltas({ workspaceId, workerId, loopId, turnId, fromSequence: nextActionIndex });
756
+ nextActionIndex += await this.#materializeStreamDeltas({ workerId, loopId, turnId, fromSequence: nextActionIndex });
743
757
  // SPEC §telemetry — git working-tree state for the telemetry section, read once
744
758
  // (a service-side `git status` shell-out) and threaded into the budget
745
759
  // rebuild too so it isn't re-shelled on overflow.
746
- const gitStatus = await GitState.status(this.#db, sessionId, this.#loopAborts.get(loopId)?.signal);
760
+ const gitStatus = await GitState.status(this.#db, workspaceId, this.#loopAborts.get(loopId)?.signal);
747
761
  // Build the spec'd packet (Packet.json) request half. The log build
748
- // queries log_entries scoped to the run — the prompt entry just
762
+ // queries log_entries scoped to the worker — the prompt entry just
749
763
  // written (if turn 1) is part of that query result.
750
- const tokenRatio = this.#tokenRatios.get(loopId) ?? 1;
751
764
  let requestPacket = await this.#packets.buildRequestPacket({
752
- initialMessages: messages, requirements, sessionId, runId, loopId,
753
- currentTurnSeq: seq, provider, gitStatus, tokenRatio,
765
+ initialMessages: messages, requirements, workspaceId, workerId, loopId,
766
+ currentTurnSeq: seq, provider, gitStatus,
754
767
  });
755
768
  // SPEC §grinder — budget grinder, pre-LLM: reclaim window on actual overflow.
756
769
  const enforced = await this.#packets.enforceBudget({
757
- packet: requestPacket, provider, runId, loopId, turnId, tokenRatio,
770
+ packet: requestPacket, provider, workerId, loopId, turnId,
758
771
  // The overflow error row is minted at the turn's running sequence (nextActionIndex), pre-generate;
759
772
  // runTurn advances the counter past it below so the post-generate dispatch rows never collide.
760
773
  mintSequence: nextActionIndex,
@@ -762,8 +775,8 @@ export default class Engine {
762
775
  // overflow row just minted surfaces THIS turn (§grinder-overflow-error-row). Safe: the
763
776
  // ephemeral buffer is empty pre-generate (events drain on the next turn's build).
764
777
  rebuild: () => this.#packets.buildRequestPacket({
765
- initialMessages: messages, requirements, sessionId, runId, loopId,
766
- currentTurnSeq: seq, provider, gitStatus, tokenRatio,
778
+ initialMessages: messages, requirements, workspaceId, workerId, loopId,
779
+ currentTurnSeq: seq, provider, gitStatus,
767
780
  }),
768
781
  });
769
782
  if (enforced.struck)
@@ -780,13 +793,13 @@ export default class Engine {
780
793
  // overflow terminates 413 — death only after the model was told. Physically
781
794
  // unsendable (over the provider's real window too) → 413 immediately; physics
782
795
  // doesn't negotiate. The pointer stays at 100% of budget — a margin would mask it.
783
- const physicallySendable = provider.contextSize === null
796
+ const physicallySendable = provider.contextWindow === null
784
797
  ? true
785
- : requestPacket.tokens * Math.max(tokenRatio, 1) <= provider.contextSize - this.#packets.decodeBudget(provider);
798
+ : this.#packets.exactPacketTokens(requestPacket, provider) <= provider.contextWindow - this.#packets.maxTokensFor(provider);
786
799
  if (physicallySendable && !this.#hardOverflowRecovery.has(loopId)) {
787
800
  this.#hardOverflowRecovery.add(loopId);
788
801
  await this.#db.engine_insert_log_entry.get({
789
- run_id: runId, loop_id: loopId, turn_id: turnId, sequence: nextActionIndex++,
802
+ worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence: nextActionIndex++,
790
803
  origin: "model", source: "engine", op: "error", suffix: "", signal: null,
791
804
  scheme: null, username: null, password: null, hostname: null, port: null,
792
805
  pathname: null, params: null, fragment: null, lineMarker: null,
@@ -799,8 +812,8 @@ export default class Engine {
799
812
  // errors sections (the same re-derive contract the soft grind uses).
800
813
  nextActionIndex += 1;
801
814
  requestPacket = await this.#packets.buildRequestPacket({
802
- initialMessages: messages, requirements, sessionId, runId, loopId,
803
- currentTurnSeq: seq, provider, gitStatus, tokenRatio,
815
+ initialMessages: messages, requirements, workspaceId, workerId, loopId,
816
+ currentTurnSeq: seq, provider, gitStatus,
804
817
  });
805
818
  }
806
819
  else {
@@ -810,7 +823,7 @@ export default class Engine {
810
823
  await this.#db.engine_close_turn.run({
811
824
  id: turnId, status: 413, packet: JSON.stringify(hardPacket),
812
825
  usage_prompt: 0, usage_completion: 0, usage_reasoning: 0, usage_cached: 0, usage_cost_pico: 0,
813
- usage_context_size: this.#packets.promptBudgetFor(provider), // #274 — the PROMPT BUDGET (window − reserves), even on a hard-413 turn: the gauge denominator the packet actually lives under
826
+ usage_prompt_budget: this.#packets.promptBudgetFor(provider), // #274 — the PROMPT BUDGET (window − reserves), even on a hard-413 turn: the gauge denominator the packet actually lives under
814
827
  finish_reason: "budget_hard_stop", model: provider.model, meta: "{}",
815
828
  });
816
829
  return { turnId, status: 413, statuses: [], fingerprint: "", budgetStruck: enforced.struck, budgetHardStop: true, steerStruck: false };
@@ -826,7 +839,7 @@ export default class Engine {
826
839
  // the input under the ceiling); the model's decode — reasoning + emission — is
827
840
  // out of band, owned by the provider's own context window. Deriving a maxTokens
828
841
  // from our budget conflated the two and guillotined a reasoning model's
829
- // out-of-band thinking as the packet filled (`ceiling - packet` → near-zero
842
+ // out-of-band reasoning as the packet filled (`ceiling - packet` → near-zero
830
843
  // decode → finish=length mid-reasoning → no emission → strike spiral). The
831
844
  // provider enforces its physical wall on its own.
832
845
  let response;
@@ -836,8 +849,8 @@ export default class Engine {
836
849
  // #249 — tag the loop (the activity) with its active plugins' attribution tags, write-once.
837
850
  if (attributions.length > 0)
838
851
  await this.#db.engine_tag_loop_attributions.run({ loop_id: loopId, attributions: JSON.stringify(attributions) });
839
- // #249 — session-stable frontend id, forwarded as Plurnk-Client by the plurnk provider only.
840
- const { client } = await SessionSettings.read(this.#db, sessionId);
852
+ // #249 — workspace-stable frontend id, forwarded as Plurnk-Client by the plurnk provider only.
853
+ const { client } = await WorkspaceSettings.read(this.#db, workspaceId);
841
854
  try {
842
855
  // §turn-lifecycle (#301) — the provider call is the long, opaque window (submit → first
843
856
  // committed op is provider latency + a full first-turn generation, ~70s local): a static
@@ -846,13 +859,19 @@ export default class Engine {
846
859
  // ops are about to land. Base telemetry/event channel (the embed_progress precedent, §tokenomics
847
860
  // clients already render it unconditionally); the abort guard keeps a cancelled loop silent.
848
861
  if (!signal?.aborted)
849
- this.#telemetry.push(sessionId, loopId, { source: "engine:turn", kind: "turn_awaiting_model", level: "info", message: "awaiting model response" });
862
+ this.#telemetry.push(workspaceId, loopId, { source: "engine:turn", kind: "turn_awaiting_model", level: "info", message: "awaiting model response" });
850
863
  // generate rides the LOOP signal (already chained from the caller's), so a loop-level
851
864
  // abort — the §operator-config-loop-timeout wall — cancels a stuck provider call, not
852
865
  // just the schemes. Bare runTurn (no runLoop) has no loop entry → the caller's signal.
853
- response = await provider.generate({ messages: modelMessages, runId: String(runId), signal: this.#loopAborts.get(loopId)?.signal ?? signal, grammar: await this.#grammarConstraint(provider), maxTokens: this.#packets.decodeBudget(provider), strikes: this.#strikes.streak(loopId), attributions: attributions.length > 0 ? attributions : undefined, client: client ?? undefined }); // strikes: first-party routing signal, 0 sent explicitly (#313) // §provider-surface-generate §provider-guarantees-single-call §provider-guarantees-signal-wired §attribution-plurnk-namespace-reserved §client-telemetry
866
+ // #391 — the turn COORDINATE (workspace + loop-seq/turn-seq of the turn being generated)
867
+ // rides as first-party metadata (Plurnk-Workspace-Id/Loop/Turn) so the endpoint flywheel keys
868
+ // on an AUTHORITATIVE hierarchy, not a scraped context-log approximation. The daemon owns
869
+ // the value; providers stamps the header (same split as Run-Id, #26). loopSeq (the 1-based
870
+ // coordinate, not the DB id) resolves the same way the prompt-slot path does (§log coords).
871
+ const loopSeq = (await this.#db.engine_loop_sequence.get({ loop_id: loopId }))?.sequence ?? loopId;
872
+ response = await provider.generate({ messages: modelMessages, workerId: String(workerId), signal: this.#loopAborts.get(loopId)?.signal ?? signal, grammar: await this.#grammarConstraint(provider), maxTokens: this.#packets.maxTokensFor(provider), strikes: this.#strikes.streak(loopId), attributions: attributions.length > 0 ? attributions : undefined, client: client ?? undefined, workspaceId: String(workspaceId), loop: loopSeq, turn: seq }); // strikes: first-party routing signal, 0 sent explicitly (#313) // §provider-surface-generate §provider-guarantees-single-call §provider-guarantees-signal-wired §attribution-plurnk-namespace-reserved §client-telemetry
854
873
  if (!signal?.aborted)
855
- this.#telemetry.push(sessionId, loopId, { source: "engine:turn", kind: "turn_generated", level: "info", message: "parsing model response" });
874
+ this.#telemetry.push(workspaceId, loopId, { source: "engine:turn", kind: "turn_generated", level: "info", message: "parsing model response" });
856
875
  }
857
876
  catch (err) {
858
877
  // §turn-never-blank — a ProviderError is an INFRASTRUCTURE failure (auth, network
@@ -866,7 +885,7 @@ export default class Engine {
866
885
  // adjudication into a model-behavior 422 — a state the system otherwise forbids,
867
886
  // a record that lied, and days of forensics pointed at the wrong suspect.
868
887
  if (err instanceof ProviderError) {
869
- this.#telemetry.push(sessionId, loopId, { source: "provider", kind: err.kind, message: err.message, level: "error" });
888
+ this.#telemetry.push(workspaceId, loopId, { source: "provider", kind: err.kind, message: err.message, level: "error" });
870
889
  }
871
890
  throw err;
872
891
  }
@@ -875,18 +894,6 @@ export default class Engine {
875
894
  // call-metadata (usage, finishReason, model) → Turn columns per
876
895
  // Turn.json. Mixing the two on packet.assistant was the wrong layer.
877
896
  const { packetAssistant, callMetadata, parseErrors } = this.#splitResponse(response); // raw assistant content is opaque — split, never interpreted — §provider-guarantees-assistantraw-opaque
878
- // §tokenomics-ceiling-calibrates-to-usage — learn the loop's real/measured ratio from the
879
- // provider's OWN prompt count (ground truth for the whole wire request, template overhead
880
- // included). Monotone max within the loop. An EXACT ruler floors at 1 (never expands);
881
- // a certified upper-bound ruler calibrates to observed truth in BOTH directions — the
882
- // worst-observed packing wins, and expansion toward ground truth cannot overshoot the
883
- // window (owner-ruled; run24: the unconditional floor halved gbuild's effective budget).
884
- if (callMetadata.usage.prompt > 0 && requestPacket.tokens > 0) {
885
- const observed = callMetadata.usage.prompt / requestPacket.tokens;
886
- const prior = this.#tokenRatios.get(loopId) ?? (gauge.exact ? 1 : observed);
887
- if (observed >= prior)
888
- this.#tokenRatios.set(loopId, observed);
889
- }
890
897
  // Surface parse errors to the model's NEXT packet so it can self-
891
898
  // correct. Without this, malformed emissions (e.g. a READ matcher
892
899
  // body starting with `//` being interpreted as xpath) silently
@@ -914,7 +921,7 @@ export default class Engine {
914
921
  const located = typeof event.position === "number"
915
922
  ? this.#offsetToLineColumn(packetAssistant.content, event.position)
916
923
  : null;
917
- this.#telemetry.push(sessionId, loopId, {
924
+ this.#telemetry.push(workspaceId, loopId, {
918
925
  source: event.source,
919
926
  kind: event.kind,
920
927
  message: event.message ?? "",
@@ -952,8 +959,14 @@ export default class Engine {
952
959
  : realOpsCount === 0 ? TURN_STATUS_NO_OPS : TURN_STATUS_IMPLICIT_CONTINUE;
953
960
  // Idle turn: an implicit-continue (102) that did no WORK — its ops are only PLAN/SEND, no mid op.
954
961
  // The model continued with nothing to do. (Skipped when premature already steered this turn.)
962
+ // #467 (owner criterion) — a turn whose op ATTEMPTS died at the parser is a FAILED-RETRIEVAL
963
+ // turn, not an idle one: the model performed an op; the op died. The root parse-400 speaks
964
+ // alone; stacking the idle-409 on it calls the turn something it factually wasn't (run65's
965
+ // one malformed FIND minted two error rows for one accident). A dispatched-but-failed op is
966
+ // already a mid op, so only the parse-emptied class needs the deference. GBNF makes the
967
+ // emitted-idle shape unemittable (grammar SPEC, no-idle-102); this 409 stays the truly-idle backstop.
955
968
  const midOpsCount = packetAssistant.ops.filter((op) => op.op !== "PLAN" && op.op !== "SEND").length;
956
- if (!steerStruck && turnStatus === TURN_STATUS_IMPLICIT_CONTINUE && midOpsCount === 0) {
969
+ if (!steerStruck && turnStatus === TURN_STATUS_IMPLICIT_CONTINUE && midOpsCount === 0 && parseErrors.length === 0) {
957
970
  // One grace turn after a retrieval-only 409 (admins specimen): the refusal steer says
958
971
  // "continuing in order to receive results" — a model that obediently waits one bare
959
972
  // [102] turn is following OUR advice, and the idle rail was executing it for that.
@@ -981,7 +994,7 @@ export default class Engine {
981
994
  usage_reasoning: usage.reasoning,
982
995
  usage_cached: usage.cached,
983
996
  usage_cost_pico: provider.costFor(usage), // §provider-surface-costfor
984
- usage_context_size: this.#packets.promptBudgetFor(provider), // #274 — the PROMPT BUDGET (window − reserves): the raw n_ctx overstated usable room by the reserve total
997
+ usage_prompt_budget: this.#packets.promptBudgetFor(provider), // #274 — the PROMPT BUDGET (window − reserves): the raw n_ctx overstated usable room by the reserve total
985
998
  finish_reason: finishReason,
986
999
  model,
987
1000
  // #252 — opaque provider→client metadata passthrough (e.g. balancePico the
@@ -991,15 +1004,13 @@ export default class Engine {
991
1004
  // Dispatch model ops starting at nextActionIndex (continues the
992
1005
  // turn's running counter after any pre-model writes).
993
1006
  //
994
- // Max-commands cap: a single emission with more than `maxCommands`
995
- // ops is the runaway-loop fingerprint observed in pathological cases
996
- // (html-attrs demo: 635 ops in one turn). Cap dispatches at the
997
- // configured limit; overflow ops are dropped without per-op log
998
- // entries (avoids bloating forensics with hundreds of identical refusals)
999
- // and the model gets a single telemetry signal next packet so it knows
1000
- // its emission was truncated.
1001
- // #232 — a session's maxCommands is a tighten-only ceiling: min() the env ceiling.
1002
- const maxCommands = Math.min(readMaxCommands(), (await SessionSettings.read(this.#db, sessionId)).maxCommands ?? Number.POSITIVE_INFINITY);
1007
+ // Max-commands ceiling: OFF by default (unlimited) — every generated op dispatches.
1008
+ // A degenerate op-loop is a sampler failure guarded at generation, not by dropping
1009
+ // already-generated work post-hoc. The ceiling is an OPT-IN operator/client bound:
1010
+ // when set, overflow ops drop without per-op log entries (no forensics flood) and the
1011
+ // model gets one telemetry signal next packet.
1012
+ // #232 — a workspace's maxCommands is a tighten-only ceiling: min() the env ceiling.
1013
+ const maxCommands = Math.min(readMaxCommands(), (await WorkspaceSettings.read(this.#db, workspaceId)).maxCommands ?? Number.POSITIVE_INFINITY);
1003
1014
  // PLAN (reasoning) and a terminal SEND (signal ≥ 200, the conclusion) are not
1004
1015
  // actions — they always dispatch and never count against the cap. maxCommands
1005
1016
  // bounds real actions only; maxCommands:0 still admits a plan and a conclusion
@@ -1016,9 +1027,9 @@ export default class Engine {
1016
1027
  let rowSeq = nextActionIndex;
1017
1028
  for (const statement of opsToDispatch) {
1018
1029
  const result = await this.dispatch({
1019
- statement, sessionId, runId, loopId, turnId,
1030
+ statement, workspaceId, workerId, loopId, turnId,
1020
1031
  sequence: rowSeq,
1021
- origin, onDispatch, gauge,
1032
+ origin, onDispatch,
1022
1033
  // §send-200-failed-ops — parse errors mint as rows AFTER this loop; the terminal
1023
1034
  // gate needs them NOW, so the count rides the dispatch context.
1024
1035
  turnParseErrors: parseErrors?.length ?? 0,
@@ -1057,25 +1068,33 @@ export default class Engine {
1057
1068
  if (droppedCount > 0)
1058
1069
  pendingEngineErrors.push("max_commands_exceeded");
1059
1070
  for (const kind of pendingEngineErrors)
1060
- await this.#telemetry.mintEngineError(kind, { runId, loopId, turnId, sequence: errSeq++ });
1071
+ await this.#telemetry.mintEngineError(kind, { workerId, loopId, turnId, sequence: errSeq++ });
1061
1072
  // §log-row-self-explains (Q2, owner-clarified) — a model-op failure is the MODEL'S OWN op
1062
1073
  // result: the op row carries its failure message on its meta line (packet-wire), and the
1063
1074
  // errors section points at the row. No separate minted item (the retired action_failure
1064
1075
  // mint dressed op results as source:"engine" faults — the jumbo model chased a phantom
1065
1076
  // "engine run 400 error" off a message-less item). Genuine engine-internal faults CRASH
1066
1077
  // (fail-hard, §turn-never-blank) and never mint model-facing rows.
1067
- // §tokenomics-output-truncated (#351) — packet honesty at the completion cap: a
1068
- // finish=length turn was GUILLOTINED mid-emission, and its parse errors are truncation
1069
- // ARTIFACTS. This one MINTS (actionless — no op row exists to self-explain): the cause
1070
- // must lead the artifact rows it explains, or the model fixes syntax forever.
1071
- if (finishReason === "length" && (parseErrors?.length ?? 0) > 0) {
1078
+ // §tokenomics-output-truncated — packet honesty at the completion cap: a finish=length turn was
1079
+ // GUILLOTINED at the decode pool, and this actionless row MINTS to LEAD the artifact rows it
1080
+ // explains (the cause must lead, or the model fixes syntax forever). Two shapes, honestly
1081
+ // distinguished: content cut MID-OP (a valid prefix dispatched; the parse errors are the
1082
+ // severed tail) vs the pool consumed with NOTHING emitted (reasoning ran away — the parse
1083
+ // 'must begin with PLAN' is an artifact of the empty emission, not a malformed turn). The parse
1084
+ // rows below STAY (the record never hides); the 413 leads and frames them for what they are.
1085
+ const truncatedEmpty = finishReason === "length" && packetAssistant.content.trim().length === 0;
1086
+ if (finishReason === "length" && (truncatedEmpty || (parseErrors?.length ?? 0) > 0)) {
1087
+ const cap = this.#packets.maxTokensFor(provider);
1088
+ const message = truncatedEmpty
1089
+ ? `output truncated at the completion cap (${cap} tokens): nothing was emitted before the pool was consumed — the parse error below is an artifact of the empty emission, not a malformed turn`
1090
+ : `output truncated at the completion cap (${cap} tokens): the emission was cut mid-op — the parse errors below are truncation artifacts`;
1072
1091
  await this.#db.engine_insert_log_entry.get({
1073
- run_id: runId, loop_id: loopId, turn_id: turnId, sequence: errSeq++,
1092
+ worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence: errSeq++,
1074
1093
  origin: "model", source: "engine", op: "error", suffix: "", signal: null,
1075
1094
  scheme: null, username: null, password: null, hostname: null, port: null,
1076
1095
  pathname: null, params: null, fragment: null, lineMarker: null,
1077
1096
  tx: "", mimetype_tx: "text/plain",
1078
- rx: JSON.stringify({ status: 413, kind: "output_truncated", message: `output truncated at the completion cap (${this.#packets.decodeBudget(provider)} tokens) mid-emission — the parse errors below are truncation artifacts; emit fewer ops per turn and continue` }),
1097
+ rx: JSON.stringify({ status: 413, kind: "output_truncated", message }),
1079
1098
  mimetype_rx: "application/json", status_rx: 413, tokens: 0, state: "failed", outcome: "output_truncated",
1080
1099
  attrs: "{}",
1081
1100
  });
@@ -1084,7 +1103,7 @@ export default class Engine {
1084
1103
  // resolved against the model's folded mirror row (§model-entry) — origin 'model', not engine.
1085
1104
  for (const { message, line, column, source } of parseErrors ?? []) {
1086
1105
  await this.#db.engine_insert_log_entry.get({
1087
- run_id: runId, loop_id: loopId, turn_id: turnId, sequence: errSeq++,
1106
+ worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence: errSeq++,
1088
1107
  origin: "model", source: "grammar", op: "error", suffix: "", signal: null,
1089
1108
  scheme: null, username: null, password: null, hostname: null, port: null,
1090
1109
  pathname: null, params: null, fragment: null, lineMarker: null,
@@ -1106,7 +1125,7 @@ export default class Engine {
1106
1125
  // introspect any prior emission of its own the same way. Empty emissions (a struck/
1107
1126
  // silent turn) write nothing — no prior output to mirror.
1108
1127
  if (packetAssistant.content.trim().length > 0) {
1109
- await this.#dispatcher.writeModelEntry({ verbatim: packetAssistant.content, runId, loopId, turnId, sequence: errSeq++, folded: true });
1128
+ await this.#dispatcher.writeModelEntry({ verbatim: packetAssistant.content, workerId, loopId, turnId, sequence: errSeq++, folded: true });
1110
1129
  }
1111
1130
  // Zero ops is NOT an error to report — the model knows it emitted
1112
1131
  // nothing. Strike accounting (engine-internal) treats it as a
@@ -1180,40 +1199,40 @@ export default class Engine {
1180
1199
  }
1181
1200
  // #note12 — the daughter-provided reference docs (schemes' + execs' `documentation`),
1182
1201
  // materialized at plurnk:///docs/<name>.md by loop_run (like operator docs).
1183
- docEntries(sessionId) {
1184
- return this.#packets.docEntries(sessionId);
1202
+ docEntries(workspaceId) {
1203
+ return this.#packets.docEntries(workspaceId);
1185
1204
  }
1186
1205
  // §env-delta (§actor-boundary-no-mutex: runs share without locks; a conflict surfaces as a delta, never prevented) — at pre-turn build, surface what changed in the shared world since this
1187
- // run last looked. No per-run snapshot (§machine-processes "a run is its log"): every
1206
+ // run last looked. No per-worker snapshot (§machine-processes "a worker is its log"): every
1188
1207
  // edit is already a span-carrying log row, so PULL other actors' EDITs on shared
1189
- // entries since this run's prior turn — real cross-run edits and the plurnk run's
1208
+ // entries since this worker's prior turn — real cross-worker edits and the plurnk worker's
1190
1209
  // fs-sync fictions — and materialize each as a FOLDED delta reusing the row's span +
1191
1210
  // cause. Returns the count so the caller advances nextActionIndex past the deltas.
1192
1211
  async #materializeEnvironmentDeltas(args) {
1193
- const { sessionId, runId, loopId, turnId, fromSequence } = args;
1194
- const boundary = await this.#db.engine_run_prior_turn_time.get({ run_id: runId, turn_id: turnId });
1212
+ const { workspaceId, workerId, loopId, turnId, fromSequence } = args;
1213
+ const boundary = await this.#db.engine_worker_prior_turn_time.get({ worker_id: workerId, turn_id: turnId });
1195
1214
  const since = boundary?.since ?? null;
1196
1215
  if (since === null)
1197
1216
  return 0; // first turn — nothing prior; the model reads current state fresh
1198
- const rows = await this.#db.engine_pull_env_deltas.all({ session_id: sessionId, run_id: runId, since });
1217
+ const rows = await this.#db.engine_pull_env_deltas.all({ workspace_id: workspaceId, worker_id: workerId, since });
1199
1218
  let written = 0;
1200
1219
  for (const r of rows) {
1201
- // source: the originating run (a real cross-run edit) or 'file' (an fs fiction);
1220
+ // source: the originating run (a real cross-worker edit) or 'file' (an fs fiction);
1202
1221
  // rx reuses the originating row's result span — the edit as it looked then.
1203
1222
  await this.#db.engine_insert_env_delta.run({
1204
- run_id: runId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
1205
- source: r.source ?? String(r.run_id), scheme: r.scheme, pathname: r.pathname, rx: r.rx,
1223
+ worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
1224
+ source: r.source ?? String(r.worker_id), scheme: r.scheme, pathname: r.pathname, rx: r.rx,
1206
1225
  });
1207
1226
  written++;
1208
1227
  }
1209
- // §run-scheme — loop-terminations: a sibling's loop reaching terminal surfaces the
1228
+ // §worker-scheme — loop-terminations: a sibling's loop reaching terminal surfaces the
1210
1229
  // same way an entry-change does, carrying its deliverable (the SEND body) or the
1211
1230
  // abandonment reason. Folded, attributed to the terminated run.
1212
- const terms = await this.#db.engine_pull_loop_terminations.all({ session_id: sessionId, run_id: runId, since });
1231
+ const terms = await this.#db.engine_pull_loop_terminations.all({ workspace_id: workspaceId, worker_id: workerId, since });
1213
1232
  for (const t of terms) {
1214
1233
  await this.#db.engine_insert_loop_termination_delta.run({
1215
- run_id: runId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
1216
- source: String(t.run_id), pathname: `/${t.run_name}`,
1234
+ worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
1235
+ source: String(t.worker_id), pathname: `/${t.worker_name}`,
1217
1236
  rx: markTerminal(t.terminated_by, t.terminal_message) ?? `loop "${t.prompt}" ended (${t.status})`,
1218
1237
  status: t.status,
1219
1238
  });
@@ -1221,13 +1240,13 @@ export default class Engine {
1221
1240
  }
1222
1241
  return written;
1223
1242
  }
1224
- // §exec-poll — EXEC `<0>` is turn-scoped: abort the run's open turn-scoped streams via their
1243
+ // §exec-poll — EXEC `<0>` is turn-scoped: abort the worker's open turn-scoped streams via their
1225
1244
  // owning scheme (the same registry-routed abort the total reap uses). Called at each pre-turn
1226
1245
  // before the turn's own spawns, so every open turn-scoped sub here is from a prior turn — it
1227
1246
  // never survives into the subsequent turn. Fire-and-forget: the spawn finalizes async and its
1228
1247
  // terminal output surfaces born-OPEN through the stream-delta path (§exec-stream).
1229
- async #reapTurnScopedStreams(runId) {
1230
- const open = await this.#db.find_open_turn_scoped_subscriptions_for_run.all({ run_id: runId });
1248
+ async #reapTurnScopedStreams(workerId) {
1249
+ const open = await this.#db.find_open_turn_scoped_subscriptions_for_worker.all({ worker_id: workerId });
1231
1250
  for (const { id, scheme } of open) {
1232
1251
  const handler = this.#schemes.get(scheme);
1233
1252
  handler?.abortSubscription?.(id);
@@ -1239,12 +1258,12 @@ export default class Engine {
1239
1258
  // streamEnd recorded on the channel's prior delta — no exec-specific surfacing, just the
1240
1259
  // env-observe loop with a byte cursor where env-delta uses a timestamp. §exec-stream
1241
1260
  async #materializeStreamDeltas(args) {
1242
- const { runId, loopId, turnId, fromSequence } = args;
1243
- const channels = await this.#db.engine_run_stream_channels.all({ run_id: runId });
1261
+ const { workerId, loopId, turnId, fromSequence } = args;
1262
+ const channels = await this.#db.engine_worker_stream_channels.all({ worker_id: workerId });
1244
1263
  let written = 0;
1245
1264
  for (const ch of channels) {
1246
1265
  const prior = await this.#db.engine_stream_cursor.get({
1247
- run_id: runId, scheme: ch.runtime, pathname: ch.coord, fragment: ch.channel,
1266
+ worker_id: workerId, scheme: ch.runtime, pathname: ch.coord, fragment: ch.channel,
1248
1267
  });
1249
1268
  const priorAttrs = prior !== undefined ? JSON.parse(prior.attrs) : {};
1250
1269
  const cursor = priorAttrs.streamEnd ?? 0;
@@ -1258,7 +1277,7 @@ export default class Engine {
1258
1277
  // pointer to the already-delivered bytes, never a re-send (§tokenomics-fetch-fits-free).
1259
1278
  if (closed && priorAttrs.terminal !== true && cursor > 0) {
1260
1279
  await this.#db.engine_insert_stream_delta.run({
1261
- run_id: runId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
1280
+ worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
1262
1281
  scheme: ch.runtime, pathname: ch.coord, fragment: ch.channel,
1263
1282
  rx: JSON.stringify({ status: ch.close_status ?? 200, content: `[ stream closed (${ch.close_status ?? 200}) — full output already delivered above; READ ${ch.runtime}://${ch.coord}#${ch.channel} to revisit ]`, mimetype: "text/stream" }),
1264
1283
  attrs: JSON.stringify({ streamEnd: ch.content.length, terminal: true }),
@@ -1272,7 +1291,7 @@ export default class Engine {
1272
1291
  // into one sequence (lines N..M, then M+1..), not N independent "1:" restarts. §exec-stream
1273
1292
  const startLine = (ch.content.slice(0, cursor).match(/\n/g)?.length ?? 0) + 1;
1274
1293
  await this.#db.engine_insert_stream_delta.run({
1275
- run_id: runId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
1294
+ worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
1276
1295
  scheme: ch.runtime, pathname: ch.coord, fragment: ch.channel,
1277
1296
  rx: JSON.stringify({ status: 200, content: ch.content.slice(cursor), mimetype: "text/stream", startLine }),
1278
1297
  attrs: JSON.stringify({ streamEnd: ch.content.length, terminal: closed }),
@@ -1283,20 +1302,20 @@ export default class Engine {
1283
1302
  return written;
1284
1303
  }
1285
1304
  // §env-delta — the filesystem as an actor. Ambient disk divergences detected at
1286
- // pre-turn (git membership re-read) are logged as the plurnk run's source=file EDIT
1305
+ // pre-turn (git membership re-read) are logged as the plurnk worker's source=file EDIT
1287
1306
  // "fictions": no op happened, but EDIT is the only grammar the model has for "your
1288
1307
  // world changed," so the fiction keeps its perspective aligned with what its tooling
1289
- // would show. The fiction lives in the plurnk run's log; every other run pulls it
1308
+ // would show. The fiction lives in the plurnk worker's log; every other run pulls it
1290
1309
  // through the one delta path, exactly like a sibling's real edit.
1291
- // §membership-emi-divergence-signal — disk divergences logged as the plurnk run's source=file EDIT fictions
1292
- async #logFsFictions(sessionId, divergences) {
1310
+ // §membership-emi-divergence-signal — disk divergences logged as the plurnk worker's source=file EDIT fictions
1311
+ async #logFsFictions(workspaceId, divergences) {
1293
1312
  if (divergences.length === 0)
1294
1313
  return;
1295
- const run = await this.#db.envelope_get_run_by_name.get({ session_id: sessionId, name: "plurnk" })
1296
- ?? await this.#db.envelope_insert_run.get({ session_id: sessionId, name: "plurnk", origin: "plurnk" });
1314
+ const run = await this.#db.envelope_get_worker_by_name.get({ workspace_id: workspaceId, name: "plurnk" })
1315
+ ?? await this.#db.envelope_insert_worker.get({ workspace_id: workspaceId, name: "plurnk", origin: "plurnk" });
1297
1316
  if (run === undefined)
1298
- throw new Error("logFsFictions: plurnk run resolution returned no row");
1299
- const loop = await this.#db.envelope_insert_client_loop.get({ run_id: run.id });
1317
+ throw new Error("logFsFictions: plurnk worker resolution returned no row");
1318
+ const loop = await this.#db.envelope_insert_client_loop.get({ worker_id: run.id });
1300
1319
  if (loop === undefined)
1301
1320
  throw new Error("logFsFictions: loop insert returned no row");
1302
1321
  const seq = await this.#db.client_turn_next_sequence.get({ loop_id: loop.id });
@@ -1307,7 +1326,7 @@ export default class Engine {
1307
1326
  for (const d of divergences) {
1308
1327
  const span = editedSpan(d.before, d.after);
1309
1328
  await this.#db.engine_insert_log_entry.get({
1310
- run_id: run.id, loop_id: loop.id, turn_id: turn.id, sequence: sequence++,
1329
+ worker_id: run.id, loop_id: loop.id, turn_id: turn.id, sequence: sequence++,
1311
1330
  origin: "plurnk", source: "file", op: "EDIT", suffix: "", signal: null,
1312
1331
  scheme: d.scheme, username: null, password: null, hostname: null, port: null,
1313
1332
  pathname: d.pathname, params: null, fragment: null, lineMarker: null,
@@ -1349,79 +1368,63 @@ export default class Engine {
1349
1368
  // run still accepting turns?" If yes, skip the wake — the active loop
1350
1369
  // will pick up the channel transition at its next turn boundary. If no,
1351
1370
  // the daemon opens a fresh loop with the wake prompt.
1352
- async hasActiveLoopForRun(runId) {
1353
- const row = await this.#db.engine_count_active_loops_for_run.get({ run_id: runId });
1371
+ async hasActiveLoopForWorker(workerId) {
1372
+ const row = await this.#db.engine_count_active_loops_for_worker.get({ worker_id: workerId });
1354
1373
  return (row?.n ?? 0) > 0;
1355
1374
  }
1356
1375
  // #290 — run the derivation pump (deep channels: symbols/refs/FTS +
1357
1376
  // embeddings, deep_hash-gated) at SESSION-SCOPE, off the per-turn path, so a freshly-created
1358
- // session's corpus warms DURING the client's startup window instead of freezing the first
1359
- // loop.run. session.create fires this and returns immediately; embed_progress live-fans-out as it
1377
+ // workspace's corpus warms DURING the client's startup window instead of freezing the first
1378
+ // loop.run. workspace.create fires this and returns immediately; embed_progress live-fans-out as it
1360
1379
  // runs. Idempotent + deep_hash-gated, so turn 1's pump finds the work done (or harmlessly re-runs);
1361
1380
  // a no-embedder build derives the cheap symbols/refs/FTS channels and skips the embed pass. Has no
1362
1381
  // loop yet — telemetry fans out live only (loopId 0), never buffered to a loop that never drains.
1363
- async warmSessionDerivations(sessionId) {
1382
+ async warmWorkspaceDerivations(workspaceId) {
1364
1383
  const ctx = {
1365
- db: this.#db, sessionId, runId: 0, loopId: 0, turnId: 0,
1384
+ db: this.#db, workspaceId, workerId: 0, loopId: 0, turnId: 0,
1366
1385
  writer: "plurnk",
1367
1386
  signal: undefined,
1368
1387
  streamEventNotify: this.#streamEventNotify,
1369
- wakeRunNotify: this.#wakeRunNotify,
1388
+ wakeWorkerNotify: this.#wakeWorkerNotify,
1370
1389
  tokenize: this.#tokenize,
1371
1390
  mimetypes: this.#mimetypes,
1372
1391
  defaultChannelFor: (s) => this.#schemes.defaultChannelFor(s),
1373
- pushTelemetry: (event) => this.#telemetry.notify(sessionId, 0, event),
1392
+ pushTelemetry: (event) => this.#telemetry.notify(workspaceId, 0, event),
1374
1393
  };
1375
1394
  await this.#queueDerivation(() => EntryManifest.maintainDerivations(ctx)); // §derivation-off-hot-path
1376
1395
  }
1377
- // Inject a prompt into the run's currently-executing loop. Writes a
1396
+ // Inject a prompt into the worker's currently-executing loop. Writes a
1378
1397
  // plurnk://prompt/<run>/<loop>/<next-turn> entry whose body becomes the
1379
1398
  // prompt section at the next turn boundary. Last-wins: if two
1380
1399
  // injects target the same next-turn slot, the second overwrites the
1381
1400
  // first.
1382
1401
  //
1383
- // Returns null when no loop in the run is currently active (status=102).
1402
+ // Returns null when no loop in the worker is currently active (status=102).
1384
1403
  // The daemon-side inject path then enqueues a fresh loop with this
1385
1404
  // prompt; engine doesn't open loops itself.
1386
1405
  //
1387
1406
  // Rummy parallel: AgentLoop.inject(). The "active drain → write
1388
1407
  // prompt entry, return immediately" branch.
1389
- // §search-prefetch — the pass's scheme context: plurnk-written, the same notifier wiring
1390
- // dispatch uses, bound to the run's current loop for teardown scoping (inject's shape).
1391
- async prefetchCtx(sessionId, runId) {
1392
- const loop = await this.#db.drain_current_loop_for_run.get({ run_id: runId });
1393
- const loopId = loop?.id ?? 0;
1394
- return {
1395
- db: this.#db, sessionId, runId, loopId, turnId: 0,
1396
- writer: "plurnk",
1397
- signal: this.#loopAborts.get(loopId)?.signal,
1398
- streamEventNotify: this.#streamEventNotify,
1399
- wakeRunNotify: this.#wakeRunNotify,
1400
- tokenize: this.#tokenize,
1401
- mimetypes: this.#mimetypes,
1402
- pushTelemetry: (event) => this.#telemetry.push(sessionId, loopId, event),
1403
- };
1404
- }
1405
- async inject(runId, prompt) {
1406
- const loopRow = await this.#db.drain_current_loop_for_run.get({ run_id: runId });
1408
+ async inject(workerId, prompt) {
1409
+ const loopRow = await this.#db.drain_current_loop_for_worker.get({ worker_id: workerId });
1407
1410
  if (loopRow === undefined)
1408
1411
  return null;
1409
1412
  const loopId = loopRow.id;
1410
1413
  const turnRow = await this.#db.drain_next_turn_seq_for_loop.get({ loop_id: loopId });
1411
1414
  const turnSeq = turnRow?.next ?? 1;
1412
- const sessionRow = await this.#db.drain_get_run_session.get({ run_id: runId });
1413
- if (sessionRow === undefined)
1414
- throw new Error(`Engine.inject: run ${runId} not found`);
1415
- const pathname = promptPathname(runId, loopRow.sequence, turnSeq); // canonical storage form, run-qualified loop-SEQ coordinates matching the turn-1 foist
1415
+ const workspaceRow = await this.#db.drain_get_worker_workspace.get({ worker_id: workerId });
1416
+ if (workspaceRow === undefined)
1417
+ throw new Error(`Engine.inject: run ${workerId} not found`);
1418
+ const pathname = promptPathname(workerId, loopRow.sequence, turnSeq); // canonical storage form, worker-qualified loop-SEQ coordinates matching the turn-1 foist
1416
1419
  const ctx = {
1417
- db: this.#db, sessionId: sessionRow.session_id, runId, loopId,
1420
+ db: this.#db, workspaceId: workspaceRow.workspace_id, workerId, loopId,
1418
1421
  turnId: 0, // no turn open at inject time; entries don't pin turnId
1419
1422
  writer: "plurnk",
1420
1423
  signal: this.#loopAborts.get(loopId)?.signal,
1421
1424
  streamEventNotify: this.#streamEventNotify,
1422
- wakeRunNotify: this.#wakeRunNotify,
1425
+ wakeWorkerNotify: this.#wakeWorkerNotify,
1423
1426
  tokenize: this.#tokenize,
1424
- pushTelemetry: (event) => this.#telemetry.push(sessionRow.session_id, loopId, event),
1427
+ pushTelemetry: (event) => this.#telemetry.push(workspaceRow.workspace_id, loopId, event),
1425
1428
  };
1426
1429
  const entry = {
1427
1430
  channels: { body: { content: prompt, mimetype: "text/markdown" } },