@plurnk/plurnk-service 0.58.0 → 0.60.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 (45) hide show
  1. package/.env.example +12 -1
  2. package/SPEC.md +38 -32
  3. package/dist/core/Engine.d.ts +2 -0
  4. package/dist/core/Engine.d.ts.map +1 -1
  5. package/dist/core/Engine.js +234 -150
  6. package/dist/core/Engine.js.map +1 -1
  7. package/dist/core/Engine.sql +48 -10
  8. package/dist/core/ExecutorRegistry.d.ts +1 -1
  9. package/dist/core/ExecutorRegistry.d.ts.map +1 -1
  10. package/dist/core/ExecutorRegistry.js.map +1 -1
  11. package/dist/core/packet-inject.d.ts.map +1 -1
  12. package/dist/core/packet-inject.js +8 -3
  13. package/dist/core/packet-inject.js.map +1 -1
  14. package/dist/core/packet-wire.d.ts +5 -1
  15. package/dist/core/packet-wire.d.ts.map +1 -1
  16. package/dist/core/packet-wire.js +52 -18
  17. package/dist/core/packet-wire.js.map +1 -1
  18. package/dist/core/run-cap.js +2 -2
  19. package/dist/core/run-cap.js.map +1 -1
  20. package/dist/core/run-ops.sql +21 -0
  21. package/dist/schemes/Exec.js +2 -2
  22. package/dist/schemes/Exec.js.map +1 -1
  23. package/dist/schemes/Run.d.ts.map +1 -1
  24. package/dist/schemes/Run.js +26 -26
  25. package/dist/schemes/Run.js.map +1 -1
  26. package/dist/schemes/_entry-manifest.d.ts.map +1 -1
  27. package/dist/schemes/_entry-manifest.js +5 -1
  28. package/dist/schemes/_entry-manifest.js.map +1 -1
  29. package/dist/schemes/_entry-semantic.d.ts.map +1 -1
  30. package/dist/schemes/_entry-semantic.js +6 -0
  31. package/dist/schemes/_entry-semantic.js.map +1 -1
  32. package/dist/server/Daemon.d.ts.map +1 -1
  33. package/dist/server/Daemon.js +24 -2
  34. package/dist/server/Daemon.js.map +1 -1
  35. package/dist/server/drain.sql +14 -0
  36. package/dist/server/methods/session_attach.d.ts.map +1 -1
  37. package/dist/server/methods/session_attach.js +4 -0
  38. package/dist/server/methods/session_attach.js.map +1 -1
  39. package/dist/server/methods/session_create.d.ts.map +1 -1
  40. package/dist/server/methods/session_create.js +4 -0
  41. package/dist/server/methods/session_create.js.map +1 -1
  42. package/docs/run.md +2 -0
  43. package/migrations/0000-00-00.01_schema.sql +5 -1
  44. package/package.json +12 -11
  45. package/requirements.md +6 -7
@@ -29,6 +29,18 @@ const MUTATING_OPS = new Set(["EDIT", "SEND", "COPY", "MOVE", "EXEC", "KILL"]);
29
29
  const DEFAULT_MAX_STRIKES = 3;
30
30
  const DEFAULT_MAX_COMMANDS = 99;
31
31
  const DEFAULT_BUDGET_CEILING = 0.9;
32
+ // §telemetry — the uniform error channel. Every engine failure is a terse op='error'
33
+ // log row: a status code + the canonical term, no prose (the packet teaches recovery).
34
+ // Each surfaces as a LogCoordinate TelemetryEvent derived from log≥400 — one channel,
35
+ // no per-kind handling. {§telemetry-uniform-error-channel}
36
+ const ENGINE_ERRORS = Object.freeze({
37
+ budget_overflow: { status: 413, term: "Budget Overflow: newest log items automatically FOLDed" },
38
+ max_commands_exceeded: { status: 429, term: "Max Commands Exceeded" },
39
+ // premature-terminate is NOT a terse engine-error: it's a SEND op-result (409 + an actionable
40
+ // outcome, §send-premature-terminate) — the SEND row records the [200] attempt faithfully and
41
+ // auto-surfaces (status≥400) like any op failure, never an erasure to 102.
42
+ idle_turn: { status: 409, term: "Idle Turn" },
43
+ });
32
44
  // Substituted into the budget readout after the assembled packet is measured
33
45
  // (the figure depends on the packet's own rendered size — chicken/egg).
34
46
  const TOKENS_FREE_PLACEHOLDER = "{{tokensFree}}";
@@ -744,12 +756,20 @@ class Engine {
744
756
  });
745
757
  // SPEC §grinder — budget grinder, pre-LLM: reclaim window on actual overflow.
746
758
  const enforced = await this.#enforceBudget({
747
- packet: requestPacket, provider, runId, loopId, turnId, sessionId, turnNumber,
748
- rebuild: (telemetryErrors) => this.#buildRequestPacket({
759
+ packet: requestPacket, provider, runId, loopId, turnId,
760
+ // The overflow error row is minted at the turn's running sequence (nextActionIndex), pre-generate;
761
+ // runTurn advances the counter past it below so the post-generate dispatch rows never collide.
762
+ mintSequence: nextActionIndex,
763
+ // No preset telemetry — the rebuild RE-DERIVES the errors section from log≥400 so the
764
+ // overflow row just minted surfaces THIS turn (§grinder-overflow-error-row). Safe: the
765
+ // ephemeral buffer is empty pre-generate (events drain on the next turn's build).
766
+ rebuild: () => this.#buildRequestPacket({
749
767
  initialMessages: messages, requirements, sessionId, runId, loopId,
750
- currentTurnSeq: seq, provider, telemetryErrors, gitStatus,
768
+ currentTurnSeq: seq, provider, gitStatus,
751
769
  }),
752
770
  });
771
+ if (enforced.struck)
772
+ nextActionIndex += 1; // the budget-overflow error row consumed a sequence
753
773
  requestPacket = enforced.packet;
754
774
  if (!enforced.fit) {
755
775
  // Hard 413: won't fit even with only the manifest left. Skip the LLM,
@@ -862,42 +882,34 @@ class Engine {
862
882
  // §grinder-strike-coupling): the loop continues, the model sees the steering hint not the strike
863
883
  // count, and a non-resolver spins out to the engine's 500.
864
884
  let steerStruck = false;
885
+ // Engine errors raised this turn, minted as op='error' log rows after dispatch (they share the
886
+ // post-dispatch sequence counter). §telemetry-uniform-error-channel
887
+ const pendingEngineErrors = [];
865
888
  // Premature terminate: a SEND[200] while the run still holds a live thing — an open stream/spawn
866
889
  // OR a non-terminal child run (§run-lifecycle: children and streams are the same kind of "live
867
- // thing a run holds"). The model declared done with work running. Downgrade the 200 to 102 so it
868
- // dispatches as a continue (its body is preserved, not discarded) and steer; the stream's/child's
869
- // own conclusion (the wake edge) or a KILL is the exit.
870
- if (sendOp?.signal === 200) {
871
- const openSubs = await this.#db.find_open_subscriptions_for_run.all({ run_id: runId });
872
- const execHandler = this.#schemes.get("exec");
873
- const liveChild = await this.#db.engine_run_has_live_child.get({ run_id: runId });
874
- if (openSubs.length > 0 || execHandler?.hasActiveSpawns?.(runId) === true || liveChild !== undefined) {
875
- sendOp.signal = TURN_STATUS_IMPLICIT_CONTINUE; // 102 — downgraded, no longer a terminal
876
- steerStruck = true;
877
- this.#pushTelemetry(sessionId, loopId, {
878
- source: "engine:rail",
879
- kind: "premature_terminate",
880
- message: "Attempted termination with active streams or child runs. Terminate with 202 to hibernate until they complete, KILL(path) with 200 again to clean up, or 499 to fail.",
881
- level: "warn",
882
- });
883
- }
884
- }
890
+ // thing a run holds"). The model declared done with work running. The SEND is REFUSED 409 at
891
+ // dispatch (#handleSendBroadcast) the row records the [200] attempt + body faithfully (no
892
+ // erasure to 102) and auto-surfaces (status≥400); the loop never goes terminal. Here we only
893
+ // flag it so the turn stays a continue and the strike couples to the grinder (steerStruck →
894
+ // turnErrors): a model that won't stop premature-200ing escalates out via the rails.
895
+ const prematureTerminate = sendOp?.signal === 200 && await this.#runHoldsLiveThing(runId);
896
+ if (prematureTerminate)
897
+ steerStruck = true;
885
898
  // Rail #41 (revised): the per-turn requirement is "emit at least one op," not "emit a terminal
886
899
  // SEND." SEND is purely a signal verb; many turns pass without one. An empty op list strikes.
887
- const turnStatus = sendOp !== undefined
888
- ? sendOp.signal
889
- : realOpsCount === 0 ? TURN_STATUS_NO_OPS : TURN_STATUS_IMPLICIT_CONTINUE;
900
+ // A refused premature-terminate keeps the turn a continue (102) though the SEND's signal stays
901
+ // 200 (the un-erased record) — the loop never went terminal, so the turn didn't either.
902
+ const turnStatus = prematureTerminate
903
+ ? TURN_STATUS_IMPLICIT_CONTINUE
904
+ : sendOp !== undefined
905
+ ? sendOp.signal
906
+ : realOpsCount === 0 ? TURN_STATUS_NO_OPS : TURN_STATUS_IMPLICIT_CONTINUE;
890
907
  // Idle turn: an implicit-continue (102) that did no WORK — its ops are only PLAN/SEND, no mid op.
891
908
  // The model continued with nothing to do. (Skipped when premature already steered this turn.)
892
909
  const midOpsCount = packetAssistant.ops.filter((op) => op.op !== "PLAN" && op.op !== "SEND").length;
893
910
  if (!steerStruck && turnStatus === TURN_STATUS_IMPLICIT_CONTINUE && midOpsCount === 0) {
894
911
  steerStruck = true;
895
- this.#pushTelemetry(sessionId, loopId, {
896
- source: "engine:rail",
897
- kind: "idle_turn",
898
- message: "If the turn's work is complete, terminate with 200. If awaiting a stream or run trigger, terminate with 202 to hibernate.",
899
- level: "warn",
900
- });
912
+ pendingEngineErrors.push("idle_turn");
901
913
  }
902
914
  // Close the turn with the final packet, status, and usage stats.
903
915
  const packet = this.#completePacket(requestPacket, packetAssistant, response.assistantRaw, provider);
@@ -948,28 +960,22 @@ class Engine {
948
960
  statement, sessionId, runId, loopId, turnId,
949
961
  sequence: rowSeq,
950
962
  origin, onDispatch,
963
+ prematureRefusal: prematureTerminate && statement === sendOp, // the pre-dispatch snapshot's decision, only for this turn's terminal SEND
951
964
  });
952
965
  statuses.push(result.status);
953
966
  rowSeq += result.rowsWritten ?? 1;
954
967
  }
955
- // max_commands_exceeded IS model-facing: dropped ops are things
956
- // the model emitted that didn't run it needs to know. Engine
957
- // bookkeeping (the cap value, our threshold reasoning) stays
958
- // internal; only the facts of what happened are reported.
959
- if (droppedCount > 0) {
960
- this.#pushTelemetry(sessionId, loopId, {
961
- source: "engine:rail",
962
- kind: "max_commands_exceeded",
963
- emitted: opsCount,
964
- dropped: droppedCount,
965
- level: "error",
966
- });
967
- }
968
- // §telemetry — parse errors as LOG ITEMS: a failed-to-parse emission records an actionless
969
- // `error` row (status 400, no target, snippet = the foldable body) at the turn's next free
970
- // sequence (after the dispatched ops). The model folds/kills/recalls it like any log entry,
971
- // and the errors section derives a pointer (status + coordinate) from log≥400 — one surface.
972
- let errSeq = rowSeq; // after every dispatched row, including a multi-file READ's fan-out
968
+ // §telemetry-uniform-error-channel every engine + parse failure mints as an op='error'
969
+ // log row at the turn's next free sequence (after every dispatched row, incl. a multi-file
970
+ // READ's fan-out). One channel: the errors section derives a LogCoordinate pointer from log≥400.
971
+ let errSeq = rowSeq;
972
+ // max_commands_exceeded IS model-facing: dropped ops the model emitted that didn't run.
973
+ if (droppedCount > 0)
974
+ pendingEngineErrors.push("max_commands_exceeded");
975
+ for (const kind of pendingEngineErrors)
976
+ await this.#mintEngineError(kind, { runId, loopId, turnId, sequence: errSeq++ });
977
+ // Parse errors carry the parser message + a content-offset line:col (a ContentOffset position),
978
+ // resolved against the model's born-OPEN emission (§model-entry) — origin 'model', not engine.
973
979
  for (const { message, line, column, source } of parseErrors ?? []) {
974
980
  await this.#db.engine_insert_log_entry.get({
975
981
  run_id: runId, loop_id: loopId, turn_id: turnId, sequence: errSeq++,
@@ -1081,10 +1087,11 @@ class Engine {
1081
1087
  // This is what inject + the turn-1 foist write into. Falls back to
1082
1088
  // the runLoop caller's messages.user for tests that bypass the
1083
1089
  // foist mechanism entirely.
1084
- const latestPromptRow = await this.#db.drain_get_latest_prompt_body_for_loop.get({ pattern: `/prompt/${loopId}/%` });
1090
+ const promptRows = (await this.#db.drain_get_all_prompt_bodies_for_loop.all({ pattern: `/prompt/${loopId}/%` }))
1091
+ .filter((r) => typeof r.content === "string" && r.content.length > 0);
1085
1092
  const promptCap = Number.parseInt(process.env.PLURNK_PROMPT_PREVIEW_CHARS ?? "", 10);
1086
- const prompt = (latestPromptRow !== undefined && typeof latestPromptRow.content === "string" && latestPromptRow.content.length > 0)
1087
- ? PacketWire.previewPrompt(latestPromptRow.content, renderAddress("plurnk", latestPromptRow.pathname), Number.isInteger(promptCap) ? promptCap : -1)
1093
+ const prompt = promptRows.length > 0
1094
+ ? PacketWire.renderActivePrompts(promptRows, Number.isInteger(promptCap) ? promptCap : -1)
1088
1095
  : byRole("user");
1089
1096
  // Requirements is engine-sourced, NOT threaded from callers — that threading is
1090
1097
  // exactly how it went missing (callers read the sysprompt but never the
@@ -1120,28 +1127,43 @@ class Engine {
1120
1127
  const sessionRoot = (await this.#db.envelope_get_session.get({ id: sessionId }))?.project_root ?? null;
1121
1128
  const systemPolicy = await readSystemPolicy(); // ~/.plurnk/AGENTS.md (or PLURNK_POLICY)
1122
1129
  const projectPolicy = await readProjectPolicy(sessionRoot); // <projectRoot>/AGENTS.md (or PLURNK_PROJECT)
1130
+ // Child-orientation (§child-orientation): the live things THIS run holds — open streams +
1131
+ // unconcluded child runs — surfaced every turn as terse `* <status> <path>` pointers (same shape
1132
+ // as errors) just above the errors section. Orienting STATE so the model never loses track of
1133
+ // what it's holding (the premature-terminate trap), never advice on what to do. Empty → omitted.
1134
+ const childStreams = (await this.#db.engine_child_streams_open.all({ run_id: runId }))
1135
+ .map((s) => ({ status: "active", path: renderAddress(s.scheme, s.pathname) }));
1136
+ const childRuns = (await this.#db.engine_child_runs_live.all({ run_id: runId }))
1137
+ .map((r) => ({ status: r.status, path: `run://${r.name}` }));
1123
1138
  const defaults = [
1124
1139
  { name: "definition", slot: "system", header: null, content: system_definition, tokens: 0 },
1125
1140
  { name: "tools", slot: "system", header: null, content: tools.join("\n"), tokens: 0 }, // titleless — the examples flow on from plurnk.md (definition) directly above
1126
- { name: "schemes", slot: "system", header: "Plurnk System Schemes", content: this.#schemes.teach(), tokens: 0 },
1141
+ { name: "schemes", slot: "system", header: "Plurnk Service Schemes", content: this.#schemes.teach(), tokens: 0 },
1127
1142
  ...(inject !== null ? [{ name: "inject", slot: "system", header: "Plurnk Operator Notes", content: inject, tokens: 0 }] : []),
1128
1143
  // policy: the client's privileged rules — ~/.plurnk/AGENTS.md (system) then <root>/AGENTS.md (project) — below grammar/tools/schemes, above budget-the-law. AGENTS is POLICY here, never a curatable READable entry. Empty content ⇒ section omitted.
1129
- { name: "system-policy", slot: "system", header: "Plurnk System Policy", content: systemPolicy ?? "", tokens: 0 },
1144
+ { name: "system-policy", slot: "system", header: "Plurnk Service Policy", content: systemPolicy ?? "", tokens: 0 },
1130
1145
  { name: "project-policy", slot: "system", header: "Project Policy", content: projectPolicy ?? "", tokens: 0 },
1131
1146
  // The packet split is a TRUST boundary: system carries only framework-authored, non-injectable
1132
1147
  // sections; anything that could carry attacker-reachable text (a READ result, exec output, the
1133
1148
  // model's own mirrored bytes) stays in user. errors + git are framework status — the errors
1134
1149
  // section is uri+status POINTERS (the error item + body live in the log), git is counts — so
1135
1150
  // neither is an injection surface; both sit at the bottom of system, just above budget-the-law.
1136
- { name: "errors", slot: "system", header: "Plurnk System Errors", content: PacketWire.renderErrors(telemetryErrors), tokens: 0 },
1137
- { name: "git", slot: "system", header: "Plurnk System Git Status", content: PacketWire.renderGit(gitStatus), tokens: 0 },
1151
+ // child-orientation: what THIS run holds live streams then runs just above errors. Terse
1152
+ // pointers (the path is the actionable address the model READs/OPENs/KILLs), never advice. §child-orientation
1153
+ { name: "child-streams", slot: "system", header: "Plurnk Service Child Streams", content: PacketWire.renderChildPointers(childStreams), tokens: 0 },
1154
+ { name: "child-runs", slot: "system", header: "Plurnk Service Child Runs", content: PacketWire.renderChildPointers(childRuns), tokens: 0 },
1155
+ { name: "errors", slot: "system", header: "Plurnk Service Errors", content: PacketWire.renderErrors(telemetryErrors), tokens: 0 },
1156
+ { name: "git", slot: "system", header: "Plurnk Service Git Status", content: PacketWire.renderGit(gitStatus), tokens: 0 },
1138
1157
  // budget is the very last system line — LAW (a hard ceiling the model must obey), the final word before the model acts.
1139
- { name: "budget", slot: "system", header: "Plurnk System Budget", content: budgetReadout, tokens: 0 },
1140
- { name: "prompt", slot: "user", header: "Plurnk System User Prompt", content: prompt, tokens: 0 },
1158
+ { name: "budget", slot: "system", header: "Plurnk Service Budget", content: budgetReadout, tokens: 0 },
1141
1159
  // log in the user slot: injectable content (READ results, exec output, the model's own mirror) — data, never rules — kept at the action point so the model consults its history.
1142
- { name: "log", slot: "user", header: "Plurnk System Log", content: PacketWire.renderLog(log, countTokens), tokens: 0 },
1160
+ { name: "log", slot: "user", header: "Plurnk Service Log", content: PacketWire.renderLog(log, countTokens), tokens: 0 },
1161
+ // the ACTIVE user prompts (all the current loop holds, in order — a loop admits injected
1162
+ // prompts) render at the BOTTOM, just above requirements — at the action point, closest to
1163
+ // the model's turn. Each is a bare heredoc (the fence is the link); §prompt-fold.
1164
+ { name: "prompt", slot: "user", header: "Plurnk Service Active User Prompts", content: prompt, tokens: 0 },
1143
1165
  // requirements renders LAST — the user-slot footer, the syntax contract closest to the model's turn (a recency carve-out for weak models).
1144
- { name: "requirements", slot: "user", header: "Plurnk System Requirements", content: baseRequirements, tokens: 0 },
1166
+ { name: "requirements", slot: "user", header: "Plurnk Service Requirements", content: baseRequirements, tokens: 0 },
1145
1167
  ];
1146
1168
  // Plugin packet control (§packet-construction): trusted schemes rewrite the
1147
1169
  // default list — add, remove, reorder — in-process, before measurement.
@@ -1167,7 +1189,7 @@ class Engine {
1167
1189
  const packetTokens = countTokens(PacketWire.renderSlot(sections, "system")) + countTokens(PacketWire.renderSlot(sections, "user"));
1168
1190
  return { tokens: packetTokens, sections, telemetryErrors };
1169
1191
  }
1170
- // Budget readout body, rendered into the `## Plurnk System Budget` section.
1192
+ // Budget readout body, rendered into the `## Plurnk Service Budget` section.
1171
1193
  // Headline `ceiling/free` only when a ceiling exists; section lines for the
1172
1194
  // curatable index/log weight the model can FOLD back. tokensFree is a
1173
1195
  // placeholder here — buildSystem substitutes it after measuring the packet.
@@ -1179,8 +1201,8 @@ class Engine {
1179
1201
  if (lines.length > 0)
1180
1202
  lines.push("");
1181
1203
  lines.push(`Log entries: ${log.entries} entries, ${log.tokens} tokens`);
1182
- // Per-turn weight — the grinder's rollback unit, oldest first: the
1183
- // model sees what's first to go (§tokenomics {§tokenomics-turn-totals}).
1204
+ // Per-turn weight — chronological (oldest first); the turn is the grinder's
1205
+ // rollback unit and the rail folds the newest first (§tokenomics {§tokenomics-turn-totals}).
1184
1206
  if (log.byTurn.length > 0) {
1185
1207
  lines.push("", "Turns:", "| turn | tokens |", "|---|--:|");
1186
1208
  for (const t of log.byTurn)
@@ -1198,7 +1220,7 @@ class Engine {
1198
1220
  }
1199
1221
  return lines.join("\n");
1200
1222
  }
1201
- // The ## Plurnk System Tools capability sheet (SPEC §tools). A hook: each enabled
1223
+ // The ## Plurnk Service Tools capability sheet (SPEC §tools). A hook: each enabled
1202
1224
  // capability contributes one line, rendered above Requirements so the model sees what
1203
1225
  // it can do before the rules. Each available executor tag contributes its self-documenting
1204
1226
  // example (plurnk-execs#7), retiring the blind EXEC.
@@ -1249,44 +1271,56 @@ class Engine {
1249
1271
  // then the catalog except the manifest lifeline. The strike it raises and the
1250
1272
  // hard-stop it can signal are returned to runLoop, which owns abandonment.
1251
1273
  // §grinder-overflow-only — fires only on actual overflow, never speculatively
1252
- async #enforceBudget({ packet, provider, runId, loopId, turnId, sessionId, turnNumber, rebuild }) {
1274
+ async #enforceBudget({ packet, provider, runId, loopId, turnId, mintSequence, rebuild }) {
1253
1275
  const ceiling = _a.computeCeiling(provider.contextSize, this.#budgetCeiling);
1254
1276
  const measure = (p) => p.tokens;
1255
1277
  if (ceiling === null || measure(packet) <= ceiling)
1256
1278
  return { packet, fit: true, struck: false };
1257
- const folded = new Map();
1258
- const note = (scheme) => { folded.set(scheme, (folded.get(scheme) ?? 0) + 1); };
1259
- // Pass 1 prior-turn rollback: fold the latest emissions (the ones that
1260
- // pushed it over). No prior turn (turn 1, env overflow) → no-op → pass 2.
1261
- const priorLogs = await this.#db.engine_grinder_prior_turn_logs.all({ loop_id: loopId, turn_id: turnId }); // prior-turn rollback folds the latest emissions — §grinder-layer1-rollback
1262
- for (const le of priorLogs)
1263
- note(le.scheme ?? "log");
1264
- if (priorLogs.length > 0)
1279
+ // The grinder may compact ONLY the newest turn — the immediately-prior turn's emissions
1280
+ // (turn N>1), or, when there is no prior turn (turn 1), THIS turn's own foists. It NEVER
1281
+ // reaches older history; the model alone curates history via FOLD/KILL, and the engine never
1282
+ // janitors stale context. §grinder-newest-turn-only
1283
+ let foldedAny = false;
1284
+ const priorLogs = await this.#db.engine_grinder_prior_turn_logs.all({ loop_id: loopId, turn_id: turnId });
1285
+ if (priorLogs.length > 0) {
1265
1286
  await this.#db.engine_grinder_fold_prior_turn_logs.run({ loop_id: loopId, turn_id: turnId });
1266
- const errors = packet.telemetryErrors;
1267
- let current = priorLogs.length > 0 ? await rebuild(errors) : packet;
1268
- if (measure(current) <= ceiling) {
1269
- this.#emitBudgetOverflow(sessionId, loopId, folded);
1270
- return { packet: current, fit: true, struck: turnNumber > 1 }; // turn 0/1 overflow is the environment, never a strike — §grinder-soft-turn-0-1
1287
+ foldedAny = true;
1271
1288
  }
1272
- // Prior-turn rollback is the only budget lever now: entries don't render
1273
- // (no index), so there is no catalog to collapse. If pass 1 didn't fit,
1274
- // the packet is over and the caller hard-413s. §grinder-hard-413-abort
1275
- this.#emitBudgetOverflow(sessionId, loopId, folded);
1276
- return { packet: current, fit: measure(current) <= ceiling, struck: turnNumber > 1 };
1277
- }
1278
- // The model-facing budget event (SPEC §grinder, §telemetry): which entries left the
1279
- // window, by scheme — the model's own terms, no mechanism vocabulary. The
1280
- // strike this overflow triggers stays engine-internal (gamification policy).
1281
- // §grinder-event-model-terms model-facing terms only; the strike stays engine-internal
1282
- #emitBudgetOverflow(sessionId, loopId, folded) {
1283
- if (folded.size === 0)
1284
- return;
1285
- this.#pushTelemetry(sessionId, loopId, {
1286
- source: "engine:rail",
1287
- kind: "budget_overflow",
1288
- folded: [...folded.entries()].map(([scheme, count]) => ({ scheme, count })),
1289
- level: "warn",
1289
+ else {
1290
+ // Turn 1 — no prior turn: fold THIS turn's own foists (the catalog/prompt that overflowed). §grinder-turn-1-self-fold (#2)
1291
+ const curLogs = await this.#db.engine_grinder_current_turn_logs.all({ loop_id: loopId, turn_id: turnId });
1292
+ if (curLogs.length > 0) {
1293
+ await this.#db.engine_grinder_fold_current_turn_logs.run({ loop_id: loopId, turn_id: turnId });
1294
+ foldedAny = true;
1295
+ }
1296
+ }
1297
+ if (!foldedAny)
1298
+ return { packet, fit: measure(packet) <= ceiling, struck: false };
1299
+ // Mint the overflow as a terse op='error' log row BEFORE the rebuild, so the rebuild's
1300
+ // re-derived errors section surfaces it THIS turn — the warning lands at strike 1, not a
1301
+ // turn late. The row is grinder-exempt, so it stacks into a visible recurrence trail. It
1302
+ // sits at the turn's reserved running sequence (mintSequence) so it never collides with the
1303
+ // post-generate dispatch rows. §telemetry-uniform-error-channel, §grinder-overflow-error-row
1304
+ await this.#mintEngineError("budget_overflow", { runId, loopId, turnId, sequence: mintSequence });
1305
+ const current = await rebuild();
1306
+ // Every compaction is a strike — including turn 0/1 (no soft exemption, #4). §grinder-compaction-strikes
1307
+ return { packet: current, fit: measure(current) <= ceiling, struck: true };
1308
+ }
1309
+ // Mint an engine failure as a uniform op='error' log row (§telemetry-uniform-error-channel):
1310
+ // a terse status + canonical term keyed by `kind` (the packet teaches recovery, not the row),
1311
+ // origin engine:rail. The errors section derives its LogCoordinate pointer from log≥400 — one
1312
+ // channel, no per-kind handling.
1313
+ async #mintEngineError(kind, { runId, loopId, turnId, sequence }) {
1314
+ const { status, term } = ENGINE_ERRORS[kind];
1315
+ await this.#db.engine_insert_log_entry.get({
1316
+ run_id: runId, loop_id: loopId, turn_id: turnId, sequence,
1317
+ origin: "plurnk", source: "rail", op: "error", suffix: "", signal: null,
1318
+ scheme: null, username: null, password: null, hostname: null, port: null,
1319
+ pathname: null, params: null, fragment: null, lineMarker: null,
1320
+ tx: "", mimetype_tx: "text/plain",
1321
+ rx: JSON.stringify({ kind, message: term }),
1322
+ mimetype_rx: "application/json",
1323
+ status_rx: status, tokens: 0, state: "resolved", outcome: null, attrs: "{}",
1290
1324
  });
1291
1325
  }
1292
1326
  // Wire projection lives in ./packet-wire.ts so Engine and
@@ -1319,25 +1353,20 @@ class Engine {
1319
1353
  // 2. Engine-buffered actionless failures (no_send, parse, watchdog, rails).
1320
1354
  // Buffer drains on read — each error appears in exactly one packet.
1321
1355
  async #buildTelemetryErrors(loopId, currentTurnSeq) {
1356
+ // The uniform error channel (§telemetry-uniform-error-channel): every 4xx/5xx log row
1357
+ // becomes a LogCoordinate-positioned TelemetryEvent — a terse pointer; the model READs the
1358
+ // row for its term + detail. Buffer events that point at the model's own emission keep their
1359
+ // ContentOffset position. info-level notices (progress) are not errors and never surface here.
1322
1360
  const rows = await this.#db.engine_render_telemetry_errors.all({ loop_id: loopId, current_turn_seq: currentTurnSeq });
1323
- const actionFailures = rows.map((r) => {
1324
- const target = r.scheme !== null
1325
- ? `${r.scheme}://${r.pathname ?? ""}`
1326
- : (r.pathname ?? null);
1327
- const parsedRx = r.mimetype_rx === "application/json" ? JSON.parse(r.rx) : r.rx;
1328
- return {
1329
- kind: "action_failure",
1330
- level: "error", // the derived error pointer is always an error — clients color off level (#276)
1331
- coordinate: `${r.loop_seq}/${r.turn_seq}/${r.sequence}`,
1332
- op: r.op,
1333
- target,
1334
- status: r.status_rx,
1335
- error: typeof parsedRx === "object" && parsedRx !== null && "error" in parsedRx
1336
- ? parsedRx.error
1337
- : typeof parsedRx === "string" ? parsedRx : "",
1338
- };
1339
- });
1340
- return [...this.#drainTelemetry(loopId), ...actionFailures];
1361
+ const logErrors = rows.map((r) => ({
1362
+ source: "engine:rail",
1363
+ kind: "log_error",
1364
+ level: "error",
1365
+ status: r.status_rx,
1366
+ position: { type: "log-coordinate", coordinate: `${r.loop_seq}/${r.turn_seq}/${r.sequence}/${r.op}` },
1367
+ }));
1368
+ const bufferEvents = this.#drainTelemetry(loopId).filter((e) => e.level !== "info");
1369
+ return [...bufferEvents, ...logErrors];
1341
1370
  }
1342
1371
  // SPEC §packet the log section — chronological action-entries for the loop.
1343
1372
  // Snapshot is taken at packet build (pre-dispatch this turn), so it
@@ -1494,7 +1523,7 @@ class Engine {
1494
1523
  }
1495
1524
  }
1496
1525
  async dispatch(context) {
1497
- const { statement, sessionId, runId, loopId, turnId, sequence, origin, onDispatch } = context;
1526
+ const { statement, sessionId, runId, loopId, turnId, sequence, origin, onDispatch, prematureRefusal } = context;
1498
1527
  const schemeCtx = this.#buildSchemeCtx({ sessionId, runId, loopId, turnId, origin });
1499
1528
  let result;
1500
1529
  let denial = this.#checkWritable(statement, origin);
@@ -1517,7 +1546,7 @@ class Engine {
1517
1546
  // those are system failures.
1518
1547
  try {
1519
1548
  if (statement.op === "SEND" && statement.target === null) {
1520
- result = await this.#handleSendBroadcast(statement, loopId);
1549
+ result = await this.#handleSendBroadcast(statement, loopId, prematureRefusal === true);
1521
1550
  }
1522
1551
  else if (statement.op === "COPY") {
1523
1552
  result = await this.#handleCopy(statement, schemeCtx);
@@ -1738,6 +1767,27 @@ class Engine {
1738
1767
  const row = await this.#db.engine_count_active_loops_for_run.get({ run_id: runId });
1739
1768
  return (row?.n ?? 0) > 0;
1740
1769
  }
1770
+ // #290 — run the derivation pump (deep channels: symbols/refs/FTS +
1771
+ // embeddings, deep_hash-gated) at SESSION-SCOPE, off the per-turn path, so a freshly-created
1772
+ // session's corpus warms DURING the client's startup window instead of freezing the first
1773
+ // loop.run. session.create fires this and returns immediately; embed_progress live-fans-out as it
1774
+ // runs. Idempotent + deep_hash-gated, so turn 1's pump finds the work done (or harmlessly re-runs);
1775
+ // a no-embedder build derives the cheap symbols/refs/FTS channels and skips the embed pass. Has no
1776
+ // loop yet — telemetry fans out live only (loopId 0), never buffered to a loop that never drains.
1777
+ async warmSessionDerivations(sessionId) {
1778
+ const ctx = {
1779
+ db: this.#db, sessionId, runId: 0, loopId: 0, turnId: 0,
1780
+ writer: "plurnk",
1781
+ signal: undefined,
1782
+ streamEventNotify: this.#streamEventNotify,
1783
+ wakeRunNotify: this.#wakeRunNotify,
1784
+ tokenize: this.#tokenize,
1785
+ mimetypes: this.#mimetypes,
1786
+ defaultChannelFor: (s) => this.#schemes.defaultChannelFor(s),
1787
+ pushTelemetry: (event) => this.#telemetryEventNotify?.(sessionId, { loopId: 0, event }),
1788
+ };
1789
+ await EntryManifest.maintainDerivations(ctx);
1790
+ }
1741
1791
  // Inject a prompt into the run's currently-executing loop. Writes a
1742
1792
  // plurnk:///prompt/<loop_id>/<next-turn> entry whose body becomes the
1743
1793
  // prompt section at the next turn boundary. Last-wins: if two
@@ -1867,10 +1917,10 @@ class Engine {
1867
1917
  if (statement.op === "EXEC") {
1868
1918
  return this.#denyIfDisallowed("exec", origin);
1869
1919
  }
1870
- // A run-fork (COPY src=run://) is gated by run://'s writableBy — its body
1871
- // is a fork prompt, not a dst path, so the entry-COPY dst-parse below
1872
- // doesn't apply. §machine-processes
1873
- if (this.#isRunFork(statement))
1920
+ // Run control (COPY target=run://, spawn or fork) is gated by run://'s writableBy — its
1921
+ // body is a seed prompt, not a dst path, so the entry-COPY dst-parse below doesn't apply.
1922
+ // §machine-processes
1923
+ if (this.#isRunCopy(statement))
1874
1924
  return this.#denyIfDisallowed("run", origin);
1875
1925
  if (statement.op === "COPY" || statement.op === "MOVE") {
1876
1926
  const dst = statement.op === "COPY" ? (statement.body === null ? null : parsePath(statement.body)) : statement.body;
@@ -1926,56 +1976,68 @@ class Engine {
1926
1976
  return null;
1927
1977
  return { status: 403, error: `scheme '${scheme}' is inactive under current loop flags` };
1928
1978
  };
1929
- if (this.#isRunFork(statement))
1930
- return check(statement.target); // body is a fork prompt, not a dst path
1979
+ if (this.#isRunCopy(statement))
1980
+ return check(statement.target); // body is a spawn/fork prompt, not a dst path
1931
1981
  if (statement.op === "COPY" || statement.op === "MOVE") {
1932
1982
  return check(statement.target) ?? check(statement.op === "COPY" ? (statement.body === null ? null : parsePath(statement.body)) : statement.body);
1933
1983
  }
1934
1984
  return check(statement.target);
1935
1985
  }
1936
- // A COPY whose SOURCE is run:// is a run-fork, not an entry-copy — its body
1937
- // is the fork's seed prompt, not a destination path. The COPY gates and
1938
- // #handleCopy branch on this so they never parse the prompt as a dst path.
1939
- #isRunFork(statement) {
1986
+ // A COPY whose TARGET is run:// is run control (spawn/fork), not an entry-copy — its body
1987
+ // is the new run's seed prompt, not a destination path. The COPY gates and #handleCopy
1988
+ // branch on this so they never parse the prompt as a dst path.
1989
+ #isRunCopy(statement) {
1940
1990
  return statement.op === "COPY" && this.#schemeNameOf(statement.target) === "run";
1941
1991
  }
1942
- // COPY(run://<src>):prompt — fork: deep-copy the source run's log into a new
1943
- // run (Fork), then start it with the prompt (ctx.injectRun). Source `run://self` =
1944
- // self (ctx.runId); a name resolves within the session (404 if absent). §run-scheme,
1945
- // §machine-processes-fork-copies-the-log
1946
- async #handleRunFork(statement, ctx) {
1992
+ // COPY(run://<dst>):prompt — run control via COPY (grammar 0.74.41 OP×resource matrix):
1993
+ // run://self → FORK: deep-copy the current run's log into a new sister (Fork), then
1994
+ // continue it with the prompt (§machine-processes-fork-copies-the-log).
1995
+ // run://<name> → SPAWN: a fresh sister (empty log) named <name>, started on the prompt.
1996
+ // A LIVE sister already holding <name> is a 409 conflict; a free or terminated name is
1997
+ // reclaimed (§run-scheme-spawn). The self form is fork; only a name spawns.
1998
+ // Both ride the daemon inject and obey the active-runs cap (508, §run-scheme-cap).
1999
+ async #handleRunCopy(statement, ctx) {
1947
2000
  const target = statement.target;
1948
2001
  if (target === null)
1949
- return { status: 400, error: "run:// fork requires a source run" };
2002
+ return { status: 400, error: "run:// control requires a run target" };
1950
2003
  const name = target.kind === "url" ? (target.hostname ?? "") : ""; // §run-scheme — run is the AUTHORITY (run://<name>), not the path
1951
2004
  if (name === "")
1952
- return { status: 400, error: "run:// fork requires a source run name or 'self' (run://self)" };
1953
- let srcRunId = ctx.runId;
1954
- if (name !== "self") {
1955
- const row = await this.#db.run_resolve_by_name.get({ session_id: ctx.sessionId, name });
1956
- if (row === undefined)
1957
- return { status: 404, error: `run://${name} not found in this session` };
1958
- srcRunId = row.id;
1959
- }
2005
+ return { status: 400, error: "run:// control requires a run name or 'self' (run://<name>)" };
1960
2006
  if (ctx.injectRun === undefined)
1961
- throw new Error("run fork: injectRun capability absent");
2007
+ throw new Error("run copy: injectRun capability absent");
1962
2008
  const denied = await RunCap.deny(this.#db, ctx.sessionId);
1963
2009
  if (denied !== null)
1964
2010
  return denied;
1965
- const branchRunId = await Fork.fork(this.#db, srcRunId);
1966
- const branch = await this.#db.fork_get_run.get({ id: branchRunId });
1967
- await ctx.injectRun({ sessionId: ctx.sessionId, runId: branchRunId, prompt: typeof statement.body === "string" ? statement.body : "" });
1968
- return { status: 200, body: branch?.name ?? "" };
2011
+ const prompt = typeof statement.body === "string" ? statement.body : "";
2012
+ if (name === "self") {
2013
+ // FORK branch the current run's log into a new sister.
2014
+ const branchRunId = await Fork.fork(this.#db, ctx.runId);
2015
+ const branch = await this.#db.fork_get_run.get({ id: branchRunId });
2016
+ await ctx.injectRun({ sessionId: ctx.sessionId, runId: branchRunId, prompt });
2017
+ return { status: 200, body: branch?.name ?? "" };
2018
+ }
2019
+ // SPAWN — a fresh sister named <name>. A name is frozen per run but reclaimable across time
2020
+ // (§machine-processes-run-origin): a LIVE sister holding it is a 409 conflict (legible, never
2021
+ // a raw UNIQUE 500); a free or terminated name is reclaimed (the resolver picks newest).
2022
+ const live = await this.#db.run_live_by_name.get({ session_id: ctx.sessionId, name });
2023
+ if (live !== undefined)
2024
+ return { status: 409, error: `run '${name}' is already running` };
2025
+ const row = await this.#db.fork_insert_run.get({
2026
+ session_id: ctx.sessionId, name, parent_run_id: ctx.runId, origin: ctx.writer,
2027
+ });
2028
+ if (row === undefined)
2029
+ throw new Error("run spawn: run insert returned no row");
2030
+ await ctx.injectRun({ sessionId: ctx.sessionId, runId: row.id, prompt });
2031
+ return { status: 200, body: name };
1969
2032
  }
1970
2033
  async #handleCopy(statement, ctx) {
1971
2034
  if (statement.op !== "COPY")
1972
2035
  throw new Error("unreachable");
1973
- if (this.#isRunFork(statement))
1974
- return await this.#handleRunFork(statement, ctx);
2036
+ if (this.#isRunCopy(statement))
2037
+ return await this.#handleRunCopy(statement, ctx);
1975
2038
  const srcPath = statement.target;
1976
- // COPY's body is an opaque raw string (grammar §COPY: a dest path OR a run-fork
1977
- // prompt); parse it to the dest path. Non-path bodies (run:// fork prompts) are
1978
- // not yet handled and surface as a 400.
2039
+ // Past the run-control branch above, COPY's body is a dest path (grammar §COPY).
2040
+ // Parse it; an unparseable dest surfaces as a 400.
1979
2041
  const dstPath = statement.body === null ? null : parsePath(statement.body);
1980
2042
  if (srcPath === null)
1981
2043
  return { status: 400, error: "COPY requires source path" };
@@ -2259,12 +2321,34 @@ class Engine {
2259
2321
  return { status: 202, attrs: writeResult.attrs, body: writeResult.body };
2260
2322
  return { status: writeResult.status, entryId: writeResult.entryId, created: writeResult.created };
2261
2323
  }
2262
- async #handleSendBroadcast(statement, loopId) {
2324
+ // A run "holds a live thing" iff it has an open stream/spawn (subscription registry or an
2325
+ // exec spawn) OR a non-terminal child run — the structured-concurrency invariant a terminal
2326
+ // SEND[200] must respect (§send-premature-terminate, §run-lifecycle: children and streams are
2327
+ // the same kind of live thing a run holds).
2328
+ async #runHoldsLiveThing(runId) {
2329
+ const openSubs = await this.#db.find_open_subscriptions_for_run.all({ run_id: runId });
2330
+ if (openSubs.length > 0)
2331
+ return true;
2332
+ const execHandler = this.#schemes.get("exec");
2333
+ if (execHandler?.hasActiveSpawns?.(runId) === true)
2334
+ return true;
2335
+ const liveChild = await this.#db.engine_run_has_live_child.get({ run_id: runId });
2336
+ return liveChild !== undefined;
2337
+ }
2338
+ async #handleSendBroadcast(statement, loopId, prematureRefusal) {
2263
2339
  if (statement.op !== "SEND")
2264
2340
  throw new Error("unreachable");
2265
2341
  const status = statement.signal;
2266
2342
  if (status === null)
2267
2343
  return { status: 400 };
2344
+ // Premature terminate (§send-premature-terminate): a terminal SEND[200] while the run holds a
2345
+ // live thing is REFUSED 409 — the row keeps the [200] emission + body (faithful, never erased),
2346
+ // the loop never goes terminal. The model hibernates [202] to wait or KILLs before terminating.
2347
+ // The decision is the runTurn PRE-DISPATCH snapshot (threaded), so a same-turn fire-and-forget
2348
+ // spawn isn't miscounted as a live thing the SEND holds.
2349
+ if (status === 200 && prematureRefusal) {
2350
+ return { status: 409, error: "Attempted [200] termination despite active streams or worker runs. You may either hibernate [202] to wait or KILL them before terminating." };
2351
+ }
2268
2352
  if (status === 200 || status === 202 || status === 499) {
2269
2353
  // The broadcast terminals (200 done, 202 parked-async, 499 cancelled) advance
2270
2354
  // the loop; each carries its body as the loop's terminal message — the deliverable.