@plurnk/plurnk-service 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/.env.defaults +20 -23
  2. package/INSTALL.md +6 -5
  3. package/README.md +1 -0
  4. package/SPEC.md +250 -149
  5. package/dist/Paths.d.ts.map +1 -1
  6. package/dist/Paths.js +2 -3
  7. package/dist/Paths.js.map +1 -1
  8. package/dist/build-info.json +1 -1
  9. package/dist/content/index.d.ts +1 -0
  10. package/dist/content/index.d.ts.map +1 -1
  11. package/dist/content/index.js +1 -0
  12. package/dist/content/index.js.map +1 -1
  13. package/dist/content/line-marker.d.ts +7 -3
  14. package/dist/content/line-marker.d.ts.map +1 -1
  15. package/dist/content/line-marker.js +2 -1
  16. package/dist/content/line-marker.js.map +1 -1
  17. package/dist/content/matcher.d.ts +1 -1
  18. package/dist/content/matcher.d.ts.map +1 -1
  19. package/dist/content/matcher.js +2 -2
  20. package/dist/content/matcher.js.map +1 -1
  21. package/dist/content/read-projector.d.ts +14 -0
  22. package/dist/content/read-projector.d.ts.map +1 -0
  23. package/dist/content/read-projector.js +86 -0
  24. package/dist/content/read-projector.js.map +1 -0
  25. package/dist/content/read-resolve.d.ts +2 -7
  26. package/dist/content/read-resolve.d.ts.map +1 -1
  27. package/dist/content/read-resolve.js +32 -107
  28. package/dist/content/read-resolve.js.map +1 -1
  29. package/dist/core/BranchReceipt.d.ts +0 -1
  30. package/dist/core/BranchReceipt.d.ts.map +1 -1
  31. package/dist/core/BranchReceipt.js +0 -3
  32. package/dist/core/BranchReceipt.js.map +1 -1
  33. package/dist/core/BudgetOverflow.d.ts +0 -3
  34. package/dist/core/BudgetOverflow.d.ts.map +1 -1
  35. package/dist/core/BudgetOverflow.js +3 -15
  36. package/dist/core/BudgetOverflow.js.map +1 -1
  37. package/dist/core/ChannelWrite.d.ts +3 -0
  38. package/dist/core/ChannelWrite.d.ts.map +1 -1
  39. package/dist/core/ChannelWrite.js +2 -2
  40. package/dist/core/ChannelWrite.js.map +1 -1
  41. package/dist/core/ChannelWrite.sql +4 -3
  42. package/dist/core/CoreSchemeServices.d.ts +10 -2
  43. package/dist/core/CoreSchemeServices.d.ts.map +1 -1
  44. package/dist/core/CoreSchemeServices.js.map +1 -1
  45. package/dist/core/Dispatcher.d.ts +3 -7
  46. package/dist/core/Dispatcher.d.ts.map +1 -1
  47. package/dist/core/Dispatcher.js +283 -217
  48. package/dist/core/Dispatcher.js.map +1 -1
  49. package/dist/core/Engine.d.ts +19 -12
  50. package/dist/core/Engine.d.ts.map +1 -1
  51. package/dist/core/Engine.js +261 -167
  52. package/dist/core/Engine.js.map +1 -1
  53. package/dist/core/Engine.sql +66 -63
  54. package/dist/core/LogBody.d.ts +2 -1
  55. package/dist/core/LogBody.d.ts.map +1 -1
  56. package/dist/core/LogBody.js +33 -1
  57. package/dist/core/LogBody.js.map +1 -1
  58. package/dist/core/LoopLifecycle.d.ts +1 -2
  59. package/dist/core/LoopLifecycle.d.ts.map +1 -1
  60. package/dist/core/LoopLifecycle.js +1 -4
  61. package/dist/core/LoopLifecycle.js.map +1 -1
  62. package/dist/core/LoopLifecycle.sql +1 -4
  63. package/dist/core/PacketBuilder.d.ts +3 -2
  64. package/dist/core/PacketBuilder.d.ts.map +1 -1
  65. package/dist/core/PacketBuilder.js +16 -16
  66. package/dist/core/PacketBuilder.js.map +1 -1
  67. package/dist/core/StrikeRail.d.ts +5 -1
  68. package/dist/core/StrikeRail.d.ts.map +1 -1
  69. package/dist/core/StrikeRail.js +4 -3
  70. package/dist/core/StrikeRail.js.map +1 -1
  71. package/dist/core/TerminalResult.d.ts +16 -0
  72. package/dist/core/TerminalResult.d.ts.map +1 -0
  73. package/dist/core/TerminalResult.js +49 -0
  74. package/dist/core/TerminalResult.js.map +1 -0
  75. package/dist/core/caps/CapsResolve.d.ts +5 -1
  76. package/dist/core/caps/CapsResolve.d.ts.map +1 -1
  77. package/dist/core/caps/CapsResolve.js +8 -4
  78. package/dist/core/caps/CapsResolve.js.map +1 -1
  79. package/dist/core/caps/DbChannelCaps.d.ts +1 -1
  80. package/dist/core/caps/DbChannelCaps.d.ts.map +1 -1
  81. package/dist/core/caps/DbChannelCaps.js +6 -4
  82. package/dist/core/caps/DbChannelCaps.js.map +1 -1
  83. package/dist/core/caps/DbEntryCaps.d.ts +1 -1
  84. package/dist/core/caps/DbEntryCaps.d.ts.map +1 -1
  85. package/dist/core/caps/DbEntryCaps.js +14 -4
  86. package/dist/core/caps/DbEntryCaps.js.map +1 -1
  87. package/dist/core/caps/DbNotifyCaps.d.ts +1 -1
  88. package/dist/core/caps/DbNotifyCaps.d.ts.map +1 -1
  89. package/dist/core/caps/DbNotifyCaps.js +6 -4
  90. package/dist/core/caps/DbNotifyCaps.js.map +1 -1
  91. package/dist/core/caps/DbSubscriptionCaps.d.ts +4 -6
  92. package/dist/core/caps/DbSubscriptionCaps.d.ts.map +1 -1
  93. package/dist/core/caps/DbSubscriptionCaps.js +27 -13
  94. package/dist/core/caps/DbSubscriptionCaps.js.map +1 -1
  95. package/dist/core/caps/DbTagCaps.d.ts +1 -1
  96. package/dist/core/caps/DbTagCaps.d.ts.map +1 -1
  97. package/dist/core/caps/DbTagCaps.js +6 -4
  98. package/dist/core/caps/DbTagCaps.js.map +1 -1
  99. package/dist/core/caps/SchemeCtxImpl.d.ts +6 -1
  100. package/dist/core/caps/SchemeCtxImpl.d.ts.map +1 -1
  101. package/dist/core/caps/SchemeCtxImpl.js +6 -6
  102. package/dist/core/caps/SchemeCtxImpl.js.map +1 -1
  103. package/dist/core/fork.sql +2 -2
  104. package/dist/core/git-iso.d.ts +3 -2
  105. package/dist/core/git-iso.d.ts.map +1 -1
  106. package/dist/core/git-iso.js +27 -12
  107. package/dist/core/git-iso.js.map +1 -1
  108. package/dist/core/git-membership.d.ts +1 -0
  109. package/dist/core/git-membership.d.ts.map +1 -1
  110. package/dist/core/git-membership.js +86 -24
  111. package/dist/core/git-membership.js.map +1 -1
  112. package/dist/core/git-state.d.ts +8 -1
  113. package/dist/core/git-state.d.ts.map +1 -1
  114. package/dist/core/git-state.js +35 -16
  115. package/dist/core/git-state.js.map +1 -1
  116. package/dist/core/namespace.d.ts +1 -0
  117. package/dist/core/namespace.d.ts.map +1 -1
  118. package/dist/core/namespace.js +20 -0
  119. package/dist/core/namespace.js.map +1 -1
  120. package/dist/core/optimistic-settlement.d.ts +2 -0
  121. package/dist/core/optimistic-settlement.d.ts.map +1 -0
  122. package/dist/core/optimistic-settlement.js +14 -0
  123. package/dist/core/optimistic-settlement.js.map +1 -0
  124. package/dist/core/packet-wire.d.ts +1 -1
  125. package/dist/core/packet-wire.d.ts.map +1 -1
  126. package/dist/core/packet-wire.js +81 -35
  127. package/dist/core/packet-wire.js.map +1 -1
  128. package/dist/core/results.d.ts +4 -2
  129. package/dist/core/results.d.ts.map +1 -1
  130. package/dist/core/results.js +6 -0
  131. package/dist/core/results.js.map +1 -1
  132. package/dist/core/scheme-types.d.ts +0 -12
  133. package/dist/core/scheme-types.d.ts.map +1 -1
  134. package/dist/core/scheme-types.js.map +1 -1
  135. package/dist/core/worker-ops.sql +4 -4
  136. package/dist/core/workspace-settings.js +1 -1
  137. package/dist/core/workspace-settings.js.map +1 -1
  138. package/dist/digest/Digest.d.ts.map +1 -1
  139. package/dist/digest/Digest.js +236 -74
  140. package/dist/digest/Digest.js.map +1 -1
  141. package/dist/digest/digest.sql +9 -5
  142. package/dist/schemes/Exec.d.ts +4 -4
  143. package/dist/schemes/Exec.d.ts.map +1 -1
  144. package/dist/schemes/Exec.js +56 -33
  145. package/dist/schemes/Exec.js.map +1 -1
  146. package/dist/schemes/ExecOutputScheme.d.ts +4 -5
  147. package/dist/schemes/ExecOutputScheme.d.ts.map +1 -1
  148. package/dist/schemes/ExecOutputScheme.js +12 -20
  149. package/dist/schemes/ExecOutputScheme.js.map +1 -1
  150. package/dist/schemes/File.d.ts +1 -3
  151. package/dist/schemes/File.d.ts.map +1 -1
  152. package/dist/schemes/File.js +11 -19
  153. package/dist/schemes/File.js.map +1 -1
  154. package/dist/schemes/Log.d.ts +5 -5
  155. package/dist/schemes/Log.d.ts.map +1 -1
  156. package/dist/schemes/Log.js +74 -172
  157. package/dist/schemes/Log.js.map +1 -1
  158. package/dist/schemes/Log.sql +20 -13
  159. package/dist/schemes/Prompt.d.ts +2 -3
  160. package/dist/schemes/Prompt.d.ts.map +1 -1
  161. package/dist/schemes/Prompt.js +1 -4
  162. package/dist/schemes/Prompt.js.map +1 -1
  163. package/dist/schemes/Skill.d.ts +2 -3
  164. package/dist/schemes/Skill.d.ts.map +1 -1
  165. package/dist/schemes/Skill.js +0 -3
  166. package/dist/schemes/Skill.js.map +1 -1
  167. package/dist/schemes/Worker.d.ts +5 -5
  168. package/dist/schemes/Worker.d.ts.map +1 -1
  169. package/dist/schemes/Worker.js +92 -81
  170. package/dist/schemes/Worker.js.map +1 -1
  171. package/dist/schemes/_entry-crud.d.ts +2 -1
  172. package/dist/schemes/_entry-crud.d.ts.map +1 -1
  173. package/dist/schemes/_entry-crud.js +14 -1
  174. package/dist/schemes/_entry-crud.js.map +1 -1
  175. package/dist/schemes/_entry-crud.sql +9 -3
  176. package/dist/schemes/_entry-find.d.ts +37 -11
  177. package/dist/schemes/_entry-find.d.ts.map +1 -1
  178. package/dist/schemes/_entry-find.js +141 -153
  179. package/dist/schemes/_entry-find.js.map +1 -1
  180. package/dist/schemes/_entry-ops.d.ts +9 -5
  181. package/dist/schemes/_entry-ops.d.ts.map +1 -1
  182. package/dist/schemes/_entry-ops.js +12 -73
  183. package/dist/schemes/_entry-ops.js.map +1 -1
  184. package/dist/schemes/_entry-ops.sql +1 -2
  185. package/dist/schemes/_entry-semantic.d.ts +5 -7
  186. package/dist/schemes/_entry-semantic.d.ts.map +1 -1
  187. package/dist/schemes/_entry-semantic.js +10 -31
  188. package/dist/schemes/_entry-semantic.js.map +1 -1
  189. package/dist/schemes/_entry-semantic.sql +2 -2
  190. package/dist/schemes/_search-index.d.ts.map +1 -1
  191. package/dist/schemes/_search-index.js +6 -4
  192. package/dist/schemes/_search-index.js.map +1 -1
  193. package/dist/server/BranchBatches.d.ts +1 -0
  194. package/dist/server/BranchBatches.d.ts.map +1 -1
  195. package/dist/server/BranchBatches.js +1 -0
  196. package/dist/server/BranchBatches.js.map +1 -1
  197. package/dist/server/Daemon.d.ts +5 -7
  198. package/dist/server/Daemon.d.ts.map +1 -1
  199. package/dist/server/Daemon.js +222 -54
  200. package/dist/server/Daemon.js.map +1 -1
  201. package/dist/server/DaemonModule.d.ts +3 -3
  202. package/dist/server/DaemonModule.d.ts.map +1 -1
  203. package/dist/server/branch-batch.sql +3 -1
  204. package/dist/server/client-input.d.ts +2 -1
  205. package/dist/server/client-input.d.ts.map +1 -1
  206. package/dist/server/client-input.js +7 -1
  207. package/dist/server/client-input.js.map +1 -1
  208. package/dist/server/drain.sql +6 -5
  209. package/dist/server/envelope.d.ts +2 -2
  210. package/dist/server/envelope.d.ts.map +1 -1
  211. package/dist/server/envelope.js +0 -1
  212. package/dist/server/envelope.js.map +1 -1
  213. package/dist/server/envelope.sql +1 -2
  214. package/dist/server/lifecycle-recovery.sql +34 -1
  215. package/dist/server/logEntry.d.ts +1 -1
  216. package/dist/server/logEntry.d.ts.map +1 -1
  217. package/dist/server/logEntry.js +2 -1
  218. package/dist/server/logEntry.js.map +1 -1
  219. package/dist/service.d.ts.map +1 -1
  220. package/dist/service.js +13 -1
  221. package/dist/service.js.map +1 -1
  222. package/migrations/001_schema.sql +238 -125
  223. package/package.json +48 -46
  224. package/dist/matrix/live-specimen.d.ts +0 -9
  225. package/dist/matrix/live-specimen.d.ts.map +0 -1
  226. package/dist/matrix/live-specimen.js +0 -37
  227. package/dist/matrix/live-specimen.js.map +0 -1
@@ -7,7 +7,6 @@ import { Mimetypes, emptyRegistry } from "@plurnk/plurnk-mimetypes";
7
7
  import Meta from "@plurnk/plurnk-meta";
8
8
  import EntryCrud from "../schemes/_entry-crud.js";
9
9
  import SearchIndex from "../schemes/_search-index.js";
10
- import { markTerminal } from "../schemes/Worker.js";
11
10
  import GitMembership from "./git-membership.js";
12
11
  import GitState from "./git-state.js";
13
12
  import WorkspaceSettings from "./workspace-settings.js";
@@ -26,6 +25,7 @@ import { setTimeout as delay } from "node:timers/promises";
26
25
  import PacketWire from "./packet-wire.js";
27
26
  import Results, { OperationFailureError } from "./results.js";
28
27
  import BranchReceipt from "./BranchReceipt.js";
28
+ import TerminalResult from "./TerminalResult.js";
29
29
  import BudgetOverflow from "./BudgetOverflow.js";
30
30
  import WorkerControlAddress from "./WorkerControlAddress.js";
31
31
  import JournalTurn from "./JournalTurn.js";
@@ -41,7 +41,20 @@ import Dispatcher from "./Dispatcher.js";
41
41
  import { observed, observedSync } from "../observe/spans.js";
42
42
  import { OPS_DISPATCHED, PROVIDER_CALLS, recordCounter } from "../observe/metrics.js";
43
43
  import { scheduleTurnOps } from "./turn-scheduler.js";
44
+ import { readOptimisticSettlementMs } from "./optimistic-settlement.js";
44
45
  const DEFAULT_MAX_STRIKES = 3;
46
+ const RECORD_STREAM_MIMETYPES = new Set(["application/jsonl", "application/x-ndjson"]);
47
+ const baseMimetype = (mimetype) => mimetype.split(";", 1)[0].trim().toLowerCase();
48
+ // {§exec-stream} Active streams publish only independently meaningful units.
49
+ // Atomic documents wait for close; JSONL stops after its last complete record.
50
+ const streamPublicationEnd = (content, mimetype, cursor, closed) => {
51
+ const type = baseMimetype(mimetype);
52
+ if (closed || type.startsWith("text/"))
53
+ return content.length;
54
+ if (!RECORD_STREAM_MIMETYPES.has(type))
55
+ return cursor;
56
+ return Math.max(cursor, content.lastIndexOf("\n") + 1);
57
+ };
45
58
  const ENGINE_PROBLEMS = Object.freeze({
46
59
  max_commands_exceeded: {
47
60
  status: 429,
@@ -102,7 +115,7 @@ const readMaxCommands = () => {
102
115
  return n;
103
116
  };
104
117
  // PLURNK_SERVICE_FILES_ITEMS — the turn-0 catalog preview. null = off;
105
- // -1 = the complete one-level map; positive N caps its file rows. 0 / unset = off.
118
+ // -1 = the ordinary markerless page; positive N explicitly caps file rows. 0 / unset = off.
106
119
  const normalizeFilesItems = (n) => (!Number.isFinite(n) || n === 0 ? null : n < 0 ? -1 : n);
107
120
  const readFilesItems = () => {
108
121
  const raw = process.env.PLURNK_SERVICE_FILES_ITEMS;
@@ -117,6 +130,7 @@ import ProviderInstantiate from "./ProviderInstantiate.js";
117
130
  // continue after dispatch ({§send}). Every admitted emission itself ends in an
118
131
  // explicit disposition SEND ({§emission-admission}).
119
132
  const TURN_STATUS_IMPLICIT_CONTINUE = 102;
133
+ const INVALID_EMISSION_RECOVERY_MESSAGE = "Your previous response contained an unrecoverable syntax error. No operations were performed. Try again.";
120
134
  const DEFAULT_MIN_CYCLES = 3;
121
135
  const DEFAULT_MAX_CYCLE_PERIOD = 4;
122
136
  const readPositiveInt = (envVar, fallback) => {
@@ -208,7 +222,13 @@ export default class Engine {
208
222
  if (!invalidate && this.#workspaceWarmStatus.get(workspaceId)?.phase === "complete") {
209
223
  return Promise.resolve();
210
224
  }
211
- const state = { dirty: false, materialize, ctx, promise: Promise.resolve() };
225
+ const state = {
226
+ dirty: false,
227
+ materialize,
228
+ ctx,
229
+ abort: new AbortController(),
230
+ promise: Promise.resolve(),
231
+ };
212
232
  // Register before publishing the first synchronous Notice. A
213
233
  // listener may request another warm from that callback; it must join
214
234
  // this state rather than opening a second pump in the re-entrant gap.
@@ -231,10 +251,14 @@ export default class Engine {
231
251
  completed: 0, total: 1, percent: 0, level: "info",
232
252
  });
233
253
  try {
254
+ const signal = current.signal === undefined
255
+ ? state.abort.signal
256
+ : AbortSignal.any([current.signal, state.abort.signal]);
257
+ const cancellable = { ...current, signal };
234
258
  if (shouldMaterialize)
235
- await GitMembership.indexGitMembership(current);
259
+ await GitMembership.indexGitMembership(cancellable);
236
260
  await SearchIndex.maintain({
237
- ...current,
261
+ ...cancellable,
238
262
  pushNotice: (notice) => {
239
263
  if (notice.kind === "embed_progress"
240
264
  && typeof notice.completed === "number"
@@ -277,9 +301,26 @@ export default class Engine {
277
301
  workspaceDerivationStatus(workspaceId) {
278
302
  return this.#workspaceWarmStatus.get(workspaceId) ?? null;
279
303
  }
280
- // Awaited by Daemon.stop before the db closes.
281
- async drainDerivations() {
282
- await Promise.all([...this.#workspaceWarms.values()].map((state) => state.promise));
304
+ cancelDerivations(reason = new DOMException("derivations cancelled", "AbortError")) {
305
+ for (const state of this.#workspaceWarms.values()) {
306
+ if (!state.abort.signal.aborted)
307
+ state.abort.abort(reason);
308
+ }
309
+ }
310
+ // Awaited by Daemon.stop before the db closes. Shutdown supplies the exact
311
+ // cancellation reason it owns; every unrelated failure remains visible.
312
+ async drainDerivations(ignoredReason) {
313
+ const results = await Promise.allSettled([...this.#workspaceWarms.values()].map((state) => state.promise));
314
+ const errors = results
315
+ .filter((result) => result.status === "rejected")
316
+ .flatMap((result) => result.reason instanceof AggregateError
317
+ ? [...result.reason.errors]
318
+ : [result.reason])
319
+ .filter((error) => error !== ignoredReason);
320
+ if (errors.length === 1)
321
+ throw errors[0];
322
+ if (errors.length > 1)
323
+ throw new AggregateError(errors, "derivation drain failed");
283
324
  }
284
325
  async drainWorkspaceDerivations(workspaceId) {
285
326
  await this.#workspaceWarms.get(workspaceId)?.promise;
@@ -449,14 +490,19 @@ export default class Engine {
449
490
  // occupancy gauge. {§tokenomics-client-gauge}, {§notifications-loop-terminated}
450
491
  async loopUsage(loopId) {
451
492
  const row = await this.#db.engine_loop_usage.get({ loop_id: loopId });
493
+ if (row === undefined)
494
+ throw new Error(`loopUsage: loop ${loopId} does not exist`);
452
495
  const parsedCosts = JSON.parse(row?.costs ?? "[]");
453
496
  if (!Array.isArray(parsedCosts))
454
497
  throw new TypeError(`loop ${loopId} monetary evidence is not an array`);
498
+ const costs = parsedCosts.map((cost) => validateProviderCost(cost));
455
499
  return {
456
500
  promptTokens: row?.prompt ?? 0,
457
501
  completionTokens: row?.completion ?? 0,
502
+ reasoningTokens: row?.reasoning ?? 0,
503
+ cachedTokens: row?.cached ?? 0,
458
504
  costUsd: row?.cost_usd ?? null,
459
- costs: parsedCosts.map((cost) => validateProviderCost(cost)),
505
+ costs,
460
506
  // Latest provider attempt on the latest turn, not the billed total.
461
507
  contextTokens: row?.context ?? 0,
462
508
  // Latest effective packet allowance; null when uncapped or unknown.
@@ -490,6 +536,7 @@ export default class Engine {
490
536
  // Its ceiling therefore counts every prior turn, not merely this process-local
491
537
  // execution segment.
492
538
  const turnIds = await this.#lifecycle.turnIds(loopId);
539
+ let invalidEmissionRecoveryEntryId = null;
493
540
  // Per-loop AbortController for scheme-side cancellation propagation.
494
541
  // Chained from the caller's `signal` so an external abort cascades.
495
542
  const loopAbort = new AbortController();
@@ -547,7 +594,7 @@ export default class Engine {
547
594
  // check, {§worker-lifecycle-wake-requeue-not-terminal}). The wake's intent is KEEP
548
595
  // RUNNING: re-claim atomically and continue — the injected prompt is already
549
596
  // this loop's next turn. Returning it as "external" broadcast a QUEUED loop
550
- // as a terminal result with status 100 — the delegation-flags flake.
597
+ // as a terminal result with status 100 — the delegation-flags race.
551
598
  await this.#db.engine_reclaim_queued_loop.run({ loop_id: loopId });
552
599
  continue; // claimed (or a racer flipped it first — the re-read decides)
553
600
  }
@@ -584,10 +631,6 @@ export default class Engine {
584
631
  cleanup("forceful", "max_turns");
585
632
  return { turnIds, result, hitMaxTurns: true, reason: "max_turns" };
586
633
  }
587
- // PLURNK_SERVICE_EXEC_WAIT_MS — a post-EXEC breath: if a spawn from the prior turn
588
- // is still in flight, give it a tunable beat to land in THIS turn's packet
589
- // before we assemble it. A fixed grace beat, never a wait-for-completion;
590
- // 0/unset = off. Abortable with the loop signal.
591
634
  const execHandler = this.#schemes.get("exec");
592
635
  // {§exec-hold-until-concluded} — hold matching runtime/effect
593
636
  // streams until conclusion or the fail-open cap, then resume the
@@ -600,11 +643,6 @@ export default class Engine {
600
643
  await delay(150, undefined, { signal });
601
644
  }
602
645
  }
603
- const execWaitMs = Number(process.env.PLURNK_SERVICE_EXEC_WAIT_MS ?? "0");
604
- if (execWaitMs > 0) {
605
- if (execHandler?.hasActiveSpawns?.(workerId) === true)
606
- await delay(execWaitMs, undefined, { signal });
607
- }
608
646
  let turn;
609
647
  const releaseWorkspace = await this.#acquireWorkspaceTurn(workspaceId, workerId);
610
648
  try {
@@ -613,6 +651,7 @@ export default class Engine {
613
651
  const t = await this.runTurn({
614
652
  provider, messages, requirements, workspaceId, workerId, loopId, origin, signal, onDispatch,
615
653
  turnNumber: turnIds.length + 1, maxTurns,
654
+ invalidEmissionRecoveryEntryId,
616
655
  });
617
656
  span.setAttribute("turn.id", t.turnId);
618
657
  return t;
@@ -635,10 +674,18 @@ export default class Engine {
635
674
  releaseWorkspace();
636
675
  }
637
676
  turnIds.push(turn.turnId);
638
- // Invalid provider emissions are retried beneath this turn and never
639
- // reach the strike rail. Exhausting that inner attempt budget is a
640
- // terminal generation failure, not one of the engine's three strikes.
677
+ // {§invalid-emission-attempts} Invalid provider emissions are retried beneath this turn and never
678
+ // reach the strike rail. The first consecutive exhaustion has already
679
+ // exposed its bounded lifeline through ordinary next-turn state. A
680
+ // second exhaustion is terminal; an admitted turn clears the sequence.
641
681
  if (turn.emissionExhausted) {
682
+ if (invalidEmissionRecoveryEntryId === null) {
683
+ if (turn.rejectedModelEntryId === undefined) {
684
+ throw new Error("an admitted invalid-emission recovery requires its rejected model-entry identity");
685
+ }
686
+ invalidEmissionRecoveryEntryId = turn.rejectedModelEntryId;
687
+ continue;
688
+ }
642
689
  const failure = Results.failure("engine:generation", "invalid-emission-exhausted", 500, `No valid PLAN...SEND turn was received after ${turn.emissionAttempts} emission attempts.`, {}, {
643
690
  attempts: turn.emissionAttempts,
644
691
  stage: "emission-validation",
@@ -650,6 +697,7 @@ export default class Engine {
650
697
  cleanup("forceful", "invalid_emission");
651
698
  return { turnIds, result, hitMaxTurns: false, reason: "invalid_emission" };
652
699
  }
700
+ invalidEmissionRecoveryEntryId = null;
653
701
  // SPEC {§grinder}: budget hard-stop — packet won't fit even collapsed → abandon.
654
702
  if (turn.budgetHardStop) {
655
703
  if (turn.budget === undefined) {
@@ -663,11 +711,11 @@ export default class Engine {
663
711
  return { turnIds, result, hitMaxTurns: false, reason: "budget_overflow" };
664
712
  }
665
713
  // {§engine-rails} — per-turn strike accounting (cycle detection, the
666
- // grinder/steer coupling, hard-failure statuses). StrikeRail owns the
714
+ // grinder/steer coupling, hard operation outcomes). StrikeRail owns the
667
715
  // bookkeeping; runLoop owns abandonment.
668
716
  const verdict = this.#strikes.assess(loopId, {
669
717
  fingerprint: turn.fingerprint,
670
- statuses: turn.statuses,
718
+ outcomes: turn.outcomes,
671
719
  budgetStruck: turn.budgetStruck,
672
720
  steerStruck: turn.steerStruck,
673
721
  minCycles, maxCyclePeriod, maxStrikes,
@@ -691,7 +739,11 @@ export default class Engine {
691
739
  }
692
740
  }
693
741
  }
694
- async runTurn({ provider, messages, requirements = "", workspaceId, workerId, loopId, origin = "model", signal, onDispatch, turnNumber = 1, maxTurns = 50, }) {
742
+ async runTurn({ provider, messages, requirements = "", workspaceId, workerId, loopId, origin = "model", signal, onDispatch, turnNumber = 1, maxTurns = 50, invalidEmissionRecoveryEntryId, }) {
743
+ const allowInvalidEmissionRecovery = invalidEmissionRecoveryEntryId === null;
744
+ const transientOpenLogEntryId = typeof invalidEmissionRecoveryEntryId === "number"
745
+ ? invalidEmissionRecoveryEntryId
746
+ : null;
695
747
  // === Turn-as-container model ===
696
748
  //
697
749
  // Turn rows are created at runTurn OPEN (status=102, placeholder
@@ -859,7 +911,10 @@ export default class Engine {
859
911
  // The warm materialized membership before deriving. This second pass is the
860
912
  // ordinary cheap change detector and captures any drift that landed meanwhile.
861
913
  const fsDivergences = await GitMembership.indexGitMembership(systemCtx);
862
- await this.#logFsFictions(workspaceId, fsDivergences);
914
+ // {§packet-git-status} — one post-reconciliation snapshot supplies both
915
+ // the compact packet summary and each causal file event's exact XY state.
916
+ const gitStatus = await GitState.status(this.#db, workspaceId, this.#loopAborts.get(loopId)?.signal);
917
+ await this.#logFsFictions(workspaceId, fsDivergences, gitStatus);
863
918
  // The refresh above may have changed bodies (including model/client edits
864
919
  // since the startup warm). Re-derive to completion before packet/model
865
920
  // construction. Membership is already current, so this pass does not
@@ -894,10 +949,10 @@ export default class Engine {
894
949
  for (const { scheme, shallow_items: shallowItems } of foistSchemes) {
895
950
  const isFile = scheme === "file";
896
951
  const pattern = this.#schemes.manifestFor(scheme)?.folderScopes === true ? "*" : "**";
897
- // Only the file map takes PLURNK_SERVICE_FILES_ITEMS as a first-N cap;
898
- // other schemes always render their complete one-level map. An empty file
899
- // survey has no range: `<1,0>` would turn useful orientation into an error.
952
+ // Only the file map takes PLURNK_SERVICE_FILES_ITEMS as an explicit first-N
953
+ // cap. Every other ordinary survey uses FIND's markerless first page.
900
954
  const cap = isFile && filesItems > 0 && shallowItems > 0 ? Math.min(filesItems, shallowItems) : null;
955
+ const catalogMarker = cap === null ? null : { marks: [1, cap] };
901
956
  // The file survey foists as the BARE relative glob — the path shape plurnk.md
902
957
  // teaches (`*`, `src/**`, `**/notes.md`; bare = project-relative) — so the turn-0
903
958
  // exemplar and the log rows the model reads never train a leading-slash or
@@ -913,7 +968,7 @@ export default class Engine {
913
968
  query: null, fragment: null,
914
969
  },
915
970
  body: null,
916
- lineMarker: cap === null ? null : { marks: [1, cap] },
971
+ lineMarker: catalogMarker,
917
972
  position: UNKNOWN_POSITION,
918
973
  };
919
974
  await this.dispatch({
@@ -921,9 +976,11 @@ export default class Engine {
921
976
  sequence: nextActionIndex, origin: "plurnk", onDispatch,
922
977
  });
923
978
  nextActionIndex++;
924
- // {§model-entry} — the same FIND, rendered back to DSL for the turn-0 echo (the model's
925
- // own survey, mirrored OPEN). The <L> cap rides as `<1,N>`, exactly as the model would type it.
926
- turnZeroMoves.push(`<<FIND(${isFile ? pattern : `${scheme}:///${pattern}`})${cap === null ? "" : `<1,${cap}>`}::FIND`);
979
+ // {§model-entry} — the same FIND, rendered back to DSL for the turn-0 echo
980
+ // (the model's own survey, mirrored OPEN). An explicit file cap rides as
981
+ // `<1,N>`; ordinary surveys teach the safe markerless default by example.
982
+ const target = isFile ? pattern : `${scheme}:///${pattern}`;
983
+ turnZeroMoves.push(`<<FIND(${target})${cap === null ? "" : `<1,${cap}>`}::FIND`);
927
984
  }
928
985
  // The kernel's self-documenting surface — FIND(worker://plurnk/docs/**), uncapped,
929
986
  // always ({§schemes-directory}, published under {§entry-owner}).
@@ -931,13 +988,13 @@ export default class Engine {
931
988
  const kernelDocsFind = {
932
989
  op: "FIND", suffix: "", signal: null,
933
990
  target: { kind: "url", raw: "worker://plurnk/docs/**", scheme: "worker", username: null, password: null, hostname: "plurnk", port: null, pathname: "/docs/**", query: null, fragment: null },
934
- body: null, lineMarker: null, position: UNKNOWN_POSITION,
991
+ body: null, lineMarker: { marks: [1, -1] }, position: UNKNOWN_POSITION,
935
992
  };
936
993
  await this.dispatch({ statement: kernelDocsFind, workspaceId, workerId, loopId, turnId, sequence: nextActionIndex, origin: "plurnk", onDispatch });
937
994
  nextActionIndex++;
938
- turnZeroMoves.push("<<FIND(worker://plurnk/docs/**)::FIND");
939
- // {§worker-scheme} — the building worker's own scratch gets the same complete
940
- // one-level map in its perspective alone. It always executes: an empty private
995
+ turnZeroMoves.push("<<FIND(worker://plurnk/docs/**)<1,-1>::FIND");
996
+ // {§worker-scheme} — the building worker's own scratch gets the same markerless
997
+ // one-level survey in its perspective alone. It always executes: an empty private
941
998
  // space is useful orientation, not grounds to hide the surface.
942
999
  const ownFind = {
943
1000
  op: "FIND", suffix: "", signal: null,
@@ -953,7 +1010,7 @@ export default class Engine {
953
1010
  // the true survey, never a frozen print — and OPEN: the worked example the model orients on,
954
1011
  // so the grammar can stay thin. Subsequent turns mirror the model's real output, folded.
955
1012
  if (workerFirstLoop) {
956
- const emission = ["<<PLAN:Initialize:PLAN", ...turnZeroMoves, "<<SEND[102]:Next, address the prompt from the initialized context.:SEND"].join("\n");
1013
+ const emission = ["<<PLAN:Survey context, then address the prompt.:PLAN", ...turnZeroMoves, "<<SEND[102]:Next, address the prompt using the survey.:SEND"].join("\n");
957
1014
  await this.#dispatcher.writeModelEntry({ verbatim: emission, workerId, loopId, turnId, sequence: 1, folded: false, origin: "plurnk" });
958
1015
  }
959
1016
  }
@@ -986,10 +1043,8 @@ export default class Engine {
986
1043
  await this.#reapTurnScopedStreams(workerId);
987
1044
  nextActionIndex += await this.#materializeEnvironmentDeltas({ workspaceId, workerId, loopId, turnId, fromSequence: nextActionIndex });
988
1045
  nextActionIndex += await this.#materializeStreamDeltas({ workerId, loopId, turnId, fromSequence: nextActionIndex });
989
- // Git working-tree state is read once for the packet and threaded
990
- // (a service-side `git status` shell-out) and threaded into the budget
991
- // rebuild too so it isn't re-shelled on overflow.
992
- const gitStatus = await GitState.status(this.#db, workspaceId, this.#loopAborts.get(loopId)?.signal);
1046
+ // The post-reconciliation Git snapshot above is threaded into the packet
1047
+ // and every budget rebuild; overflow never shells again.
993
1048
  // Notices are non-terminal observations, never operation-failure truth.
994
1049
  // Drain once and thread the same set through every grinder rebuild.
995
1050
  const notices = this.#notices.drain(loopId)
@@ -999,7 +1054,7 @@ export default class Engine {
999
1054
  // written (if turn 1) is part of that query result.
1000
1055
  let requestPacket = await this.#packets.buildRequestPacket({
1001
1056
  initialMessages: messages, requirements, workspaceId, workerId, loopId,
1002
- currentTurnSeq: seq, provider, gitStatus, notices,
1057
+ currentTurnSeq: seq, provider, gitStatus, notices, transientOpenLogEntryId,
1003
1058
  });
1004
1059
  // SPEC {§grinder} — budget grinder, pre-LLM: reclaim window on actual overflow.
1005
1060
  const enforced = await this.#packets.enforceBudget({
@@ -1011,18 +1066,18 @@ export default class Engine {
1011
1066
  // set; neither path can duplicate or swallow a product failure.
1012
1067
  rebuild: () => this.#packets.buildRequestPacket({
1013
1068
  initialMessages: messages, requirements, workspaceId, workerId, loopId,
1014
- currentTurnSeq: seq, provider, gitStatus, notices,
1069
+ currentTurnSeq: seq, provider, gitStatus, notices, transientOpenLogEntryId,
1015
1070
  }),
1016
1071
  });
1017
1072
  if (enforced.recorded)
1018
1073
  nextActionIndex += 1; // a fold-to-fit Problem consumed the reserved sequence
1019
1074
  requestPacket = enforced.packet;
1020
- let operationConstraint;
1021
1075
  if (!enforced.fit) {
1022
1076
  // {§grinder-hard-413-recovery}/{§grinder-hard-413-abort} — admit
1023
- // one physically sendable constrained recovery turn; a physical
1077
+ // one physically sendable informed recovery turn; a physical
1024
1078
  // overflow or consecutive policy overflow terminates immediately.
1025
1079
  let physicalAdmission = await this.#packets.physicalAdmission(requestPacket, provider, this.#loopAborts.get(loopId)?.signal);
1080
+ let recoveryAdmitted = false;
1026
1081
  if (physicalAdmission.admitted && !this.#hardOverflowRecovery.has(loopId)) {
1027
1082
  const ceiling = this.#packets.ceilingFor(provider);
1028
1083
  if (ceiling === null) {
@@ -1041,20 +1096,16 @@ export default class Engine {
1041
1096
  // errors sections (the same re-derive contract the soft grind uses).
1042
1097
  requestPacket = await this.#packets.buildRequestPacket({
1043
1098
  initialMessages: messages, requirements, workspaceId, workerId, loopId,
1044
- currentTurnSeq: seq, provider, gitStatus, notices,
1099
+ currentTurnSeq: seq, provider, gitStatus, notices, transientOpenLogEntryId,
1045
1100
  });
1046
1101
  physicalAdmission = await this.#packets.physicalAdmission(requestPacket, provider, this.#loopAborts.get(loopId)?.signal);
1047
1102
  if (physicalAdmission.admitted) {
1048
1103
  this.#hardOverflowRecovery.add(loopId);
1049
- operationConstraint = {
1050
- code: "budget-recovery",
1051
- detail: "budget recovery is active",
1052
- allowedOperations: BudgetOverflow.recoveryOperations,
1053
- };
1104
+ recoveryAdmitted = true;
1054
1105
  }
1055
1106
  }
1056
- if (operationConstraint === undefined) {
1057
- // Hard 413: physically unsendable, or still over after the constrained recovery turn.
1107
+ if (!recoveryAdmitted) {
1108
+ // Hard 413: physically unsendable, or still over after the informed recovery turn.
1058
1109
  const ceiling = this.#packets.ceilingFor(provider);
1059
1110
  if (ceiling === null) {
1060
1111
  throw new Error("an unbounded prompt budget cannot hard-stop");
@@ -1080,14 +1131,12 @@ export default class Engine {
1080
1131
  });
1081
1132
  requestPacket = await this.#packets.buildRequestPacket({
1082
1133
  initialMessages: messages, requirements, workspaceId, workerId, loopId,
1083
- currentTurnSeq: seq, provider, gitStatus, notices,
1134
+ currentTurnSeq: seq, provider, gitStatus, notices, transientOpenLogEntryId,
1084
1135
  });
1085
1136
  }
1086
1137
  // Skip the LLM, close the turn, and let runLoop abandon.
1087
1138
  await this.#db.engine_close_turn.run({
1088
1139
  id: turnId, status: 413, packet: StoredPacket.stringify(requestPacket),
1089
- usage_prompt: 0, usage_completion: 0, usage_reasoning: 0, usage_cached: 0,
1090
- usage_cost: "[]", usage_cost_usd: 0,
1091
1140
  // The attempted turn retains its effective allowance even
1092
1141
  // when no provider exchange completed. {§tokenomics-client-gauge}
1093
1142
  usage_prompt_budget: this.#packets.promptBudgetFor(provider),
@@ -1096,7 +1145,7 @@ export default class Engine {
1096
1145
  return {
1097
1146
  turnId,
1098
1147
  status: 413,
1099
- statuses: [],
1148
+ outcomes: [],
1100
1149
  fingerprint: "",
1101
1150
  budgetStruck: enforced.struck,
1102
1151
  budgetHardStop: true,
@@ -1121,43 +1170,42 @@ export default class Engine {
1121
1170
  let railGrammar;
1122
1171
  let railEvidence;
1123
1172
  let emissionAttempts = 0;
1124
- const usage = { prompt: 0, completion: 0, reasoning: 0, cached: 0 };
1125
- let usageCostUsd = 0;
1126
- const providerCosts = [];
1127
1173
  let providerCallInFlight = false;
1128
1174
  let providerAttemptSequence = 0;
1175
+ let providerAttemptId = null;
1129
1176
  let providerAttemptAttributions = [];
1130
1177
  const providerSignal = this.#loopAborts.get(loopId)?.signal ?? signal;
1131
1178
  // {§client-metadata}
1132
1179
  const { client } = await WorkspaceSettings.read(this.#db, workspaceId);
1133
- const recordProviderAttempt = async (attemptResponse, attemptSplit, sequence, accepted, attributions) => {
1134
- const attemptUsage = attemptSplit.callMetadata.usage;
1135
- const attemptCost = providerCostFor(provider, attemptUsage, attemptResponse.charge);
1136
- const attemptCostUsd = providerCostUsd(attemptCost);
1137
- await this.#db.engine_record_turn_attempt.run({
1138
- turn_id: turnId,
1139
- sequence,
1140
- accepted: accepted ? 1 : 0,
1180
+ const observeProviderAttempt = async (id, attemptResponse) => {
1181
+ const attemptUsage = attemptResponse.assistant.usage;
1182
+ await this.#db.engine_observe_turn_attempt_response.run({
1183
+ id,
1141
1184
  response: JSON.stringify(attemptResponse),
1142
- parse_errors: JSON.stringify(attemptSplit.parseErrors),
1143
- attributions: JSON.stringify(attributions),
1144
1185
  usage_prompt: attemptUsage.prompt,
1145
1186
  usage_completion: attemptUsage.completion,
1146
1187
  usage_reasoning: attemptUsage.reasoning,
1147
1188
  usage_cached: attemptUsage.cached,
1189
+ usage_cost: JSON.stringify({
1190
+ kind: "unknown",
1191
+ reason: "provider response retained before monetary classification",
1192
+ }),
1193
+ finish_reason: attemptResponse.assistant.finishReason,
1194
+ model: attemptResponse.assistant.model,
1195
+ });
1196
+ };
1197
+ const classifyProviderAttempt = async (id, attemptResponse, attemptSplit, sequence, accepted, failure = null) => {
1198
+ const attemptUsage = attemptSplit.callMetadata.usage;
1199
+ const attemptCost = providerCostFor(provider, attemptUsage, attemptResponse.charge);
1200
+ const attemptCostUsd = providerCostUsd(attemptCost);
1201
+ await this.#db.engine_classify_turn_attempt_response.run({
1202
+ id,
1203
+ accepted: accepted ? 1 : 0,
1204
+ parse_errors: JSON.stringify(attemptSplit.parseErrors),
1205
+ failure: failure === null ? null : JSON.stringify(failure),
1148
1206
  usage_cost: JSON.stringify(attemptCost),
1149
1207
  usage_cost_usd: attemptCostUsd,
1150
- finish_reason: attemptSplit.callMetadata.finishReason,
1151
- model: attemptSplit.callMetadata.model,
1152
1208
  });
1153
- usage.prompt += attemptUsage.prompt;
1154
- usage.completion += attemptUsage.completion;
1155
- usage.reasoning += attemptUsage.reasoning;
1156
- usage.cached += attemptUsage.cached;
1157
- providerCosts.push(attemptCost);
1158
- usageCostUsd = usageCostUsd === null || attemptCostUsd === null
1159
- ? null
1160
- : usageCostUsd + attemptCostUsd;
1161
1209
  emissionAttempts = sequence;
1162
1210
  };
1163
1211
  try {
@@ -1186,6 +1234,16 @@ export default class Engine {
1186
1234
  });
1187
1235
  providerAttemptAttributions = await this.#attemptAttributions(provider, attributionContext);
1188
1236
  requestPacket = { ...requestPacket, attributions: providerAttemptAttributions };
1237
+ const attemptRow = await this.#db.engine_open_turn_attempt.get({
1238
+ turn_id: turnId,
1239
+ sequence: attempt,
1240
+ attributions: JSON.stringify(providerAttemptAttributions),
1241
+ model: provider.model,
1242
+ });
1243
+ if (attemptRow === undefined) {
1244
+ throw new Error(`Engine.runTurn: provider attempt ${attempt} did not open`);
1245
+ }
1246
+ providerAttemptId = attemptRow.id;
1189
1247
  providerCallInFlight = true;
1190
1248
  const completedResponse = await observed(// {§observability-boundary}
1191
1249
  "provider.generate", { model: provider.model, attempt }, async (span) => {
@@ -1205,6 +1263,7 @@ export default class Engine {
1205
1263
  loop: loopSeq,
1206
1264
  turn: seq,
1207
1265
  }); // {§provider-surface-generate} {§provider-guarantees-signal-wired} {§provider-guarantees-serial-attempts} {§attribution} {§client-metadata}
1266
+ providerCallInFlight = false;
1208
1267
  recordCounter(PROVIDER_CALLS, {
1209
1268
  model: provider.model,
1210
1269
  attempt,
@@ -1214,12 +1273,12 @@ export default class Engine {
1214
1273
  return generated;
1215
1274
  });
1216
1275
  response = completedResponse;
1217
- providerCallInFlight = false;
1276
+ await observeProviderAttempt(attemptRow.id, completedResponse);
1218
1277
  railEvidence = railGrammar === undefined
1219
1278
  ? undefined
1220
1279
  : Engine.#requireGrammarEvidence(completedResponse);
1221
1280
  splitResponse = this.#splitResponse(completedResponse);
1222
- await recordProviderAttempt(completedResponse, splitResponse, attempt, splitResponse.emissionValid, providerAttemptAttributions);
1281
+ await classifyProviderAttempt(attemptRow.id, completedResponse, splitResponse, attempt, splitResponse.emissionValid);
1223
1282
  if (splitResponse.emissionValid)
1224
1283
  break;
1225
1284
  }
@@ -1231,20 +1290,43 @@ export default class Engine {
1231
1290
  // and engine-contract failures retain their original source.
1232
1291
  if (!providerCallInFlight)
1233
1292
  throw err;
1293
+ providerCallInFlight = false;
1294
+ if (providerAttemptId === null) {
1295
+ throw new Error("provider call failed without a durable attempt identity", { cause: err });
1296
+ }
1297
+ const failure = err instanceof ProviderError
1298
+ ? { status: err.problem.status, problem: err.problem }
1299
+ : providerSignal?.aborted === true
1300
+ ? Results.failure("engine:provider", providerSignal.reason === LOOP_TIMEOUT_REASON ? "provider-call-timeout" : "provider-call-cancelled", providerSignal.reason === LOOP_TIMEOUT_REASON ? 504 : 499, providerSignal.reason === LOOP_TIMEOUT_REASON
1301
+ ? "The provider call was interrupted by the loop deadline."
1302
+ : "The provider call was interrupted by loop cancellation.", {}, { stage: "provider-request", retryable: false })
1303
+ : (() => {
1304
+ console.error("Provider failed outside its Problem Details contract:", err);
1305
+ return Results.failure("engine:provider", "provider-contract-violation", 502, "The provider failed without returning its required Problem Details.", {}, {
1306
+ stage: "provider-request",
1307
+ retryable: false,
1308
+ });
1309
+ })();
1234
1310
  // {§provider-interrupted-attempt} — a provider-declared interruption
1235
1311
  // carries response evidence without becoming a completed exchange.
1236
1312
  // Persist it as an unaccepted attempt before settling the failure.
1237
1313
  if (err instanceof ProviderError && err.attempt !== undefined) {
1238
1314
  response = err.attempt;
1315
+ await observeProviderAttempt(providerAttemptId, response);
1239
1316
  splitResponse = this.#splitResponse(response);
1240
- await recordProviderAttempt(response, splitResponse, providerAttemptSequence, false, providerAttemptAttributions);
1317
+ await classifyProviderAttempt(providerAttemptId, response, splitResponse, providerAttemptSequence, false, failure);
1241
1318
  }
1242
1319
  else {
1243
- providerCosts.push({
1320
+ const unknownCost = {
1244
1321
  kind: "unknown",
1245
1322
  reason: "provider call failed without response-bearing charge evidence",
1323
+ };
1324
+ await this.#db.engine_fail_turn_attempt.run({
1325
+ id: providerAttemptId,
1326
+ failure: JSON.stringify(failure),
1327
+ usage_cost: JSON.stringify(unknownCost),
1246
1328
  });
1247
- usageCostUsd = null;
1329
+ emissionAttempts = providerAttemptSequence;
1248
1330
  }
1249
1331
  // {§turn-never-blank} — a ProviderError means no completed exchange exists.
1250
1332
  // Persist its exact RFC 9457 result before propagating it. Grammar evidence
@@ -1258,12 +1340,6 @@ export default class Engine {
1258
1340
  id: turnId,
1259
1341
  status: providerSignal.reason === LOOP_TIMEOUT_REASON ? 504 : 499,
1260
1342
  packet: StoredPacket.stringify(requestPacket),
1261
- usage_prompt: usage.prompt,
1262
- usage_completion: usage.completion,
1263
- usage_reasoning: usage.reasoning,
1264
- usage_cached: usage.cached,
1265
- usage_cost: JSON.stringify(providerCosts),
1266
- usage_cost_usd: usageCostUsd,
1267
1343
  usage_prompt_budget: this.#packets.promptBudgetFor(provider),
1268
1344
  finish_reason: splitResponse?.callMetadata.finishReason ?? null,
1269
1345
  model: splitResponse?.callMetadata.model ?? provider.model,
@@ -1271,15 +1347,6 @@ export default class Engine {
1271
1347
  });
1272
1348
  throw err;
1273
1349
  }
1274
- const failure = err instanceof ProviderError
1275
- ? { status: err.problem.status, problem: err.problem }
1276
- : (() => {
1277
- console.error("Provider failed outside its Problem Details contract:", err);
1278
- return Results.failure("engine:provider", "provider-contract-violation", 502, "The provider failed without returning its required Problem Details.", {}, {
1279
- stage: "provider-request",
1280
- retryable: false,
1281
- });
1282
- })();
1283
1350
  const recorded = await this.#problems.record({
1284
1351
  workerId,
1285
1352
  loopId,
@@ -1296,12 +1363,6 @@ export default class Engine {
1296
1363
  id: turnId,
1297
1364
  status: recorded.result.status,
1298
1365
  packet: StoredPacket.stringify(requestPacket),
1299
- usage_prompt: usage.prompt,
1300
- usage_completion: usage.completion,
1301
- usage_reasoning: usage.reasoning,
1302
- usage_cached: usage.cached,
1303
- usage_cost: JSON.stringify(providerCosts),
1304
- usage_cost_usd: usageCostUsd,
1305
1366
  usage_prompt_budget: this.#packets.promptBudgetFor(provider),
1306
1367
  finish_reason: splitResponse?.callMetadata.finishReason ?? null,
1307
1368
  model: splitResponse?.callMetadata.model ?? provider.model,
@@ -1313,16 +1374,30 @@ export default class Engine {
1313
1374
  throw new Error("provider attempt loop completed without a response");
1314
1375
  }
1315
1376
  if (!splitResponse.emissionValid) {
1377
+ // {§invalid-emission-attempts} The first consecutive exhaustion
1378
+ // publishes only the raw final response and generic recovery fact.
1379
+ let rejectedModelEntryId;
1380
+ if (allowInvalidEmissionRecovery) {
1381
+ rejectedModelEntryId = await this.#dispatcher.writeModelEntry({
1382
+ verbatim: splitResponse.packetAssistant.content,
1383
+ workerId,
1384
+ loopId,
1385
+ turnId,
1386
+ sequence: nextActionIndex,
1387
+ folded: true,
1388
+ admission: "rejected",
1389
+ });
1390
+ this.#notices.push(workspaceId, loopId, {
1391
+ source: "engine:grammar",
1392
+ kind: "invalid_emission",
1393
+ level: "error",
1394
+ message: INVALID_EMISSION_RECOVERY_MESSAGE,
1395
+ });
1396
+ }
1316
1397
  await this.#db.engine_close_turn.run({
1317
1398
  id: turnId,
1318
- status: 500,
1399
+ status: allowInvalidEmissionRecovery ? TURN_STATUS_IMPLICIT_CONTINUE : 500,
1319
1400
  packet: StoredPacket.stringify(requestPacket),
1320
- usage_prompt: usage.prompt,
1321
- usage_completion: usage.completion,
1322
- usage_reasoning: usage.reasoning,
1323
- usage_cached: usage.cached,
1324
- usage_cost: JSON.stringify(providerCosts),
1325
- usage_cost_usd: usageCostUsd,
1326
1401
  usage_prompt_budget: this.#packets.promptBudgetFor(provider),
1327
1402
  finish_reason: splitResponse.callMetadata.finishReason,
1328
1403
  model: splitResponse.callMetadata.model,
@@ -1330,14 +1405,15 @@ export default class Engine {
1330
1405
  });
1331
1406
  return {
1332
1407
  turnId,
1333
- status: 500,
1334
- statuses: [],
1408
+ status: allowInvalidEmissionRecovery ? TURN_STATUS_IMPLICIT_CONTINUE : 500,
1409
+ outcomes: [],
1335
1410
  fingerprint: "",
1336
1411
  budgetStruck: enforced.struck,
1337
1412
  budgetHardStop: false,
1338
1413
  steerStruck: false,
1339
1414
  emissionAttempts,
1340
1415
  emissionExhausted: true,
1416
+ ...(rejectedModelEntryId === undefined ? {} : { rejectedModelEntryId }),
1341
1417
  };
1342
1418
  }
1343
1419
  // {§packet-stored-shape} — admitted emission data extends the packet;
@@ -1348,7 +1424,7 @@ export default class Engine {
1348
1424
  }
1349
1425
  // Non-fatal provider transport notices on an accepted turn. Forward each
1350
1426
  // Notice with a content-offset `line:col`;
1351
- // the model resolves it against its own emission — READ the folded `model` mirror row at the
1427
+ // the model resolves it against its own emission — READ the folded model-emission row at the
1352
1428
  // cited lines ({§model-entry}) — not an embedded snippet that would duplicate the emission.
1353
1429
  for (const notice of response.notices ?? []) {
1354
1430
  const located = typeof notice.position === "number"
@@ -1442,12 +1518,6 @@ export default class Engine {
1442
1518
  id: turnId,
1443
1519
  status: turnStatus,
1444
1520
  packet: StoredPacket.stringify(packet),
1445
- usage_prompt: usage.prompt,
1446
- usage_completion: usage.completion,
1447
- usage_reasoning: usage.reasoning,
1448
- usage_cached: usage.cached,
1449
- usage_cost: JSON.stringify(providerCosts),
1450
- usage_cost_usd: usageCostUsd,
1451
1521
  usage_prompt_budget: this.#packets.promptBudgetFor(provider), // {§tokenomics-client-gauge}
1452
1522
  finish_reason: callMetadata.finishReason,
1453
1523
  model: callMetadata.model,
@@ -1471,22 +1541,17 @@ export default class Engine {
1471
1541
  // (the PLAN/SEND ops, zero actions), which is its only coherent meaning.
1472
1542
  let realCommands = 0;
1473
1543
  const admittedOps = packetAssistant.ops.filter((op) => {
1474
- const constrained = operationConstraint !== undefined
1475
- && !operationConstraint.allowedOperations.includes(op.op);
1476
1544
  return op.op === "PLAN"
1477
1545
  || op === sendOp
1478
- || constrained
1479
1546
  || realCommands++ < maxCommands;
1480
1547
  });
1481
1548
  const opsToDispatch = scheduleTurnOps(admittedOps);
1482
- await this.#dispatcher.prepareEditBatches(opsToDispatch.filter((statement) => statement.op === "EDIT"
1483
- && (operationConstraint === undefined
1484
- || operationConstraint.allowedOperations.includes(statement.op))), {
1549
+ await this.#dispatcher.prepareEditBatches(opsToDispatch.filter((statement) => statement.op === "EDIT"), {
1485
1550
  workspaceId, workerId, loopId, turnId,
1486
1551
  origin, onDispatch,
1487
1552
  });
1488
1553
  const droppedCount = opsCount - opsToDispatch.length;
1489
- const statuses = [];
1554
+ const outcomes = [];
1490
1555
  // Running counter — a multi-file READ writes N rows from one statement (rowsWritten),
1491
1556
  // so the next op's sequence picks up after them. Collapses to nextActionIndex+i when
1492
1557
  // every op writes one row (the common case).
@@ -1513,7 +1578,7 @@ export default class Engine {
1513
1578
  retryable: false,
1514
1579
  }),
1515
1580
  });
1516
- statuses.push(recorded.result.status);
1581
+ outcomes.push({ op: null, status: recorded.result.status });
1517
1582
  onDispatch?.(recorded.id);
1518
1583
  }
1519
1584
  };
@@ -1522,20 +1587,25 @@ export default class Engine {
1522
1587
  && typeof statement.signal === "number"
1523
1588
  && TERMINAL_SEND_SIGNALS.has(statement.signal)) {
1524
1589
  await recordRecoverableParseErrors();
1590
+ // {§worker-optimistic-settlement} — SEND judges the refreshed
1591
+ // lifecycle after this turn's own fast streams receive one
1592
+ // bounded opportunity to conclude. This is not a sleep and
1593
+ // does not delay a later turn for older monitored streams.
1594
+ const execHandler = this.#schemes.get("exec");
1595
+ await execHandler?.settleTurnSpawns?.(workerId, turnId, readOptimisticSettlementMs(), providerSignal);
1525
1596
  }
1526
1597
  const result = await observed(// {§observability-boundary}
1527
1598
  "op.dispatch", { op: statement.op }, async (span) => {
1528
1599
  const dispatchResult = await this.#dispatcher.dispatch({
1529
1600
  statement, workspaceId, workerId, loopId, turnId,
1530
1601
  sequence: rowSeq,
1531
- ...(operationConstraint === undefined ? {} : { operationConstraint }),
1532
1602
  origin, onDispatch,
1533
1603
  });
1534
1604
  span.setAttribute("status", dispatchResult.status);
1535
1605
  recordCounter(OPS_DISPATCHED, { op: statement.op, status: dispatchResult.status });
1536
1606
  return dispatchResult;
1537
1607
  });
1538
- statuses.push(result.status);
1608
+ outcomes.push({ op: statement.op, status: result.status });
1539
1609
  for (const normalization of result.scopeNormalizations ?? []) {
1540
1610
  this.#notices.push(workspaceId, loopId, {
1541
1611
  source: "engine:slicer",
@@ -1622,7 +1692,7 @@ export default class Engine {
1622
1692
  return {
1623
1693
  turnId,
1624
1694
  status: turnStatus,
1625
- statuses,
1695
+ outcomes,
1626
1696
  fingerprint: StrikeRail.fingerprintTurn(packetAssistant.ops),
1627
1697
  budgetStruck: enforced.struck,
1628
1698
  budgetHardStop: false,
@@ -1647,10 +1717,8 @@ export default class Engine {
1647
1717
  const preParsedOps = assistant.ops;
1648
1718
  const ops = [];
1649
1719
  // PLAN is an ordinary op — emitted by the model, dispatched, and passed to the
1650
- // client as a log entry. No special hoisting into the reasoning field (that
1651
- // only structured operations are executable). Interstitial free text is DROPPED — the prior
1652
- // #free-text-capture synthesis of SEND[103] log ops was retired as tech debt
1653
- // (grammar 0.70 forbids free text between ops, so a prose-only turn strikes 422).
1720
+ // client as a log entry. No special hoisting into the reasoning field. Only
1721
+ // structured operations are executable; interstitial text is not an operation.
1654
1722
  // Full PlurnkParseError context is preserved on rejected attempt evidence;
1655
1723
  // warnings remain admissible Notices. {§parse-diagnostics}
1656
1724
  const parseErrors = [];
@@ -1671,7 +1739,6 @@ export default class Engine {
1671
1739
  if (item.kind === "statement") {
1672
1740
  ops.push(item.statement);
1673
1741
  }
1674
- // Free text (kind "text") is dropped — #free-text-capture retired (above).
1675
1742
  else if (item.kind === "error") {
1676
1743
  const err = item.error;
1677
1744
  if (err instanceof PlurnkParseError) {
@@ -1758,11 +1825,28 @@ export default class Engine {
1758
1825
  || r.op === null || r.status_rx === null)
1759
1826
  continue;
1760
1827
  const termination = r.kind === "loop_termination";
1761
- const rx = termination
1762
- ? BranchReceipt.append(markTerminal(r.terminated_by, r.rx) ?? `loop "${r.prompt ?? ""}" ended (${r.status_rx})`, await BranchReceipt.render(this.#db, r.producer_worker_id))
1763
- : r.rx;
1764
- if (rx === null)
1828
+ if (r.rx === null)
1765
1829
  throw new Error(`ambient event ${r.event_id} has no materializable result`);
1830
+ const terminal = termination
1831
+ ? TerminalResult.parse(r.rx, `ambient loop-termination event ${r.event_id}`)
1832
+ : null;
1833
+ if (terminal !== null && terminal.status !== r.status_rx) {
1834
+ throw new Error(`ambient loop-termination event ${r.event_id} status ${r.status_rx} does not match its terminal result status ${terminal.status}`);
1835
+ }
1836
+ let attrs = r.attrs ?? "{}";
1837
+ if (terminal !== null) {
1838
+ const inherited = JSON.parse(attrs);
1839
+ if (inherited === null || typeof inherited !== "object" || Array.isArray(inherited)) {
1840
+ throw new TypeError(`ambient loop-termination event ${r.event_id} attrs must be an object`);
1841
+ }
1842
+ const receipt = await BranchReceipt.render(this.#db, r.producer_worker_id);
1843
+ attrs = JSON.stringify({
1844
+ ...inherited,
1845
+ kind: "loop_termination",
1846
+ ...(r.terminated_by === null ? {} : { terminatedBy: r.terminated_by }),
1847
+ ...(receipt === null ? {} : { receipt }),
1848
+ });
1849
+ }
1766
1850
  const inserted = await this.#db.engine_insert_ambient_delta.get({
1767
1851
  worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
1768
1852
  event_id: r.event_id,
@@ -1771,11 +1855,11 @@ export default class Engine {
1771
1855
  scheme: r.scheme,
1772
1856
  hostname: r.hostname,
1773
1857
  pathname: r.pathname,
1774
- rx,
1775
- mimetype_rx: termination ? "text/markdown" : "application/json",
1858
+ rx: r.rx,
1859
+ mimetype_rx: "application/json",
1776
1860
  status: r.status_rx,
1777
- expanded: termination && r.status_rx >= 200 && r.status_rx < 300 ? 1 : 0,
1778
- attrs: r.attrs ?? "{}",
1861
+ expanded: terminal !== null && terminal.status >= 200 && terminal.status < 300 ? 1 : 0,
1862
+ attrs,
1779
1863
  });
1780
1864
  if (inserted !== undefined)
1781
1865
  written++;
@@ -1801,11 +1885,10 @@ export default class Engine {
1801
1885
  return this.#liveSubscriptions.cancel(subscriptionId);
1802
1886
  }
1803
1887
  // {§env-delta} — exec streams as an instance of the ambient-observe machine:
1804
- // each turn, emit each owned channel's unshown byte-delta as a foisted READ row. It is 200
1805
- // while the channel streams and preserves the exact terminal result when closed. Ongoing
1806
- // deltas fold; the terminal delta auto-OPENs. The cursor is the
1807
- // streamEnd recorded on the channel's prior delta — no exec-specific surfacing, just the
1808
- // env-observe loop with a byte cursor where env-delta uses a timestamp. {§exec-stream}
1888
+ // each turn, emit each owned channel's next publishable content as a foisted READ row. It is
1889
+ // 200 while the channel streams and preserves the exact terminal result when closed. Ongoing
1890
+ // observations fold; the terminal observation auto-OPENs. The cursor is the streamEnd recorded
1891
+ // on the channel's prior observation. {§exec-stream}
1809
1892
  async #materializeStreamDeltas(args) {
1810
1893
  const { workerId, loopId, turnId, fromSequence } = args;
1811
1894
  const channels = await this.#db.engine_worker_stream_channels.all({ worker_id: workerId });
@@ -1847,14 +1930,15 @@ export default class Engine {
1847
1930
  }
1848
1931
  return result;
1849
1932
  };
1850
- if (ch.content.length <= cursor) {
1851
- // The cursor-terminal race (owner's dogfood find): a channel written in one final
1933
+ const publishEnd = streamPublicationEnd(ch.content, ch.mimetype, cursor, closed);
1934
+ if (publishEnd <= cursor) {
1935
+ // The cursor-terminal race: a channel written in one final
1852
1936
  // burst gets fully shown FOLDED while still active; the close then has zero new
1853
- // bytes and the auto-OPEN terminal delta never fired — the model was never shown
1937
+ // content and the auto-OPEN terminal observation never fired — the model was never shown
1854
1938
  // the conclusion of a stream whose result it already holds folded. The same
1855
- // observation is required when the stream produced zero bytes: completion is
1856
- // information independently of payload. Emit the terminal marker ONCE: open,
1857
- // terse, carrying the close status ({§tokenomics-fetch-fits-free}).
1939
+ // observation is required when the channel produced no publishable content:
1940
+ // completion is information independently of payload. Text streams retain their
1941
+ // terse marker; structured channels emit a bodyless typed conclusion.
1858
1942
  if (closed && priorAttrs.terminal !== true) {
1859
1943
  const streamTarget = renderTarget({
1860
1944
  scheme: ch.runtime,
@@ -1867,12 +1951,15 @@ export default class Engine {
1867
1951
  ? `full output already delivered above; READ ${streamTarget} to revisit`
1868
1952
  : "stream produced no output";
1869
1953
  const sequence = fromSequence + written;
1954
+ const content = baseMimetype(ch.mimetype).startsWith("text/")
1955
+ ? `[ stream closed (${ch.close_status ?? 200}) - ${pointer} ]`
1956
+ : "";
1870
1957
  await this.#db.engine_insert_stream_delta.run({
1871
1958
  worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence,
1872
1959
  scheme: ch.runtime, pathname: ch.coord, fragment: visibleFragment,
1873
1960
  rx: JSON.stringify(await terminalResult({
1874
- content: `[ stream closed (${ch.close_status ?? 200}) - ${pointer} ]`,
1875
- mimetype: "text/stream",
1961
+ content,
1962
+ mimetype: ch.mimetype,
1876
1963
  }, sequence)),
1877
1964
  status: terminal?.status ?? 200,
1878
1965
  attrs: JSON.stringify({ streamEnd: ch.content.length, terminal: true }),
@@ -1886,16 +1973,19 @@ export default class Engine {
1886
1973
  // into one sequence (lines N..M, then M+1..), not N independent "1:" restarts. {§exec-stream}
1887
1974
  const startLine = (ch.content.slice(0, cursor).match(/\n/g)?.length ?? 0) + 1;
1888
1975
  const sequence = fromSequence + written;
1889
- const result = closed
1890
- ? await terminalResult({ content: ch.content.slice(cursor), mimetype: "text/stream", startLine }, sequence)
1891
- : { status: 200, content: ch.content.slice(cursor), mimetype: "text/stream", startLine };
1976
+ const content = ch.content.slice(cursor, publishEnd);
1977
+ const terminalDelivery = closed && publishEnd === ch.content.length;
1978
+ const fields = { content, mimetype: ch.mimetype, startLine };
1979
+ const result = terminalDelivery
1980
+ ? await terminalResult(fields, sequence)
1981
+ : { status: 200, ...fields };
1892
1982
  await this.#db.engine_insert_stream_delta.run({
1893
1983
  worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence,
1894
1984
  scheme: ch.runtime, pathname: ch.coord, fragment: visibleFragment,
1895
1985
  rx: JSON.stringify(result),
1896
1986
  status: result.status,
1897
- attrs: JSON.stringify({ streamEnd: ch.content.length, terminal: closed }),
1898
- expanded: closed ? 1 : 0, // {§exec-stream} — terminal delta auto-OPENs; ongoing folds
1987
+ attrs: JSON.stringify({ streamEnd: publishEnd, terminal: terminalDelivery }),
1988
+ expanded: terminalDelivery ? 1 : 0, // {§exec-stream} — terminal observation auto-OPENs; ongoing folds
1899
1989
  });
1900
1990
  written++;
1901
1991
  }
@@ -1903,9 +1993,10 @@ export default class Engine {
1903
1993
  }
1904
1994
  // {§env-delta-filesystem-narration} {§membership-emi-divergence-signal}
1905
1995
  // — journal project-file divergence once through the reserved actor.
1906
- async #logFsFictions(workspaceId, divergences) {
1996
+ async #logFsFictions(workspaceId, divergences, gitStatus) {
1907
1997
  if (divergences.length === 0)
1908
1998
  return;
1999
+ const gitByPath = new Map(gitStatus?.files.map(({ path, status }) => [path, status]) ?? []);
1909
2000
  const worker = await this.#db.envelope_get_worker_by_name.get({ workspace_id: workspaceId, name: "plurnk" })
1910
2001
  ?? await this.#db.envelope_insert_worker.get({ workspace_id: workspaceId, name: "plurnk", origin: "plurnk" });
1911
2002
  if (worker === undefined)
@@ -1926,7 +2017,10 @@ export default class Engine {
1926
2017
  pathname: d.pathname, query: null, fragment: null, lineMarker: null,
1927
2018
  tx: "", mimetype_tx: "text/plain",
1928
2019
  rx: JSON.stringify({ status: 200, entryId: d.entryId, channel: d.channel, span }), mimetype_rx: "application/json",
1929
- status_rx: 200, tokens: 0, state: "resolved", outcome: null, attrs: "{}",
2020
+ status_rx: 200, tokens: 0, state: "resolved", outcome: null,
2021
+ attrs: gitByPath.has(d.pathname)
2022
+ ? JSON.stringify({ git: gitByPath.get(d.pathname) })
2023
+ : "{}",
1930
2024
  });
1931
2025
  }
1932
2026
  }