@plurnk/plurnk-service 0.60.0 → 0.62.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 +54 -16
- package/PLURNK_PERSONALITY.md +53 -0
- package/SPEC.md +22 -18
- package/dist/core/Dispatcher.d.ts +61 -0
- package/dist/core/Dispatcher.d.ts.map +1 -0
- package/dist/core/Dispatcher.js +811 -0
- package/dist/core/Dispatcher.js.map +1 -0
- package/dist/core/Engine.d.ts +9 -46
- package/dist/core/Engine.d.ts.map +1 -1
- package/dist/core/Engine.js +209 -1513
- package/dist/core/Engine.js.map +1 -1
- package/dist/core/Engine.sql +7 -2
- package/dist/core/ExecutorRegistry.d.ts +2 -1
- package/dist/core/ExecutorRegistry.d.ts.map +1 -1
- package/dist/core/ExecutorRegistry.js +16 -3
- package/dist/core/ExecutorRegistry.js.map +1 -1
- package/dist/core/PacketBuilder.d.ts +64 -0
- package/dist/core/PacketBuilder.d.ts.map +1 -0
- package/dist/core/PacketBuilder.js +380 -0
- package/dist/core/PacketBuilder.js.map +1 -0
- package/dist/core/ProposalLifecycle.d.ts +56 -0
- package/dist/core/ProposalLifecycle.d.ts.map +1 -0
- package/dist/core/ProposalLifecycle.js +197 -0
- package/dist/core/ProposalLifecycle.js.map +1 -0
- package/dist/core/SchemeRegistry.d.ts +2 -0
- package/dist/core/SchemeRegistry.d.ts.map +1 -1
- package/dist/core/SchemeRegistry.js +20 -15
- package/dist/core/SchemeRegistry.js.map +1 -1
- package/dist/core/StrikeRail.d.ts +27 -0
- package/dist/core/StrikeRail.d.ts.map +1 -0
- package/dist/core/StrikeRail.js +147 -0
- package/dist/core/StrikeRail.js.map +1 -0
- package/dist/core/TelemetryChannel.d.ts +37 -0
- package/dist/core/TelemetryChannel.d.ts.map +1 -0
- package/dist/core/TelemetryChannel.js +77 -0
- package/dist/core/TelemetryChannel.js.map +1 -0
- package/dist/core/fork.d.ts +1 -0
- package/dist/core/fork.d.ts.map +1 -1
- package/dist/core/fork.js +17 -4
- package/dist/core/fork.js.map +1 -1
- package/dist/core/fork.sql +6 -0
- package/dist/core/packet-wire.js +1 -1
- package/dist/core/packet-wire.js.map +1 -1
- package/dist/core/plurnk-uri.d.ts +2 -0
- package/dist/core/plurnk-uri.d.ts.map +1 -1
- package/dist/core/plurnk-uri.js +11 -0
- package/dist/core/plurnk-uri.js.map +1 -1
- package/dist/core/run-cap.js +1 -1
- package/dist/core/run-cap.js.map +1 -1
- package/dist/digest/Digest.d.ts +14 -0
- package/dist/digest/Digest.d.ts.map +1 -0
- package/dist/digest/Digest.js +321 -0
- package/dist/digest/Digest.js.map +1 -0
- package/dist/digest/digest.sql +55 -0
- package/dist/schemes/Exec.d.ts.map +1 -1
- package/dist/schemes/Exec.js +22 -23
- package/dist/schemes/Exec.js.map +1 -1
- package/dist/schemes/File.d.ts.map +1 -1
- package/dist/schemes/File.js +7 -4
- package/dist/schemes/File.js.map +1 -1
- package/dist/schemes/Log.d.ts.map +1 -1
- package/dist/schemes/Log.js +5 -1
- package/dist/schemes/Log.js.map +1 -1
- package/dist/schemes/Run.js +3 -3
- package/dist/schemes/Run.js.map +1 -1
- package/dist/server/ClientConnection.d.ts.map +1 -1
- package/dist/server/ClientConnection.js +48 -2
- package/dist/server/ClientConnection.js.map +1 -1
- package/dist/server/Daemon.d.ts +1 -1
- package/dist/server/Daemon.d.ts.map +1 -1
- package/dist/server/Daemon.js +5 -1
- package/dist/server/Daemon.js.map +1 -1
- package/dist/server/methods/auth.d.ts +6 -0
- package/dist/server/methods/auth.d.ts.map +1 -0
- package/dist/server/methods/auth.js +45 -0
- package/dist/server/methods/auth.js.map +1 -0
- package/dist/server/methods/mcp_install.d.ts +6 -0
- package/dist/server/methods/mcp_install.d.ts.map +1 -0
- package/dist/server/methods/mcp_install.js +66 -0
- package/dist/server/methods/mcp_install.js.map +1 -0
- package/dist/server/methods/op_copy.d.ts.map +1 -1
- package/dist/server/methods/op_copy.js +1 -0
- package/dist/server/methods/op_copy.js.map +1 -1
- package/dist/server/methods/op_dispatch.d.ts.map +1 -1
- package/dist/server/methods/op_dispatch.js +1 -0
- package/dist/server/methods/op_dispatch.js.map +1 -1
- package/dist/server/methods/op_edit.d.ts.map +1 -1
- package/dist/server/methods/op_edit.js +1 -0
- package/dist/server/methods/op_edit.js.map +1 -1
- package/dist/server/methods/op_exec.d.ts.map +1 -1
- package/dist/server/methods/op_exec.js +1 -0
- package/dist/server/methods/op_exec.js.map +1 -1
- package/dist/server/methods/op_move.d.ts.map +1 -1
- package/dist/server/methods/op_move.js +1 -0
- package/dist/server/methods/op_move.js.map +1 -1
- package/dist/server/yolo.js +1 -1
- package/dist/server/yolo.js.map +1 -1
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +31 -2
- package/dist/service.js.map +1 -1
- package/docs/run.md +7 -3
- package/migrations/0000-00-00.01_schema.sql +3 -3
- package/package.json +22 -17
- package/requirements.md +2 -1
package/dist/core/Engine.js
CHANGED
|
@@ -1,63 +1,28 @@
|
|
|
1
|
-
|
|
2
|
-
import { PlurnkParser, PlurnkParseError, parsePath } from "@plurnk/plurnk-grammar";
|
|
1
|
+
import { PlurnkParser, PlurnkParseError } from "@plurnk/plurnk-grammar";
|
|
3
2
|
import { Mimetypes, emptyRegistry } from "@plurnk/plurnk-mimetypes";
|
|
4
3
|
import EntryCrud from "../schemes/_entry-crud.js";
|
|
5
4
|
import EntryManifest from "../schemes/_entry-manifest.js";
|
|
6
5
|
import GitMembership from "./git-membership.js";
|
|
7
|
-
import { foldAuthorityIntoPath, renderAddress } from "./plurnk-uri.js";
|
|
8
6
|
import GitState from "./git-state.js";
|
|
9
|
-
import Fork from "./fork.js";
|
|
10
|
-
import RunCap from "./run-cap.js";
|
|
11
|
-
import { teachingLine, docsExcludeSet } from "./teaching.js";
|
|
12
|
-
import { readPacketInject, readSystemPolicy, readProjectPolicy } from "./packet-inject.js";
|
|
13
7
|
import SessionSettings from "./session-settings.js";
|
|
14
|
-
import {
|
|
15
|
-
import { DEFAULT_LOOP_FLAGS } from "./scheme-types.js";
|
|
16
|
-
import { LineMarkerOps, MimetypeBinary, editedSpan } from "../content/index.js";
|
|
8
|
+
import { editedSpan } from "../content/index.js";
|
|
17
9
|
import { readFile } from "node:fs/promises";
|
|
18
10
|
import { fileURLToPath } from "node:url";
|
|
19
11
|
import { setTimeout as delay } from "node:timers/promises";
|
|
20
|
-
import Paths from "../Paths.js";
|
|
21
|
-
import SchemeCtxImpl from "./caps/SchemeCtxImpl.js";
|
|
22
12
|
// Shared module imported by both Engine and bin/digest.ts, so wire
|
|
23
13
|
// projection and digest projection are structurally one function — no
|
|
24
14
|
// drift between wire and digest possible.
|
|
15
|
+
// Format: markdown (user pick over rummy's XML alternative, 2026-05-22).
|
|
25
16
|
import PacketWire from "./packet-wire.js";
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
17
|
+
// The engine's collaborators — each owns one machine; Engine owns the loop/turn
|
|
18
|
+
// lifecycle and wires them together as the public facade.
|
|
19
|
+
import TelemetryChannel from "./TelemetryChannel.js";
|
|
20
|
+
import StrikeRail from "./StrikeRail.js";
|
|
21
|
+
import PacketBuilder from "./PacketBuilder.js";
|
|
22
|
+
import ProposalLifecycle from "./ProposalLifecycle.js";
|
|
23
|
+
import Dispatcher from "./Dispatcher.js";
|
|
29
24
|
const DEFAULT_MAX_STRIKES = 3;
|
|
30
25
|
const DEFAULT_MAX_COMMANDS = 99;
|
|
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
|
-
});
|
|
44
|
-
// Substituted into the budget readout after the assembled packet is measured
|
|
45
|
-
// (the figure depends on the packet's own rendered size — chicken/egg).
|
|
46
|
-
const TOKENS_FREE_PLACEHOLDER = "{{tokensFree}}";
|
|
47
|
-
const TOKEN_USAGE_PLACEHOLDER = "{{tokenUsage}}";
|
|
48
|
-
const TOKEN_PERCENT_PLACEHOLDER = "{{tokenPercent}}";
|
|
49
|
-
// PLURNK_BUDGET_CEILING is dual-mode: <=1 is a fraction of the provider's
|
|
50
|
-
// context window, >1 is an absolute token wall — lets a demo pin a tiny
|
|
51
|
-
// ceiling regardless of the model's real window to force the grinder.
|
|
52
|
-
const readCeiling = () => {
|
|
53
|
-
const raw = process.env.PLURNK_BUDGET_CEILING;
|
|
54
|
-
if (raw === undefined || raw.length === 0)
|
|
55
|
-
return DEFAULT_BUDGET_CEILING;
|
|
56
|
-
const n = Number.parseFloat(raw);
|
|
57
|
-
if (!Number.isFinite(n) || n <= 0)
|
|
58
|
-
return DEFAULT_BUDGET_CEILING;
|
|
59
|
-
return n;
|
|
60
|
-
};
|
|
61
26
|
const readMaxStrikes = () => {
|
|
62
27
|
const raw = process.env.PLURNK_MAX_STRIKES;
|
|
63
28
|
if (raw === undefined || raw.length === 0)
|
|
@@ -86,23 +51,6 @@ const readFilesItems = () => {
|
|
|
86
51
|
return normalizeFilesItems(Number.parseInt(raw, 10));
|
|
87
52
|
};
|
|
88
53
|
import { ProviderError } from "@plurnk/plurnk-providers";
|
|
89
|
-
// Resolution timeout — proposed entries auto-cancel if nothing arrives
|
|
90
|
-
// within this window. SPEC.md §engine-rails (proposal lifecycle) + §methods (loop.resolve).
|
|
91
|
-
const PROPOSAL_TIMEOUT_DEFAULT_MS = 300000;
|
|
92
|
-
const readProposalTimeoutMs = () => {
|
|
93
|
-
const raw = process.env.PLURNK_PROPOSAL_TIMEOUT_MS;
|
|
94
|
-
if (raw === undefined || raw.length === 0)
|
|
95
|
-
return PROPOSAL_TIMEOUT_DEFAULT_MS;
|
|
96
|
-
const n = Number(raw);
|
|
97
|
-
if (!Number.isFinite(n) || n <= 0)
|
|
98
|
-
return PROPOSAL_TIMEOUT_DEFAULT_MS;
|
|
99
|
-
return n;
|
|
100
|
-
};
|
|
101
|
-
const pathnameFromPath = (path) => {
|
|
102
|
-
if (path.kind === "regex")
|
|
103
|
-
return path.raw; // regex source — parens are syntax, never encoded
|
|
104
|
-
return decodePathParens(path.kind === "url" ? path.pathname : path.raw); // #239 item 4
|
|
105
|
-
};
|
|
106
54
|
// Default turn.status when ops were emitted but no SEND. Model is implicitly
|
|
107
55
|
// continuing; loop.status stays 102 either way (only SEND broadcast advances
|
|
108
56
|
// loop terminal). No strike, no telemetry.
|
|
@@ -110,10 +58,6 @@ const TURN_STATUS_IMPLICIT_CONTINUE = 102;
|
|
|
110
58
|
// Status assigned to a turn that emitted NO ops at all. Strike-worthy; the
|
|
111
59
|
// action routes through telemetry.errors[] (§telemetry, §telemetry-no-error-scheme — never an error:// scheme).
|
|
112
60
|
const TURN_STATUS_NO_OPS = 422;
|
|
113
|
-
// Rail #38: action-entry statuses that DON'T accumulate strikes. Model adapted
|
|
114
|
-
// to a finding (not_found, op_not_supported); no penalty. Rummy parallel:
|
|
115
|
-
// SOFT_FAILURE_OUTCOMES = {"not_found", "unparsed"}.
|
|
116
|
-
const SOFT_FAILURE_STATUSES = new Set([404, 501]);
|
|
117
61
|
const DEFAULT_MIN_CYCLES = 3;
|
|
118
62
|
const DEFAULT_MAX_CYCLE_PERIOD = 4;
|
|
119
63
|
const readPositiveInt = (envVar, fallback) => {
|
|
@@ -125,101 +69,24 @@ const readPositiveInt = (envVar, fallback) => {
|
|
|
125
69
|
return fallback;
|
|
126
70
|
return n;
|
|
127
71
|
};
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
// matchers on the same target ARE different activities (the model is
|
|
135
|
-
// exploring different queries, not repeating one).
|
|
136
|
-
const fingerprintOp = (stmt) => {
|
|
137
|
-
const path = stmt.target;
|
|
138
|
-
const matcherDiscriminator = () => {
|
|
139
|
-
// For matcher-bearing ops, the body's `raw` (matcher source) plus
|
|
140
|
-
// any lineMarker forms the activity discriminator.
|
|
141
|
-
const parts = [];
|
|
142
|
-
const body = stmt.body;
|
|
143
|
-
if (body !== null && typeof body === "object" && typeof body.raw === "string") {
|
|
144
|
-
parts.push(`body:${body.raw.slice(0, 64)}`);
|
|
145
|
-
}
|
|
146
|
-
const lm = stmt.lineMarker;
|
|
147
|
-
if (lm !== null && lm !== undefined)
|
|
148
|
-
parts.push(`L:${lm.marks.join(",")}`);
|
|
149
|
-
return parts.length > 0 ? `|${parts.join("|")}` : "";
|
|
150
|
-
};
|
|
151
|
-
if (path === null) {
|
|
152
|
-
// Path-less ops need an activity-defining discriminator other
|
|
153
|
-
// than `target`. Picked per op so the cycle detector reflects
|
|
154
|
-
// intent rather than syntax:
|
|
155
|
-
// - EXEC: the command body IS the activity. Without a body
|
|
156
|
-
// digest, varied shell commands (find / ls / wc) collapse to
|
|
157
|
-
// one fingerprint and the detector mislabels exploration
|
|
158
|
-
// as a loop.
|
|
159
|
-
// - SEND: the status code (signal) IS the activity. Different
|
|
160
|
-
// SEND[X] are different intentions; same SEND[X] with
|
|
161
|
-
// different message bodies is the same termination signal.
|
|
162
|
-
if (stmt.op === "EXEC") {
|
|
163
|
-
const body = typeof stmt.body === "string" ? stmt.body : "";
|
|
164
|
-
return `EXEC|(no-path)${body.length > 0 ? `|body:${body.slice(0, 64)}` : ""}`;
|
|
165
|
-
}
|
|
166
|
-
if (stmt.op === "SEND") {
|
|
167
|
-
const signal = typeof stmt.signal === "number" ? stmt.signal : "";
|
|
168
|
-
return `SEND|(no-path)|signal:${signal}`;
|
|
169
|
-
}
|
|
170
|
-
return `${stmt.op}|(no-path)`;
|
|
171
|
-
}
|
|
172
|
-
const base = path.kind === "url"
|
|
173
|
-
? `${stmt.op}|${path.scheme}://${path.pathname}`
|
|
174
|
-
: `${stmt.op}|local:${path.raw}`;
|
|
175
|
-
if (stmt.op === "FIND" || stmt.op === "READ" || stmt.op === "OPEN" || stmt.op === "FOLD") {
|
|
176
|
-
return `${base}${matcherDiscriminator()}`;
|
|
177
|
-
}
|
|
178
|
-
return base;
|
|
179
|
-
};
|
|
180
|
-
class Engine {
|
|
72
|
+
// §operator-config-loop-timeout — the loop's wall-clock budget (PLURNK_LOOP_TIMEOUT).
|
|
73
|
+
const DEFAULT_LOOP_TIMEOUT_MS = 86400000;
|
|
74
|
+
const readLoopTimeoutMs = () => readPositiveInt("PLURNK_LOOP_TIMEOUT", DEFAULT_LOOP_TIMEOUT_MS);
|
|
75
|
+
// The wall's abort reason — runLoop branches a mid-turn teardown to the 504 terminal on it.
|
|
76
|
+
const LOOP_TIMEOUT_REASON = "loop_timeout";
|
|
77
|
+
export default class Engine {
|
|
181
78
|
static computeCeiling(contextSize, config) {
|
|
182
|
-
|
|
183
|
-
// mode is to pin a ceiling even when the provider reports no window; cap at
|
|
184
|
-
// the real window when one is known. Ratio mode needs a window to scale.
|
|
185
|
-
if (config > 1)
|
|
186
|
-
return contextSize === null ? Math.floor(config) : Math.min(Math.floor(config), contextSize);
|
|
187
|
-
return contextSize === null ? null : Math.floor(contextSize * config);
|
|
79
|
+
return PacketBuilder.computeCeiling(contextSize, config);
|
|
188
80
|
}
|
|
189
|
-
// Per-turn fingerprint: sorted set of per-op fingerprints, joined. Order
|
|
190
|
-
// within a turn doesn't matter — we want the SET of activities.
|
|
191
81
|
static fingerprintTurn(ops) {
|
|
192
|
-
return
|
|
82
|
+
return StrikeRail.fingerprintTurn(ops);
|
|
193
83
|
}
|
|
194
|
-
// Rail #39 cycle detector. For each candidate period k in [1, maxCyclePeriod],
|
|
195
|
-
// check whether the last k*minCycles entries form minCycles repetitions of the
|
|
196
|
-
// same length-k pattern. O(maxCyclePeriod × minCycles × max k) ≈ tiny. Rummy
|
|
197
|
-
// parallel: src/plugins/error/error.js detectCycle.
|
|
198
84
|
static detectCycle(history, minCycles, maxCyclePeriod) {
|
|
199
|
-
|
|
200
|
-
const needed = k * minCycles;
|
|
201
|
-
if (history.length < needed)
|
|
202
|
-
continue;
|
|
203
|
-
const tail = history.slice(-needed);
|
|
204
|
-
const cycle = tail.slice(0, k);
|
|
205
|
-
let match = true;
|
|
206
|
-
outer: for (let rep = 0; rep < minCycles; rep++) {
|
|
207
|
-
for (let j = 0; j < k; j++) {
|
|
208
|
-
if (tail[rep * k + j] !== cycle[j]) {
|
|
209
|
-
match = false;
|
|
210
|
-
break outer;
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
if (match)
|
|
215
|
-
return { detected: true, period: k, cycles: minCycles };
|
|
216
|
-
}
|
|
217
|
-
return { detected: false };
|
|
85
|
+
return StrikeRail.detectCycle(history, minCycles, maxCyclePeriod);
|
|
218
86
|
}
|
|
219
87
|
#db;
|
|
220
88
|
#schemes;
|
|
221
89
|
#mimetypes;
|
|
222
|
-
#budgetCeiling;
|
|
223
90
|
// Write-time tokenizer (SPEC §tokenomics). Synchronous per the provider
|
|
224
91
|
// contract (§provider-surface). Populated from the active provider's countTokens via
|
|
225
92
|
// the Daemon; a divisor tripwire stands in only for bare/standalone
|
|
@@ -229,26 +96,14 @@ class Engine {
|
|
|
229
96
|
// Boot-discovered runtime executors. Daemon builds + sets via
|
|
230
97
|
// setExecutors at start(); undefined until then (and in bare tests).
|
|
231
98
|
#executors;
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
#
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
#strikeState = new Map();
|
|
241
|
-
// Proposal lifecycle: pending dispatch pauses waiting for resolution.
|
|
242
|
-
// Engine.runTurn awaits the promise when a scheme returns status 202;
|
|
243
|
-
// Engine.resolveProposal feeds the resolution back in. Map is per-log-
|
|
244
|
-
// entry-id; entries clear on resolution. SPEC.md §engine-rails + §methods (loop.resolve).
|
|
245
|
-
#pendingProposals = new Map();
|
|
246
|
-
// External observers of proposal lifecycle events. Daemon subscribes
|
|
247
|
-
// here to push `loop/proposal` notifications when an entry enters
|
|
248
|
-
// pending state. YOLO listener (Phase E.3) subscribes here too. Lean
|
|
249
|
-
// event emitter — no priority, no veto chain at this layer; filter
|
|
250
|
-
// chains come later if a real consumer needs them.
|
|
251
|
-
#proposalPendingListeners = [];
|
|
99
|
+
// The collaborators. Engine constructs them (they share its deps via
|
|
100
|
+
// thunks where the value is late-injected — executors, loop signals)
|
|
101
|
+
// and fronts their public surface.
|
|
102
|
+
#telemetry;
|
|
103
|
+
#strikes;
|
|
104
|
+
#packets;
|
|
105
|
+
#proposals;
|
|
106
|
+
#dispatcher;
|
|
252
107
|
// Per-loop AbortController for cancellation propagation into scheme
|
|
253
108
|
// ctx.signal. runLoop creates one at entry, cleans up at end. Engine
|
|
254
109
|
// cancellation paths (strikes, max_turns, external) abort it.
|
|
@@ -257,14 +112,6 @@ class Engine {
|
|
|
257
112
|
#loopAborts = new Map();
|
|
258
113
|
#streamEventNotify;
|
|
259
114
|
#wakeRunNotify;
|
|
260
|
-
#injectRun;
|
|
261
|
-
#cancelRun;
|
|
262
|
-
// Telemetry event fan-out: every TelemetryEvent pushed to the loop's
|
|
263
|
-
// buffer is also broadcast live to the connected client(s) on the
|
|
264
|
-
// session. Without this, the client sees `loop/terminated` with a
|
|
265
|
-
// status code but has no way to surface why the loop degraded.
|
|
266
|
-
// Per-grammar 0.17.0 protocol — see SPEC §telemetry.
|
|
267
|
-
#telemetryEventNotify;
|
|
268
115
|
// Cached plurnk GBNF — read once on the first constrained generate (#189).
|
|
269
116
|
#gbnfCache = null;
|
|
270
117
|
constructor({ db, schemes, mimetypes, streamEventNotify, wakeRunNotify, injectRun, cancelRun, telemetryEventNotify, tokenize }) {
|
|
@@ -272,9 +119,6 @@ class Engine {
|
|
|
272
119
|
this.#schemes = schemes;
|
|
273
120
|
this.#streamEventNotify = streamEventNotify;
|
|
274
121
|
this.#wakeRunNotify = wakeRunNotify;
|
|
275
|
-
this.#injectRun = injectRun;
|
|
276
|
-
this.#cancelRun = cancelRun;
|
|
277
|
-
this.#telemetryEventNotify = telemetryEventNotify;
|
|
278
122
|
// Default to empty discovery — standalone Engine construction (in
|
|
279
123
|
// tests) gets no handlers, and content flows through the framework's
|
|
280
124
|
// raw-content fitContent fallback. Daemon-managed Engine receives a
|
|
@@ -282,17 +126,48 @@ class Engine {
|
|
|
282
126
|
this.#mimetypes = mimetypes ?? new Mimetypes({
|
|
283
127
|
discovery: { registry: emptyRegistry(), handlers: new Map() },
|
|
284
128
|
});
|
|
285
|
-
this.#budgetCeiling = readCeiling();
|
|
286
129
|
// Tripwire default matches the Mimetypes boot affordance (SPEC §mimetype-surface):
|
|
287
130
|
// the divisor stands in only until the provider-backed tokenizer is
|
|
288
131
|
// wired by the Daemon. Real counts come from provider.countTokens.
|
|
289
132
|
this.#tokenize = tokenize ?? ((text) => Math.ceil(text.length / 4));
|
|
133
|
+
const executors = () => this.#executors;
|
|
134
|
+
const loopSignal = (loopId) => this.#loopAborts.get(loopId)?.signal;
|
|
135
|
+
this.#telemetry = new TelemetryChannel({ db, notify: telemetryEventNotify });
|
|
136
|
+
this.#strikes = new StrikeRail();
|
|
137
|
+
this.#packets = new PacketBuilder({ db, schemes, telemetry: this.#telemetry, executors });
|
|
138
|
+
this.#proposals = new ProposalLifecycle({
|
|
139
|
+
db, schemes, telemetry: this.#telemetry,
|
|
140
|
+
streamEventNotify, wakeRunNotify,
|
|
141
|
+
tokenize: this.#tokenize, executors, loopSignal,
|
|
142
|
+
});
|
|
143
|
+
this.#dispatcher = new Dispatcher({
|
|
144
|
+
db, schemes, mimetypes: this.#mimetypes,
|
|
145
|
+
tokenize: this.#tokenize,
|
|
146
|
+
telemetry: this.#telemetry, proposals: this.#proposals,
|
|
147
|
+
executors, loopSignal,
|
|
148
|
+
streamEventNotify, wakeRunNotify, injectRun, cancelRun,
|
|
149
|
+
});
|
|
290
150
|
}
|
|
291
151
|
// Late injection: the executor registry is async-built at daemon start()
|
|
292
152
|
// (discover + probe), after Engine construction.
|
|
293
153
|
setExecutors(executors) {
|
|
294
154
|
this.#executors = executors;
|
|
295
155
|
}
|
|
156
|
+
// Runtime hotload (#289) — register an executor TAG live, after boot (the /mcp route: an MCP
|
|
157
|
+
// server connected at runtime becomes EXEC[<server>]). Registers on BOTH registries the boot path
|
|
158
|
+
// wires: the ExecutorRegistry (dispatch resolves the tag; the tools sheet, rebuilt per packet, then
|
|
159
|
+
// offers it to the model) and the SchemeRegistry face (the tag's READ/FIND/KILL scheme), sharing the
|
|
160
|
+
// same reserved/cross-family arbitration boot uses. Fail-hard if the registry isn't wired yet — a
|
|
161
|
+
// hotload before daemon start() is a caller bug, not a silent no-op.
|
|
162
|
+
hotloadRuntime(tag, entry) {
|
|
163
|
+
if (this.#executors === undefined)
|
|
164
|
+
throw new Error("hotloadRuntime: executor registry not wired yet (call after daemon start)");
|
|
165
|
+
// Scheme face FIRST — it is the arbitration gate (reserved / cross-family collision, #240) and
|
|
166
|
+
// throws before we mutate the executor registry, so a rejected tag leaves neither registry
|
|
167
|
+
// half-written. A brand-new tag registers on both; a reserved/claimed tag throws here untouched.
|
|
168
|
+
this.#schemes.registerRuntimeScheme(tag, entry.executor);
|
|
169
|
+
this.#executors.register(tag, entry);
|
|
170
|
+
}
|
|
296
171
|
// Grammar-constrained sampling (#189): when PLURNK_PROVIDERS_GBNF is enabled
|
|
297
172
|
// (the only knob — default-on in .env.example), hand the provider the plurnk
|
|
298
173
|
// GBNF (the full shipped multi-op root, read once + cached). The provider
|
|
@@ -334,25 +209,6 @@ class Engine {
|
|
|
334
209
|
meta: JSON.parse(row?.meta ?? "{}"),
|
|
335
210
|
};
|
|
336
211
|
}
|
|
337
|
-
#pushTelemetry(sessionId, loopId, event) {
|
|
338
|
-
const existing = this.#telemetryBuffer.get(loopId);
|
|
339
|
-
if (existing === undefined)
|
|
340
|
-
this.#telemetryBuffer.set(loopId, [event]);
|
|
341
|
-
else
|
|
342
|
-
existing.push(event);
|
|
343
|
-
// Live fan-out: client sees the event the moment it lands in the
|
|
344
|
-
// model's buffer (not at the next packet build). Same envelope on
|
|
345
|
-
// both sides per the grammar 0.17.0 TelemetryEvent protocol.
|
|
346
|
-
this.#telemetryEventNotify?.(sessionId, { loopId, event });
|
|
347
|
-
}
|
|
348
|
-
// Telemetry drains as it's read into the packet — each event surfaces once. §telemetry-drain-on-read
|
|
349
|
-
#drainTelemetry(loopId) {
|
|
350
|
-
const buf = this.#telemetryBuffer.get(loopId);
|
|
351
|
-
if (buf === undefined)
|
|
352
|
-
return [];
|
|
353
|
-
this.#telemetryBuffer.delete(loopId);
|
|
354
|
-
return buf;
|
|
355
|
-
}
|
|
356
212
|
// A @plurnk/gbnf divergence position (providers#24) is a CODE-POINT offset into the
|
|
357
213
|
// model's content; the snippet/telemetry surface speaks 1-based line + 0-based column.
|
|
358
214
|
// Convert over code points (not UTF-16 units) so an astral char doesn't skew the line,
|
|
@@ -386,6 +242,18 @@ class Engine {
|
|
|
386
242
|
signal.addEventListener("abort", () => loopAbort.abort(signal.reason), { once: true });
|
|
387
243
|
}
|
|
388
244
|
this.#loopAborts.set(loopId, loopAbort);
|
|
245
|
+
// §operator-config-loop-timeout — the wall-clock budget. Expiry aborts the loop signal, so a
|
|
246
|
+
// mid-flight provider call (generate rides this signal) and in-flight spawns tear down; the
|
|
247
|
+
// loop terminates 504 (kin to the exec <T> reap's 504, §exec-timeout) — a legible engine
|
|
248
|
+
// terminal, never an outside kill. unref'd: the wall never holds the process open.
|
|
249
|
+
const wall = setTimeout(() => loopAbort.abort(LOOP_TIMEOUT_REASON), readLoopTimeoutMs());
|
|
250
|
+
wall.unref();
|
|
251
|
+
const timedOut = () => loopAbort.signal.aborted && loopAbort.signal.reason === LOOP_TIMEOUT_REASON;
|
|
252
|
+
const ruleTimeout = async () => {
|
|
253
|
+
await this.#db.engine_loop_set_status.run({ loop_id: loopId, status: 504, message: "loop_timeout" });
|
|
254
|
+
cleanup("forceful", "loop_timeout");
|
|
255
|
+
return { turnIds, finalStatus: 504, hitMaxTurns: false, reason: "loop_timeout" };
|
|
256
|
+
};
|
|
389
257
|
// Cleanup splits by termination kind:
|
|
390
258
|
// - "graceful" (SEND[202] Accepted): in-flight streaming-scheme spawns
|
|
391
259
|
// are ALLOWED to outlive the loop — they complete naturally, write final
|
|
@@ -394,14 +262,18 @@ class Engine {
|
|
|
394
262
|
// - "forceful" (SEND[200] done, max_turns, strike, cancel, budget, 4xx/5xx):
|
|
395
263
|
// fire the loop-level abort so leftover spawns tear down. "Done" reaps.
|
|
396
264
|
const cleanup = (kind, reason) => {
|
|
265
|
+
clearTimeout(wall);
|
|
397
266
|
if (kind === "forceful" && !loopAbort.signal.aborted) {
|
|
398
267
|
loopAbort.abort(reason ?? "loop_forceful_termination");
|
|
399
268
|
}
|
|
400
269
|
this.#loopAborts.delete(loopId);
|
|
401
|
-
this.#
|
|
402
|
-
this.#
|
|
270
|
+
this.#strikes.delete(loopId);
|
|
271
|
+
this.#telemetry.delete(loopId);
|
|
403
272
|
};
|
|
404
273
|
while (true) {
|
|
274
|
+
// The wall fired between turns — rule 504 before anything else reads the loop.
|
|
275
|
+
if (timedOut())
|
|
276
|
+
return await ruleTimeout();
|
|
405
277
|
signal?.throwIfAborted();
|
|
406
278
|
const row = await this.#db.engine_loop_status.get({ loop_id: loopId });
|
|
407
279
|
if (row === undefined)
|
|
@@ -430,10 +302,20 @@ class Engine {
|
|
|
430
302
|
if (execHandler?.hasActiveSpawns?.(runId) === true)
|
|
431
303
|
await delay(execWaitMs, undefined, { signal });
|
|
432
304
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
305
|
+
let turn;
|
|
306
|
+
try {
|
|
307
|
+
turn = await this.runTurn({
|
|
308
|
+
provider, messages, requirements, sessionId, runId, loopId, origin, signal, onDispatch,
|
|
309
|
+
turnNumber: turnIds.length + 1, maxTurns,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
catch (err) {
|
|
313
|
+
// The wall fired mid-turn — the abort tore the turn down (generate rides the loop
|
|
314
|
+
// signal); rule the legible 504, never a generic drain error.
|
|
315
|
+
if (timedOut())
|
|
316
|
+
return await ruleTimeout();
|
|
317
|
+
throw err;
|
|
318
|
+
}
|
|
437
319
|
turnIds.push(turn.turnId);
|
|
438
320
|
// SPEC §grinder: budget hard-stop — packet won't fit even collapsed → abandon.
|
|
439
321
|
if (turn.budgetHardStop) {
|
|
@@ -442,59 +324,26 @@ class Engine {
|
|
|
442
324
|
cleanup("forceful", "budget_overflow");
|
|
443
325
|
return { turnIds, finalStatus: 413, hitMaxTurns: false, reason: "budget_overflow" };
|
|
444
326
|
}
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
if (
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
// Rail #38: strike accounting. Three sources strike a turn:
|
|
465
|
-
// 1. recordedFailed — any action-entry at hard failure status
|
|
466
|
-
// (>= 400 and not in SOFT_FAILURE_STATUSES).
|
|
467
|
-
// 2. noOps — turn.status === TURN_STATUS_NO_OPS (per #41).
|
|
468
|
-
// 3. turnErrors — externally bumped by per-turn rails (#39 cycle).
|
|
469
|
-
// Struck → streak++; clean → streak = 0. Threshold → abandon.
|
|
470
|
-
// Strike accounting is engine-internal bookkeeping. Per rummy
|
|
471
|
-
// precedent (plugins/error/error.js#verdict) and SPEC §telemetry
|
|
472
|
-
// policy: model sees errors that happened (parse_error,
|
|
473
|
-
// action_failure), never the engine's accounting about them
|
|
474
|
-
// (strike counts, cycle detection, sudden-death threshold).
|
|
475
|
-
// Surfacing internal state to the model creates a gamification
|
|
476
|
-
// surface — model optimizes for engine metrics rather than
|
|
477
|
-
// task progress.
|
|
478
|
-
const recordedFailed = turn.statuses.some((s) => s >= 400 && !SOFT_FAILURE_STATUSES.has(s));
|
|
479
|
-
const noOps = turn.status === TURN_STATUS_NO_OPS;
|
|
480
|
-
const struck = noOps || recordedFailed || state.turnErrors > 0;
|
|
481
|
-
if (struck) {
|
|
482
|
-
state.streak++;
|
|
483
|
-
if (state.streak >= maxStrikes) {
|
|
484
|
-
// §loop-terminals — a cycle-driven strike is the model spinning in place
|
|
485
|
-
// (508 Loop Detected); a failure/no-op strike is the model failing (500
|
|
486
|
-
// Internal Server Error). The straw that crossed the threshold picks it.
|
|
487
|
-
const status = cycle.detected ? 508 : 500;
|
|
488
|
-
await this.#db.engine_loop_set_status.run({ loop_id: loopId, status, message: "strike_threshold" });
|
|
489
|
-
cleanup("forceful", "strike_threshold");
|
|
490
|
-
return { turnIds, finalStatus: status, hitMaxTurns: false, reason: "strike_threshold" };
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
else {
|
|
494
|
-
state.streak = 0;
|
|
327
|
+
// Rails #38/#39 — per-turn strike accounting (cycle detection, the
|
|
328
|
+
// grinder/steer coupling, hard-failure statuses). StrikeRail owns the
|
|
329
|
+
// bookkeeping; runLoop owns abandonment.
|
|
330
|
+
const verdict = this.#strikes.assess(loopId, {
|
|
331
|
+
fingerprint: turn.fingerprint,
|
|
332
|
+
statuses: turn.statuses,
|
|
333
|
+
noOps: turn.status === TURN_STATUS_NO_OPS,
|
|
334
|
+
budgetStruck: turn.budgetStruck,
|
|
335
|
+
steerStruck: turn.steerStruck,
|
|
336
|
+
minCycles, maxCyclePeriod, maxStrikes,
|
|
337
|
+
});
|
|
338
|
+
if (verdict.thresholdCrossed) {
|
|
339
|
+
// §loop-terminals — a cycle-driven strike is the model spinning in place
|
|
340
|
+
// (508 Loop Detected); a failure/no-op strike is the model failing (500
|
|
341
|
+
// Internal Server Error). The straw that crossed the threshold picks it.
|
|
342
|
+
const status = verdict.cycleDetected ? 508 : 500;
|
|
343
|
+
await this.#db.engine_loop_set_status.run({ loop_id: loopId, status, message: "strike_threshold" });
|
|
344
|
+
cleanup("forceful", "strike_threshold");
|
|
345
|
+
return { turnIds, finalStatus: status, hitMaxTurns: false, reason: "strike_threshold" };
|
|
495
346
|
}
|
|
496
|
-
state.turnErrors = 0;
|
|
497
|
-
this.#strikeState.set(loopId, state);
|
|
498
347
|
// Sudden-death threshold is engine-internal — abandonment
|
|
499
348
|
// happens at maxTurns regardless. Per gamification policy:
|
|
500
349
|
// we don't warn the model that it's nearing our limit.
|
|
@@ -615,7 +464,7 @@ class Engine {
|
|
|
615
464
|
tokenize: this.#tokenize,
|
|
616
465
|
mimetypes: this.#mimetypes,
|
|
617
466
|
defaultChannelFor: (s) => this.#schemes.defaultChannelFor(s),
|
|
618
|
-
pushTelemetry: (event) => this.#
|
|
467
|
+
pushTelemetry: (event) => this.#telemetry.push(sessionId, loopId, event),
|
|
619
468
|
};
|
|
620
469
|
// SPEC §membership D4/D5 — git-ls-files workspace membership, resolved at
|
|
621
470
|
// prompt-composition (EMI is eager + exhaustive — git is the only bound). When the
|
|
@@ -729,7 +578,7 @@ class Engine {
|
|
|
729
578
|
// so the grammar can stay thin. Subsequent turns mirror the model's real output, folded.
|
|
730
579
|
if (runFirstLoop) {
|
|
731
580
|
const emission = ["<<PLAN:Initialize:PLAN", ...turnZeroMoves, "<<SEND[102]:Initialized:SEND"].join("\n");
|
|
732
|
-
await this.#writeModelEntry({ verbatim: emission, runId, loopId, turnId, sequence: 1, folded: false, origin: "plurnk" });
|
|
581
|
+
await this.#dispatcher.writeModelEntry({ verbatim: emission, runId, loopId, turnId, sequence: 1, folded: false, origin: "plurnk" });
|
|
733
582
|
}
|
|
734
583
|
}
|
|
735
584
|
// §environment-observation — pre-seed the run's ambient observations (what changed since
|
|
@@ -747,15 +596,15 @@ class Engine {
|
|
|
747
596
|
// (a service-side `git status` shell-out) and threaded into the budget
|
|
748
597
|
// rebuild too so it isn't re-shelled on overflow.
|
|
749
598
|
const gitStatus = await GitState.status(this.#db, sessionId, this.#loopAborts.get(loopId)?.signal);
|
|
750
|
-
// Build the spec'd packet (Packet.json) request half.
|
|
599
|
+
// Build the spec'd packet (Packet.json) request half. The log build
|
|
751
600
|
// queries log_entries scoped to the run — the prompt entry just
|
|
752
601
|
// written (if turn 1) is part of that query result.
|
|
753
|
-
let requestPacket = await this.#buildRequestPacket({
|
|
602
|
+
let requestPacket = await this.#packets.buildRequestPacket({
|
|
754
603
|
initialMessages: messages, requirements, sessionId, runId, loopId,
|
|
755
604
|
currentTurnSeq: seq, provider, gitStatus,
|
|
756
605
|
});
|
|
757
606
|
// SPEC §grinder — budget grinder, pre-LLM: reclaim window on actual overflow.
|
|
758
|
-
const enforced = await this.#enforceBudget({
|
|
607
|
+
const enforced = await this.#packets.enforceBudget({
|
|
759
608
|
packet: requestPacket, provider, runId, loopId, turnId,
|
|
760
609
|
// The overflow error row is minted at the turn's running sequence (nextActionIndex), pre-generate;
|
|
761
610
|
// runTurn advances the counter past it below so the post-generate dispatch rows never collide.
|
|
@@ -763,7 +612,7 @@ class Engine {
|
|
|
763
612
|
// No preset telemetry — the rebuild RE-DERIVES the errors section from log≥400 so the
|
|
764
613
|
// overflow row just minted surfaces THIS turn (§grinder-overflow-error-row). Safe: the
|
|
765
614
|
// ephemeral buffer is empty pre-generate (events drain on the next turn's build).
|
|
766
|
-
rebuild: () => this.#buildRequestPacket({
|
|
615
|
+
rebuild: () => this.#packets.buildRequestPacket({
|
|
767
616
|
initialMessages: messages, requirements, sessionId, runId, loopId,
|
|
768
617
|
currentTurnSeq: seq, provider, gitStatus,
|
|
769
618
|
}),
|
|
@@ -774,7 +623,7 @@ class Engine {
|
|
|
774
623
|
if (!enforced.fit) {
|
|
775
624
|
// Hard 413: won't fit even with only the manifest left. Skip the LLM,
|
|
776
625
|
// close the turn, and let runLoop abandon (499).
|
|
777
|
-
const hardPacket = this.#completePacket(requestPacket, { content: "", ops: [], reasoning: null }, null, provider);
|
|
626
|
+
const hardPacket = this.#packets.completePacket(requestPacket, { content: "", ops: [], reasoning: null }, null, provider);
|
|
778
627
|
await this.#db.engine_close_turn.run({
|
|
779
628
|
id: turnId, status: 413, packet: JSON.stringify(hardPacket),
|
|
780
629
|
usage_prompt: 0, usage_completion: 0, usage_cached: 0, usage_cost_pico: 0,
|
|
@@ -783,7 +632,7 @@ class Engine {
|
|
|
783
632
|
});
|
|
784
633
|
return { turnId, status: 413, statuses: [], fingerprint: "", budgetStruck: enforced.struck, budgetHardStop: true, steerStruck: false };
|
|
785
634
|
}
|
|
786
|
-
const modelMessages =
|
|
635
|
+
const modelMessages = PacketWire.packetToWireMessages(requestPacket);
|
|
787
636
|
// No decode cap. Our budget governs the TRANSMISSION packet (the grinder folds
|
|
788
637
|
// the input under the ceiling); the model's decode — reasoning + emission — is
|
|
789
638
|
// out of band, owned by the provider's own context window. Deriving a maxTokens
|
|
@@ -801,7 +650,20 @@ class Engine {
|
|
|
801
650
|
// #249 — session-stable frontend id, forwarded as Plurnk-Client by the plurnk provider only.
|
|
802
651
|
const { client } = await SessionSettings.read(this.#db, sessionId);
|
|
803
652
|
try {
|
|
804
|
-
|
|
653
|
+
// §turn-lifecycle (#301) — the provider call is the long, opaque window (submit → first
|
|
654
|
+
// committed op is provider latency + a full first-turn generation, ~70s local): a static
|
|
655
|
+
// screen there is indistinguishable from a hang. Bracket generate() with two telemetry beats
|
|
656
|
+
// so a client can show "awaiting model" the instant the turn starts and flip to "parsing" when
|
|
657
|
+
// ops are about to land. Base telemetry/event channel (the embed_progress precedent, §tokenomics
|
|
658
|
+
// clients already render it unconditionally); the abort guard keeps a cancelled loop silent.
|
|
659
|
+
if (!signal?.aborted)
|
|
660
|
+
this.#telemetry.push(sessionId, loopId, { source: "engine:turn", kind: "turn_awaiting_model", level: "info", message: "awaiting model response" });
|
|
661
|
+
// generate rides the LOOP signal (already chained from the caller's), so a loop-level
|
|
662
|
+
// abort — the §operator-config-loop-timeout wall — cancels a stuck provider call, not
|
|
663
|
+
// just the schemes. Bare runTurn (no runLoop) has no loop entry → the caller's signal.
|
|
664
|
+
response = await provider.generate({ messages: modelMessages, runId: String(runId), signal: this.#loopAborts.get(loopId)?.signal ?? signal, grammar: await this.#grammarConstraint(), attributions: attributions.length > 0 ? attributions : undefined, client: client ?? undefined }); // §provider-surface-generate §provider-guarantees-single-call §provider-guarantees-signal-wired §attribution-plurnk-namespace-reserved §client-telemetry
|
|
665
|
+
if (!signal?.aborted)
|
|
666
|
+
this.#telemetry.push(sessionId, loopId, { source: "engine:turn", kind: "turn_generated", level: "info", message: "parsing model response" });
|
|
805
667
|
}
|
|
806
668
|
catch (err) {
|
|
807
669
|
// Every provider error surfaces as telemetry (the client/model sees the cause). #256:
|
|
@@ -814,7 +676,7 @@ class Engine {
|
|
|
814
676
|
// In GBNF-filter mode the provider returns the bytes with a grammar_unenforced telemetry
|
|
815
677
|
// event instead — recovered on the success path below (response.telemetry), no empty turn.
|
|
816
678
|
if (err instanceof ProviderError) {
|
|
817
|
-
this.#
|
|
679
|
+
this.#telemetry.push(sessionId, loopId, { source: "provider", kind: err.kind, message: err.message, level: "error" });
|
|
818
680
|
if (err.kind !== "grammar_unenforced")
|
|
819
681
|
throw err;
|
|
820
682
|
response = {
|
|
@@ -861,7 +723,7 @@ class Engine {
|
|
|
861
723
|
: null;
|
|
862
724
|
if (located !== null)
|
|
863
725
|
hadContentOffsetNotice = true;
|
|
864
|
-
this.#
|
|
726
|
+
this.#telemetry.push(sessionId, loopId, {
|
|
865
727
|
source: event.source,
|
|
866
728
|
kind: event.kind,
|
|
867
729
|
message: event.message ?? "",
|
|
@@ -888,17 +750,44 @@ class Engine {
|
|
|
888
750
|
// Premature terminate: a SEND[200] while the run still holds a live thing — an open stream/spawn
|
|
889
751
|
// OR a non-terminal child run (§run-lifecycle: children and streams are the same kind of "live
|
|
890
752
|
// thing a run holds"). The model declared done with work running. The SEND is REFUSED 409 at
|
|
891
|
-
// dispatch (
|
|
892
|
-
// erasure to 102) and auto-surfaces (status≥400); the loop never goes terminal.
|
|
893
|
-
// flag it so the turn stays a continue and the strike couples to the grinder
|
|
894
|
-
// turnErrors): a model that won't stop premature-200ing escalates out via the rails.
|
|
895
|
-
|
|
753
|
+
// dispatch (Dispatcher's send-broadcast handler) — the row records the [200] attempt + body
|
|
754
|
+
// faithfully (no erasure to 102) and auto-surfaces (status≥400); the loop never goes terminal.
|
|
755
|
+
// Here we only flag it so the turn stays a continue and the strike couples to the grinder
|
|
756
|
+
// (steerStruck → turnErrors): a model that won't stop premature-200ing escalates out via the rails.
|
|
757
|
+
// A terminal SEND[200] is premature when the run still holds a live thing (open stream/spawn or
|
|
758
|
+
// a non-terminal child), OR when the model submitted a READ THIS turn whose result it cannot have
|
|
759
|
+
// seen — results fold back on the NEXT turn, so terminating now means terminating on data it
|
|
760
|
+
// doesn't have (the classic "READ + SEND[200] in one turn" that ends mid-sentence). Both refuse
|
|
761
|
+
// 409 + strike; live-thing takes precedence when both hold.
|
|
762
|
+
let prematureReason;
|
|
763
|
+
if (sendOp?.signal === 200) {
|
|
764
|
+
prematureReason = (await this.#runHoldsLiveThing(runId)) ? "live-thing"
|
|
765
|
+
: packetAssistant.ops.some((op) => op.op === "READ") ? "submitted-read"
|
|
766
|
+
: undefined;
|
|
767
|
+
}
|
|
768
|
+
else if (sendOp?.signal === 202) {
|
|
769
|
+
// §send-groundless-hibernate — a park is refused only when it ORPHANS work: the turn
|
|
770
|
+
// submitted a READ (its result folds back on a next turn this park may never have) AND no
|
|
771
|
+
// wake edge exists — nothing held (the pre-dispatch snapshot) and nothing wake-capable
|
|
772
|
+
// opened this turn (a spawn takes effect mid-turn, after the snapshot — the same
|
|
773
|
+
// emission-scan shape as submitted-read). A bare park holding nothing stays LEGAL — the
|
|
774
|
+
// voice door: a sibling irc / operator inject wakes it (§actor-boundary-passive-wake) and
|
|
775
|
+
// the daemon surfaces it as loop/quiesced (§run-lifecycle-quiesced), so it is never refused.
|
|
776
|
+
if (packetAssistant.ops.some((op) => op.op === "READ")) {
|
|
777
|
+
const wakeEdge = (await this.#runHoldsLiveThing(runId))
|
|
778
|
+
|| packetAssistant.ops.some((op) => Engine.#opCanOpenWakeEdge(op));
|
|
779
|
+
if (!wakeEdge)
|
|
780
|
+
prematureReason = "groundless-hibernate";
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
const prematureTerminate = prematureReason !== undefined;
|
|
896
784
|
if (prematureTerminate)
|
|
897
785
|
steerStruck = true;
|
|
898
786
|
// Rail #41 (revised): the per-turn requirement is "emit at least one op," not "emit a terminal
|
|
899
787
|
// SEND." SEND is purely a signal verb; many turns pass without one. An empty op list strikes.
|
|
900
|
-
// A refused premature
|
|
901
|
-
//
|
|
788
|
+
// A refused terminal (premature [200] or groundless [202]) keeps the turn a continue (102)
|
|
789
|
+
// though the SEND's signal stays on the row (the un-erased record) — the loop never went
|
|
790
|
+
// terminal, so the turn didn't either.
|
|
902
791
|
const turnStatus = prematureTerminate
|
|
903
792
|
? TURN_STATUS_IMPLICIT_CONTINUE
|
|
904
793
|
: sendOp !== undefined
|
|
@@ -912,7 +801,7 @@ class Engine {
|
|
|
912
801
|
pendingEngineErrors.push("idle_turn");
|
|
913
802
|
}
|
|
914
803
|
// Close the turn with the final packet, status, and usage stats.
|
|
915
|
-
const packet = this.#completePacket(requestPacket, packetAssistant, response.assistantRaw, provider);
|
|
804
|
+
const packet = this.#packets.completePacket(requestPacket, packetAssistant, response.assistantRaw, provider);
|
|
916
805
|
const { usage, finishReason, model } = callMetadata;
|
|
917
806
|
await this.#db.engine_close_turn.run({
|
|
918
807
|
id: turnId,
|
|
@@ -960,7 +849,7 @@ class Engine {
|
|
|
960
849
|
statement, sessionId, runId, loopId, turnId,
|
|
961
850
|
sequence: rowSeq,
|
|
962
851
|
origin, onDispatch,
|
|
963
|
-
prematureRefusal: prematureTerminate && statement === sendOp, // the pre-dispatch snapshot's
|
|
852
|
+
prematureRefusal: prematureTerminate && statement === sendOp ? prematureReason : undefined, // the pre-dispatch snapshot's reason, only for this turn's terminal SEND
|
|
964
853
|
});
|
|
965
854
|
statuses.push(result.status);
|
|
966
855
|
rowSeq += result.rowsWritten ?? 1;
|
|
@@ -973,7 +862,7 @@ class Engine {
|
|
|
973
862
|
if (droppedCount > 0)
|
|
974
863
|
pendingEngineErrors.push("max_commands_exceeded");
|
|
975
864
|
for (const kind of pendingEngineErrors)
|
|
976
|
-
await this.#mintEngineError(kind, { runId, loopId, turnId, sequence: errSeq++ });
|
|
865
|
+
await this.#telemetry.mintEngineError(kind, { runId, loopId, turnId, sequence: errSeq++ });
|
|
977
866
|
// Parse errors carry the parser message + a content-offset line:col (a ContentOffset position),
|
|
978
867
|
// resolved against the model's born-OPEN emission (§model-entry) — origin 'model', not engine.
|
|
979
868
|
for (const { message, line, column, source } of parseErrors ?? []) {
|
|
@@ -999,13 +888,13 @@ class Engine {
|
|
|
999
888
|
// struck/silent turn) write nothing — no prior output to mirror.
|
|
1000
889
|
if (packetAssistant.content.trim().length > 0) {
|
|
1001
890
|
const hadEmissionError = (parseErrors?.length ?? 0) > 0 || hadContentOffsetNotice;
|
|
1002
|
-
await this.#writeModelEntry({ verbatim: packetAssistant.content, runId, loopId, turnId, sequence: errSeq++, folded: !hadEmissionError });
|
|
891
|
+
await this.#dispatcher.writeModelEntry({ verbatim: packetAssistant.content, runId, loopId, turnId, sequence: errSeq++, folded: !hadEmissionError });
|
|
1003
892
|
}
|
|
1004
893
|
// Zero ops is NOT an error to report — the model knows it emitted
|
|
1005
894
|
// nothing. Strike accounting (engine-internal) treats it as a
|
|
1006
895
|
// struck turn; the model just sees an empty packet next turn.
|
|
1007
896
|
// Per SPEC §telemetry gamification policy.
|
|
1008
|
-
return { turnId, status: turnStatus, statuses, fingerprint:
|
|
897
|
+
return { turnId, status: turnStatus, statuses, fingerprint: StrikeRail.fingerprintTurn(packetAssistant.ops), budgetStruck: enforced.struck, budgetHardStop: false, steerStruck };
|
|
1009
898
|
}
|
|
1010
899
|
// Split the wire-level ProviderResponse into the two destinations:
|
|
1011
900
|
// packet.assistant gets the model's emission (content, ops, reasoning);
|
|
@@ -1071,340 +960,10 @@ class Engine {
|
|
|
1071
960
|
parseErrors,
|
|
1072
961
|
};
|
|
1073
962
|
}
|
|
1074
|
-
// Assemble the request half of the spec'd packet (Packet.json §system
|
|
1075
|
-
// and §user) BEFORE the provider call. The same packet object is then
|
|
1076
|
-
// completed with assistant + assistantRaw after the model responds, so
|
|
1077
|
-
// the stored packet and the wire payload share one source of truth.
|
|
1078
|
-
async #buildRequestPacket({ initialMessages, requirements, sessionId, runId, loopId, currentTurnSeq, provider, gitStatus, telemetryErrors: presetTelemetry, }) {
|
|
1079
|
-
const byRole = (role) => initialMessages.filter((m) => m.role === role).map((m) => m.content).join("\n\n");
|
|
1080
|
-
// plurnk.md (grammar/dialects) ONLY — the definition is the hot-path grammar.
|
|
1081
|
-
// The scheme catalogue is its own `schemes` section below tools (§schemes-directory),
|
|
1082
|
-
// NOT appended here: grammar 0.49+ is scheme-agnostic, so the service advertises
|
|
1083
|
-
// the scheme set at packet-time (grammar#239 item 7) via SchemeRegistry.teach().
|
|
1084
|
-
const system_definition = byRole("system");
|
|
1085
|
-
// the prompt section sources from the loop's most recent prompt entry first
|
|
1086
|
-
// (plurnk:///prompt/<loop_id>/<N> for the highest N written to date).
|
|
1087
|
-
// This is what inject + the turn-1 foist write into. Falls back to
|
|
1088
|
-
// the runLoop caller's messages.user for tests that bypass the
|
|
1089
|
-
// foist mechanism entirely.
|
|
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);
|
|
1092
|
-
const promptCap = Number.parseInt(process.env.PLURNK_PROMPT_PREVIEW_CHARS ?? "", 10);
|
|
1093
|
-
const prompt = promptRows.length > 0
|
|
1094
|
-
? PacketWire.renderActivePrompts(promptRows, Number.isInteger(promptCap) ? promptCap : -1)
|
|
1095
|
-
: byRole("user");
|
|
1096
|
-
// Requirements is engine-sourced, NOT threaded from callers — that threading is
|
|
1097
|
-
// exactly how it went missing (callers read the sysprompt but never the
|
|
1098
|
-
// requirements). Read Paths.defaultRequirements (PLURNK_REQUIREMENTS env →
|
|
1099
|
-
// requirements.md) fresh each build so edits take effect; a non-empty param wins.
|
|
1100
|
-
const baseRequirements = requirements.length > 0 ? requirements : await readFile(Paths.defaultRequirements, "utf8");
|
|
1101
|
-
// No injected syntax line: the grammar already headlines the system definition (§Syntax) and
|
|
1102
|
-
// leads requirements.md, so a third copy here was pure duplication in the model's packet. PLAN
|
|
1103
|
-
// is mandated unconditionally by plurnk.md §Imperatives (grammar 0.70 requires every turn to
|
|
1104
|
-
// lead with PLAN), so the service injects no separate plan directive either (the former
|
|
1105
|
-
// PLURNK_PLAN gating is retired — PLURNK_PLAN is no longer a flag).
|
|
1106
|
-
const log = await this.#buildLog(runId);
|
|
1107
|
-
const telemetryErrors = presetTelemetry ?? await this.#buildTelemetryErrors(loopId, currentTurnSeq);
|
|
1108
|
-
const countTokens = (t) => provider.countTokens(t); // §provider-surface-counttokens
|
|
1109
|
-
const tools = this.#collectTools();
|
|
1110
|
-
// Budget readout (SPEC.md §tokenomics). Two-pass: render the budget from
|
|
1111
|
-
// the structured log's subtotals with a {{tokensFree}} placeholder, build
|
|
1112
|
-
// the section list, measure the assembled total, resolve free, substitute.
|
|
1113
|
-
// Subtotals come from the real log render — meta and fences included — not
|
|
1114
|
-
// a serialized approximation. ceiling is the provider's window ×
|
|
1115
|
-
// PLURNK_BUDGET_CEILING (null when no window is reported → headline
|
|
1116
|
-
// omitted, section lines still shown). §tokenomics-render-weight-budget
|
|
1117
|
-
const ceiling = _a.computeCeiling(provider.contextSize, this.#budgetCeiling);
|
|
1118
|
-
const budgetReadout = this.#renderBudget(PacketWire.measureLogBudget(log, countTokens), ceiling);
|
|
1119
|
-
// The default packet: an ordered list of addressable sections (§packet-construction).
|
|
1120
|
-
// `slot` is a TRUST boundary (and the prompt-cache boundary): system holds only
|
|
1121
|
-
// framework-authored, non-injectable sections — the static head (definition/tools/
|
|
1122
|
-
// schemes/policy) forms the cached prefix, then the volatile-but-trusted tail of
|
|
1123
|
-
// errors/git/budget; user holds injectable content (the log, the operator prompt) plus
|
|
1124
|
-
// the requirements footer. The budget section carries its {{tokensFree}} placeholders
|
|
1125
|
-
// here; they resolve below once the assembled total is known.
|
|
1126
|
-
const inject = await readPacketInject(); // #240 — operator section, per-turn, fail-hard on a broken path
|
|
1127
|
-
const sessionRoot = (await this.#db.envelope_get_session.get({ id: sessionId }))?.project_root ?? null;
|
|
1128
|
-
const systemPolicy = await readSystemPolicy(); // ~/.plurnk/AGENTS.md (or PLURNK_POLICY)
|
|
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}` }));
|
|
1138
|
-
const defaults = [
|
|
1139
|
-
{ name: "definition", slot: "system", header: null, content: system_definition, tokens: 0 },
|
|
1140
|
-
{ name: "tools", slot: "system", header: null, content: tools.join("\n"), tokens: 0 }, // titleless — the examples flow on from plurnk.md (definition) directly above
|
|
1141
|
-
{ name: "schemes", slot: "system", header: "Plurnk Service Schemes", content: this.#schemes.teach(), tokens: 0 },
|
|
1142
|
-
...(inject !== null ? [{ name: "inject", slot: "system", header: "Plurnk Operator Notes", content: inject, tokens: 0 }] : []),
|
|
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.
|
|
1144
|
-
{ name: "system-policy", slot: "system", header: "Plurnk Service Policy", content: systemPolicy ?? "", tokens: 0 },
|
|
1145
|
-
{ name: "project-policy", slot: "system", header: "Project Policy", content: projectPolicy ?? "", tokens: 0 },
|
|
1146
|
-
// The packet split is a TRUST boundary: system carries only framework-authored, non-injectable
|
|
1147
|
-
// sections; anything that could carry attacker-reachable text (a READ result, exec output, the
|
|
1148
|
-
// model's own mirrored bytes) stays in user. errors + git are framework status — the errors
|
|
1149
|
-
// section is uri+status POINTERS (the error item + body live in the log), git is counts — so
|
|
1150
|
-
// neither is an injection surface; both sit at the bottom of system, just above budget-the-law.
|
|
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 },
|
|
1157
|
-
// budget is the very last system line — LAW (a hard ceiling the model must obey), the final word before the model acts.
|
|
1158
|
-
{ name: "budget", slot: "system", header: "Plurnk Service Budget", content: budgetReadout, tokens: 0 },
|
|
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.
|
|
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 },
|
|
1165
|
-
// requirements renders LAST — the user-slot footer, the syntax contract closest to the model's turn (a recency carve-out for weak models).
|
|
1166
|
-
{ name: "requirements", slot: "user", header: "Plurnk Service Requirements", content: baseRequirements, tokens: 0 },
|
|
1167
|
-
];
|
|
1168
|
-
// Plugin packet control (§packet-construction): trusted schemes rewrite the
|
|
1169
|
-
// default list — add, remove, reorder — in-process, before measurement.
|
|
1170
|
-
const sections = await this.#schemes.transformSections(defaults);
|
|
1171
|
-
// Pass 1: measure the assembled total with the placeholder budget in
|
|
1172
|
-
// place, resolve free/percent, substitute into the budget section.
|
|
1173
|
-
const total = countTokens(PacketWire.renderSlot(sections, "system")) + countTokens(PacketWire.renderSlot(sections, "user"));
|
|
1174
|
-
const tokensFree = ceiling === null ? null : Math.max(0, ceiling - total); // free floors at 0 on overshoot — §tokenomics-over-budget-floor
|
|
1175
|
-
const percent = ceiling === null ? null : Math.round((total / ceiling) * 100); // usage as % of the ceiling — §tokenomics-context-percent
|
|
1176
|
-
if (tokensFree !== null) {
|
|
1177
|
-
const budgetSec = sections.find((s) => s.name === "budget"); // a plugin may have removed it
|
|
1178
|
-
if (budgetSec) {
|
|
1179
|
-
budgetSec.content = budgetSec.content
|
|
1180
|
-
.replace(TOKEN_USAGE_PLACEHOLDER, String(total))
|
|
1181
|
-
.replace(TOKEN_PERCENT_PLACEHOLDER, percent === 0 && total > 0 ? "<1" : String(percent))
|
|
1182
|
-
.replace(TOKENS_FREE_PLACEHOLDER, String(tokensFree));
|
|
1183
|
-
}
|
|
1184
|
-
}
|
|
1185
|
-
// Pass 2: per-section render-weight + the assembled packet total (post
|
|
1186
|
-
// substitution — the placeholder/number length delta is negligible).
|
|
1187
|
-
for (const s of sections)
|
|
1188
|
-
s.tokens = countTokens(PacketWire.renderSection(s));
|
|
1189
|
-
const packetTokens = countTokens(PacketWire.renderSlot(sections, "system")) + countTokens(PacketWire.renderSlot(sections, "user"));
|
|
1190
|
-
return { tokens: packetTokens, sections, telemetryErrors };
|
|
1191
|
-
}
|
|
1192
|
-
// Budget readout body, rendered into the `## Plurnk Service Budget` section.
|
|
1193
|
-
// Headline `ceiling/free` only when a ceiling exists; section lines for the
|
|
1194
|
-
// curatable index/log weight the model can FOLD back. tokensFree is a
|
|
1195
|
-
// placeholder here — buildSystem substitutes it after measuring the packet.
|
|
1196
|
-
#renderBudget(log, ceiling) {
|
|
1197
|
-
const lines = [];
|
|
1198
|
-
if (ceiling !== null)
|
|
1199
|
-
lines.push(`Token Ceiling ${ceiling} · Token Usage ${TOKEN_USAGE_PLACEHOLDER} (${TOKEN_PERCENT_PLACEHOLDER}%) · Tokens Free ${TOKENS_FREE_PLACEHOLDER}`);
|
|
1200
|
-
if (log.entries > 0) {
|
|
1201
|
-
if (lines.length > 0)
|
|
1202
|
-
lines.push("");
|
|
1203
|
-
lines.push(`Log entries: ${log.entries} entries, ${log.tokens} tokens`);
|
|
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}).
|
|
1206
|
-
if (log.byTurn.length > 0) {
|
|
1207
|
-
lines.push("", "Turns:", "| turn | tokens |", "|---|--:|");
|
|
1208
|
-
for (const t of log.byTurn)
|
|
1209
|
-
lines.push(`| ${t.turn} | ${t.tokens} |`);
|
|
1210
|
-
}
|
|
1211
|
-
// The heaviest individual log items — the FOLD targets behind the weight
|
|
1212
|
-
// (§tokenomics {§tokenomics-largest-entries}). "items", not "entries": the readout
|
|
1213
|
-
// lists log:/// rows (log items), distinct from catalog entries (plurnk.md: "EDIT
|
|
1214
|
-
// is only for entries. Do not attempt to edit log items.").
|
|
1215
|
-
if (log.largest.length > 0) {
|
|
1216
|
-
lines.push("", "Heaviest items:", "| item | tokens |", "|---|--:|");
|
|
1217
|
-
for (const e of log.largest)
|
|
1218
|
-
lines.push(`| ${e.path} | ${e.tokens} |`);
|
|
1219
|
-
}
|
|
1220
|
-
}
|
|
1221
|
-
return lines.join("\n");
|
|
1222
|
-
}
|
|
1223
|
-
// The ## Plurnk Service Tools capability sheet (SPEC §tools). A hook: each enabled
|
|
1224
|
-
// capability contributes one line, rendered above Requirements so the model sees what
|
|
1225
|
-
// it can do before the rules. Each available executor tag contributes its self-documenting
|
|
1226
|
-
// example (plurnk-execs#7), retiring the blind EXEC.
|
|
1227
|
-
// The capability sheet — the live tool surface (wired executor tags). §tools-capability-sheet
|
|
1228
|
-
#collectTools() {
|
|
1229
|
-
const tools = [];
|
|
1230
|
-
// Each available runtime tag contributes its self-documenting example —
|
|
1231
|
-
// the example carries syntax + purpose, so there's no prose line. Tags
|
|
1232
|
-
// with no example (sh/node, covered by the core prompt) contribute
|
|
1233
|
-
// nothing; available-only, so the model never sees an unusable tag. `* `
|
|
1234
|
-
// bullets + bare op forms match the packet's list/op rendering (no `- `,
|
|
1235
|
-
// no backticks — see packet-wire.ts).
|
|
1236
|
-
if (this.#executors !== undefined) {
|
|
1237
|
-
const excluded = docsExcludeSet();
|
|
1238
|
-
for (const tag of this.#executors.availableRuntimes()) {
|
|
1239
|
-
if (excluded.has(tag))
|
|
1240
|
-
continue; // #240 — PLURNK_DOCS_EXCLUDE drops the oneliner + the doc
|
|
1241
|
-
const entry = this.#executors.entry(tag);
|
|
1242
|
-
// #240 — identical treatment with the scheme directory: the example IS the oneliner,
|
|
1243
|
-
// the fuller doc (materialized at plurnk://docs/<tag>.md) rides an inline link whose
|
|
1244
|
-
// token cost lives on that manifest entry. No example → no line (like a provisional scheme).
|
|
1245
|
-
if (entry?.example)
|
|
1246
|
-
tools.push(teachingLine(entry.example));
|
|
1247
|
-
}
|
|
1248
|
-
}
|
|
1249
|
-
return tools;
|
|
1250
|
-
}
|
|
1251
963
|
// #note12 — the daughter-provided reference docs (schemes' + execs' `documentation`),
|
|
1252
|
-
// materialized at plurnk:///docs/<name>.md by loop_run (like operator docs)
|
|
1253
|
-
// catalogue's doc-links READ and the manifest carries each doc's token cost.
|
|
964
|
+
// materialized at plurnk:///docs/<name>.md by loop_run (like operator docs).
|
|
1254
965
|
docEntries() {
|
|
1255
|
-
|
|
1256
|
-
if (this.#executors !== undefined) {
|
|
1257
|
-
const excluded = docsExcludeSet();
|
|
1258
|
-
for (const tag of this.#executors.availableRuntimes()) {
|
|
1259
|
-
if (excluded.has(tag))
|
|
1260
|
-
continue; // #240 — exec docs honor the same exclude
|
|
1261
|
-
const doc = this.#executors.entry(tag)?.documentation;
|
|
1262
|
-
if (doc !== undefined && doc.length > 0)
|
|
1263
|
-
out.push({ name: tag, content: doc });
|
|
1264
|
-
}
|
|
1265
|
-
}
|
|
1266
|
-
return out;
|
|
1267
|
-
}
|
|
1268
|
-
// SPEC §grinder — the budget grinder. Runs pre-LLM (in runTurn, after the packet
|
|
1269
|
-
// is built, before provider.generate); fires only on actual overflow. Two
|
|
1270
|
-
// passes, re-measuring between. Folds (never deletes) — the prior turn's logs,
|
|
1271
|
-
// then the catalog except the manifest lifeline. The strike it raises and the
|
|
1272
|
-
// hard-stop it can signal are returned to runLoop, which owns abandonment.
|
|
1273
|
-
// §grinder-overflow-only — fires only on actual overflow, never speculatively
|
|
1274
|
-
async #enforceBudget({ packet, provider, runId, loopId, turnId, mintSequence, rebuild }) {
|
|
1275
|
-
const ceiling = _a.computeCeiling(provider.contextSize, this.#budgetCeiling);
|
|
1276
|
-
const measure = (p) => p.tokens;
|
|
1277
|
-
if (ceiling === null || measure(packet) <= ceiling)
|
|
1278
|
-
return { packet, fit: true, struck: false };
|
|
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) {
|
|
1286
|
-
await this.#db.engine_grinder_fold_prior_turn_logs.run({ loop_id: loopId, turn_id: turnId });
|
|
1287
|
-
foldedAny = true;
|
|
1288
|
-
}
|
|
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: "{}",
|
|
1324
|
-
});
|
|
1325
|
-
}
|
|
1326
|
-
// Wire projection lives in ./packet-wire.ts so Engine and
|
|
1327
|
-
// bin/digest.ts import the exact same function — structurally one
|
|
1328
|
-
// implementation, no drift between wire and digest possible.
|
|
1329
|
-
// Format: markdown (user pick over rummy's XML alternative, 2026-05-22).
|
|
1330
|
-
#packetToWireMessages(packet) {
|
|
1331
|
-
return PacketWire.packetToWireMessages(packet);
|
|
1332
|
-
}
|
|
1333
|
-
// Complete the packet by adding the model's response. After this the
|
|
1334
|
-
// packet matches Packet.json fully and is ready for storage.
|
|
1335
|
-
#completePacket(requestPacket, assistant, assistantRaw, provider) {
|
|
1336
|
-
const assistantTokens = provider.countTokens(assistant.content);
|
|
1337
|
-
return {
|
|
1338
|
-
tokens: requestPacket.tokens + assistantTokens,
|
|
1339
|
-
sections: requestPacket.sections,
|
|
1340
|
-
telemetryErrors: requestPacket.telemetryErrors,
|
|
1341
|
-
assistant,
|
|
1342
|
-
assistantRaw,
|
|
1343
|
-
};
|
|
1344
|
-
}
|
|
1345
|
-
// Render-time mimetype invocation (SPEC §mimetype {§mimetype-handlers-fire-render-time},
|
|
1346
|
-
// §per-entry-channels {§per-entry-channels-preview-is-handler-output}). For each (run, entry, channel)
|
|
1347
|
-
// with expanded=1, pass the channel's current content through
|
|
1348
|
-
// mimetype.preview(content, budget). State is included verbatim — engine
|
|
1349
|
-
// does NOT branch on it (§channel-state {§channel-state-engine-does-not-branch-on-state}).
|
|
1350
|
-
// SPEC §telemetry: model-facing alert surface.
|
|
1351
|
-
// Two sources, merged on each packet build:
|
|
1352
|
-
// 1. Previous-turn action-bound failures (status_rx >= 400 on log_entries).
|
|
1353
|
-
// 2. Engine-buffered actionless failures (no_send, parse, watchdog, rails).
|
|
1354
|
-
// Buffer drains on read — each error appears in exactly one packet.
|
|
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.
|
|
1360
|
-
const rows = await this.#db.engine_render_telemetry_errors.all({ loop_id: loopId, current_turn_seq: currentTurnSeq });
|
|
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];
|
|
1370
|
-
}
|
|
1371
|
-
// SPEC §packet the log section — chronological action-entries for the loop.
|
|
1372
|
-
// Snapshot is taken at packet build (pre-dispatch this turn), so it
|
|
1373
|
-
// reflects "what has happened before this turn." Each row carries a
|
|
1374
|
-
// log:///<loop_seq>/<turn_seq>/<sequence> coordinate the model can READ.
|
|
1375
|
-
async #buildLog(runId) {
|
|
1376
|
-
// SPEC §packet-terms: runs own log entries — log is the run's history,
|
|
1377
|
-
// not the loop's. Span all loops in the run so the model sees
|
|
1378
|
-
// earlier loops' work as conversational memory.
|
|
1379
|
-
//
|
|
1380
|
-
// User prompts are first-class log entries: runTurn writes a
|
|
1381
|
-
// client-origin SEND[200] row at sequence=0 of each new
|
|
1382
|
-
// turn-1. Prompts thus surface naturally in this query — no
|
|
1383
|
-
// synthetic / shim layer.
|
|
1384
|
-
const rows = await this.#db.engine_render_log.all({ run_id: runId });
|
|
1385
|
-
return rows.map((r) => ({
|
|
1386
|
-
coordinate: `${r.loop_seq}/${r.turn_seq}/${r.sequence}`,
|
|
1387
|
-
origin: r.origin,
|
|
1388
|
-
op: r.op,
|
|
1389
|
-
suffix: r.suffix,
|
|
1390
|
-
signal: r.signal === null ? null : JSON.parse(r.signal),
|
|
1391
|
-
target: {
|
|
1392
|
-
scheme: r.scheme,
|
|
1393
|
-
username: r.username, password: r.password,
|
|
1394
|
-
hostname: r.hostname, port: r.port,
|
|
1395
|
-
pathname: r.pathname,
|
|
1396
|
-
params: r.params === null ? null : JSON.parse(r.params),
|
|
1397
|
-
fragment: r.fragment,
|
|
1398
|
-
},
|
|
1399
|
-
status: r.status_rx,
|
|
1400
|
-
rx: r.mimetype_rx === "application/json" ? JSON.parse(r.rx) : r.rx,
|
|
1401
|
-
mimetype_rx: r.mimetype_rx,
|
|
1402
|
-
tx: r.mimetype_tx === "application/json" ? JSON.parse(r.tx) : r.tx,
|
|
1403
|
-
mimetype_tx: r.mimetype_tx,
|
|
1404
|
-
folded: r.expanded === 0,
|
|
1405
|
-
source: r.source,
|
|
1406
|
-
attrs: r.attrs === null ? null : JSON.parse(r.attrs),
|
|
1407
|
-
}));
|
|
966
|
+
return this.#packets.docEntries();
|
|
1408
967
|
}
|
|
1409
968
|
// §env-delta (§actor-boundary-no-mutex: runs share without locks; a conflict surfaces as a delta, never prevented) — at pre-turn build, surface what changed in the shared world since this
|
|
1410
969
|
// run last looked. No per-run snapshot (§machine-processes "a run is its log"): every
|
|
@@ -1523,241 +1082,27 @@ class Engine {
|
|
|
1523
1082
|
}
|
|
1524
1083
|
}
|
|
1525
1084
|
async dispatch(context) {
|
|
1526
|
-
|
|
1527
|
-
const schemeCtx = this.#buildSchemeCtx({ sessionId, runId, loopId, turnId, origin });
|
|
1528
|
-
let result;
|
|
1529
|
-
let denial = this.#checkWritable(statement, origin);
|
|
1530
|
-
if (denial === null)
|
|
1531
|
-
denial = await this.#checkFlagsGate(statement, loopId);
|
|
1532
|
-
if (denial !== null) {
|
|
1533
|
-
result = denial;
|
|
1534
|
-
}
|
|
1535
|
-
else if (_a.#readFansOut(statement)) {
|
|
1536
|
-
// READ honors FIND: a glob/folder scope or a matcher fans out to one log row per MATCH
|
|
1537
|
-
// (its own writeLogs), returning early. A READ never proposes, so it bypasses the
|
|
1538
|
-
// single-row path below. A bare entry, body-less, falls through to the direct read. #286
|
|
1539
|
-
return await this.#handleReadFanout(statement, schemeCtx, { runId, loopId, turnId, sequence, origin, onDispatch });
|
|
1540
|
-
}
|
|
1541
|
-
else {
|
|
1542
|
-
// SPEC §scheme-surface + plurnk-schemes#1: action-entry-as-outcome. Scheme-handler
|
|
1543
|
-
// exceptions become the action-entry's outcome (status 500), not a
|
|
1544
|
-
// thrown bubble. The log_entry is the durable record; engine never
|
|
1545
|
-
// skips it. Logging failures (#writeLog throws) are NOT caught —
|
|
1546
|
-
// those are system failures.
|
|
1547
|
-
try {
|
|
1548
|
-
if (statement.op === "SEND" && statement.target === null) {
|
|
1549
|
-
result = await this.#handleSendBroadcast(statement, loopId, prematureRefusal === true);
|
|
1550
|
-
}
|
|
1551
|
-
else if (statement.op === "COPY") {
|
|
1552
|
-
result = await this.#handleCopy(statement, schemeCtx);
|
|
1553
|
-
}
|
|
1554
|
-
else if (statement.op === "MOVE") {
|
|
1555
|
-
result = await this.#handleMove(statement, schemeCtx);
|
|
1556
|
-
}
|
|
1557
|
-
else if (statement.op === "KILL") {
|
|
1558
|
-
result = await this.#handleKill(statement, schemeCtx);
|
|
1559
|
-
}
|
|
1560
|
-
else if (statement.op === "PLAN") {
|
|
1561
|
-
result = this.#handlePlan(statement);
|
|
1562
|
-
}
|
|
1563
|
-
else if (statement.op === "EXEC") {
|
|
1564
|
-
// EXEC's target slot is `cwd`, not a scheme address.
|
|
1565
|
-
// Per plurnk.md the op routes unconditionally to the
|
|
1566
|
-
// exec scheme; the scheme handler reads runtime
|
|
1567
|
-
// (signal), cwd (target), and command (body).
|
|
1568
|
-
result = await this.#run("exec", statement, schemeCtx);
|
|
1569
|
-
}
|
|
1570
|
-
else {
|
|
1571
|
-
result = await this.#run(this.#schemeNameOf(statement.target), statement, schemeCtx); // §op-methods-op-dispatch
|
|
1572
|
-
}
|
|
1573
|
-
}
|
|
1574
|
-
catch (err) { // a scheme exception becomes the op's 500 outcome — §scheme-surface-exception-500
|
|
1575
|
-
result = {
|
|
1576
|
-
status: 500,
|
|
1577
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1578
|
-
};
|
|
1579
|
-
}
|
|
1580
|
-
}
|
|
1581
|
-
const logEntryId = await this.#writeLog({ statement, result, runId, loopId, turnId, sequence, origin });
|
|
1582
|
-
onDispatch?.(logEntryId);
|
|
1583
|
-
// Proposal lifecycle (SPEC.md §engine-rails + §methods loop.resolve; §proposal-202-pauses). When a
|
|
1584
|
-
// side-effecting op returns status 202 (a broadcast SEND[202] park is model
|
|
1585
|
-
// speech, not a proposal — #isProposal, #255), the entry is written
|
|
1586
|
-
// state='proposed'; dispatch then PAUSES on a per-entry waiter until
|
|
1587
|
-
// resolution arrives via Engine.resolveProposal (from the loop/resolve RPC,
|
|
1588
|
-
// YOLO listener, or timeout). The post-resolution status replaces 202 in the
|
|
1589
|
-
// result the caller sees, so runTurn never branches on a pending state.
|
|
1590
|
-
if (_a.#isProposal(statement, result)) {
|
|
1591
|
-
// Effect-gated auto-run (read/pure runtimes, plurnk-service#182):
|
|
1592
|
-
// no human gate, no loop/proposal notification. Accept + apply
|
|
1593
|
-
// in-process; the model sees the outcome directly, never a review.
|
|
1594
|
-
if (result.attrs?.inline === true) {
|
|
1595
|
-
const effective = await this.#runApplyResolution(statement, result, { decision: "accept" }, { sessionId, runId, loopId, turnId });
|
|
1596
|
-
return this.#applyResolution(logEntryId, effective);
|
|
1597
|
-
}
|
|
1598
|
-
// Register the resolution waiter SYNCHRONOUSLY before any await
|
|
1599
|
-
// yields. A same-tick resolveProposal() (e.g. from a test that
|
|
1600
|
-
// awaits the onDispatch callback and immediately resolves) must
|
|
1601
|
-
// find the waiter registered — adding an await between insert
|
|
1602
|
-
// and waiter-registration would open a race window.
|
|
1603
|
-
const resolutionPromise = this.#awaitResolution(logEntryId);
|
|
1604
|
-
// Notify external listeners (Daemon broadcasts loop/proposal;
|
|
1605
|
-
// YOLO listener auto-resolves) BEFORE awaiting — they may
|
|
1606
|
-
// resolve synchronously inside their handlers.
|
|
1607
|
-
const target = this.#extractTarget(statement.target);
|
|
1608
|
-
const flags = await this.#loadLoopFlags(loopId); // the loop/proposal notification carries flags (yolo) — §dual-yolo-proposal-carries-flags
|
|
1609
|
-
// #note10 — if the target diverged on disk this turn, the model's EDIT is based
|
|
1610
|
-
// on a stale read; flag it so a YOLO auto-accept rejects instead of clobbering.
|
|
1611
|
-
const diverged = await this.#db.engine_target_diverged_this_turn.get({ run_id: runId, turn_id: turnId, scheme: target.scheme, pathname: target.pathname });
|
|
1612
|
-
const event = {
|
|
1613
|
-
logEntryId, sessionId, runId, loopId, turnId,
|
|
1614
|
-
op: statement.op,
|
|
1615
|
-
target: { scheme: target.scheme, pathname: target.pathname },
|
|
1616
|
-
body: typeof result.body === "string" ? result.body : "",
|
|
1617
|
-
attrs: (result.attrs ?? {}),
|
|
1618
|
-
flags,
|
|
1619
|
-
staleClobberRisk: diverged !== undefined,
|
|
1620
|
-
};
|
|
1621
|
-
for (const listener of this.#proposalPendingListeners) {
|
|
1622
|
-
try {
|
|
1623
|
-
listener(event);
|
|
1624
|
-
}
|
|
1625
|
-
catch (_) { /* listener errors don't break dispatch */ }
|
|
1626
|
-
}
|
|
1627
|
-
const resolution = await resolutionPromise;
|
|
1628
|
-
// Run the scheme's applyResolution hook on accept (writes the
|
|
1629
|
-
// file, spawns the process, etc.). If applyResolution returns a
|
|
1630
|
-
// 4xx/5xx or throws, the resolution is downgraded to a reject
|
|
1631
|
-
// with the failure outcome — engine treats it like a client
|
|
1632
|
-
// rejection.
|
|
1633
|
-
const effective = await this.#runApplyResolution(statement, result, resolution, { sessionId, runId, loopId, turnId });
|
|
1634
|
-
// MOVE into a proposed dest: the deferred source-delete fires ONLY now,
|
|
1635
|
-
// after the dest write landed (accept). On reject the source survives.
|
|
1636
|
-
if (effective.decision === "accept") {
|
|
1637
|
-
const moveSource = result.attrs?.moveSource;
|
|
1638
|
-
if (moveSource !== undefined) {
|
|
1639
|
-
const srcHandler = this.#schemes.get(moveSource.scheme);
|
|
1640
|
-
if (srcHandler !== undefined && typeof srcHandler.deleteEntry === "function")
|
|
1641
|
-
await srcHandler.deleteEntry(moveSource.pathname, schemeCtx);
|
|
1642
|
-
}
|
|
1643
|
-
}
|
|
1644
|
-
const post = await this.#applyResolution(logEntryId, effective);
|
|
1645
|
-
return post;
|
|
1646
|
-
}
|
|
1647
|
-
return result;
|
|
1085
|
+
return this.#dispatcher.dispatch(context);
|
|
1648
1086
|
}
|
|
1649
1087
|
// op.look (#283) — resolve a READ and return its content WITHOUT writing a
|
|
1650
|
-
// log_entries row: the client's off-run inspection primitive
|
|
1651
|
-
// invisible to the model). READ never mutates and never proposes, so this is
|
|
1652
|
-
// dispatch's resolve path minus #writeLog. Runs on the client loop, so the
|
|
1653
|
-
// human's inspection is never constrained by a model loop's flags. {§op-look}
|
|
1088
|
+
// log_entries row: the client's off-run inspection primitive. {§op-look}
|
|
1654
1089
|
async look(context) {
|
|
1655
|
-
|
|
1656
|
-
if (statement.op !== "READ")
|
|
1657
|
-
throw new Error(`look resolves READ only; got ${statement.op}`);
|
|
1658
|
-
// turnId is a write-time FK only — a look writes no row, so 0 (no turn) is inert.
|
|
1659
|
-
const schemeCtx = this.#buildSchemeCtx({ sessionId, runId, loopId, turnId: 0, origin });
|
|
1660
|
-
const denial = await this.#checkFlagsGate(statement, loopId);
|
|
1661
|
-
if (denial !== null)
|
|
1662
|
-
return denial;
|
|
1663
|
-
return this.#run(this.#schemeNameOf(statement.target), statement, schemeCtx);
|
|
1664
|
-
}
|
|
1665
|
-
#buildSchemeCtx(ids) {
|
|
1666
|
-
const { sessionId, runId, loopId, turnId, origin } = ids;
|
|
1667
|
-
return {
|
|
1668
|
-
db: this.#db,
|
|
1669
|
-
sessionId, runId, loopId, turnId,
|
|
1670
|
-
writer: origin,
|
|
1671
|
-
signal: this.#loopAborts.get(loopId)?.signal,
|
|
1672
|
-
streamEventNotify: this.#streamEventNotify,
|
|
1673
|
-
wakeRunNotify: this.#wakeRunNotify,
|
|
1674
|
-
injectRun: this.#injectRun,
|
|
1675
|
-
mimetypes: this.#mimetypes,
|
|
1676
|
-
tokenize: this.#tokenize,
|
|
1677
|
-
pushTelemetry: (event) => this.#pushTelemetry(sessionId, loopId, event),
|
|
1678
|
-
executors: this.#executors,
|
|
1679
|
-
};
|
|
1680
|
-
}
|
|
1681
|
-
// On accept, run the scheme's applyResolution — File writes disk, Exec spawns. §proposal-accept-applies
|
|
1682
|
-
async #runApplyResolution(statement, originalResult, resolution, ids) {
|
|
1683
|
-
const { sessionId, runId, loopId, turnId } = ids;
|
|
1684
|
-
if (resolution.decision !== "accept")
|
|
1685
|
-
return resolution;
|
|
1686
|
-
// EXEC routes to the exec scheme regardless of target (cwd, not
|
|
1687
|
-
// a scheme address). All other ops resolve their handler from
|
|
1688
|
-
// statement.target's scheme.
|
|
1689
|
-
// COPY/MOVE write the DEST (statement.body), not the source (target): the
|
|
1690
|
-
// accept must reach the dest scheme's applyResolution (File writes disk).
|
|
1691
|
-
const schemeName = statement.op === "EXEC" ? "exec"
|
|
1692
|
-
: (statement.op === "COPY" || statement.op === "MOVE") ? this.#schemeNameOf(statement.body)
|
|
1693
|
-
: this.#schemeNameOf(statement.target);
|
|
1694
|
-
if (schemeName === null)
|
|
1695
|
-
return resolution;
|
|
1696
|
-
const handler = this.#schemes.get(schemeName);
|
|
1697
|
-
if (handler === undefined || typeof handler.applyResolution !== "function")
|
|
1698
|
-
return resolution;
|
|
1699
|
-
try {
|
|
1700
|
-
// Build a ctx for the scheme's applyResolution. The proposal
|
|
1701
|
-
// was raised inside a specific (session, run, loop, turn);
|
|
1702
|
-
// the scheme uses ctx to write the entry that makes the
|
|
1703
|
-
// operation's artifact visible in the next packet's index.
|
|
1704
|
-
const applyCtx = {
|
|
1705
|
-
db: this.#db, sessionId, runId, loopId, turnId,
|
|
1706
|
-
writer: "model", signal: this.#loopAborts.get(loopId)?.signal,
|
|
1707
|
-
streamEventNotify: this.#streamEventNotify,
|
|
1708
|
-
wakeRunNotify: this.#wakeRunNotify,
|
|
1709
|
-
tokenize: this.#tokenize,
|
|
1710
|
-
pushTelemetry: (event) => this.#pushTelemetry(sessionId, loopId, event),
|
|
1711
|
-
executors: this.#executors,
|
|
1712
|
-
};
|
|
1713
|
-
const applyResult = await handler.applyResolution({
|
|
1714
|
-
attrs: (originalResult.attrs ?? {}),
|
|
1715
|
-
body: resolution.body,
|
|
1716
|
-
}, applyCtx);
|
|
1717
|
-
if (applyResult.status >= 400) {
|
|
1718
|
-
return {
|
|
1719
|
-
decision: "reject",
|
|
1720
|
-
outcome: applyResult.outcome ?? "apply_failed",
|
|
1721
|
-
body: applyResult.body,
|
|
1722
|
-
};
|
|
1723
|
-
}
|
|
1724
|
-
// Propagate applyResolution.outcome onto the accepted resolution
|
|
1725
|
-
// (operational metadata, e.g. exec's "exit_N") AND its body — an
|
|
1726
|
-
// inline (read/pure) run returns its output as the body, which has
|
|
1727
|
-
// to reach the model-facing result this turn, not just stream to
|
|
1728
|
-
// the entry. Host accepts carry no body (fire-and-forget).
|
|
1729
|
-
const withOutcome = applyResult.outcome !== undefined && resolution.outcome === undefined
|
|
1730
|
-
? { ...resolution, outcome: applyResult.outcome }
|
|
1731
|
-
: resolution;
|
|
1732
|
-
return applyResult.body === undefined ? withOutcome : { ...withOutcome, body: applyResult.body };
|
|
1733
|
-
}
|
|
1734
|
-
catch (err) {
|
|
1735
|
-
return {
|
|
1736
|
-
decision: "reject",
|
|
1737
|
-
outcome: "apply_threw",
|
|
1738
|
-
body: err instanceof Error ? err.message : String(err),
|
|
1739
|
-
};
|
|
1740
|
-
}
|
|
1090
|
+
return this.#dispatcher.look(context);
|
|
1741
1091
|
}
|
|
1742
|
-
//
|
|
1743
|
-
//
|
|
1744
|
-
// the in-tree YOLO listener (Phase E.3), or the timeout watcher. Throws
|
|
1745
|
-
// when the logEntryId has no pending waiter — duplicate resolutions, IDs
|
|
1746
|
-
// for non-proposed entries, or entries already-resolved are caller
|
|
1747
|
-
// errors.
|
|
1092
|
+
// External API to feed a resolution into a pending proposal — the loop/resolve
|
|
1093
|
+
// RPC handler, the in-tree YOLO listener, or the timeout watcher.
|
|
1748
1094
|
resolveProposal(logEntryId, resolution) {
|
|
1749
|
-
|
|
1750
|
-
if (waiter === undefined) {
|
|
1751
|
-
throw new Error(`Engine.resolveProposal: no pending proposal for log_entry ${logEntryId}`);
|
|
1752
|
-
}
|
|
1753
|
-
clearTimeout(waiter.timeoutHandle);
|
|
1754
|
-
this.#pendingProposals.delete(logEntryId);
|
|
1755
|
-
waiter.resolve(resolution);
|
|
1095
|
+
this.#proposals.resolve(logEntryId, resolution);
|
|
1756
1096
|
}
|
|
1757
|
-
// Snapshot of pending proposals (for diagnostic / RPC listings).
|
|
1758
|
-
// the log entry IDs currently awaiting resolution.
|
|
1097
|
+
// Snapshot of pending proposals (for diagnostic / RPC listings).
|
|
1759
1098
|
pendingProposalIds() {
|
|
1760
|
-
return
|
|
1099
|
+
return this.#proposals.pendingIds();
|
|
1100
|
+
}
|
|
1101
|
+
// Subscribe to proposal-pending events. Daemon registers a listener
|
|
1102
|
+
// that broadcasts the loop/proposal WS notification; YOLO listener
|
|
1103
|
+
// registers one that auto-resolves.
|
|
1104
|
+
onProposalPending(listener) {
|
|
1105
|
+
this.#proposals.onPending(listener);
|
|
1761
1106
|
}
|
|
1762
1107
|
// Used by wake-on-completion (daemon side): "is there any loop in this
|
|
1763
1108
|
// run still accepting turns?" If yes, skip the wake — the active loop
|
|
@@ -1784,7 +1129,7 @@ class Engine {
|
|
|
1784
1129
|
tokenize: this.#tokenize,
|
|
1785
1130
|
mimetypes: this.#mimetypes,
|
|
1786
1131
|
defaultChannelFor: (s) => this.#schemes.defaultChannelFor(s),
|
|
1787
|
-
pushTelemetry: (event) => this.#
|
|
1132
|
+
pushTelemetry: (event) => this.#telemetry.notify(sessionId, 0, event),
|
|
1788
1133
|
};
|
|
1789
1134
|
await EntryManifest.maintainDerivations(ctx);
|
|
1790
1135
|
}
|
|
@@ -1810,7 +1155,7 @@ class Engine {
|
|
|
1810
1155
|
const sessionRow = await this.#db.drain_get_run_session.get({ run_id: runId });
|
|
1811
1156
|
if (sessionRow === undefined)
|
|
1812
1157
|
throw new Error(`Engine.inject: run ${runId} not found`);
|
|
1813
|
-
const pathname = `/prompt/${loopId}/${turnSeq}`; // canonical storage form (leading slash), matching the foist
|
|
1158
|
+
const pathname = `/prompt/${loopId}/${turnSeq}`; // canonical storage form (leading slash), matching the turn-1 foist
|
|
1814
1159
|
const ctx = {
|
|
1815
1160
|
db: this.#db, sessionId: sessionRow.session_id, runId, loopId,
|
|
1816
1161
|
turnId: 0, // no turn open at inject time; entries don't pin turnId
|
|
@@ -1819,7 +1164,7 @@ class Engine {
|
|
|
1819
1164
|
streamEventNotify: this.#streamEventNotify,
|
|
1820
1165
|
wakeRunNotify: this.#wakeRunNotify,
|
|
1821
1166
|
tokenize: this.#tokenize,
|
|
1822
|
-
pushTelemetry: (event) => this.#
|
|
1167
|
+
pushTelemetry: (event) => this.#telemetry.push(sessionRow.session_id, loopId, event),
|
|
1823
1168
|
};
|
|
1824
1169
|
const entry = {
|
|
1825
1170
|
channels: { body: { content: prompt, mimetype: "text/markdown" } },
|
|
@@ -1828,503 +1173,26 @@ class Engine {
|
|
|
1828
1173
|
await EntryCrud.writeEntry(pathname, entry, ctx, "plurnk");
|
|
1829
1174
|
return { loopId, turnSeq };
|
|
1830
1175
|
}
|
|
1831
|
-
//
|
|
1832
|
-
//
|
|
1833
|
-
// (
|
|
1834
|
-
//
|
|
1835
|
-
//
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
return DEFAULT_LOOP_FLAGS;
|
|
1847
|
-
try {
|
|
1848
|
-
const parsed = JSON.parse(row.flags);
|
|
1849
|
-
return { ...DEFAULT_LOOP_FLAGS, ...parsed };
|
|
1850
|
-
}
|
|
1851
|
-
catch {
|
|
1852
|
-
return DEFAULT_LOOP_FLAGS;
|
|
1853
|
-
}
|
|
1854
|
-
}
|
|
1855
|
-
#awaitResolution(logEntryId) {
|
|
1856
|
-
const timeoutMs = readProposalTimeoutMs();
|
|
1857
|
-
return new Promise((resolve) => {
|
|
1858
|
-
const timeoutHandle = setTimeout(() => {
|
|
1859
|
-
// Timeout: synthesize a cancel resolution and feed it back
|
|
1860
|
-
// through the same path as any other resolution. State
|
|
1861
|
-
// transitions to cancelled with outcome='timeout'.
|
|
1862
|
-
if (this.#pendingProposals.has(logEntryId)) {
|
|
1863
|
-
this.#pendingProposals.delete(logEntryId);
|
|
1864
|
-
resolve({ decision: "cancel", outcome: "timeout" }); // §proposal-timeout-cancels
|
|
1865
|
-
}
|
|
1866
|
-
}, timeoutMs);
|
|
1867
|
-
this.#pendingProposals.set(logEntryId, { resolve, timeoutHandle });
|
|
1868
|
-
});
|
|
1869
|
-
}
|
|
1870
|
-
async #applyResolution(logEntryId, resolution) {
|
|
1871
|
-
// Map decision → terminal state + HTTP-aligned status:
|
|
1872
|
-
// accept → state='resolved', status=200
|
|
1873
|
-
// reject → state='failed', status=400, outcome='rejected' (default) §proposal-reject-fails
|
|
1874
|
-
// cancel → state='cancelled',status=499, outcome='loop_aborted' (default) §proposal-cancel-aborts
|
|
1875
|
-
// resolution.outcome wins over the default when supplied; this is how
|
|
1876
|
-
// veto filters (Phase E.2 proposal.accepting) can specify a more
|
|
1877
|
-
// precise outcome string like 'policy_veto' or 'timeout'.
|
|
1878
|
-
const decision = resolution.decision;
|
|
1879
|
-
const state = decision === "accept" ? "resolved"
|
|
1880
|
-
: decision === "reject" ? "failed"
|
|
1881
|
-
: "cancelled";
|
|
1882
|
-
const status = decision === "accept" ? 200
|
|
1883
|
-
: decision === "reject" ? 400
|
|
1884
|
-
: 499;
|
|
1885
|
-
const defaultOutcome = decision === "accept" ? null
|
|
1886
|
-
: decision === "reject" ? "rejected"
|
|
1887
|
-
: "loop_aborted";
|
|
1888
|
-
const outcome = resolution.outcome ?? defaultOutcome;
|
|
1889
|
-
// rx is the model-facing operation result. Status always; outcome is
|
|
1890
|
-
// operational (stays on log_entries for forensics, never model-facing).
|
|
1891
|
-
// Body is normally dropped — the propose preview was an input echo —
|
|
1892
|
-
// EXCEPT an inline auto-run (read/pure) carries its run output AS the
|
|
1893
|
-
// body, which is exactly the "what happened" the model needs this turn.
|
|
1894
|
-
// Per AGENTS.md "Operational hygiene on what the model sees."
|
|
1895
|
-
const rx = (decision === "accept" && resolution.body !== undefined)
|
|
1896
|
-
? JSON.stringify({ status, body: resolution.body })
|
|
1897
|
-
: JSON.stringify({ status });
|
|
1898
|
-
await this.#db.engine_resolve_log_entry.run({
|
|
1899
|
-
id: logEntryId, state, outcome, status_rx: status, rx,
|
|
1900
|
-
});
|
|
1901
|
-
return { status, outcome, body: resolution.body };
|
|
1902
|
-
}
|
|
1903
|
-
// SPEC §scheme-surface: engine rejects writes whose origin is outside the target
|
|
1904
|
-
// scheme's manifest.writableBy.
|
|
1905
|
-
// - Read-side ops (READ, FIND, OPEN, FOLD) are not gated.
|
|
1906
|
-
// - SEND broadcast (path=null) has no target scheme; not gated.
|
|
1907
|
-
// - COPY: dst scheme writableBy applies.
|
|
1908
|
-
// - MOVE: both src (delete) and dst (write) schemes' writableBy apply.
|
|
1909
|
-
// - Schemes without a manifest are not gated (legacy / future allowance).
|
|
1910
|
-
#checkWritable(statement, origin) {
|
|
1911
|
-
if (!MUTATING_OPS.has(statement.op))
|
|
1912
|
-
return null;
|
|
1913
|
-
if (statement.op === "SEND" && statement.target === null)
|
|
1914
|
-
return null;
|
|
1915
|
-
// EXEC's target slot is `cwd`, not a scheme address. The op's
|
|
1916
|
-
// authority always belongs to the exec scheme regardless of cwd.
|
|
1917
|
-
if (statement.op === "EXEC") {
|
|
1918
|
-
return this.#denyIfDisallowed("exec", origin);
|
|
1919
|
-
}
|
|
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))
|
|
1924
|
-
return this.#denyIfDisallowed("run", origin);
|
|
1925
|
-
if (statement.op === "COPY" || statement.op === "MOVE") {
|
|
1926
|
-
const dst = statement.op === "COPY" ? (statement.body === null ? null : parsePath(statement.body)) : statement.body;
|
|
1927
|
-
const dstScheme = this.#schemeNameOf(dst);
|
|
1928
|
-
const dstDenial = this.#denyIfDisallowed(dstScheme, origin);
|
|
1929
|
-
if (dstDenial !== null)
|
|
1930
|
-
return dstDenial;
|
|
1931
|
-
if (statement.op === "MOVE") {
|
|
1932
|
-
const srcScheme = this.#schemeNameOf(statement.target);
|
|
1933
|
-
if (srcScheme !== dstScheme) {
|
|
1934
|
-
const srcDenial = this.#denyIfDisallowed(srcScheme, origin);
|
|
1935
|
-
if (srcDenial !== null)
|
|
1936
|
-
return srcDenial;
|
|
1937
|
-
}
|
|
1938
|
-
}
|
|
1939
|
-
return null;
|
|
1940
|
-
}
|
|
1941
|
-
const target = this.#schemeNameOf(statement.target);
|
|
1942
|
-
return this.#denyIfDisallowed(target, origin);
|
|
1943
|
-
}
|
|
1944
|
-
#denyIfDisallowed(schemeName, origin) {
|
|
1945
|
-
if (schemeName === null)
|
|
1946
|
-
return null;
|
|
1947
|
-
const handler = this.#schemes.get(schemeName);
|
|
1948
|
-
if (handler === undefined)
|
|
1949
|
-
return null;
|
|
1950
|
-
const manifest = handler.constructor.manifest;
|
|
1951
|
-
if (manifest === undefined)
|
|
1952
|
-
return null;
|
|
1953
|
-
if (manifest.writableBy.includes(origin))
|
|
1954
|
-
return null;
|
|
1955
|
-
return { status: 403, error: `writer '${origin}' is not in writableBy for scheme '${schemeName}'` }; // §scheme-surface-writableby-403
|
|
1956
|
-
}
|
|
1957
|
-
// Per-loop flag gating. Schemes self-declare their flag affinity in
|
|
1958
|
-
// their manifest (excludedInAsk / requiresWeb /
|
|
1959
|
-
// requiresInteraction); SchemeRegistry.resolveForLoop returns the
|
|
1960
|
-
// active set under the loop's persisted flags. Anything outside the
|
|
1961
|
-
// set returns 403 — action-entry-as-outcome carries the rejection.
|
|
1962
|
-
async #checkFlagsGate(statement, loopId) {
|
|
1963
|
-
// Broadcast SEND has no scheme to gate.
|
|
1964
|
-
if (statement.op === "SEND" && statement.target === null)
|
|
1965
|
-
return null;
|
|
1966
|
-
const flags = await this.#loadLoopFlags(loopId);
|
|
1967
|
-
// Fast path: default flags gate nothing. (yolo never gates.)
|
|
1968
|
-
if (!flags.noWeb && !flags.noInteraction && flags.mode === "act")
|
|
1969
|
-
return null;
|
|
1970
|
-
const active = this.#schemes.resolveForLoop(flags);
|
|
1971
|
-
const check = (target) => {
|
|
1972
|
-
const scheme = this.#schemeNameOf(target);
|
|
1973
|
-
if (scheme === null)
|
|
1974
|
-
return null;
|
|
1975
|
-
if (active.has(scheme))
|
|
1976
|
-
return null;
|
|
1977
|
-
return { status: 403, error: `scheme '${scheme}' is inactive under current loop flags` };
|
|
1978
|
-
};
|
|
1979
|
-
if (this.#isRunCopy(statement))
|
|
1980
|
-
return check(statement.target); // body is a spawn/fork prompt, not a dst path
|
|
1981
|
-
if (statement.op === "COPY" || statement.op === "MOVE") {
|
|
1982
|
-
return check(statement.target) ?? check(statement.op === "COPY" ? (statement.body === null ? null : parsePath(statement.body)) : statement.body);
|
|
1983
|
-
}
|
|
1984
|
-
return check(statement.target);
|
|
1985
|
-
}
|
|
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) {
|
|
1990
|
-
return statement.op === "COPY" && this.#schemeNameOf(statement.target) === "run";
|
|
1991
|
-
}
|
|
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) {
|
|
2000
|
-
const target = statement.target;
|
|
2001
|
-
if (target === null)
|
|
2002
|
-
return { status: 400, error: "run:// control requires a run target" };
|
|
2003
|
-
const name = target.kind === "url" ? (target.hostname ?? "") : ""; // §run-scheme — run is the AUTHORITY (run://<name>), not the path
|
|
2004
|
-
if (name === "")
|
|
2005
|
-
return { status: 400, error: "run:// control requires a run name or 'self' (run://<name>)" };
|
|
2006
|
-
if (ctx.injectRun === undefined)
|
|
2007
|
-
throw new Error("run copy: injectRun capability absent");
|
|
2008
|
-
const denied = await RunCap.deny(this.#db, ctx.sessionId);
|
|
2009
|
-
if (denied !== null)
|
|
2010
|
-
return denied;
|
|
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 };
|
|
2032
|
-
}
|
|
2033
|
-
async #handleCopy(statement, ctx) {
|
|
2034
|
-
if (statement.op !== "COPY")
|
|
2035
|
-
throw new Error("unreachable");
|
|
2036
|
-
if (this.#isRunCopy(statement))
|
|
2037
|
-
return await this.#handleRunCopy(statement, ctx);
|
|
2038
|
-
const srcPath = statement.target;
|
|
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.
|
|
2041
|
-
const dstPath = statement.body === null ? null : parsePath(statement.body);
|
|
2042
|
-
if (srcPath === null)
|
|
2043
|
-
return { status: 400, error: "COPY requires source path" };
|
|
2044
|
-
if (dstPath === null)
|
|
2045
|
-
return { status: 400, error: "COPY destination must be a parseable path in the body slot" };
|
|
2046
|
-
return await this.#copyOrchestration({ statement, srcPath, dstPath, ctx });
|
|
2047
|
-
}
|
|
2048
|
-
async #handleMove(statement, ctx) {
|
|
2049
|
-
if (statement.op !== "MOVE")
|
|
2050
|
-
throw new Error("unreachable");
|
|
2051
|
-
const srcPath = statement.target;
|
|
2052
|
-
const dstPath = statement.body;
|
|
2053
|
-
if (srcPath === null)
|
|
2054
|
-
return { status: 400, error: "MOVE requires source path" };
|
|
2055
|
-
// MOVE is relocation only — deletion is KILL's job (§move, §move-dev-null-not-special). The /dev/null
|
|
2056
|
-
// and null-body delete-by-MOVE back-compat is retired: no silent debt.
|
|
2057
|
-
if (dstPath === null)
|
|
2058
|
-
return { status: 400, error: "MOVE requires a destination; use KILL to delete" }; // §move-null-body-400
|
|
2059
|
-
const srcSchemeName = this.#schemeNameOf(srcPath);
|
|
2060
|
-
if (srcSchemeName === null)
|
|
2061
|
-
return { status: 400, error: "MOVE source must be a URL path with a scheme" };
|
|
2062
|
-
const srcHandler = this.#schemes.get(srcSchemeName);
|
|
2063
|
-
if (srcHandler === undefined || typeof srcHandler.deleteEntry !== "function")
|
|
2064
|
-
return { status: 501 };
|
|
2065
|
-
// Relocation: COPY then DELETE source (§move-relocation-deletes-source).
|
|
2066
|
-
const copyResult = await this.#copyOrchestration({ statement, srcPath, dstPath, ctx });
|
|
2067
|
-
if (copyResult.status >= 400)
|
|
2068
|
-
return copyResult;
|
|
2069
|
-
const srcPathname = pathnameFromPath(srcPath);
|
|
2070
|
-
// If the dest write is a pending proposal (file dest → §membership review), the
|
|
2071
|
-
// source-delete MUST wait until the dest actually lands — a rejected
|
|
2072
|
-
// proposal would otherwise lose the source. Thread it into the resolution:
|
|
2073
|
-
// dispatch deletes the source AFTER the dest applies on accept.
|
|
2074
|
-
if (copyResult.status === 202) {
|
|
2075
|
-
return { ...copyResult, attrs: { ...copyResult.attrs, moveSource: { scheme: srcSchemeName, pathname: srcPathname } } };
|
|
2076
|
-
}
|
|
2077
|
-
const delResult = await srcHandler.deleteEntry(srcPathname, ctx);
|
|
2078
|
-
if (delResult.status >= 400)
|
|
2079
|
-
return { status: delResult.status };
|
|
2080
|
-
return copyResult;
|
|
2081
|
-
}
|
|
2082
|
-
// KILL — scheme-polymorphic destroy (plurnk-grammar#203 / 0.28.0). Entry-KILL
|
|
2083
|
-
// permanently deletes the entry: the canonical delete now, MOVE→/dev/null
|
|
2084
|
-
// retired from the model's vocabulary. Process-KILL (exec:///) aborts the
|
|
2085
|
-
// running spawn's controller (the same teardown loop.cancel rides), addressed
|
|
2086
|
-
// by coordinate pathname (#203). The KILL body is an opaque
|
|
2087
|
-
// annotation with no runtime meaning; it survives into the log row's tx for
|
|
2088
|
-
// free via the statement serialization. Status: 200 killed · 404 unknown ·
|
|
2089
|
-
// 405 log:/// (append-only) · 403 writableBy (the #checkWritable gate, KILL ∈
|
|
2090
|
-
// MUTATING_OPS) · 200/410/304/404 exec (killed / killed-earlier / exited / unknown) · 501 no-kill/delete scheme.
|
|
2091
|
-
async #handleKill(statement, ctx) {
|
|
2092
|
-
if (statement.op !== "KILL")
|
|
2093
|
-
throw new Error("unreachable");
|
|
2094
|
-
const path = statement.target;
|
|
2095
|
-
if (path === null)
|
|
2096
|
-
return { status: 400, error: "KILL requires a target path" };
|
|
2097
|
-
const schemeName = this.#schemeNameOf(path);
|
|
2098
|
-
if (schemeName === null)
|
|
2099
|
-
return { status: 400, error: "KILL target must be a URL path with a scheme" };
|
|
2100
|
-
// KILL on log:/// erases the log row(s) — the model's DB-storage curation lever
|
|
2101
|
-
// (plurnk.md:36, :98), routed to Log.kill below via the killable.kill path. The old
|
|
2102
|
-
// "append-only" 405 forbade what the grammar requires; FOLD only collapses the render.
|
|
2103
|
-
// Process-KILL: any scheme whose handler exposes kill() aborts a live stream — the
|
|
2104
|
-
// exec handler, registered as "exec" + under every runtime tag (sh/node), so a tag-
|
|
2105
|
-
// addressed stream (sh:///l/t/s) routes here, not to deleteEntry. §exec
|
|
2106
|
-
const killable = this.#schemes.get(schemeName);
|
|
2107
|
-
if (killable !== undefined && typeof killable.kill === "function") {
|
|
2108
|
-
return await killable.kill(pathnameFromPath(path), statement.signal, ctx);
|
|
2109
|
-
}
|
|
2110
|
-
if (schemeName === "run") {
|
|
2111
|
-
// Entry-path present → KILL a run-scope scratch ENTRY (delete it), self-only —
|
|
2112
|
-
// NOT run cancellation. The authority (hostname) names the owner, the pathname the
|
|
2113
|
-
// entry; only the path-ABSENT form (run://<name>) terminates the run-as-actor. §run-scheme
|
|
2114
|
-
const entryPath = path.kind === "url" ? (path.pathname ?? "") : "";
|
|
2115
|
-
if (entryPath !== "" && entryPath !== "/") {
|
|
2116
|
-
const runHandler = this.#schemes.get("run");
|
|
2117
|
-
return await runHandler.deleteEntry(statement, ctx);
|
|
2118
|
-
}
|
|
2119
|
-
// terminate — abort any run by address; whoever holds it may end it.
|
|
2120
|
-
// `run://self` = self. cancelRun (→ Daemon.cancelDrain) aborts the run's signal
|
|
2121
|
-
// (its loop closes 499); an idle run is a no-op-200, a missing run 404.
|
|
2122
|
-
const name = path.kind === "url" ? (path.hostname ?? "") : ""; // §run-scheme — run is the AUTHORITY
|
|
2123
|
-
if (name === "")
|
|
2124
|
-
return { status: 400, error: "run:// kill requires a run name or 'self' (run://<name>)" };
|
|
2125
|
-
let runId = ctx.runId;
|
|
2126
|
-
if (name !== "self") {
|
|
2127
|
-
const row = await this.#db.run_resolve_by_name.get({ session_id: ctx.sessionId, name });
|
|
2128
|
-
if (row === undefined)
|
|
2129
|
-
return { status: 404, error: `run://${name} not found in this session` };
|
|
2130
|
-
runId = row.id;
|
|
2131
|
-
}
|
|
2132
|
-
if (this.#cancelRun === undefined)
|
|
2133
|
-
throw new Error("run kill: cancelRun capability absent");
|
|
2134
|
-
this.#cancelRun(runId);
|
|
2135
|
-
return { status: 200 };
|
|
2136
|
-
}
|
|
2137
|
-
const handler = this.#schemes.get(schemeName);
|
|
2138
|
-
if (handler === undefined || typeof handler.deleteEntry !== "function")
|
|
2139
|
-
return { status: 501 };
|
|
2140
|
-
const delResult = await handler.deleteEntry(pathnameFromPath(path), ctx);
|
|
2141
|
-
return { status: delResult.status };
|
|
2142
|
-
}
|
|
2143
|
-
// Multi-file READ fan-out (SPEC §matcher-result — "the companion to FIND's survey"). A glob
|
|
2144
|
-
// READ target resolves to MANY files; READ returns one log row per file that matches, each
|
|
2145
|
-
// holding that file's matching lines. The matched SET is exactly FIND's survey (which files
|
|
2146
|
-
// + where — matchLines), so we reuse the scheme's own find, then READ each matched file. One
|
|
2147
|
-
// model command, N log rows — each row addresses its concrete file, so it folds/kills/re-READs
|
|
2148
|
-
// on its own. The running sequence counter in runTurn advances by rowsWritten.
|
|
2149
|
-
// A READ fans out (honors FIND) when it resolves to more than the single exact entry: a glob
|
|
2150
|
-
// or folder scope, OR a matcher (which selects per-match within whatever the target resolved).
|
|
2151
|
-
// A bare entry, body-less, is the one direct read. #286
|
|
2152
|
-
static #readFansOut(statement) {
|
|
2153
|
-
if (statement.op !== "READ")
|
|
2154
|
-
return false;
|
|
2155
|
-
if ("body" in statement && statement.body !== null)
|
|
2156
|
-
return true; // a matcher → per-match fan-out
|
|
2157
|
-
const t = statement.target;
|
|
2158
|
-
const p = t === null ? "" : (t.kind === "url" ? t.pathname : t.raw);
|
|
2159
|
-
return p.includes("*") || p.endsWith("/"); // glob/folder scope → fan out its contents
|
|
2160
|
-
}
|
|
2161
|
-
// Clone the READ onto one concrete match — the FIND already matched, so the per-match READ
|
|
2162
|
-
// delivers content at the span: strip the body (no re-match) and set <L> to the span. A null
|
|
2163
|
-
// span (body-less folder/glob fan-out) reads the whole entry. #286
|
|
2164
|
-
static #retargetRead(statement, pathname, span) {
|
|
2165
|
-
const t = statement.target;
|
|
2166
|
-
const target = t !== null && t.kind === "url"
|
|
2167
|
-
? { ...t, pathname, raw: `${t.scheme}://${pathname}` }
|
|
2168
|
-
: { ...t, raw: pathname };
|
|
2169
|
-
const lineMarker = span !== null ? { marks: [span.lineStart, span.lineEnd] } : null;
|
|
2170
|
-
return { ...statement, target: target, lineMarker, body: null };
|
|
2171
|
-
}
|
|
2172
|
-
async #handleReadFanout(statement, ctx, ids) {
|
|
2173
|
-
const { runId, loopId, turnId, sequence, origin, onDispatch } = ids;
|
|
2174
|
-
const schemeName = this.#schemeNameOf(statement.target);
|
|
2175
|
-
const found = await this.#run(schemeName, { ...statement, op: "FIND" }, ctx);
|
|
2176
|
-
const matches = found.matches ?? [];
|
|
2177
|
-
// Find-less scheme, a matcher/scope error, or zero matches → a single row carrying the
|
|
2178
|
-
// status, exactly like a non-fanned READ. The model sees the empty/failed result, not silence.
|
|
2179
|
-
if (found.status !== 200 || matches.length === 0) {
|
|
2180
|
-
const result = { status: found.status === 200 ? 204 : found.status };
|
|
2181
|
-
const id = await this.#writeLog({ statement, result, runId, loopId, turnId, sequence, origin });
|
|
2182
|
-
onDispatch?.(id);
|
|
2183
|
-
return { ...result, rowsWritten: 1 };
|
|
2184
|
-
}
|
|
2185
|
-
// One READ row per MATCH — the span's source lines (or the whole entry for a body-less
|
|
2186
|
-
// folder/glob). The match span is SOURCE LINES, so deliver via a raw line-slice — NOT the
|
|
2187
|
-
// scheme's <L> (which is item-index for application/json, structural for xml). Read each
|
|
2188
|
-
// distinct entry's content once, then line-slice per match. #286
|
|
2189
|
-
const wholeByPath = new Map();
|
|
2190
|
-
const fannedStatuses = [];
|
|
2191
|
-
let written = 0;
|
|
2192
|
-
for (const m of matches) {
|
|
2193
|
-
let whole = wholeByPath.get(m.pathname);
|
|
2194
|
-
if (whole === undefined) {
|
|
2195
|
-
whole = await this.#run(schemeName, _a.#retargetRead(statement, m.pathname, null), ctx);
|
|
2196
|
-
wholeByPath.set(m.pathname, whole);
|
|
2197
|
-
}
|
|
2198
|
-
const result = _a.#sliceMatch(whole, m.span);
|
|
2199
|
-
const id = await this.#writeLog({ statement: _a.#retargetRead(statement, m.pathname, m.span), result, runId, loopId, turnId, sequence: sequence + written, origin });
|
|
2200
|
-
onDispatch?.(id);
|
|
2201
|
-
fannedStatuses.push(result.status);
|
|
2202
|
-
written++;
|
|
2203
|
-
}
|
|
2204
|
-
return { status: 200, rowsWritten: written, fannedStatuses };
|
|
2205
|
-
}
|
|
2206
|
-
// Deliver one match: the whole entry (body-less, span null) or the source lines at the span —
|
|
2207
|
-
// a RAW line-slice, so a structural mimetype (json item-index / xml) doesn't mis-slice a span
|
|
2208
|
-
// that is, by construction, source line numbers (#286).
|
|
2209
|
-
static #sliceMatch(whole, span) {
|
|
2210
|
-
if (whole.status !== 200 || span === null)
|
|
2211
|
-
return whole;
|
|
2212
|
-
const sliced = LineMarkerOps.sliceLines(typeof whole.content === "string" ? whole.content : "", { marks: [span.lineStart, span.lineEnd] });
|
|
2213
|
-
if (sliced.status !== 200)
|
|
2214
|
-
return { status: sliced.status, error: sliced.error };
|
|
2215
|
-
return { status: 200, content: sliced.text ?? "", mimetype: "text/markdown", startLine: sliced.startLine ?? span.lineStart };
|
|
2216
|
-
}
|
|
2217
|
-
// §model-entry — mirror a verbatim model emission back as an actionless `model` log row, so
|
|
2218
|
-
// the model can finally SEE its own prior output (and reason through its own syntax errors).
|
|
2219
|
-
// Born FOLDED by default (budget-neutral until OPENed); the turn-0 exemplar passes folded:false
|
|
2220
|
-
// (born open — the one worked example the model orients on, thinning the grammar). text/vnd.plurnk.
|
|
2221
|
-
async #writeModelEntry({ verbatim, runId, loopId, turnId, sequence, folded, origin = "model" }) {
|
|
2222
|
-
const row = await this.#db.engine_insert_log_entry.get({
|
|
2223
|
-
run_id: runId, loop_id: loopId, turn_id: turnId, sequence,
|
|
2224
|
-
origin, source: null, op: "model", suffix: "", signal: null,
|
|
2225
|
-
scheme: null, username: null, password: null, hostname: null, port: null,
|
|
2226
|
-
pathname: null, params: null, fragment: null, lineMarker: null,
|
|
2227
|
-
tx: "", mimetype_tx: "text/vnd.plurnk",
|
|
2228
|
-
rx: JSON.stringify({ content: verbatim, mimetype: "text/vnd.plurnk" }),
|
|
2229
|
-
mimetype_rx: "application/json",
|
|
2230
|
-
status_rx: 200, tokens: this.#tokenize(verbatim), state: "resolved", outcome: null, attrs: "{}",
|
|
2231
|
-
});
|
|
2232
|
-
if (row === undefined)
|
|
2233
|
-
throw new Error("Engine.#writeModelEntry: insert returned no row");
|
|
2234
|
-
if (folded)
|
|
2235
|
-
await this.#db.engine_fold_log_entry.run({ id: row.id });
|
|
2236
|
-
return row.id;
|
|
2237
|
-
}
|
|
2238
|
-
// PLAN — the model's reasoning op (the 11th op). An ordinary op: dispatched like any
|
|
2239
|
-
// other, logged, and broadcast to the client as a log entry — but a pure no-op for
|
|
2240
|
-
// state (PLAN ∉ MUTATING_OPS); its body serializes into the log row's tx, no effect.
|
|
2241
|
-
#handlePlan(statement) {
|
|
2242
|
-
if (statement.op !== "PLAN")
|
|
2243
|
-
throw new Error("unreachable");
|
|
2244
|
-
return { status: 200 };
|
|
2245
|
-
}
|
|
2246
|
-
// Same- and cross-scheme COPY share one orchestrator — §copy-cross-scheme-copy §move-cross-scheme-move
|
|
2247
|
-
async #copyOrchestration({ statement, srcPath, dstPath, ctx }) {
|
|
2248
|
-
const srcSchemeName = this.#schemeNameOf(srcPath);
|
|
2249
|
-
const dstSchemeName = this.#schemeNameOf(dstPath);
|
|
2250
|
-
if (srcSchemeName === null || dstSchemeName === null)
|
|
2251
|
-
return { status: 400, error: "COPY/MOVE require URL paths with schemes" };
|
|
2252
|
-
const srcHandler = this.#schemes.get(srcSchemeName);
|
|
2253
|
-
const dstHandler = this.#schemes.get(dstSchemeName);
|
|
2254
|
-
if (srcHandler === undefined || dstHandler === undefined)
|
|
2255
|
-
return { status: 501 };
|
|
2256
|
-
if (typeof srcHandler.readEntry !== "function" || typeof dstHandler.writeEntry !== "function")
|
|
2257
|
-
return { status: 501 };
|
|
2258
|
-
const srcPathname = pathnameFromPath(srcPath);
|
|
2259
|
-
const dstPathname = pathnameFromPath(dstPath);
|
|
2260
|
-
const srcResult = await srcHandler.readEntry(srcPathname, ctx);
|
|
2261
|
-
if (srcResult.status !== 200 || srcResult.entry === null)
|
|
2262
|
-
return { status: 404, error: `COPY/MOVE source not found: ${srcSchemeName}://${srcPathname}` }; // §copy-missing-source-404 §move-missing-source-404
|
|
2263
|
-
const entry = srcResult.entry;
|
|
2264
|
-
// Destination read — the conflict/no-op verdict is deferred until the
|
|
2265
|
-
// to-be-written content is known (after <L> slice + tag resolution below),
|
|
2266
|
-
// so an identical re-copy resolves to 304 instead of a phantom 409.
|
|
2267
|
-
const dstExisting = typeof dstHandler.readEntry === "function"
|
|
2268
|
-
? await dstHandler.readEntry(dstPathname, ctx)
|
|
2269
|
-
: null;
|
|
2270
|
-
// Mimetype compatibility check against the destination scheme's manifest
|
|
2271
|
-
const dstManifest = dstHandler.constructor.manifest;
|
|
2272
|
-
const dstChannels = dstManifest?.channels ?? {};
|
|
2273
|
-
for (const [channelName, channelData] of Object.entries(entry.channels)) {
|
|
2274
|
-
const expectedMimetype = dstChannels[channelName];
|
|
2275
|
-
if (expectedMimetype !== undefined && expectedMimetype !== channelData.mimetype) {
|
|
2276
|
-
return { status: 415, error: `mimetype mismatch on channel '${channelName}': ${channelData.mimetype} vs ${expectedMimetype}` }; // cross-mimetype COPY/MOVE → 415, never coerce — §channel-mimetype-cross-mimetype-415
|
|
2277
|
-
}
|
|
2278
|
-
}
|
|
2279
|
-
// `<L>` source range slicing per SPEC.md §op-invariants (symmetric with READ
|
|
2280
|
-
// `<L>` — source range, no line-number prefix).
|
|
2281
|
-
// Applied to every channel of the source entry. Binary channels return
|
|
2282
|
-
// 415 since line semantics don't apply.
|
|
2283
|
-
const lineMarker = statement.lineMarker ?? null;
|
|
2284
|
-
let channels = entry.channels;
|
|
2285
|
-
if (lineMarker !== null) {
|
|
2286
|
-
const sliced = {};
|
|
2287
|
-
for (const [channelName, channelData] of Object.entries(entry.channels)) {
|
|
2288
|
-
if (MimetypeBinary.isBinaryMimetype(channelData.mimetype)) {
|
|
2289
|
-
return { status: 415, error: `cannot slice <L> on binary channel '${channelName}' (${channelData.mimetype})` };
|
|
2290
|
-
}
|
|
2291
|
-
const r = LineMarkerOps.sliceLinesRaw(channelData.content ?? "", lineMarker);
|
|
2292
|
-
if (r.status !== 200)
|
|
2293
|
-
return { status: r.status, error: r.error };
|
|
2294
|
-
sliced[channelName] = { ...channelData, content: r.text ?? "" };
|
|
2295
|
-
}
|
|
2296
|
-
channels = sliced;
|
|
2297
|
-
}
|
|
2298
|
-
// Tag resolution: signal = replace (§copy-signal-replaces-source-tags); absent/empty = carry from source (§copy-no-signal-carries-source-tags)
|
|
2299
|
-
const tags = (Array.isArray(statement.signal) && statement.signal.length > 0)
|
|
2300
|
-
? statement.signal
|
|
2301
|
-
: entry.tags;
|
|
2302
|
-
// 304/409 on an existing destination (SPEC §copy): a re-copy that would write
|
|
2303
|
-
// exactly what's already there — same channel contents, same tags — is a no-op
|
|
2304
|
-
// (304), mirroring EDIT's 304-on-noop (§edit). A divergent destination is a real
|
|
2305
|
-
// collision (409); COPY/MOVE never clobbers.
|
|
2306
|
-
if (dstExisting !== null && dstExisting.status === 200 && dstExisting.entry !== null) {
|
|
2307
|
-
const dstChannels = dstExisting.entry.channels;
|
|
2308
|
-
const writeNames = Object.keys(channels).sort();
|
|
2309
|
-
const dstNames = Object.keys(dstChannels).sort();
|
|
2310
|
-
const sameContent = writeNames.length === dstNames.length
|
|
2311
|
-
&& writeNames.every((n, i) => n === dstNames[i] && (channels[n]?.content ?? "") === (dstChannels[n]?.content ?? ""));
|
|
2312
|
-
const sameTags = [...tags].sort().join("") === [...dstExisting.entry.tags].sort().join("");
|
|
2313
|
-
if (sameContent && sameTags)
|
|
2314
|
-
return { status: 304 }; // identical → §copy-noop-304
|
|
2315
|
-
return { status: 409, error: `COPY/MOVE destination exists: ${dstSchemeName}://${dstPathname}` }; // §copy-conflict-409
|
|
2316
|
-
}
|
|
2317
|
-
const writeResult = await dstHandler.writeEntry(dstPathname, { channels, tags }, ctx);
|
|
2318
|
-
// A file dest returns 202 (disk write → §membership review): propagate the
|
|
2319
|
-
// proposal so dispatch runs the gate + routes applyResolution to the dest.
|
|
2320
|
-
if (writeResult.status === 202)
|
|
2321
|
-
return { status: 202, attrs: writeResult.attrs, body: writeResult.body };
|
|
2322
|
-
return { status: writeResult.status, entryId: writeResult.entryId, created: writeResult.created };
|
|
1176
|
+
// §send-groundless-hibernate — can this op open a wake edge mid-turn? The grounding scan for a
|
|
1177
|
+
// same-turn spawn-then-hibernate: an EXEC (stream conclusion / poll cadence wakes), a COPY to
|
|
1178
|
+
// run:// (child-conclusion wake, §run-lifecycle-child-wake), a directed SEND to run:// (irc — the
|
|
1179
|
+
// addressee can act and conclude back), or an http READ (a web fetch streams into a subscription).
|
|
1180
|
+
// Conservative on purpose: a false PERMIT risks a dead park only in the spawn-failed corner; a
|
|
1181
|
+
// false REFUSE breaks legitimate hibernation.
|
|
1182
|
+
static #opCanOpenWakeEdge(op) {
|
|
1183
|
+
if (op.op === "EXEC")
|
|
1184
|
+
return true;
|
|
1185
|
+
const scheme = op.target !== null && op.target.kind === "url" ? op.target.scheme : null;
|
|
1186
|
+
if (op.op === "COPY" && scheme === "run")
|
|
1187
|
+
return true;
|
|
1188
|
+
if (op.op === "SEND" && scheme === "run")
|
|
1189
|
+
return true;
|
|
1190
|
+
return op.op === "READ" && (scheme === "http" || scheme === "https");
|
|
2323
1191
|
}
|
|
2324
1192
|
// A run "holds a live thing" iff it has an open stream/spawn (subscription registry or an
|
|
2325
1193
|
// exec spawn) OR a non-terminal child run — the structured-concurrency invariant a terminal
|
|
2326
|
-
// SEND
|
|
2327
|
-
// the same kind of live thing a run holds).
|
|
1194
|
+
// SEND must respect (§send-premature-terminate, §send-groundless-hibernate, §run-lifecycle:
|
|
1195
|
+
// children and streams are the same kind of live thing a run holds).
|
|
2328
1196
|
async #runHoldsLiveThing(runId) {
|
|
2329
1197
|
const openSubs = await this.#db.find_open_subscriptions_for_run.all({ run_id: runId });
|
|
2330
1198
|
if (openSubs.length > 0)
|
|
@@ -2335,177 +1203,5 @@ class Engine {
|
|
|
2335
1203
|
const liveChild = await this.#db.engine_run_has_live_child.get({ run_id: runId });
|
|
2336
1204
|
return liveChild !== undefined;
|
|
2337
1205
|
}
|
|
2338
|
-
async #handleSendBroadcast(statement, loopId, prematureRefusal) {
|
|
2339
|
-
if (statement.op !== "SEND")
|
|
2340
|
-
throw new Error("unreachable");
|
|
2341
|
-
const status = statement.signal;
|
|
2342
|
-
if (status === null)
|
|
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
|
-
}
|
|
2352
|
-
if (status === 200 || status === 202 || status === 499) {
|
|
2353
|
-
// The broadcast terminals (200 done, 202 parked-async, 499 cancelled) advance
|
|
2354
|
-
// the loop; each carries its body as the loop's terminal message — the deliverable.
|
|
2355
|
-
const body = statement.body;
|
|
2356
|
-
const message = body === null ? null : typeof body === "string" ? body : body.raw;
|
|
2357
|
-
await this.#db.engine_loop_set_status.run({ status, loop_id: loopId, message });
|
|
2358
|
-
}
|
|
2359
|
-
return { status };
|
|
2360
|
-
}
|
|
2361
|
-
async #run(schemeName, statement, ctx) {
|
|
2362
|
-
if (schemeName === null)
|
|
2363
|
-
return { status: 400 };
|
|
2364
|
-
const handler = this.#schemes.get(schemeName);
|
|
2365
|
-
if (handler === undefined)
|
|
2366
|
-
return { status: 501 };
|
|
2367
|
-
const methodName = statement.op.toLowerCase();
|
|
2368
|
-
const method = handler[methodName];
|
|
2369
|
-
if (typeof method !== "function")
|
|
2370
|
-
return { status: 501 };
|
|
2371
|
-
// External @plurnk/plurnk-schemes-* siblings receive the DB-free SchemeCtx
|
|
2372
|
-
// (caps), never the raw PlurnkSchemeContext (schemes SPEC §channels). The dynamic
|
|
2373
|
-
// dispatch is typed for in-tree schemes; the cast bridges the ctx shapes —
|
|
2374
|
-
// the sibling reads caps, the in-tree handler reads db.
|
|
2375
|
-
if (this.#schemes.isExternal(schemeName)) {
|
|
2376
|
-
return method.call(handler, statement, new SchemeCtxImpl(ctx, schemeName));
|
|
2377
|
-
}
|
|
2378
|
-
return method.call(handler, statement, ctx);
|
|
2379
|
-
}
|
|
2380
|
-
// Bare paths default to the file scheme per plurnk.md (grammar sysprompt):
|
|
2381
|
-
// "Bare paths (no scheme) default to local relative project file paths."
|
|
2382
|
-
// file:/// remains an optional explicit form for absolute paths.
|
|
2383
|
-
#schemeNameOf(path) {
|
|
2384
|
-
if (path === null)
|
|
2385
|
-
return null;
|
|
2386
|
-
// http + https are one scheme — the http sibling owns both prefixes (#195).
|
|
2387
|
-
if (path.kind === "url")
|
|
2388
|
-
return path.scheme === "https" ? "http" : path.scheme;
|
|
2389
|
-
return "file"; // local (bare) → file
|
|
2390
|
-
}
|
|
2391
|
-
// A status-202 result is a reviewable PROPOSAL (a side-effecting op — EDIT/EXEC/
|
|
2392
|
-
// directed write — paused for client resolution) UNLESS it is a broadcast SEND.
|
|
2393
|
-
// A broadcast SEND[202] is the model PARKING the loop (a terminal disposition,
|
|
2394
|
-
// plurnk.md), never a side-effect — #255: gating the propose/await path on the
|
|
2395
|
-
// bare 202 surfaced model speech as a loop/proposal and froze clients. The 202
|
|
2396
|
-
// is overloaded (proposal-pause vs parked-terminal); the op disambiguates it.
|
|
2397
|
-
static #isProposal(statement, result) {
|
|
2398
|
-
return result.status === 202 && !(statement.op === "SEND" && statement.target === null);
|
|
2399
|
-
}
|
|
2400
|
-
async #writeLog({ statement, result, runId, loopId, turnId, sequence, origin, }) {
|
|
2401
|
-
const target = this.#extractTarget(statement.target);
|
|
2402
|
-
const lineMarkerJson = "lineMarker" in statement && statement.lineMarker !== null
|
|
2403
|
-
? JSON.stringify(statement.lineMarker)
|
|
2404
|
-
: null;
|
|
2405
|
-
// A proposal (status 202 from a side-effecting op) is written to the log in
|
|
2406
|
-
// state='proposed' until the proposal lifecycle resolves it; attrs holds the
|
|
2407
|
-
// scheme-supplied payload (file diff, exec command, etc.) the client renders
|
|
2408
|
-
// for review and the scheme consumes on accept. A broadcast SEND[202] is a
|
|
2409
|
-
// parked-terminal, NOT a proposal (#isProposal / #255) → state='resolved'.
|
|
2410
|
-
const isProposed = _a.#isProposal(statement, result);
|
|
2411
|
-
let attrsObj = (result.attrs !== undefined && result.attrs !== null)
|
|
2412
|
-
? { ...result.attrs }
|
|
2413
|
-
: {};
|
|
2414
|
-
// EXEC produces a stream entry addressed by RUNTIME TAG as authority (§exec): it lives
|
|
2415
|
-
// at <runtime>:///<loop_seq>/<turn_seq>/<sequence> (e.g. sh:///1/1/2). That address is a
|
|
2416
|
-
// SEPARATE `stream` link in attrs — NOT an overload of `target`, which stays faithful to
|
|
2417
|
-
// the EXEC's own slot (the cwd, or the path to the executable). The log:/// coordinate
|
|
2418
|
-
// shares the trailing <loop>/<turn>/<seq>, so the op still correlates to its stream.
|
|
2419
|
-
// Runtime comes from statement.signal (EXEC's runtime slot), resolvable for failed execs
|
|
2420
|
-
// too; empty/absent = the default shell.
|
|
2421
|
-
if (statement.op === "EXEC") {
|
|
2422
|
-
const seqs = await this.#db.engine_loop_turn_seqs.get({
|
|
2423
|
-
loop_id: loopId, turn_id: turnId,
|
|
2424
|
-
});
|
|
2425
|
-
if (seqs === undefined)
|
|
2426
|
-
throw new Error(`Engine.#writeLog: loop_turn_seqs returned no row for loop=${loopId} turn=${turnId}`);
|
|
2427
|
-
const runtime = (typeof statement.signal === "string" && statement.signal.length > 0) ? statement.signal : "sh";
|
|
2428
|
-
const coordPathname = `/${seqs.loop_seq}/${seqs.turn_seq}/${sequence}`;
|
|
2429
|
-
attrsObj.pathname = coordPathname;
|
|
2430
|
-
attrsObj.stream = `${runtime}://${coordPathname}`;
|
|
2431
|
-
// Mutate the in-memory result.attrs too: the dispatch path
|
|
2432
|
-
// hands originalResult.attrs to handler.applyResolution after
|
|
2433
|
-
// proposal accept (see #acceptResolution). Both views — the
|
|
2434
|
-
// stored row AND the in-memory proposal — need the same
|
|
2435
|
-
// pathname so applyResolution writes the entry at the same URI.
|
|
2436
|
-
if (result.attrs !== undefined && result.attrs !== null) {
|
|
2437
|
-
result.attrs.pathname = coordPathname;
|
|
2438
|
-
}
|
|
2439
|
-
}
|
|
2440
|
-
const attrs = JSON.stringify(attrsObj);
|
|
2441
|
-
const txJson = JSON.stringify(statement);
|
|
2442
|
-
const rxJson = JSON.stringify(result);
|
|
2443
|
-
const row = await this.#db.engine_insert_log_entry.get({
|
|
2444
|
-
run_id: runId,
|
|
2445
|
-
loop_id: loopId,
|
|
2446
|
-
turn_id: turnId,
|
|
2447
|
-
sequence: sequence,
|
|
2448
|
-
origin,
|
|
2449
|
-
source: null, // dispatch entries are self-authored; §env-delta deltas set this
|
|
2450
|
-
op: statement.op,
|
|
2451
|
-
suffix: statement.suffix,
|
|
2452
|
-
signal: this.#signalToJson(statement.signal),
|
|
2453
|
-
scheme: target.scheme,
|
|
2454
|
-
username: target.username,
|
|
2455
|
-
password: target.password,
|
|
2456
|
-
hostname: target.hostname,
|
|
2457
|
-
port: target.port,
|
|
2458
|
-
pathname: target.pathname,
|
|
2459
|
-
params: target.params,
|
|
2460
|
-
fragment: target.fragment,
|
|
2461
|
-
lineMarker: lineMarkerJson,
|
|
2462
|
-
tx: txJson,
|
|
2463
|
-
mimetype_tx: "application/json",
|
|
2464
|
-
rx: rxJson,
|
|
2465
|
-
mimetype_rx: "application/json",
|
|
2466
|
-
status_rx: result.status,
|
|
2467
|
-
tokens: this.#tokenize(txJson) + this.#tokenize(rxJson),
|
|
2468
|
-
state: isProposed ? "proposed" : "resolved",
|
|
2469
|
-
outcome: null,
|
|
2470
|
-
attrs,
|
|
2471
|
-
});
|
|
2472
|
-
if (row === undefined)
|
|
2473
|
-
throw new Error("Engine.#writeLog: INSERT ... RETURNING produced no row");
|
|
2474
|
-
return row.id;
|
|
2475
|
-
}
|
|
2476
|
-
// Normalize a parsed path for storage. The `file` scheme is a routing
|
|
2477
|
-
// internal — never stored, never rendered to the model. Both bare paths
|
|
2478
|
-
// and `file:///...` inputs collapse to scheme=null at this boundary, so
|
|
2479
|
-
// entries.scheme / log_entries.scheme never carry the string "file".
|
|
2480
|
-
#extractTarget(path) {
|
|
2481
|
-
if (path === null)
|
|
2482
|
-
return { scheme: null, username: null, password: null, hostname: null, port: null, pathname: null, params: null, fragment: null };
|
|
2483
|
-
// `local` (bare path) and `regex` (grammar 0.46 `#pattern#flags` target) carry no URL parts — store the raw text as the pathname for the log record, scheme=null.
|
|
2484
|
-
if (path.kind === "regex")
|
|
2485
|
-
return { scheme: null, username: null, password: null, hostname: null, port: null, pathname: path.raw, params: null, fragment: null }; // regex source — no decode
|
|
2486
|
-
if (path.kind === "local")
|
|
2487
|
-
return { scheme: null, username: null, password: null, hostname: null, port: null, pathname: decodePathParens(path.raw), params: null, fragment: null }; // #239 item 4
|
|
2488
|
-
const scheme = path.scheme === "file" ? null : path.scheme;
|
|
2489
|
-
// Every registered (plurnk-namespace) scheme uses its authority as a namespace segment — fold
|
|
2490
|
-
// it into the canonical pathname so known://x ≡ known:///x ≡ /x and the log keys identically to
|
|
2491
|
-
// the entry (/prompt/<loop>, /docs/x.md). A foreign web host (http://, unregistered) is NOT a
|
|
2492
|
-
// namespace: keep it in hostname. run:// is the one registered EXCEPTION — its authority IS the
|
|
2493
|
-
// run selector (§run-scheme), and run://self must stay distinct from run://name, so Run.ts
|
|
2494
|
-
// folds the owner into the storage path itself, never here.
|
|
2495
|
-
const foldNs = scheme !== null && scheme !== "run" && this.#schemes.has(scheme);
|
|
2496
|
-
return {
|
|
2497
|
-
scheme, username: path.username, password: path.password,
|
|
2498
|
-
hostname: foldNs ? null : path.hostname, port: path.port,
|
|
2499
|
-
pathname: decodePathParens(foldNs ? foldAuthorityIntoPath(path.hostname, path.pathname) : path.pathname), // #239 item 4
|
|
2500
|
-
params: JSON.stringify(path.params), fragment: path.fragment,
|
|
2501
|
-
};
|
|
2502
|
-
}
|
|
2503
|
-
#signalToJson(signal) {
|
|
2504
|
-
if (signal === null || signal === undefined)
|
|
2505
|
-
return null;
|
|
2506
|
-
return JSON.stringify(signal);
|
|
2507
|
-
}
|
|
2508
1206
|
}
|
|
2509
|
-
_a = Engine;
|
|
2510
|
-
export default Engine;
|
|
2511
1207
|
//# sourceMappingURL=Engine.js.map
|