@plurnk/plurnk-service 0.59.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.
- package/.env.example +12 -1
- package/SPEC.md +38 -32
- package/dist/core/Engine.d.ts +2 -0
- package/dist/core/Engine.d.ts.map +1 -1
- package/dist/core/Engine.js +234 -150
- package/dist/core/Engine.js.map +1 -1
- package/dist/core/Engine.sql +48 -10
- package/dist/core/packet-inject.d.ts.map +1 -1
- package/dist/core/packet-inject.js +8 -3
- package/dist/core/packet-inject.js.map +1 -1
- package/dist/core/packet-wire.d.ts +5 -1
- package/dist/core/packet-wire.d.ts.map +1 -1
- package/dist/core/packet-wire.js +52 -18
- package/dist/core/packet-wire.js.map +1 -1
- package/dist/core/run-cap.js +2 -2
- package/dist/core/run-cap.js.map +1 -1
- package/dist/core/run-ops.sql +21 -0
- package/dist/schemes/Exec.js +1 -1
- package/dist/schemes/Exec.js.map +1 -1
- package/dist/schemes/Run.d.ts.map +1 -1
- package/dist/schemes/Run.js +26 -26
- package/dist/schemes/Run.js.map +1 -1
- package/dist/schemes/_entry-manifest.d.ts.map +1 -1
- package/dist/schemes/_entry-manifest.js +5 -1
- package/dist/schemes/_entry-manifest.js.map +1 -1
- package/dist/schemes/_entry-semantic.d.ts.map +1 -1
- package/dist/schemes/_entry-semantic.js +6 -0
- package/dist/schemes/_entry-semantic.js.map +1 -1
- package/dist/server/Daemon.d.ts.map +1 -1
- package/dist/server/Daemon.js +24 -2
- package/dist/server/Daemon.js.map +1 -1
- package/dist/server/drain.sql +14 -0
- package/dist/server/methods/session_attach.d.ts.map +1 -1
- package/dist/server/methods/session_attach.js +4 -0
- package/dist/server/methods/session_attach.js.map +1 -1
- package/dist/server/methods/session_create.d.ts.map +1 -1
- package/dist/server/methods/session_create.js +4 -0
- package/dist/server/methods/session_create.js.map +1 -1
- package/migrations/0000-00-00.01_schema.sql +5 -1
- package/package.json +12 -12
- package/requirements.md +6 -9
package/dist/core/Engine.js
CHANGED
|
@@ -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,
|
|
748
|
-
|
|
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,
|
|
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.
|
|
868
|
-
//
|
|
869
|
-
//
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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
|
-
|
|
888
|
-
|
|
889
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
956
|
-
//
|
|
957
|
-
//
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
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
|
|
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 =
|
|
1087
|
-
? PacketWire.
|
|
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
|
|
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
|
|
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
|
-
|
|
1137
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 —
|
|
1183
|
-
//
|
|
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
|
|
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,
|
|
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
|
-
|
|
1258
|
-
|
|
1259
|
-
//
|
|
1260
|
-
//
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
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
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
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
|
-
//
|
|
1871
|
-
// is a
|
|
1872
|
-
//
|
|
1873
|
-
if (this.#
|
|
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.#
|
|
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
|
|
1937
|
-
// is the
|
|
1938
|
-
//
|
|
1939
|
-
#
|
|
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://<
|
|
1943
|
-
// run
|
|
1944
|
-
//
|
|
1945
|
-
//
|
|
1946
|
-
|
|
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://
|
|
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://
|
|
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
|
|
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
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
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.#
|
|
1974
|
-
return await this.#
|
|
2036
|
+
if (this.#isRunCopy(statement))
|
|
2037
|
+
return await this.#handleRunCopy(statement, ctx);
|
|
1975
2038
|
const srcPath = statement.target;
|
|
1976
|
-
// COPY's body is
|
|
1977
|
-
//
|
|
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
|
-
|
|
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.
|