@plurnk/plurnk-service 1.0.5 → 1.0.7

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 (201) hide show
  1. package/.env.defaults +66 -48
  2. package/INSTALL.md +3 -3
  3. package/README.md +4 -4
  4. package/SPEC.md +212 -196
  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 +15 -11
  13. package/dist/core/Dispatcher.d.ts.map +1 -1
  14. package/dist/core/Dispatcher.js +124 -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 +210 -185
  19. package/dist/core/Engine.js.map +1 -1
  20. package/dist/core/Engine.sql +106 -109
  21. package/dist/core/PacketBuilder.d.ts +9 -9
  22. package/dist/core/PacketBuilder.d.ts.map +1 -1
  23. package/dist/core/PacketBuilder.js +199 -151
  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/fork.d.ts +1 -1
  52. package/dist/core/fork.d.ts.map +1 -1
  53. package/dist/core/fork.js +31 -26
  54. package/dist/core/fork.js.map +1 -1
  55. package/dist/core/fork.sql +41 -32
  56. package/dist/core/git-env.d.ts.map +1 -1
  57. package/dist/core/git-env.js +23 -7
  58. package/dist/core/git-env.js.map +1 -1
  59. package/dist/core/git-iso.d.ts +9 -0
  60. package/dist/core/git-iso.d.ts.map +1 -0
  61. package/dist/core/git-iso.js +227 -0
  62. package/dist/core/git-iso.js.map +1 -0
  63. package/dist/core/git-membership.d.ts +2 -2
  64. package/dist/core/git-membership.d.ts.map +1 -1
  65. package/dist/core/git-membership.js +70 -50
  66. package/dist/core/git-membership.js.map +1 -1
  67. package/dist/core/git-state.d.ts +1 -1
  68. package/dist/core/git-state.d.ts.map +1 -1
  69. package/dist/core/git-state.js +21 -11
  70. package/dist/core/git-state.js.map +1 -1
  71. package/dist/core/packet-wire.d.ts.map +1 -1
  72. package/dist/core/packet-wire.js +81 -22
  73. package/dist/core/packet-wire.js.map +1 -1
  74. package/dist/core/plurnk-uri.d.ts +2 -2
  75. package/dist/core/plurnk-uri.d.ts.map +1 -1
  76. package/dist/core/plurnk-uri.js +19 -12
  77. package/dist/core/plurnk-uri.js.map +1 -1
  78. package/dist/core/run-ops.sql +26 -26
  79. package/dist/core/scheme-types.d.ts +6 -6
  80. package/dist/core/scheme-types.d.ts.map +1 -1
  81. package/dist/core/search-gate.js +0 -0
  82. package/dist/core/search-gate.js.map +1 -1
  83. package/dist/core/token-ruler.js +2 -2
  84. package/dist/core/token-ruler.js.map +1 -1
  85. package/dist/core/worker-cap.d.ts +8 -0
  86. package/dist/core/worker-cap.d.ts.map +1 -0
  87. package/dist/core/worker-cap.js +20 -0
  88. package/dist/core/worker-cap.js.map +1 -0
  89. package/dist/core/{session-settings.d.ts → workspace-settings.d.ts} +5 -5
  90. package/dist/core/workspace-settings.d.ts.map +1 -0
  91. package/dist/core/{session-settings.js → workspace-settings.js} +13 -13
  92. package/dist/core/workspace-settings.js.map +1 -0
  93. package/dist/digest/Digest.d.ts +3 -3
  94. package/dist/digest/Digest.d.ts.map +1 -1
  95. package/dist/digest/Digest.js +83 -73
  96. package/dist/digest/Digest.js.map +1 -1
  97. package/dist/digest/digest.sql +18 -18
  98. package/dist/schemes/Exec.d.ts +9 -2
  99. package/dist/schemes/Exec.d.ts.map +1 -1
  100. package/dist/schemes/Exec.js +124 -68
  101. package/dist/schemes/Exec.js.map +1 -1
  102. package/dist/schemes/ExecOutputScheme.js +3 -3
  103. package/dist/schemes/ExecOutputScheme.js.map +1 -1
  104. package/dist/schemes/File.js +23 -23
  105. package/dist/schemes/File.js.map +1 -1
  106. package/dist/schemes/Known.js +5 -5
  107. package/dist/schemes/Known.js.map +1 -1
  108. package/dist/schemes/Log.d.ts.map +1 -1
  109. package/dist/schemes/Log.js +63 -14
  110. package/dist/schemes/Log.js.map +1 -1
  111. package/dist/schemes/Log.sql +49 -8
  112. package/dist/schemes/Plurnk.js +5 -5
  113. package/dist/schemes/Plurnk.js.map +1 -1
  114. package/dist/schemes/Skill.js +5 -5
  115. package/dist/schemes/Skill.js.map +1 -1
  116. package/dist/schemes/Unknown.js +5 -5
  117. package/dist/schemes/Unknown.js.map +1 -1
  118. package/dist/schemes/{Run.d.ts → Worker.d.ts} +2 -2
  119. package/dist/schemes/Worker.d.ts.map +1 -0
  120. package/dist/schemes/{Run.js → Worker.js} +67 -67
  121. package/dist/schemes/Worker.js.map +1 -0
  122. package/dist/schemes/_entry-crud.js +8 -8
  123. package/dist/schemes/_entry-crud.js.map +1 -1
  124. package/dist/schemes/_entry-crud.sql +29 -29
  125. package/dist/schemes/_entry-find.d.ts +2 -1
  126. package/dist/schemes/_entry-find.d.ts.map +1 -1
  127. package/dist/schemes/_entry-find.js +31 -19
  128. package/dist/schemes/_entry-find.js.map +1 -1
  129. package/dist/schemes/_entry-find.sql +9 -9
  130. package/dist/schemes/_entry-graph.d.ts +2 -2
  131. package/dist/schemes/_entry-graph.d.ts.map +1 -1
  132. package/dist/schemes/_entry-graph.js +18 -18
  133. package/dist/schemes/_entry-graph.js.map +1 -1
  134. package/dist/schemes/_entry-graph.sql +8 -8
  135. package/dist/schemes/_entry-manifest.d.ts +1 -1
  136. package/dist/schemes/_entry-manifest.d.ts.map +1 -1
  137. package/dist/schemes/_entry-manifest.js +72 -28
  138. package/dist/schemes/_entry-manifest.js.map +1 -1
  139. package/dist/schemes/_entry-ops.d.ts +4 -4
  140. package/dist/schemes/_entry-ops.d.ts.map +1 -1
  141. package/dist/schemes/_entry-ops.js +25 -25
  142. package/dist/schemes/_entry-ops.js.map +1 -1
  143. package/dist/schemes/_entry-ops.sql +9 -9
  144. package/dist/schemes/_entry-semantic.d.ts +2 -1
  145. package/dist/schemes/_entry-semantic.d.ts.map +1 -1
  146. package/dist/schemes/_entry-semantic.js +23 -4
  147. package/dist/schemes/_entry-semantic.js.map +1 -1
  148. package/dist/schemes/_entry-semantic.sql +18 -4
  149. package/dist/schemes/_entry-send.d.ts +1 -1
  150. package/dist/schemes/_entry-send.d.ts.map +1 -1
  151. package/dist/schemes/_entry-send.js +6 -6
  152. package/dist/schemes/_entry-send.js.map +1 -1
  153. package/dist/schemes/exec-abort.js +1 -1
  154. package/dist/schemes/exec-abort.js.map +1 -1
  155. package/dist/server/Daemon.d.ts +48 -48
  156. package/dist/server/Daemon.d.ts.map +1 -1
  157. package/dist/server/Daemon.js +295 -292
  158. package/dist/server/Daemon.js.map +1 -1
  159. package/dist/server/client-input.js +20 -20
  160. package/dist/server/client-input.js.map +1 -1
  161. package/dist/server/dispatch-as-plurnk.d.ts +1 -1
  162. package/dist/server/dispatch-as-plurnk.d.ts.map +1 -1
  163. package/dist/server/dispatch-as-plurnk.js +7 -7
  164. package/dist/server/dispatch-as-plurnk.js.map +1 -1
  165. package/dist/server/drain.sql +27 -27
  166. package/dist/server/envelope.d.ts +20 -20
  167. package/dist/server/envelope.d.ts.map +1 -1
  168. package/dist/server/envelope.js +86 -86
  169. package/dist/server/envelope.js.map +1 -1
  170. package/dist/server/envelope.sql +39 -39
  171. package/dist/server/logEntry.d.ts +1 -1
  172. package/dist/server/logEntry.d.ts.map +1 -1
  173. package/dist/server/logEntry.js +1 -1
  174. package/dist/server/logEntry.js.map +1 -1
  175. package/dist/server/loopDocs.d.ts +1 -1
  176. package/dist/server/loopDocs.d.ts.map +1 -1
  177. package/dist/server/loopDocs.js +6 -6
  178. package/dist/server/loopDocs.js.map +1 -1
  179. package/dist/server/seam-entry-read.sql +2 -2
  180. package/dist/server/seam-log-read.sql +3 -3
  181. package/dist/server/seam-loop.sql +3 -3
  182. package/dist/server/seam-proposal-list.sql +4 -4
  183. package/dist/server/yolo.js +2 -2
  184. package/dist/server/yolo.js.map +1 -1
  185. package/dist/service.d.ts.map +1 -1
  186. package/dist/service.js +22 -7
  187. package/dist/service.js.map +1 -1
  188. package/migrations/0000-00-00.01_schema.sql +82 -70
  189. package/package.json +18 -15
  190. package/dist/core/run-cap.d.ts +0 -8
  191. package/dist/core/run-cap.d.ts.map +0 -1
  192. package/dist/core/run-cap.js +0 -20
  193. package/dist/core/run-cap.js.map +0 -1
  194. package/dist/core/search-prefetch.d.ts +0 -17
  195. package/dist/core/search-prefetch.d.ts.map +0 -1
  196. package/dist/core/search-prefetch.js +0 -164
  197. package/dist/core/search-prefetch.js.map +0 -1
  198. package/dist/core/session-settings.d.ts.map +0 -1
  199. package/dist/core/session-settings.js.map +0 -1
  200. package/dist/schemes/Run.d.ts.map +0 -1
  201. 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";
5
+ import { markTerminal } from "../schemes/Worker.js";
6
6
  import GitMembership from "./git-membership.js";
7
7
  import GitState from "./git-state.js";
8
- import SessionSettings from "./session-settings.js";
8
+ import WorkspaceSettings from "./workspace-settings.js";
9
9
  import { editedSpan } from "../content/index.js";
10
10
  import { promptPathname } from "./plurnk-uri.js";
11
11
  import { rulerCount } from "./token-ruler.js";
12
- import SearchPrefetch from "./search-prefetch.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;
@@ -142,11 +143,11 @@ export default class Engine {
142
143
  // (the steer's own advice is to wait; in-memory, fail-open on restart).
143
144
  #retrievalRefusalGrace = new Set();
144
145
  // §derivation-off-hot-path — the background derivation chain: the per-turn pump and the
145
- // 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
146
147
  // ingest starved every loop for ~28min, #316). Serialized (never two pumps interleaved),
147
148
  // drained at daemon stop (never racing db close), failures logged (never swallowed).
148
149
  // A turn never waits on an embedding; a ~query warms its own candidate slice inline
149
- // (§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.
150
151
  #derivationChain = Promise.resolve();
151
152
  #queueDerivation(job) {
152
153
  const run = this.#derivationChain.then(job).catch((err) => {
@@ -160,14 +161,15 @@ export default class Engine {
160
161
  await this.#derivationChain;
161
162
  }
162
163
  #streamEventNotify;
163
- #wakeRunNotify;
164
+ #wakeWorkerNotify;
164
165
  // Cached plurnk GBNF — read once on the first constrained generate (#189).
165
166
  #gbnfCache = new Map(); // variant name -> GBNF text (per-alias selection, #353)
166
- 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 }) {
167
169
  this.#db = db;
168
170
  this.#schemes = schemes;
169
171
  this.#streamEventNotify = streamEventNotify;
170
- this.#wakeRunNotify = wakeRunNotify;
172
+ this.#wakeWorkerNotify = wakeWorkerNotify;
171
173
  // Default to empty discovery — standalone Engine construction (in
172
174
  // tests) gets no handlers, and content flows through the framework's
173
175
  // raw-content fitContent fallback. Daemon-managed Engine receives a
@@ -184,12 +186,9 @@ export default class Engine {
184
186
  this.#telemetry = new TelemetryChannel({ db, notify: telemetryEventNotify });
185
187
  this.#strikes = new StrikeRail();
186
188
  this.#packets = new PacketBuilder({ db, schemes, telemetry: this.#telemetry, executors });
187
- // §search-prefetch — the web-member sync pass; the daemon's wake handler drives it,
188
- // the hold loop below honors it (holds() = a pass in flight for the run).
189
- this.searchPrefetch = new SearchPrefetch({ db: this.#db, schemes: this.#schemes, tokenize: this.#tokenize });
190
189
  this.#proposals = new ProposalLifecycle({
191
190
  db, schemes, telemetry: this.#telemetry,
192
- streamEventNotify, wakeRunNotify,
191
+ streamEventNotify, wakeWorkerNotify,
193
192
  tokenize: this.#tokenize, mimetypes: this.#mimetypes, executors, loopSignal,
194
193
  });
195
194
  this.#dispatcher = new Dispatcher({ searchGate: this.searchGate,
@@ -197,7 +196,7 @@ export default class Engine {
197
196
  tokenize: this.#tokenize,
198
197
  telemetry: this.#telemetry, proposals: this.#proposals,
199
198
  executors, loopSignal,
200
- streamEventNotify, wakeRunNotify, injectRun, cancelRun,
199
+ streamEventNotify, wakeWorkerNotify, injectWorker, cancelWorker,
201
200
  parkDeadlines: this.parkDeadlines,
202
201
  joinTargets: this.joinTargets,
203
202
  });
@@ -238,18 +237,36 @@ export default class Engine {
238
237
  // that constrain sampling (llama-server, response_format backends); a cloud model that IGNORES the grammar
239
238
  // gets a filter-mode divergence event every turn for nothing. So the bare default is OFF
240
239
  // and the GBNF-capable aliases opt IN via a PLURNK_PROVIDERS_GBNF_<alias> suffix.
241
- 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 ?? "";
242
253
  const variant = scopeEnvToAlias(process.env, alias, ["PLURNK_PROVIDERS_GBNF"]).PLURNK_PROVIDERS_GBNF;
243
- 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
+ }
244
259
  return undefined;
260
+ }
245
261
  const hit = this.#gbnfCache.get(variant);
246
262
  if (hit !== undefined)
247
263
  return hit;
248
264
  const path = variant.startsWith("/") || variant.startsWith(".")
249
265
  ? variant
250
266
  : fileURLToPath(import.meta.resolve(`@plurnk/plurnk-grammar/${variant}`));
251
- const text = await readFile(path, "utf8");
267
+ const text = await readFile(path, "utf8"); // unresolvable/unreadable throws — a configured rail never silently degrades
252
268
  this.#gbnfCache.set(variant, text);
269
+ process.stderr.write(`plurnk-engine: grammar rail: ${alias || "(bare)"} → ${variant} (${text.length} chars)\n`);
253
270
  return text;
254
271
  }
255
272
  // Per-loop usage totals (#197): SUM the loop's turns (usage is stored per
@@ -272,7 +289,7 @@ export default class Engine {
272
289
  // summed promptTokens above, which overcounts a context that grows across turns.
273
290
  contextTokens: row?.context ?? 0,
274
291
  // #274 — the last turn's model window (denominator); null when the provider reports none.
275
- contextSize: row?.context_size ?? null,
292
+ promptBudget: row?.context_size ?? null,
276
293
  // #252 — the latest turn's opaque provider blob, parsed for the wire. Empty {} when the
277
294
  // provider returned no meta. The service forwards it; it never reads a field within.
278
295
  meta: JSON.parse(row?.meta ?? "{}"),
@@ -298,7 +315,7 @@ export default class Engine {
298
315
  }
299
316
  return { line, column };
300
317
  }
301
- 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, }) {
302
319
  const turnIds = [];
303
320
  const suddenDeathThreshold = maxTurns - maxStrikes;
304
321
  // Per-loop AbortController for scheme-side cancellation propagation.
@@ -352,7 +369,7 @@ export default class Engine {
352
369
  if (row.status === 100) {
353
370
  // NOT a terminal — a wake re-queued this loop while its own live drain was
354
371
  // between turns (a child concluded in the gap between our 202 write and this
355
- // 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
356
373
  // RUNNING: re-claim atomically and continue — the injected prompt is already
357
374
  // this loop's next turn. Returning it as "external" broadcast a QUEUED loop
358
375
  // as loop/terminated {finalStatus: 100} — the delegation-flags flake.
@@ -390,19 +407,19 @@ export default class Engine {
390
407
  const holdCapMs = Number(process.env.PLURNK_SERVICE_EXEC_HOLD_MS ?? "300000");
391
408
  if (holdSet.size > 0 && holdCapMs > 0 && execHandler?.hasActiveHoldSpawns !== undefined) {
392
409
  const holdStart = Date.now();
393
- while ((execHandler.hasActiveHoldSpawns(runId, holdSet) || this.searchPrefetch.holds(runId)) && Date.now() - holdStart < holdCapMs) {
410
+ while (execHandler.hasActiveHoldSpawns(workerId, holdSet) && Date.now() - holdStart < holdCapMs) {
394
411
  await delay(150, undefined, { signal });
395
412
  }
396
413
  }
397
414
  const execWaitMs = Number(process.env.PLURNK_SERVICE_EXEC_WAIT_MS ?? "0");
398
415
  if (execWaitMs > 0) {
399
- if (execHandler?.hasActiveSpawns?.(runId) === true)
416
+ if (execHandler?.hasActiveSpawns?.(workerId) === true)
400
417
  await delay(execWaitMs, undefined, { signal });
401
418
  }
402
419
  let turn;
403
420
  try {
404
421
  turn = await this.runTurn({
405
- provider, messages, requirements, sessionId, runId, loopId, origin, signal, onDispatch,
422
+ provider, messages, requirements, workspaceId, workerId, loopId, origin, signal, onDispatch,
406
423
  turnNumber: turnIds.length + 1, maxTurns,
407
424
  });
408
425
  }
@@ -449,7 +466,7 @@ export default class Engine {
449
466
  }
450
467
  }
451
468
  }
452
- 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, }) {
453
470
  // === Turn-as-container model ===
454
471
  //
455
472
  // Turn rows are created at runTurn OPEN (status=102, placeholder
@@ -462,8 +479,8 @@ export default class Engine {
462
479
  // writes consume low indices; model ops continue from there.
463
480
  const seqRow = await this.#db.engine_next_turn_sequence.get({ loop_id: loopId });
464
481
  const seq = seqRow.next;
465
- // #269 — loops.sequence is the loop's ordinal WITHIN the run. Turn-0 foists that belong to the
466
- // 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
467
484
  // first turn; per-loop foists (the prompt, @file) still fire each loop. Read once, turn-1 only.
468
485
  const loopRow = seq === 1
469
486
  ? await this.#db.engine_get_loop_prompt.get({ loop_id: loopId })
@@ -487,7 +504,7 @@ export default class Engine {
487
504
  // from sequence=2 onward on prompt-foisted turns; 1 onward
488
505
  // otherwise.
489
506
  let nextActionIndex = 1;
490
- // §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
491
508
  // worked turn PLAN → the environment FINDs the foist ACTUALLY dispatches → SEND[102]. Built
492
509
  // from the real ops below (not a static print — we lean into the genuine echo paradigm) and
493
510
  // written at sequence 1, so it reads first as the emission with the foisted results following.
@@ -496,15 +513,15 @@ export default class Engine {
496
513
  if (runFirstLoop)
497
514
  nextActionIndex = 2; // reserve sequence 1 for the turn-0 echo
498
515
  // Operator doc READs (PLURNK_SERVICE_MD_<ALIAS>, §actor-boundary-doc-injection). The docs were materialized
499
- // 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
500
517
  // §actor-boundary keystone); foist a READ of each into THIS turn-0 so the model
501
518
  // reads them inline. It sees only the READ — the materializing EDIT
502
- // lives in the plurnk run's log, never the model's.
503
- // #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
504
521
  // each materialized plurnk:///<alias>.md (loop_run materialized the same set).
505
- const { mdDocs } = await SessionSettings.read(this.#db, sessionId);
506
- // #269 — operator docs are run-once; foist them only on the run's first loop.
507
- 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) : []) {
508
525
  const docTarget = {
509
526
  kind: "url", raw: `plurnk:///${doc.entryName}`, scheme: "plurnk",
510
527
  username: null, password: null, hostname: null, port: null,
@@ -515,7 +532,7 @@ export default class Engine {
515
532
  lineMarker: null, body: null, position: { line: 1, column: 1 },
516
533
  };
517
534
  await this.dispatch({
518
- statement: docRead, sessionId, runId, loopId, turnId,
535
+ statement: docRead, workspaceId, workerId, loopId, turnId,
519
536
  sequence: nextActionIndex, origin: "plurnk", onDispatch,
520
537
  });
521
538
  nextActionIndex++;
@@ -523,7 +540,7 @@ export default class Engine {
523
540
  const promptRow = loopRow; // #269 — already read above (per-loop; fires every loop's turn 1)
524
541
  if (promptRow !== undefined && typeof promptRow.prompt === "string" && promptRow.prompt.length > 0) {
525
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)
526
- const promptPath = promptTarget(runId, promptLoopSeq, seq);
543
+ const promptPath = promptTarget(workerId, promptLoopSeq, seq);
527
544
  const promptStmt = {
528
545
  op: "EDIT", suffix: "", signal: null,
529
546
  target: promptPath, lineMarker: null,
@@ -531,7 +548,7 @@ export default class Engine {
531
548
  };
532
549
  let promptLogId;
533
550
  await this.dispatch({
534
- statement: promptStmt, sessionId, runId, loopId, turnId,
551
+ statement: promptStmt, workspaceId, workerId, loopId, turnId,
535
552
  sequence: nextActionIndex, origin: "plurnk",
536
553
  onDispatch: (id) => { promptLogId = id; onDispatch?.(id); },
537
554
  });
@@ -545,14 +562,19 @@ export default class Engine {
545
562
  // when it runs fewer than 12 (whole-read form doubles as teaching). Prior prompts
546
563
  // stay listed by path in the system packet's User Prompts section — reachable,
547
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");
548
570
  const promptLineCount = promptRow.prompt.split("\n").length;
549
571
  const promptRead = {
550
572
  op: "READ", suffix: "", signal: null, target: promptPath,
551
- lineMarker: { marks: promptLineCount >= 12 ? [1, 12] : [1, -1] },
573
+ lineMarker: { marks: promptLineCount >= previewLines ? [1, previewLines] : [1, -1] },
552
574
  body: null, position: { line: 1, column: 1 },
553
575
  };
554
576
  await this.dispatch({
555
- statement: promptRead, sessionId, runId, loopId, turnId,
577
+ statement: promptRead, workspaceId, workerId, loopId, turnId,
556
578
  sequence: nextActionIndex, origin: "plurnk", onDispatch,
557
579
  });
558
580
  nextActionIndex++;
@@ -564,18 +586,18 @@ export default class Engine {
564
586
  if (seq > 1) {
565
587
  const loopSeqRow = await this.#db.engine_loop_sequence.get({ loop_id: loopId });
566
588
  const loopSeq = loopSeqRow?.sequence ?? loopId;
567
- 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) });
568
590
  const injectedRow = injected.find((r) => typeof r.content === "string" && r.content.length > 0);
569
591
  if (injectedRow !== undefined) {
570
592
  const lineCount = injectedRow.content.split("\n").length;
571
- const injTarget = promptTarget(runId, loopSeq, seq);
593
+ const injTarget = promptTarget(workerId, loopSeq, seq);
572
594
  const injRead = {
573
595
  op: "READ", suffix: "", signal: null, target: injTarget,
574
596
  lineMarker: { marks: lineCount >= 12 ? [1, 12] : [1, -1] },
575
597
  body: null, position: { line: 1, column: 1 },
576
598
  };
577
599
  await this.dispatch({
578
- statement: injRead, sessionId, runId, loopId, turnId,
600
+ statement: injRead, workspaceId, workerId, loopId, turnId,
579
601
  sequence: nextActionIndex, origin: "plurnk", onDispatch,
580
602
  });
581
603
  nextActionIndex++;
@@ -585,46 +607,46 @@ export default class Engine {
585
607
  // every entry's deep channels (symbols/refs/embeddings/FTS, deep_hash-gated) so the
586
608
  // catalog and FIND read current data. NOT an action: no log entry, no sequence slot,
587
609
  // not dispatched. There is no plurnk:///manifest.json entry — the catalog is served
588
- // 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.
589
611
  // #312 — the turn's token gauge: the ACTIVE provider's tokenizer identity + exact counter
590
612
  // (mimetypes seam; provider upper bound surfaced as tokenizer_unavailable when inexact).
591
613
  // Threaded per turn — never engine state — so concurrent loops on different providers
592
614
  // each read their own honest numbers.
593
615
  const systemCtx = {
594
- db: this.#db, sessionId, runId, loopId, turnId,
616
+ db: this.#db, workspaceId, workerId, loopId, turnId,
595
617
  writer: "plurnk",
596
618
  signal: this.#loopAborts.get(loopId)?.signal,
597
619
  streamEventNotify: this.#streamEventNotify,
598
- wakeRunNotify: this.#wakeRunNotify,
620
+ wakeWorkerNotify: this.#wakeWorkerNotify,
599
621
  tokenize: this.#tokenize,
600
622
  mimetypes: this.#mimetypes,
601
623
  defaultChannelFor: (s) => this.#schemes.defaultChannelFor(s),
602
- pushTelemetry: (event) => this.#telemetry.push(sessionId, loopId, event),
624
+ pushTelemetry: (event) => this.#telemetry.push(workspaceId, loopId, event),
603
625
  };
604
626
  // SPEC §membership D4/D5 — git-ls-files workspace membership, resolved at
605
627
  // prompt-composition (EMI is eager + exhaustive — git is the only bound). When the
606
- // 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
607
629
  // members without a client `add`; active members are materialized
608
630
  // (disk → body channel) so they appear in the catalog. No-ops
609
- // on headless / non-git sessions. Runs BEFORE the derivation pump so
631
+ // on headless / non-git workspaces. Runs BEFORE the derivation pump so
610
632
  // this turn's packet reflects them.
611
633
  const fsDivergences = await GitMembership.indexGitMembership(systemCtx);
612
- await this.#logFsFictions(sessionId, fsDivergences);
634
+ await this.#logFsFictions(workspaceId, fsDivergences);
613
635
  this.#queueDerivation(() => EntryManifest.maintainDerivations(systemCtx)); // §derivation-off-hot-path — the turn proceeds; ~queries warm their own slice
614
636
  // Turn-0 catalog preview (PLURNK_SERVICE_FILES_ITEMS, §actor-boundary-catalog-preview):
615
- // 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
616
638
  // model turn so it opens with its catalog (the per-scheme arrays that replaced the
617
639
  // single manifest.json). -1 → each scheme's whole catalog; N → its first N rows
618
640
  // (clamped to the scheme's count so FIND's strict <L> never 416s); off by default.
619
641
  if (seq === 1) {
620
- // #231 — a session's client-chosen filesItems REPLACES the env default outright.
621
- const { filesItems: sessionMI } = await SessionSettings.read(this.#db, sessionId);
622
- 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();
623
645
  if (filesItems !== null && runFirstLoop) { // #269 — catalog preview is run-once
624
- // engine_scheme_catalog_summary is the scheme source: session-scoped, ordered,
646
+ // engine_scheme_catalog_summary is the scheme source: workspace-scoped, ordered,
625
647
  // one row per scheme that has entries (scheme=null → file). log:// is absent —
626
648
  // it lives in log_entries, not the catalog (present-mode, the # Log section).
627
- 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 });
628
650
  // known:/// + unknown:/// + file ALWAYS foist, even at zero entries — else the
629
651
  // model burns a turn running the FIND itself, assuming the catalog is merely
630
652
  // being withheld. An empty FIND(**) is orienting, not noise (owner): it tells
@@ -666,7 +688,7 @@ export default class Engine {
666
688
  position: { line: 1, column: 1 },
667
689
  };
668
690
  await this.dispatch({
669
- statement: catalogFind, sessionId, runId, loopId, turnId,
691
+ statement: catalogFind, workspaceId, workerId, loopId, turnId,
670
692
  sequence: nextActionIndex, origin: "plurnk", onDispatch,
671
693
  });
672
694
  nextActionIndex++;
@@ -674,21 +696,21 @@ export default class Engine {
674
696
  // own survey, mirrored OPEN). The <L> cap rides as `<1,N>`, exactly as the model would type it.
675
697
  turnZeroMoves.push(`<<FIND(${isPlurnk ? "plurnk://docs/**" : isFile ? "**" : `${schemeName}:///**`})${cap === null ? "" : `<1,${cap}>`}::FIND`);
676
698
  }
677
- // §run-scheme — Manifest(run) = session-scope ∪ THIS run's run-scope. Foist the
678
- // 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
679
701
  // manage its private workspace) so it's catalogued in ITS perspective alone; other
680
- // runs reach it only via explicit FIND(run://<name>/**). A run with no scratch foists nothing.
681
- const selfRun = await this.#db.run_name_by_id.get({ run_id: runId });
682
- 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;
683
705
  if (scratch > 0) {
684
706
  const runFind = {
685
707
  op: "FIND", suffix: "", signal: null,
686
- 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 },
687
709
  body: null, lineMarker: null, position: { line: 1, column: 1 },
688
710
  };
689
- 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 });
690
712
  nextActionIndex++;
691
- 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
692
714
  }
693
715
  }
694
716
  // #260 — foist a turn-0 READ of each client-passed @file path so its content sits in front
@@ -707,7 +729,7 @@ export default class Engine {
707
729
  body: null, position: { line: 1, column: 1 },
708
730
  };
709
731
  await this.dispatch({
710
- statement: fileRead, sessionId, runId, loopId, turnId,
732
+ statement: fileRead, workspaceId, workerId, loopId, turnId,
711
733
  sequence: nextActionIndex, origin: "plurnk", onDispatch,
712
734
  });
713
735
  nextActionIndex++;
@@ -718,34 +740,34 @@ export default class Engine {
718
740
  // so the grammar can stay thin. Subsequent turns mirror the model's real output, folded.
719
741
  if (runFirstLoop) {
720
742
  const emission = ["<<PLAN:Initialize:PLAN", ...turnZeroMoves, "<<SEND[102]:Initialized:SEND"].join("\n");
721
- 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" });
722
744
  }
723
745
  }
724
- // §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
725
747
  // it last looked) as foisted rows before the packet composes; advance the action index
726
748
  // past them so model ops continue after. Two instances of one machine: env-delta (sibling
727
749
  // edits · timestamp cursor · always folded) and exec streams (channel bytes · byte cursor ·
728
750
  // terminal delta opens). §env-delta §exec-stream
729
- // §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
730
752
  // from a prior turn — this runs before the turn's own spawns) so a `<0>` never survives into
731
753
  // the subsequent turn. The terminal output then surfaces born-OPEN via the stream-delta path.
732
- await this.#reapTurnScopedStreams(runId);
733
- nextActionIndex += await this.#materializeEnvironmentDeltas({ sessionId, runId, loopId, turnId, fromSequence: nextActionIndex });
734
- 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 });
735
757
  // SPEC §telemetry — git working-tree state for the telemetry section, read once
736
758
  // (a service-side `git status` shell-out) and threaded into the budget
737
759
  // rebuild too so it isn't re-shelled on overflow.
738
- 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);
739
761
  // Build the spec'd packet (Packet.json) request half. The log build
740
- // queries log_entries scoped to the run — the prompt entry just
762
+ // queries log_entries scoped to the worker — the prompt entry just
741
763
  // written (if turn 1) is part of that query result.
742
764
  let requestPacket = await this.#packets.buildRequestPacket({
743
- initialMessages: messages, requirements, sessionId, runId, loopId,
765
+ initialMessages: messages, requirements, workspaceId, workerId, loopId,
744
766
  currentTurnSeq: seq, provider, gitStatus,
745
767
  });
746
768
  // SPEC §grinder — budget grinder, pre-LLM: reclaim window on actual overflow.
747
769
  const enforced = await this.#packets.enforceBudget({
748
- packet: requestPacket, provider, runId, loopId, turnId,
770
+ packet: requestPacket, provider, workerId, loopId, turnId,
749
771
  // The overflow error row is minted at the turn's running sequence (nextActionIndex), pre-generate;
750
772
  // runTurn advances the counter past it below so the post-generate dispatch rows never collide.
751
773
  mintSequence: nextActionIndex,
@@ -753,7 +775,7 @@ export default class Engine {
753
775
  // overflow row just minted surfaces THIS turn (§grinder-overflow-error-row). Safe: the
754
776
  // ephemeral buffer is empty pre-generate (events drain on the next turn's build).
755
777
  rebuild: () => this.#packets.buildRequestPacket({
756
- initialMessages: messages, requirements, sessionId, runId, loopId,
778
+ initialMessages: messages, requirements, workspaceId, workerId, loopId,
757
779
  currentTurnSeq: seq, provider, gitStatus,
758
780
  }),
759
781
  });
@@ -771,13 +793,13 @@ export default class Engine {
771
793
  // overflow terminates 413 — death only after the model was told. Physically
772
794
  // unsendable (over the provider's real window too) → 413 immediately; physics
773
795
  // doesn't negotiate. The pointer stays at 100% of budget — a margin would mask it.
774
- const physicallySendable = provider.contextSize === null
796
+ const physicallySendable = provider.contextWindow === null
775
797
  ? true
776
- : this.#packets.exactPacketTokens(requestPacket, provider) <= provider.contextSize - this.#packets.decodeBudget(provider);
798
+ : this.#packets.exactPacketTokens(requestPacket, provider) <= provider.contextWindow - (this.#packets.maxTokensFor(provider) ?? 0);
777
799
  if (physicallySendable && !this.#hardOverflowRecovery.has(loopId)) {
778
800
  this.#hardOverflowRecovery.add(loopId);
779
801
  await this.#db.engine_insert_log_entry.get({
780
- run_id: runId, loop_id: loopId, turn_id: turnId, sequence: nextActionIndex++,
802
+ worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence: nextActionIndex++,
781
803
  origin: "model", source: "engine", op: "error", suffix: "", signal: null,
782
804
  scheme: null, username: null, password: null, hostname: null, port: null,
783
805
  pathname: null, params: null, fragment: null, lineMarker: null,
@@ -790,7 +812,7 @@ export default class Engine {
790
812
  // errors sections (the same re-derive contract the soft grind uses).
791
813
  nextActionIndex += 1;
792
814
  requestPacket = await this.#packets.buildRequestPacket({
793
- initialMessages: messages, requirements, sessionId, runId, loopId,
815
+ initialMessages: messages, requirements, workspaceId, workerId, loopId,
794
816
  currentTurnSeq: seq, provider, gitStatus,
795
817
  });
796
818
  }
@@ -801,7 +823,7 @@ export default class Engine {
801
823
  await this.#db.engine_close_turn.run({
802
824
  id: turnId, status: 413, packet: JSON.stringify(hardPacket),
803
825
  usage_prompt: 0, usage_completion: 0, usage_reasoning: 0, usage_cached: 0, usage_cost_pico: 0,
804
- 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
805
827
  finish_reason: "budget_hard_stop", model: provider.model, meta: "{}",
806
828
  });
807
829
  return { turnId, status: 413, statuses: [], fingerprint: "", budgetStruck: enforced.struck, budgetHardStop: true, steerStruck: false };
@@ -817,7 +839,7 @@ export default class Engine {
817
839
  // the input under the ceiling); the model's decode — reasoning + emission — is
818
840
  // out of band, owned by the provider's own context window. Deriving a maxTokens
819
841
  // from our budget conflated the two and guillotined a reasoning model's
820
- // 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
821
843
  // decode → finish=length mid-reasoning → no emission → strike spiral). The
822
844
  // provider enforces its physical wall on its own.
823
845
  let response;
@@ -827,8 +849,8 @@ export default class Engine {
827
849
  // #249 — tag the loop (the activity) with its active plugins' attribution tags, write-once.
828
850
  if (attributions.length > 0)
829
851
  await this.#db.engine_tag_loop_attributions.run({ loop_id: loopId, attributions: JSON.stringify(attributions) });
830
- // #249 — session-stable frontend id, forwarded as Plurnk-Client by the plurnk provider only.
831
- 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);
832
854
  try {
833
855
  // §turn-lifecycle (#301) — the provider call is the long, opaque window (submit → first
834
856
  // committed op is provider latency + a full first-turn generation, ~70s local): a static
@@ -837,13 +859,19 @@ export default class Engine {
837
859
  // ops are about to land. Base telemetry/event channel (the embed_progress precedent, §tokenomics
838
860
  // clients already render it unconditionally); the abort guard keeps a cancelled loop silent.
839
861
  if (!signal?.aborted)
840
- 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" });
841
863
  // generate rides the LOOP signal (already chained from the caller's), so a loop-level
842
864
  // abort — the §operator-config-loop-timeout wall — cancels a stuck provider call, not
843
865
  // just the schemes. Bare runTurn (no runLoop) has no loop entry → the caller's signal.
844
- 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) ?? undefined, 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
845
873
  if (!signal?.aborted)
846
- 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" });
847
875
  }
848
876
  catch (err) {
849
877
  // §turn-never-blank — a ProviderError is an INFRASTRUCTURE failure (auth, network
@@ -857,7 +885,7 @@ export default class Engine {
857
885
  // adjudication into a model-behavior 422 — a state the system otherwise forbids,
858
886
  // a record that lied, and days of forensics pointed at the wrong suspect.
859
887
  if (err instanceof ProviderError) {
860
- 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" });
861
889
  }
862
890
  throw err;
863
891
  }
@@ -893,7 +921,7 @@ export default class Engine {
893
921
  const located = typeof event.position === "number"
894
922
  ? this.#offsetToLineColumn(packetAssistant.content, event.position)
895
923
  : null;
896
- this.#telemetry.push(sessionId, loopId, {
924
+ this.#telemetry.push(workspaceId, loopId, {
897
925
  source: event.source,
898
926
  kind: event.kind,
899
927
  message: event.message ?? "",
@@ -931,8 +959,14 @@ export default class Engine {
931
959
  : realOpsCount === 0 ? TURN_STATUS_NO_OPS : TURN_STATUS_IMPLICIT_CONTINUE;
932
960
  // Idle turn: an implicit-continue (102) that did no WORK — its ops are only PLAN/SEND, no mid op.
933
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.
934
968
  const midOpsCount = packetAssistant.ops.filter((op) => op.op !== "PLAN" && op.op !== "SEND").length;
935
- if (!steerStruck && turnStatus === TURN_STATUS_IMPLICIT_CONTINUE && midOpsCount === 0) {
969
+ if (!steerStruck && turnStatus === TURN_STATUS_IMPLICIT_CONTINUE && midOpsCount === 0 && parseErrors.length === 0) {
936
970
  // One grace turn after a retrieval-only 409 (admins specimen): the refusal steer says
937
971
  // "continuing in order to receive results" — a model that obediently waits one bare
938
972
  // [102] turn is following OUR advice, and the idle rail was executing it for that.
@@ -960,7 +994,7 @@ export default class Engine {
960
994
  usage_reasoning: usage.reasoning,
961
995
  usage_cached: usage.cached,
962
996
  usage_cost_pico: provider.costFor(usage), // §provider-surface-costfor
963
- 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
964
998
  finish_reason: finishReason,
965
999
  model,
966
1000
  // #252 — opaque provider→client metadata passthrough (e.g. balancePico the
@@ -970,15 +1004,13 @@ export default class Engine {
970
1004
  // Dispatch model ops starting at nextActionIndex (continues the
971
1005
  // turn's running counter after any pre-model writes).
972
1006
  //
973
- // Max-commands cap: a single emission with more than `maxCommands`
974
- // ops is the runaway-loop fingerprint observed in pathological cases
975
- // (html-attrs demo: 635 ops in one turn). Cap dispatches at the
976
- // configured limit; overflow ops are dropped without per-op log
977
- // entries (avoids bloating forensics with hundreds of identical refusals)
978
- // and the model gets a single telemetry signal next packet so it knows
979
- // its emission was truncated.
980
- // #232 — a session's maxCommands is a tighten-only ceiling: min() the env ceiling.
981
- 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);
982
1014
  // PLAN (reasoning) and a terminal SEND (signal ≥ 200, the conclusion) are not
983
1015
  // actions — they always dispatch and never count against the cap. maxCommands
984
1016
  // bounds real actions only; maxCommands:0 still admits a plan and a conclusion
@@ -995,7 +1027,7 @@ export default class Engine {
995
1027
  let rowSeq = nextActionIndex;
996
1028
  for (const statement of opsToDispatch) {
997
1029
  const result = await this.dispatch({
998
- statement, sessionId, runId, loopId, turnId,
1030
+ statement, workspaceId, workerId, loopId, turnId,
999
1031
  sequence: rowSeq,
1000
1032
  origin, onDispatch,
1001
1033
  // §send-200-failed-ops — parse errors mint as rows AFTER this loop; the terminal
@@ -1036,25 +1068,33 @@ export default class Engine {
1036
1068
  if (droppedCount > 0)
1037
1069
  pendingEngineErrors.push("max_commands_exceeded");
1038
1070
  for (const kind of pendingEngineErrors)
1039
- await this.#telemetry.mintEngineError(kind, { runId, loopId, turnId, sequence: errSeq++ });
1071
+ await this.#telemetry.mintEngineError(kind, { workerId, loopId, turnId, sequence: errSeq++ });
1040
1072
  // §log-row-self-explains (Q2, owner-clarified) — a model-op failure is the MODEL'S OWN op
1041
1073
  // result: the op row carries its failure message on its meta line (packet-wire), and the
1042
1074
  // errors section points at the row. No separate minted item (the retired action_failure
1043
1075
  // mint dressed op results as source:"engine" faults — the jumbo model chased a phantom
1044
1076
  // "engine run 400 error" off a message-less item). Genuine engine-internal faults CRASH
1045
1077
  // (fail-hard, §turn-never-blank) and never mint model-facing rows.
1046
- // §tokenomics-output-truncated (#351) — packet honesty at the completion cap: a
1047
- // finish=length turn was GUILLOTINED mid-emission, and its parse errors are truncation
1048
- // ARTIFACTS. This one MINTS (actionless — no op row exists to self-explain): the cause
1049
- // must lead the artifact rows it explains, or the model fixes syntax forever.
1050
- 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`;
1051
1091
  await this.#db.engine_insert_log_entry.get({
1052
- run_id: runId, loop_id: loopId, turn_id: turnId, sequence: errSeq++,
1092
+ worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence: errSeq++,
1053
1093
  origin: "model", source: "engine", op: "error", suffix: "", signal: null,
1054
1094
  scheme: null, username: null, password: null, hostname: null, port: null,
1055
1095
  pathname: null, params: null, fragment: null, lineMarker: null,
1056
1096
  tx: "", mimetype_tx: "text/plain",
1057
- 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 }),
1058
1098
  mimetype_rx: "application/json", status_rx: 413, tokens: 0, state: "failed", outcome: "output_truncated",
1059
1099
  attrs: "{}",
1060
1100
  });
@@ -1063,7 +1103,7 @@ export default class Engine {
1063
1103
  // resolved against the model's folded mirror row (§model-entry) — origin 'model', not engine.
1064
1104
  for (const { message, line, column, source } of parseErrors ?? []) {
1065
1105
  await this.#db.engine_insert_log_entry.get({
1066
- run_id: runId, loop_id: loopId, turn_id: turnId, sequence: errSeq++,
1106
+ worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence: errSeq++,
1067
1107
  origin: "model", source: "grammar", op: "error", suffix: "", signal: null,
1068
1108
  scheme: null, username: null, password: null, hostname: null, port: null,
1069
1109
  pathname: null, params: null, fragment: null, lineMarker: null,
@@ -1084,8 +1124,9 @@ export default class Engine {
1084
1124
  // else does: the model that cares READs the folded row at the lines it wants — and can
1085
1125
  // introspect any prior emission of its own the same way. Empty emissions (a struck/
1086
1126
  // silent turn) write nothing — no prior output to mirror.
1087
- if (packetAssistant.content.trim().length > 0) {
1088
- await this.#dispatcher.writeModelEntry({ verbatim: packetAssistant.content, runId, loopId, turnId, sequence: errSeq++, folded: true });
1127
+ const sealed = response.assistant.reasoningEncrypted;
1128
+ if (packetAssistant.content.trim().length > 0 || (sealed !== undefined && sealed.length > 0)) {
1129
+ await this.#dispatcher.writeModelEntry({ verbatim: packetAssistant.content, workerId, loopId, turnId, sequence: errSeq++, folded: true, ...(sealed !== undefined && sealed.length > 0 ? { reasoningEncrypted: sealed } : {}) });
1089
1130
  }
1090
1131
  // Zero ops is NOT an error to report — the model knows it emitted
1091
1132
  // nothing. Strike accounting (engine-internal) treats it as a
@@ -1159,40 +1200,40 @@ export default class Engine {
1159
1200
  }
1160
1201
  // #note12 — the daughter-provided reference docs (schemes' + execs' `documentation`),
1161
1202
  // materialized at plurnk:///docs/<name>.md by loop_run (like operator docs).
1162
- docEntries(sessionId) {
1163
- return this.#packets.docEntries(sessionId);
1203
+ docEntries(workspaceId) {
1204
+ return this.#packets.docEntries(workspaceId);
1164
1205
  }
1165
1206
  // §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
1166
- // run last looked. No per-run snapshot (§machine-processes "a run is its log"): every
1207
+ // run last looked. No per-worker snapshot (§machine-processes "a worker is its log"): every
1167
1208
  // edit is already a span-carrying log row, so PULL other actors' EDITs on shared
1168
- // entries since this run's prior turn — real cross-run edits and the plurnk run's
1209
+ // entries since this worker's prior turn — real cross-worker edits and the plurnk worker's
1169
1210
  // fs-sync fictions — and materialize each as a FOLDED delta reusing the row's span +
1170
1211
  // cause. Returns the count so the caller advances nextActionIndex past the deltas.
1171
1212
  async #materializeEnvironmentDeltas(args) {
1172
- const { sessionId, runId, loopId, turnId, fromSequence } = args;
1173
- const boundary = await this.#db.engine_run_prior_turn_time.get({ run_id: runId, turn_id: turnId });
1213
+ const { workspaceId, workerId, loopId, turnId, fromSequence } = args;
1214
+ const boundary = await this.#db.engine_worker_prior_turn_time.get({ worker_id: workerId, turn_id: turnId });
1174
1215
  const since = boundary?.since ?? null;
1175
1216
  if (since === null)
1176
1217
  return 0; // first turn — nothing prior; the model reads current state fresh
1177
- const rows = await this.#db.engine_pull_env_deltas.all({ session_id: sessionId, run_id: runId, since });
1218
+ const rows = await this.#db.engine_pull_env_deltas.all({ workspace_id: workspaceId, worker_id: workerId, since });
1178
1219
  let written = 0;
1179
1220
  for (const r of rows) {
1180
- // source: the originating run (a real cross-run edit) or 'file' (an fs fiction);
1221
+ // source: the originating run (a real cross-worker edit) or 'file' (an fs fiction);
1181
1222
  // rx reuses the originating row's result span — the edit as it looked then.
1182
1223
  await this.#db.engine_insert_env_delta.run({
1183
- run_id: runId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
1184
- source: r.source ?? String(r.run_id), scheme: r.scheme, pathname: r.pathname, rx: r.rx,
1224
+ worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
1225
+ source: r.source ?? String(r.worker_id), scheme: r.scheme, pathname: r.pathname, rx: r.rx,
1185
1226
  });
1186
1227
  written++;
1187
1228
  }
1188
- // §run-scheme — loop-terminations: a sibling's loop reaching terminal surfaces the
1229
+ // §worker-scheme — loop-terminations: a sibling's loop reaching terminal surfaces the
1189
1230
  // same way an entry-change does, carrying its deliverable (the SEND body) or the
1190
1231
  // abandonment reason. Folded, attributed to the terminated run.
1191
- const terms = await this.#db.engine_pull_loop_terminations.all({ session_id: sessionId, run_id: runId, since });
1232
+ const terms = await this.#db.engine_pull_loop_terminations.all({ workspace_id: workspaceId, worker_id: workerId, since });
1192
1233
  for (const t of terms) {
1193
1234
  await this.#db.engine_insert_loop_termination_delta.run({
1194
- run_id: runId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
1195
- source: String(t.run_id), pathname: `/${t.run_name}`,
1235
+ worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
1236
+ source: String(t.worker_id), pathname: `/${t.worker_name}`,
1196
1237
  rx: markTerminal(t.terminated_by, t.terminal_message) ?? `loop "${t.prompt}" ended (${t.status})`,
1197
1238
  status: t.status,
1198
1239
  });
@@ -1200,13 +1241,13 @@ export default class Engine {
1200
1241
  }
1201
1242
  return written;
1202
1243
  }
1203
- // §exec-poll — EXEC `<0>` is turn-scoped: abort the run's open turn-scoped streams via their
1244
+ // §exec-poll — EXEC `<0>` is turn-scoped: abort the worker's open turn-scoped streams via their
1204
1245
  // owning scheme (the same registry-routed abort the total reap uses). Called at each pre-turn
1205
1246
  // before the turn's own spawns, so every open turn-scoped sub here is from a prior turn — it
1206
1247
  // never survives into the subsequent turn. Fire-and-forget: the spawn finalizes async and its
1207
1248
  // terminal output surfaces born-OPEN through the stream-delta path (§exec-stream).
1208
- async #reapTurnScopedStreams(runId) {
1209
- const open = await this.#db.find_open_turn_scoped_subscriptions_for_run.all({ run_id: runId });
1249
+ async #reapTurnScopedStreams(workerId) {
1250
+ const open = await this.#db.find_open_turn_scoped_subscriptions_for_worker.all({ worker_id: workerId });
1210
1251
  for (const { id, scheme } of open) {
1211
1252
  const handler = this.#schemes.get(scheme);
1212
1253
  handler?.abortSubscription?.(id);
@@ -1218,12 +1259,12 @@ export default class Engine {
1218
1259
  // streamEnd recorded on the channel's prior delta — no exec-specific surfacing, just the
1219
1260
  // env-observe loop with a byte cursor where env-delta uses a timestamp. §exec-stream
1220
1261
  async #materializeStreamDeltas(args) {
1221
- const { runId, loopId, turnId, fromSequence } = args;
1222
- const channels = await this.#db.engine_run_stream_channels.all({ run_id: runId });
1262
+ const { workerId, loopId, turnId, fromSequence } = args;
1263
+ const channels = await this.#db.engine_worker_stream_channels.all({ worker_id: workerId });
1223
1264
  let written = 0;
1224
1265
  for (const ch of channels) {
1225
1266
  const prior = await this.#db.engine_stream_cursor.get({
1226
- run_id: runId, scheme: ch.runtime, pathname: ch.coord, fragment: ch.channel,
1267
+ worker_id: workerId, scheme: ch.runtime, pathname: ch.coord, fragment: ch.channel,
1227
1268
  });
1228
1269
  const priorAttrs = prior !== undefined ? JSON.parse(prior.attrs) : {};
1229
1270
  const cursor = priorAttrs.streamEnd ?? 0;
@@ -1237,7 +1278,7 @@ export default class Engine {
1237
1278
  // pointer to the already-delivered bytes, never a re-send (§tokenomics-fetch-fits-free).
1238
1279
  if (closed && priorAttrs.terminal !== true && cursor > 0) {
1239
1280
  await this.#db.engine_insert_stream_delta.run({
1240
- run_id: runId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
1281
+ worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
1241
1282
  scheme: ch.runtime, pathname: ch.coord, fragment: ch.channel,
1242
1283
  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" }),
1243
1284
  attrs: JSON.stringify({ streamEnd: ch.content.length, terminal: true }),
@@ -1251,7 +1292,7 @@ export default class Engine {
1251
1292
  // into one sequence (lines N..M, then M+1..), not N independent "1:" restarts. §exec-stream
1252
1293
  const startLine = (ch.content.slice(0, cursor).match(/\n/g)?.length ?? 0) + 1;
1253
1294
  await this.#db.engine_insert_stream_delta.run({
1254
- run_id: runId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
1295
+ worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
1255
1296
  scheme: ch.runtime, pathname: ch.coord, fragment: ch.channel,
1256
1297
  rx: JSON.stringify({ status: 200, content: ch.content.slice(cursor), mimetype: "text/stream", startLine }),
1257
1298
  attrs: JSON.stringify({ streamEnd: ch.content.length, terminal: closed }),
@@ -1262,20 +1303,20 @@ export default class Engine {
1262
1303
  return written;
1263
1304
  }
1264
1305
  // §env-delta — the filesystem as an actor. Ambient disk divergences detected at
1265
- // pre-turn (git membership re-read) are logged as the plurnk run's source=file EDIT
1306
+ // pre-turn (git membership re-read) are logged as the plurnk worker's source=file EDIT
1266
1307
  // "fictions": no op happened, but EDIT is the only grammar the model has for "your
1267
1308
  // world changed," so the fiction keeps its perspective aligned with what its tooling
1268
- // would show. The fiction lives in the plurnk run's log; every other run pulls it
1309
+ // would show. The fiction lives in the plurnk worker's log; every other run pulls it
1269
1310
  // through the one delta path, exactly like a sibling's real edit.
1270
- // §membership-emi-divergence-signal — disk divergences logged as the plurnk run's source=file EDIT fictions
1271
- async #logFsFictions(sessionId, divergences) {
1311
+ // §membership-emi-divergence-signal — disk divergences logged as the plurnk worker's source=file EDIT fictions
1312
+ async #logFsFictions(workspaceId, divergences) {
1272
1313
  if (divergences.length === 0)
1273
1314
  return;
1274
- const run = await this.#db.envelope_get_run_by_name.get({ session_id: sessionId, name: "plurnk" })
1275
- ?? await this.#db.envelope_insert_run.get({ session_id: sessionId, name: "plurnk", origin: "plurnk" });
1315
+ const run = await this.#db.envelope_get_worker_by_name.get({ workspace_id: workspaceId, name: "plurnk" })
1316
+ ?? await this.#db.envelope_insert_worker.get({ workspace_id: workspaceId, name: "plurnk", origin: "plurnk" });
1276
1317
  if (run === undefined)
1277
- throw new Error("logFsFictions: plurnk run resolution returned no row");
1278
- const loop = await this.#db.envelope_insert_client_loop.get({ run_id: run.id });
1318
+ throw new Error("logFsFictions: plurnk worker resolution returned no row");
1319
+ const loop = await this.#db.envelope_insert_client_loop.get({ worker_id: run.id });
1279
1320
  if (loop === undefined)
1280
1321
  throw new Error("logFsFictions: loop insert returned no row");
1281
1322
  const seq = await this.#db.client_turn_next_sequence.get({ loop_id: loop.id });
@@ -1286,7 +1327,7 @@ export default class Engine {
1286
1327
  for (const d of divergences) {
1287
1328
  const span = editedSpan(d.before, d.after);
1288
1329
  await this.#db.engine_insert_log_entry.get({
1289
- run_id: run.id, loop_id: loop.id, turn_id: turn.id, sequence: sequence++,
1330
+ worker_id: run.id, loop_id: loop.id, turn_id: turn.id, sequence: sequence++,
1290
1331
  origin: "plurnk", source: "file", op: "EDIT", suffix: "", signal: null,
1291
1332
  scheme: d.scheme, username: null, password: null, hostname: null, port: null,
1292
1333
  pathname: d.pathname, params: null, fragment: null, lineMarker: null,
@@ -1328,79 +1369,63 @@ export default class Engine {
1328
1369
  // run still accepting turns?" If yes, skip the wake — the active loop
1329
1370
  // will pick up the channel transition at its next turn boundary. If no,
1330
1371
  // the daemon opens a fresh loop with the wake prompt.
1331
- async hasActiveLoopForRun(runId) {
1332
- const row = await this.#db.engine_count_active_loops_for_run.get({ run_id: runId });
1372
+ async hasActiveLoopForWorker(workerId) {
1373
+ const row = await this.#db.engine_count_active_loops_for_worker.get({ worker_id: workerId });
1333
1374
  return (row?.n ?? 0) > 0;
1334
1375
  }
1335
1376
  // #290 — run the derivation pump (deep channels: symbols/refs/FTS +
1336
- // embeddings, deep_hash-gated) at SESSION-SCOPE, off the per-turn path, so a freshly-created
1337
- // session's corpus warms DURING the client's startup window instead of freezing the first
1338
- // loop.run. session.create fires this and returns immediately; embed_progress live-fans-out as it
1377
+ // embeddings, deep_hash-gated) at WORKSPACE-SCOPE, off the per-turn path, so a freshly-created
1378
+ // workspace's corpus warms DURING the client's startup window instead of freezing the first
1379
+ // loop.run. workspace.create fires this and returns immediately; embed_progress live-fans-out as it
1339
1380
  // runs. Idempotent + deep_hash-gated, so turn 1's pump finds the work done (or harmlessly re-runs);
1340
1381
  // a no-embedder build derives the cheap symbols/refs/FTS channels and skips the embed pass. Has no
1341
1382
  // loop yet — telemetry fans out live only (loopId 0), never buffered to a loop that never drains.
1342
- async warmSessionDerivations(sessionId) {
1383
+ async warmWorkspaceDerivations(workspaceId) {
1343
1384
  const ctx = {
1344
- db: this.#db, sessionId, runId: 0, loopId: 0, turnId: 0,
1385
+ db: this.#db, workspaceId, workerId: 0, loopId: 0, turnId: 0,
1345
1386
  writer: "plurnk",
1346
1387
  signal: undefined,
1347
1388
  streamEventNotify: this.#streamEventNotify,
1348
- wakeRunNotify: this.#wakeRunNotify,
1389
+ wakeWorkerNotify: this.#wakeWorkerNotify,
1349
1390
  tokenize: this.#tokenize,
1350
1391
  mimetypes: this.#mimetypes,
1351
1392
  defaultChannelFor: (s) => this.#schemes.defaultChannelFor(s),
1352
- pushTelemetry: (event) => this.#telemetry.notify(sessionId, 0, event),
1393
+ pushTelemetry: (event) => this.#telemetry.notify(workspaceId, 0, event),
1353
1394
  };
1354
1395
  await this.#queueDerivation(() => EntryManifest.maintainDerivations(ctx)); // §derivation-off-hot-path
1355
1396
  }
1356
- // Inject a prompt into the run's currently-executing loop. Writes a
1397
+ // Inject a prompt into the worker's currently-executing loop. Writes a
1357
1398
  // plurnk://prompt/<run>/<loop>/<next-turn> entry whose body becomes the
1358
1399
  // prompt section at the next turn boundary. Last-wins: if two
1359
1400
  // injects target the same next-turn slot, the second overwrites the
1360
1401
  // first.
1361
1402
  //
1362
- // Returns null when no loop in the run is currently active (status=102).
1403
+ // Returns null when no loop in the worker is currently active (status=102).
1363
1404
  // The daemon-side inject path then enqueues a fresh loop with this
1364
1405
  // prompt; engine doesn't open loops itself.
1365
1406
  //
1366
1407
  // Rummy parallel: AgentLoop.inject(). The "active drain → write
1367
1408
  // prompt entry, return immediately" branch.
1368
- // §search-prefetch — the pass's scheme context: plurnk-written, the same notifier wiring
1369
- // dispatch uses, bound to the run's current loop for teardown scoping (inject's shape).
1370
- async prefetchCtx(sessionId, runId) {
1371
- const loop = await this.#db.drain_current_loop_for_run.get({ run_id: runId });
1372
- const loopId = loop?.id ?? 0;
1373
- return {
1374
- db: this.#db, sessionId, runId, loopId, turnId: 0,
1375
- writer: "plurnk",
1376
- signal: this.#loopAborts.get(loopId)?.signal,
1377
- streamEventNotify: this.#streamEventNotify,
1378
- wakeRunNotify: this.#wakeRunNotify,
1379
- tokenize: this.#tokenize,
1380
- mimetypes: this.#mimetypes,
1381
- pushTelemetry: (event) => this.#telemetry.push(sessionId, loopId, event),
1382
- };
1383
- }
1384
- async inject(runId, prompt) {
1385
- const loopRow = await this.#db.drain_current_loop_for_run.get({ run_id: runId });
1409
+ async inject(workerId, prompt) {
1410
+ const loopRow = await this.#db.drain_current_loop_for_worker.get({ worker_id: workerId });
1386
1411
  if (loopRow === undefined)
1387
1412
  return null;
1388
1413
  const loopId = loopRow.id;
1389
1414
  const turnRow = await this.#db.drain_next_turn_seq_for_loop.get({ loop_id: loopId });
1390
1415
  const turnSeq = turnRow?.next ?? 1;
1391
- const sessionRow = await this.#db.drain_get_run_session.get({ run_id: runId });
1392
- if (sessionRow === undefined)
1393
- throw new Error(`Engine.inject: run ${runId} not found`);
1394
- const pathname = promptPathname(runId, loopRow.sequence, turnSeq); // canonical storage form, run-qualified loop-SEQ coordinates matching the turn-1 foist
1416
+ const workspaceRow = await this.#db.drain_get_worker_workspace.get({ worker_id: workerId });
1417
+ if (workspaceRow === undefined)
1418
+ throw new Error(`Engine.inject: run ${workerId} not found`);
1419
+ const pathname = promptPathname(workerId, loopRow.sequence, turnSeq); // canonical storage form, worker-qualified loop-SEQ coordinates matching the turn-1 foist
1395
1420
  const ctx = {
1396
- db: this.#db, sessionId: sessionRow.session_id, runId, loopId,
1421
+ db: this.#db, workspaceId: workspaceRow.workspace_id, workerId, loopId,
1397
1422
  turnId: 0, // no turn open at inject time; entries don't pin turnId
1398
1423
  writer: "plurnk",
1399
1424
  signal: this.#loopAborts.get(loopId)?.signal,
1400
1425
  streamEventNotify: this.#streamEventNotify,
1401
- wakeRunNotify: this.#wakeRunNotify,
1426
+ wakeWorkerNotify: this.#wakeWorkerNotify,
1402
1427
  tokenize: this.#tokenize,
1403
- pushTelemetry: (event) => this.#telemetry.push(sessionRow.session_id, loopId, event),
1428
+ pushTelemetry: (event) => this.#telemetry.push(workspaceRow.workspace_id, loopId, event),
1404
1429
  };
1405
1430
  const entry = {
1406
1431
  channels: { body: { content: prompt, mimetype: "text/markdown" } },