@plurnk/plurnk-service 0.59.0 → 0.61.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 +41 -3
- package/PLURNK_PERSONALITY.md +61 -0
- package/SPEC.md +54 -45
- 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 +10 -45
- package/dist/core/Engine.d.ts.map +1 -1
- package/dist/core/Engine.js +269 -1489
- package/dist/core/Engine.js.map +1 -1
- package/dist/core/Engine.sql +55 -12
- 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 +365 -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 +195 -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-inject.d.ts.map +1 -1
- package/dist/core/packet-inject.js +8 -3
- package/dist/core/packet-inject.js.map +1 -1
- package/dist/core/packet-wire.d.ts +5 -1
- package/dist/core/packet-wire.d.ts.map +1 -1
- package/dist/core/packet-wire.js +53 -19
- 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 +2 -2
- package/dist/core/run-cap.js.map +1 -1
- package/dist/core/run-ops.sql +21 -0
- 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 +23 -24
- package/dist/schemes/Exec.js.map +1 -1
- package/dist/schemes/File.js +2 -2
- 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.d.ts.map +1 -1
- package/dist/schemes/Run.js +27 -27
- package/dist/schemes/Run.js.map +1 -1
- package/dist/schemes/_entry-manifest.d.ts.map +1 -1
- package/dist/schemes/_entry-manifest.js +5 -1
- package/dist/schemes/_entry-manifest.js.map +1 -1
- package/dist/schemes/_entry-semantic.d.ts.map +1 -1
- package/dist/schemes/_entry-semantic.js +6 -0
- package/dist/schemes/_entry-semantic.js.map +1 -1
- package/dist/server/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 +29 -3
- package/dist/server/Daemon.js.map +1 -1
- package/dist/server/drain.sql +14 -0
- 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/methods/session_attach.d.ts.map +1 -1
- package/dist/server/methods/session_attach.js +4 -0
- package/dist/server/methods/session_attach.js.map +1 -1
- package/dist/server/methods/session_create.d.ts.map +1 -1
- package/dist/server/methods/session_create.js +4 -0
- package/dist/server/methods/session_create.js.map +1 -1
- package/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 +6 -0
- package/dist/service.js.map +1 -1
- package/docs/run.md +7 -3
- package/migrations/0000-00-00.01_schema.sql +8 -4
- package/package.json +22 -17
- package/requirements.md +6 -9
package/dist/core/Engine.js
CHANGED
|
@@ -1,51 +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
|
-
// Substituted into the budget readout after the assembled packet is measured
|
|
33
|
-
// (the figure depends on the packet's own rendered size — chicken/egg).
|
|
34
|
-
const TOKENS_FREE_PLACEHOLDER = "{{tokensFree}}";
|
|
35
|
-
const TOKEN_USAGE_PLACEHOLDER = "{{tokenUsage}}";
|
|
36
|
-
const TOKEN_PERCENT_PLACEHOLDER = "{{tokenPercent}}";
|
|
37
|
-
// PLURNK_BUDGET_CEILING is dual-mode: <=1 is a fraction of the provider's
|
|
38
|
-
// context window, >1 is an absolute token wall — lets a demo pin a tiny
|
|
39
|
-
// ceiling regardless of the model's real window to force the grinder.
|
|
40
|
-
const readCeiling = () => {
|
|
41
|
-
const raw = process.env.PLURNK_BUDGET_CEILING;
|
|
42
|
-
if (raw === undefined || raw.length === 0)
|
|
43
|
-
return DEFAULT_BUDGET_CEILING;
|
|
44
|
-
const n = Number.parseFloat(raw);
|
|
45
|
-
if (!Number.isFinite(n) || n <= 0)
|
|
46
|
-
return DEFAULT_BUDGET_CEILING;
|
|
47
|
-
return n;
|
|
48
|
-
};
|
|
49
26
|
const readMaxStrikes = () => {
|
|
50
27
|
const raw = process.env.PLURNK_MAX_STRIKES;
|
|
51
28
|
if (raw === undefined || raw.length === 0)
|
|
@@ -74,23 +51,6 @@ const readFilesItems = () => {
|
|
|
74
51
|
return normalizeFilesItems(Number.parseInt(raw, 10));
|
|
75
52
|
};
|
|
76
53
|
import { ProviderError } from "@plurnk/plurnk-providers";
|
|
77
|
-
// Resolution timeout — proposed entries auto-cancel if nothing arrives
|
|
78
|
-
// within this window. SPEC.md §engine-rails (proposal lifecycle) + §methods (loop.resolve).
|
|
79
|
-
const PROPOSAL_TIMEOUT_DEFAULT_MS = 300000;
|
|
80
|
-
const readProposalTimeoutMs = () => {
|
|
81
|
-
const raw = process.env.PLURNK_PROPOSAL_TIMEOUT_MS;
|
|
82
|
-
if (raw === undefined || raw.length === 0)
|
|
83
|
-
return PROPOSAL_TIMEOUT_DEFAULT_MS;
|
|
84
|
-
const n = Number(raw);
|
|
85
|
-
if (!Number.isFinite(n) || n <= 0)
|
|
86
|
-
return PROPOSAL_TIMEOUT_DEFAULT_MS;
|
|
87
|
-
return n;
|
|
88
|
-
};
|
|
89
|
-
const pathnameFromPath = (path) => {
|
|
90
|
-
if (path.kind === "regex")
|
|
91
|
-
return path.raw; // regex source — parens are syntax, never encoded
|
|
92
|
-
return decodePathParens(path.kind === "url" ? path.pathname : path.raw); // #239 item 4
|
|
93
|
-
};
|
|
94
54
|
// Default turn.status when ops were emitted but no SEND. Model is implicitly
|
|
95
55
|
// continuing; loop.status stays 102 either way (only SEND broadcast advances
|
|
96
56
|
// loop terminal). No strike, no telemetry.
|
|
@@ -98,10 +58,6 @@ const TURN_STATUS_IMPLICIT_CONTINUE = 102;
|
|
|
98
58
|
// Status assigned to a turn that emitted NO ops at all. Strike-worthy; the
|
|
99
59
|
// action routes through telemetry.errors[] (§telemetry, §telemetry-no-error-scheme — never an error:// scheme).
|
|
100
60
|
const TURN_STATUS_NO_OPS = 422;
|
|
101
|
-
// Rail #38: action-entry statuses that DON'T accumulate strikes. Model adapted
|
|
102
|
-
// to a finding (not_found, op_not_supported); no penalty. Rummy parallel:
|
|
103
|
-
// SOFT_FAILURE_OUTCOMES = {"not_found", "unparsed"}.
|
|
104
|
-
const SOFT_FAILURE_STATUSES = new Set([404, 501]);
|
|
105
61
|
const DEFAULT_MIN_CYCLES = 3;
|
|
106
62
|
const DEFAULT_MAX_CYCLE_PERIOD = 4;
|
|
107
63
|
const readPositiveInt = (envVar, fallback) => {
|
|
@@ -113,101 +69,24 @@ const readPositiveInt = (envVar, fallback) => {
|
|
|
113
69
|
return fallback;
|
|
114
70
|
return n;
|
|
115
71
|
};
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
// matchers on the same target ARE different activities (the model is
|
|
123
|
-
// exploring different queries, not repeating one).
|
|
124
|
-
const fingerprintOp = (stmt) => {
|
|
125
|
-
const path = stmt.target;
|
|
126
|
-
const matcherDiscriminator = () => {
|
|
127
|
-
// For matcher-bearing ops, the body's `raw` (matcher source) plus
|
|
128
|
-
// any lineMarker forms the activity discriminator.
|
|
129
|
-
const parts = [];
|
|
130
|
-
const body = stmt.body;
|
|
131
|
-
if (body !== null && typeof body === "object" && typeof body.raw === "string") {
|
|
132
|
-
parts.push(`body:${body.raw.slice(0, 64)}`);
|
|
133
|
-
}
|
|
134
|
-
const lm = stmt.lineMarker;
|
|
135
|
-
if (lm !== null && lm !== undefined)
|
|
136
|
-
parts.push(`L:${lm.marks.join(",")}`);
|
|
137
|
-
return parts.length > 0 ? `|${parts.join("|")}` : "";
|
|
138
|
-
};
|
|
139
|
-
if (path === null) {
|
|
140
|
-
// Path-less ops need an activity-defining discriminator other
|
|
141
|
-
// than `target`. Picked per op so the cycle detector reflects
|
|
142
|
-
// intent rather than syntax:
|
|
143
|
-
// - EXEC: the command body IS the activity. Without a body
|
|
144
|
-
// digest, varied shell commands (find / ls / wc) collapse to
|
|
145
|
-
// one fingerprint and the detector mislabels exploration
|
|
146
|
-
// as a loop.
|
|
147
|
-
// - SEND: the status code (signal) IS the activity. Different
|
|
148
|
-
// SEND[X] are different intentions; same SEND[X] with
|
|
149
|
-
// different message bodies is the same termination signal.
|
|
150
|
-
if (stmt.op === "EXEC") {
|
|
151
|
-
const body = typeof stmt.body === "string" ? stmt.body : "";
|
|
152
|
-
return `EXEC|(no-path)${body.length > 0 ? `|body:${body.slice(0, 64)}` : ""}`;
|
|
153
|
-
}
|
|
154
|
-
if (stmt.op === "SEND") {
|
|
155
|
-
const signal = typeof stmt.signal === "number" ? stmt.signal : "";
|
|
156
|
-
return `SEND|(no-path)|signal:${signal}`;
|
|
157
|
-
}
|
|
158
|
-
return `${stmt.op}|(no-path)`;
|
|
159
|
-
}
|
|
160
|
-
const base = path.kind === "url"
|
|
161
|
-
? `${stmt.op}|${path.scheme}://${path.pathname}`
|
|
162
|
-
: `${stmt.op}|local:${path.raw}`;
|
|
163
|
-
if (stmt.op === "FIND" || stmt.op === "READ" || stmt.op === "OPEN" || stmt.op === "FOLD") {
|
|
164
|
-
return `${base}${matcherDiscriminator()}`;
|
|
165
|
-
}
|
|
166
|
-
return base;
|
|
167
|
-
};
|
|
168
|
-
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 {
|
|
169
78
|
static computeCeiling(contextSize, config) {
|
|
170
|
-
|
|
171
|
-
// mode is to pin a ceiling even when the provider reports no window; cap at
|
|
172
|
-
// the real window when one is known. Ratio mode needs a window to scale.
|
|
173
|
-
if (config > 1)
|
|
174
|
-
return contextSize === null ? Math.floor(config) : Math.min(Math.floor(config), contextSize);
|
|
175
|
-
return contextSize === null ? null : Math.floor(contextSize * config);
|
|
79
|
+
return PacketBuilder.computeCeiling(contextSize, config);
|
|
176
80
|
}
|
|
177
|
-
// Per-turn fingerprint: sorted set of per-op fingerprints, joined. Order
|
|
178
|
-
// within a turn doesn't matter — we want the SET of activities.
|
|
179
81
|
static fingerprintTurn(ops) {
|
|
180
|
-
return
|
|
82
|
+
return StrikeRail.fingerprintTurn(ops);
|
|
181
83
|
}
|
|
182
|
-
// Rail #39 cycle detector. For each candidate period k in [1, maxCyclePeriod],
|
|
183
|
-
// check whether the last k*minCycles entries form minCycles repetitions of the
|
|
184
|
-
// same length-k pattern. O(maxCyclePeriod × minCycles × max k) ≈ tiny. Rummy
|
|
185
|
-
// parallel: src/plugins/error/error.js detectCycle.
|
|
186
84
|
static detectCycle(history, minCycles, maxCyclePeriod) {
|
|
187
|
-
|
|
188
|
-
const needed = k * minCycles;
|
|
189
|
-
if (history.length < needed)
|
|
190
|
-
continue;
|
|
191
|
-
const tail = history.slice(-needed);
|
|
192
|
-
const cycle = tail.slice(0, k);
|
|
193
|
-
let match = true;
|
|
194
|
-
outer: for (let rep = 0; rep < minCycles; rep++) {
|
|
195
|
-
for (let j = 0; j < k; j++) {
|
|
196
|
-
if (tail[rep * k + j] !== cycle[j]) {
|
|
197
|
-
match = false;
|
|
198
|
-
break outer;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
if (match)
|
|
203
|
-
return { detected: true, period: k, cycles: minCycles };
|
|
204
|
-
}
|
|
205
|
-
return { detected: false };
|
|
85
|
+
return StrikeRail.detectCycle(history, minCycles, maxCyclePeriod);
|
|
206
86
|
}
|
|
207
87
|
#db;
|
|
208
88
|
#schemes;
|
|
209
89
|
#mimetypes;
|
|
210
|
-
#budgetCeiling;
|
|
211
90
|
// Write-time tokenizer (SPEC §tokenomics). Synchronous per the provider
|
|
212
91
|
// contract (§provider-surface). Populated from the active provider's countTokens via
|
|
213
92
|
// the Daemon; a divisor tripwire stands in only for bare/standalone
|
|
@@ -217,26 +96,14 @@ class Engine {
|
|
|
217
96
|
// Boot-discovered runtime executors. Daemon builds + sets via
|
|
218
97
|
// setExecutors at start(); undefined until then (and in bare tests).
|
|
219
98
|
#executors;
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
#
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
#strikeState = new Map();
|
|
229
|
-
// Proposal lifecycle: pending dispatch pauses waiting for resolution.
|
|
230
|
-
// Engine.runTurn awaits the promise when a scheme returns status 202;
|
|
231
|
-
// Engine.resolveProposal feeds the resolution back in. Map is per-log-
|
|
232
|
-
// entry-id; entries clear on resolution. SPEC.md §engine-rails + §methods (loop.resolve).
|
|
233
|
-
#pendingProposals = new Map();
|
|
234
|
-
// External observers of proposal lifecycle events. Daemon subscribes
|
|
235
|
-
// here to push `loop/proposal` notifications when an entry enters
|
|
236
|
-
// pending state. YOLO listener (Phase E.3) subscribes here too. Lean
|
|
237
|
-
// event emitter — no priority, no veto chain at this layer; filter
|
|
238
|
-
// chains come later if a real consumer needs them.
|
|
239
|
-
#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;
|
|
240
107
|
// Per-loop AbortController for cancellation propagation into scheme
|
|
241
108
|
// ctx.signal. runLoop creates one at entry, cleans up at end. Engine
|
|
242
109
|
// cancellation paths (strikes, max_turns, external) abort it.
|
|
@@ -245,14 +112,6 @@ class Engine {
|
|
|
245
112
|
#loopAborts = new Map();
|
|
246
113
|
#streamEventNotify;
|
|
247
114
|
#wakeRunNotify;
|
|
248
|
-
#injectRun;
|
|
249
|
-
#cancelRun;
|
|
250
|
-
// Telemetry event fan-out: every TelemetryEvent pushed to the loop's
|
|
251
|
-
// buffer is also broadcast live to the connected client(s) on the
|
|
252
|
-
// session. Without this, the client sees `loop/terminated` with a
|
|
253
|
-
// status code but has no way to surface why the loop degraded.
|
|
254
|
-
// Per-grammar 0.17.0 protocol — see SPEC §telemetry.
|
|
255
|
-
#telemetryEventNotify;
|
|
256
115
|
// Cached plurnk GBNF — read once on the first constrained generate (#189).
|
|
257
116
|
#gbnfCache = null;
|
|
258
117
|
constructor({ db, schemes, mimetypes, streamEventNotify, wakeRunNotify, injectRun, cancelRun, telemetryEventNotify, tokenize }) {
|
|
@@ -260,9 +119,6 @@ class Engine {
|
|
|
260
119
|
this.#schemes = schemes;
|
|
261
120
|
this.#streamEventNotify = streamEventNotify;
|
|
262
121
|
this.#wakeRunNotify = wakeRunNotify;
|
|
263
|
-
this.#injectRun = injectRun;
|
|
264
|
-
this.#cancelRun = cancelRun;
|
|
265
|
-
this.#telemetryEventNotify = telemetryEventNotify;
|
|
266
122
|
// Default to empty discovery — standalone Engine construction (in
|
|
267
123
|
// tests) gets no handlers, and content flows through the framework's
|
|
268
124
|
// raw-content fitContent fallback. Daemon-managed Engine receives a
|
|
@@ -270,17 +126,48 @@ class Engine {
|
|
|
270
126
|
this.#mimetypes = mimetypes ?? new Mimetypes({
|
|
271
127
|
discovery: { registry: emptyRegistry(), handlers: new Map() },
|
|
272
128
|
});
|
|
273
|
-
this.#budgetCeiling = readCeiling();
|
|
274
129
|
// Tripwire default matches the Mimetypes boot affordance (SPEC §mimetype-surface):
|
|
275
130
|
// the divisor stands in only until the provider-backed tokenizer is
|
|
276
131
|
// wired by the Daemon. Real counts come from provider.countTokens.
|
|
277
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
|
+
});
|
|
278
150
|
}
|
|
279
151
|
// Late injection: the executor registry is async-built at daemon start()
|
|
280
152
|
// (discover + probe), after Engine construction.
|
|
281
153
|
setExecutors(executors) {
|
|
282
154
|
this.#executors = executors;
|
|
283
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
|
+
}
|
|
284
171
|
// Grammar-constrained sampling (#189): when PLURNK_PROVIDERS_GBNF is enabled
|
|
285
172
|
// (the only knob — default-on in .env.example), hand the provider the plurnk
|
|
286
173
|
// GBNF (the full shipped multi-op root, read once + cached). The provider
|
|
@@ -322,25 +209,6 @@ class Engine {
|
|
|
322
209
|
meta: JSON.parse(row?.meta ?? "{}"),
|
|
323
210
|
};
|
|
324
211
|
}
|
|
325
|
-
#pushTelemetry(sessionId, loopId, event) {
|
|
326
|
-
const existing = this.#telemetryBuffer.get(loopId);
|
|
327
|
-
if (existing === undefined)
|
|
328
|
-
this.#telemetryBuffer.set(loopId, [event]);
|
|
329
|
-
else
|
|
330
|
-
existing.push(event);
|
|
331
|
-
// Live fan-out: client sees the event the moment it lands in the
|
|
332
|
-
// model's buffer (not at the next packet build). Same envelope on
|
|
333
|
-
// both sides per the grammar 0.17.0 TelemetryEvent protocol.
|
|
334
|
-
this.#telemetryEventNotify?.(sessionId, { loopId, event });
|
|
335
|
-
}
|
|
336
|
-
// Telemetry drains as it's read into the packet — each event surfaces once. §telemetry-drain-on-read
|
|
337
|
-
#drainTelemetry(loopId) {
|
|
338
|
-
const buf = this.#telemetryBuffer.get(loopId);
|
|
339
|
-
if (buf === undefined)
|
|
340
|
-
return [];
|
|
341
|
-
this.#telemetryBuffer.delete(loopId);
|
|
342
|
-
return buf;
|
|
343
|
-
}
|
|
344
212
|
// A @plurnk/gbnf divergence position (providers#24) is a CODE-POINT offset into the
|
|
345
213
|
// model's content; the snippet/telemetry surface speaks 1-based line + 0-based column.
|
|
346
214
|
// Convert over code points (not UTF-16 units) so an astral char doesn't skew the line,
|
|
@@ -374,6 +242,18 @@ class Engine {
|
|
|
374
242
|
signal.addEventListener("abort", () => loopAbort.abort(signal.reason), { once: true });
|
|
375
243
|
}
|
|
376
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
|
+
};
|
|
377
257
|
// Cleanup splits by termination kind:
|
|
378
258
|
// - "graceful" (SEND[202] Accepted): in-flight streaming-scheme spawns
|
|
379
259
|
// are ALLOWED to outlive the loop — they complete naturally, write final
|
|
@@ -382,14 +262,18 @@ class Engine {
|
|
|
382
262
|
// - "forceful" (SEND[200] done, max_turns, strike, cancel, budget, 4xx/5xx):
|
|
383
263
|
// fire the loop-level abort so leftover spawns tear down. "Done" reaps.
|
|
384
264
|
const cleanup = (kind, reason) => {
|
|
265
|
+
clearTimeout(wall);
|
|
385
266
|
if (kind === "forceful" && !loopAbort.signal.aborted) {
|
|
386
267
|
loopAbort.abort(reason ?? "loop_forceful_termination");
|
|
387
268
|
}
|
|
388
269
|
this.#loopAborts.delete(loopId);
|
|
389
|
-
this.#
|
|
390
|
-
this.#
|
|
270
|
+
this.#strikes.delete(loopId);
|
|
271
|
+
this.#telemetry.delete(loopId);
|
|
391
272
|
};
|
|
392
273
|
while (true) {
|
|
274
|
+
// The wall fired between turns — rule 504 before anything else reads the loop.
|
|
275
|
+
if (timedOut())
|
|
276
|
+
return await ruleTimeout();
|
|
393
277
|
signal?.throwIfAborted();
|
|
394
278
|
const row = await this.#db.engine_loop_status.get({ loop_id: loopId });
|
|
395
279
|
if (row === undefined)
|
|
@@ -418,10 +302,20 @@ class Engine {
|
|
|
418
302
|
if (execHandler?.hasActiveSpawns?.(runId) === true)
|
|
419
303
|
await delay(execWaitMs, undefined, { signal });
|
|
420
304
|
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
+
}
|
|
425
319
|
turnIds.push(turn.turnId);
|
|
426
320
|
// SPEC §grinder: budget hard-stop — packet won't fit even collapsed → abandon.
|
|
427
321
|
if (turn.budgetHardStop) {
|
|
@@ -430,59 +324,26 @@ class Engine {
|
|
|
430
324
|
cleanup("forceful", "budget_overflow");
|
|
431
325
|
return { turnIds, finalStatus: 413, hitMaxTurns: false, reason: "budget_overflow" };
|
|
432
326
|
}
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
if (
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
// Rail #38: strike accounting. Three sources strike a turn:
|
|
453
|
-
// 1. recordedFailed — any action-entry at hard failure status
|
|
454
|
-
// (>= 400 and not in SOFT_FAILURE_STATUSES).
|
|
455
|
-
// 2. noOps — turn.status === TURN_STATUS_NO_OPS (per #41).
|
|
456
|
-
// 3. turnErrors — externally bumped by per-turn rails (#39 cycle).
|
|
457
|
-
// Struck → streak++; clean → streak = 0. Threshold → abandon.
|
|
458
|
-
// Strike accounting is engine-internal bookkeeping. Per rummy
|
|
459
|
-
// precedent (plugins/error/error.js#verdict) and SPEC §telemetry
|
|
460
|
-
// policy: model sees errors that happened (parse_error,
|
|
461
|
-
// action_failure), never the engine's accounting about them
|
|
462
|
-
// (strike counts, cycle detection, sudden-death threshold).
|
|
463
|
-
// Surfacing internal state to the model creates a gamification
|
|
464
|
-
// surface — model optimizes for engine metrics rather than
|
|
465
|
-
// task progress.
|
|
466
|
-
const recordedFailed = turn.statuses.some((s) => s >= 400 && !SOFT_FAILURE_STATUSES.has(s));
|
|
467
|
-
const noOps = turn.status === TURN_STATUS_NO_OPS;
|
|
468
|
-
const struck = noOps || recordedFailed || state.turnErrors > 0;
|
|
469
|
-
if (struck) {
|
|
470
|
-
state.streak++;
|
|
471
|
-
if (state.streak >= maxStrikes) {
|
|
472
|
-
// §loop-terminals — a cycle-driven strike is the model spinning in place
|
|
473
|
-
// (508 Loop Detected); a failure/no-op strike is the model failing (500
|
|
474
|
-
// Internal Server Error). The straw that crossed the threshold picks it.
|
|
475
|
-
const status = cycle.detected ? 508 : 500;
|
|
476
|
-
await this.#db.engine_loop_set_status.run({ loop_id: loopId, status, message: "strike_threshold" });
|
|
477
|
-
cleanup("forceful", "strike_threshold");
|
|
478
|
-
return { turnIds, finalStatus: status, hitMaxTurns: false, reason: "strike_threshold" };
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
else {
|
|
482
|
-
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" };
|
|
483
346
|
}
|
|
484
|
-
state.turnErrors = 0;
|
|
485
|
-
this.#strikeState.set(loopId, state);
|
|
486
347
|
// Sudden-death threshold is engine-internal — abandonment
|
|
487
348
|
// happens at maxTurns regardless. Per gamification policy:
|
|
488
349
|
// we don't warn the model that it's nearing our limit.
|
|
@@ -603,7 +464,7 @@ class Engine {
|
|
|
603
464
|
tokenize: this.#tokenize,
|
|
604
465
|
mimetypes: this.#mimetypes,
|
|
605
466
|
defaultChannelFor: (s) => this.#schemes.defaultChannelFor(s),
|
|
606
|
-
pushTelemetry: (event) => this.#
|
|
467
|
+
pushTelemetry: (event) => this.#telemetry.push(sessionId, loopId, event),
|
|
607
468
|
};
|
|
608
469
|
// SPEC §membership D4/D5 — git-ls-files workspace membership, resolved at
|
|
609
470
|
// prompt-composition (EMI is eager + exhaustive — git is the only bound). When the
|
|
@@ -717,7 +578,7 @@ class Engine {
|
|
|
717
578
|
// so the grammar can stay thin. Subsequent turns mirror the model's real output, folded.
|
|
718
579
|
if (runFirstLoop) {
|
|
719
580
|
const emission = ["<<PLAN:Initialize:PLAN", ...turnZeroMoves, "<<SEND[102]:Initialized:SEND"].join("\n");
|
|
720
|
-
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" });
|
|
721
582
|
}
|
|
722
583
|
}
|
|
723
584
|
// §environment-observation — pre-seed the run's ambient observations (what changed since
|
|
@@ -735,26 +596,34 @@ class Engine {
|
|
|
735
596
|
// (a service-side `git status` shell-out) and threaded into the budget
|
|
736
597
|
// rebuild too so it isn't re-shelled on overflow.
|
|
737
598
|
const gitStatus = await GitState.status(this.#db, sessionId, this.#loopAborts.get(loopId)?.signal);
|
|
738
|
-
// Build the spec'd packet (Packet.json) request half.
|
|
599
|
+
// Build the spec'd packet (Packet.json) request half. The log build
|
|
739
600
|
// queries log_entries scoped to the run — the prompt entry just
|
|
740
601
|
// written (if turn 1) is part of that query result.
|
|
741
|
-
let requestPacket = await this.#buildRequestPacket({
|
|
602
|
+
let requestPacket = await this.#packets.buildRequestPacket({
|
|
742
603
|
initialMessages: messages, requirements, sessionId, runId, loopId,
|
|
743
604
|
currentTurnSeq: seq, provider, gitStatus,
|
|
744
605
|
});
|
|
745
606
|
// SPEC §grinder — budget grinder, pre-LLM: reclaim window on actual overflow.
|
|
746
|
-
const enforced = await this.#enforceBudget({
|
|
747
|
-
packet: requestPacket, provider, runId, loopId, turnId,
|
|
748
|
-
|
|
607
|
+
const enforced = await this.#packets.enforceBudget({
|
|
608
|
+
packet: requestPacket, provider, runId, loopId, turnId,
|
|
609
|
+
// The overflow error row is minted at the turn's running sequence (nextActionIndex), pre-generate;
|
|
610
|
+
// runTurn advances the counter past it below so the post-generate dispatch rows never collide.
|
|
611
|
+
mintSequence: nextActionIndex,
|
|
612
|
+
// No preset telemetry — the rebuild RE-DERIVES the errors section from log≥400 so the
|
|
613
|
+
// overflow row just minted surfaces THIS turn (§grinder-overflow-error-row). Safe: the
|
|
614
|
+
// ephemeral buffer is empty pre-generate (events drain on the next turn's build).
|
|
615
|
+
rebuild: () => this.#packets.buildRequestPacket({
|
|
749
616
|
initialMessages: messages, requirements, sessionId, runId, loopId,
|
|
750
|
-
currentTurnSeq: seq, provider,
|
|
617
|
+
currentTurnSeq: seq, provider, gitStatus,
|
|
751
618
|
}),
|
|
752
619
|
});
|
|
620
|
+
if (enforced.struck)
|
|
621
|
+
nextActionIndex += 1; // the budget-overflow error row consumed a sequence
|
|
753
622
|
requestPacket = enforced.packet;
|
|
754
623
|
if (!enforced.fit) {
|
|
755
624
|
// Hard 413: won't fit even with only the manifest left. Skip the LLM,
|
|
756
625
|
// close the turn, and let runLoop abandon (499).
|
|
757
|
-
const hardPacket = this.#completePacket(requestPacket, { content: "", ops: [], reasoning: null }, null, provider);
|
|
626
|
+
const hardPacket = this.#packets.completePacket(requestPacket, { content: "", ops: [], reasoning: null }, null, provider);
|
|
758
627
|
await this.#db.engine_close_turn.run({
|
|
759
628
|
id: turnId, status: 413, packet: JSON.stringify(hardPacket),
|
|
760
629
|
usage_prompt: 0, usage_completion: 0, usage_cached: 0, usage_cost_pico: 0,
|
|
@@ -763,7 +632,7 @@ class Engine {
|
|
|
763
632
|
});
|
|
764
633
|
return { turnId, status: 413, statuses: [], fingerprint: "", budgetStruck: enforced.struck, budgetHardStop: true, steerStruck: false };
|
|
765
634
|
}
|
|
766
|
-
const modelMessages =
|
|
635
|
+
const modelMessages = PacketWire.packetToWireMessages(requestPacket);
|
|
767
636
|
// No decode cap. Our budget governs the TRANSMISSION packet (the grinder folds
|
|
768
637
|
// the input under the ceiling); the model's decode — reasoning + emission — is
|
|
769
638
|
// out of band, owned by the provider's own context window. Deriving a maxTokens
|
|
@@ -781,7 +650,20 @@ class Engine {
|
|
|
781
650
|
// #249 — session-stable frontend id, forwarded as Plurnk-Client by the plurnk provider only.
|
|
782
651
|
const { client } = await SessionSettings.read(this.#db, sessionId);
|
|
783
652
|
try {
|
|
784
|
-
|
|
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" });
|
|
785
667
|
}
|
|
786
668
|
catch (err) {
|
|
787
669
|
// Every provider error surfaces as telemetry (the client/model sees the cause). #256:
|
|
@@ -794,7 +676,7 @@ class Engine {
|
|
|
794
676
|
// In GBNF-filter mode the provider returns the bytes with a grammar_unenforced telemetry
|
|
795
677
|
// event instead — recovered on the success path below (response.telemetry), no empty turn.
|
|
796
678
|
if (err instanceof ProviderError) {
|
|
797
|
-
this.#
|
|
679
|
+
this.#telemetry.push(sessionId, loopId, { source: "provider", kind: err.kind, message: err.message, level: "error" });
|
|
798
680
|
if (err.kind !== "grammar_unenforced")
|
|
799
681
|
throw err;
|
|
800
682
|
response = {
|
|
@@ -841,7 +723,7 @@ class Engine {
|
|
|
841
723
|
: null;
|
|
842
724
|
if (located !== null)
|
|
843
725
|
hadContentOffsetNotice = true;
|
|
844
|
-
this.#
|
|
726
|
+
this.#telemetry.push(sessionId, loopId, {
|
|
845
727
|
source: event.source,
|
|
846
728
|
kind: event.kind,
|
|
847
729
|
message: event.message ?? "",
|
|
@@ -862,45 +744,64 @@ class Engine {
|
|
|
862
744
|
// §grinder-strike-coupling): the loop continues, the model sees the steering hint not the strike
|
|
863
745
|
// count, and a non-resolver spins out to the engine's 500.
|
|
864
746
|
let steerStruck = false;
|
|
747
|
+
// Engine errors raised this turn, minted as op='error' log rows after dispatch (they share the
|
|
748
|
+
// post-dispatch sequence counter). §telemetry-uniform-error-channel
|
|
749
|
+
const pendingEngineErrors = [];
|
|
865
750
|
// Premature terminate: a SEND[200] while the run still holds a live thing — an open stream/spawn
|
|
866
751
|
// OR a non-terminal child run (§run-lifecycle: children and streams are the same kind of "live
|
|
867
|
-
// thing a run holds"). The model declared done with work running.
|
|
868
|
-
//
|
|
869
|
-
//
|
|
752
|
+
// thing a run holds"). The model declared done with work running. The SEND is REFUSED 409 at
|
|
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;
|
|
870
763
|
if (sendOp?.signal === 200) {
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
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";
|
|
883
781
|
}
|
|
884
782
|
}
|
|
783
|
+
const prematureTerminate = prematureReason !== undefined;
|
|
784
|
+
if (prematureTerminate)
|
|
785
|
+
steerStruck = true;
|
|
885
786
|
// Rail #41 (revised): the per-turn requirement is "emit at least one op," not "emit a terminal
|
|
886
787
|
// SEND." SEND is purely a signal verb; many turns pass without one. An empty op list strikes.
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
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.
|
|
791
|
+
const turnStatus = prematureTerminate
|
|
792
|
+
? TURN_STATUS_IMPLICIT_CONTINUE
|
|
793
|
+
: sendOp !== undefined
|
|
794
|
+
? sendOp.signal
|
|
795
|
+
: realOpsCount === 0 ? TURN_STATUS_NO_OPS : TURN_STATUS_IMPLICIT_CONTINUE;
|
|
890
796
|
// Idle turn: an implicit-continue (102) that did no WORK — its ops are only PLAN/SEND, no mid op.
|
|
891
797
|
// The model continued with nothing to do. (Skipped when premature already steered this turn.)
|
|
892
798
|
const midOpsCount = packetAssistant.ops.filter((op) => op.op !== "PLAN" && op.op !== "SEND").length;
|
|
893
799
|
if (!steerStruck && turnStatus === TURN_STATUS_IMPLICIT_CONTINUE && midOpsCount === 0) {
|
|
894
800
|
steerStruck = true;
|
|
895
|
-
|
|
896
|
-
source: "engine:rail",
|
|
897
|
-
kind: "idle_turn",
|
|
898
|
-
message: "If the turn's work is complete, terminate with 200. If awaiting a stream or run trigger, terminate with 202 to hibernate.",
|
|
899
|
-
level: "warn",
|
|
900
|
-
});
|
|
801
|
+
pendingEngineErrors.push("idle_turn");
|
|
901
802
|
}
|
|
902
803
|
// Close the turn with the final packet, status, and usage stats.
|
|
903
|
-
const packet = this.#completePacket(requestPacket, packetAssistant, response.assistantRaw, provider);
|
|
804
|
+
const packet = this.#packets.completePacket(requestPacket, packetAssistant, response.assistantRaw, provider);
|
|
904
805
|
const { usage, finishReason, model } = callMetadata;
|
|
905
806
|
await this.#db.engine_close_turn.run({
|
|
906
807
|
id: turnId,
|
|
@@ -948,28 +849,22 @@ class Engine {
|
|
|
948
849
|
statement, sessionId, runId, loopId, turnId,
|
|
949
850
|
sequence: rowSeq,
|
|
950
851
|
origin, onDispatch,
|
|
852
|
+
prematureRefusal: prematureTerminate && statement === sendOp ? prematureReason : undefined, // the pre-dispatch snapshot's reason, only for this turn's terminal SEND
|
|
951
853
|
});
|
|
952
854
|
statuses.push(result.status);
|
|
953
855
|
rowSeq += result.rowsWritten ?? 1;
|
|
954
856
|
}
|
|
955
|
-
//
|
|
956
|
-
//
|
|
957
|
-
//
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
});
|
|
967
|
-
}
|
|
968
|
-
// §telemetry — parse errors as LOG ITEMS: a failed-to-parse emission records an actionless
|
|
969
|
-
// `error` row (status 400, no target, snippet = the foldable body) at the turn's next free
|
|
970
|
-
// sequence (after the dispatched ops). The model folds/kills/recalls it like any log entry,
|
|
971
|
-
// and the errors section derives a pointer (status + coordinate) from log≥400 — one surface.
|
|
972
|
-
let errSeq = rowSeq; // after every dispatched row, including a multi-file READ's fan-out
|
|
857
|
+
// §telemetry-uniform-error-channel — every engine + parse failure mints as an op='error'
|
|
858
|
+
// log row at the turn's next free sequence (after every dispatched row, incl. a multi-file
|
|
859
|
+
// READ's fan-out). One channel: the errors section derives a LogCoordinate pointer from log≥400.
|
|
860
|
+
let errSeq = rowSeq;
|
|
861
|
+
// max_commands_exceeded IS model-facing: dropped ops the model emitted that didn't run.
|
|
862
|
+
if (droppedCount > 0)
|
|
863
|
+
pendingEngineErrors.push("max_commands_exceeded");
|
|
864
|
+
for (const kind of pendingEngineErrors)
|
|
865
|
+
await this.#telemetry.mintEngineError(kind, { runId, loopId, turnId, sequence: errSeq++ });
|
|
866
|
+
// Parse errors carry the parser message + a content-offset line:col (a ContentOffset position),
|
|
867
|
+
// resolved against the model's born-OPEN emission (§model-entry) — origin 'model', not engine.
|
|
973
868
|
for (const { message, line, column, source } of parseErrors ?? []) {
|
|
974
869
|
await this.#db.engine_insert_log_entry.get({
|
|
975
870
|
run_id: runId, loop_id: loopId, turn_id: turnId, sequence: errSeq++,
|
|
@@ -993,13 +888,13 @@ class Engine {
|
|
|
993
888
|
// struck/silent turn) write nothing — no prior output to mirror.
|
|
994
889
|
if (packetAssistant.content.trim().length > 0) {
|
|
995
890
|
const hadEmissionError = (parseErrors?.length ?? 0) > 0 || hadContentOffsetNotice;
|
|
996
|
-
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 });
|
|
997
892
|
}
|
|
998
893
|
// Zero ops is NOT an error to report — the model knows it emitted
|
|
999
894
|
// nothing. Strike accounting (engine-internal) treats it as a
|
|
1000
895
|
// struck turn; the model just sees an empty packet next turn.
|
|
1001
896
|
// Per SPEC §telemetry gamification policy.
|
|
1002
|
-
return { turnId, status: turnStatus, statuses, fingerprint:
|
|
897
|
+
return { turnId, status: turnStatus, statuses, fingerprint: StrikeRail.fingerprintTurn(packetAssistant.ops), budgetStruck: enforced.struck, budgetHardStop: false, steerStruck };
|
|
1003
898
|
}
|
|
1004
899
|
// Split the wire-level ProviderResponse into the two destinations:
|
|
1005
900
|
// packet.assistant gets the model's emission (content, ops, reasoning);
|
|
@@ -1065,317 +960,10 @@ class Engine {
|
|
|
1065
960
|
parseErrors,
|
|
1066
961
|
};
|
|
1067
962
|
}
|
|
1068
|
-
// Assemble the request half of the spec'd packet (Packet.json §system
|
|
1069
|
-
// and §user) BEFORE the provider call. The same packet object is then
|
|
1070
|
-
// completed with assistant + assistantRaw after the model responds, so
|
|
1071
|
-
// the stored packet and the wire payload share one source of truth.
|
|
1072
|
-
async #buildRequestPacket({ initialMessages, requirements, sessionId, runId, loopId, currentTurnSeq, provider, gitStatus, telemetryErrors: presetTelemetry, }) {
|
|
1073
|
-
const byRole = (role) => initialMessages.filter((m) => m.role === role).map((m) => m.content).join("\n\n");
|
|
1074
|
-
// plurnk.md (grammar/dialects) ONLY — the definition is the hot-path grammar.
|
|
1075
|
-
// The scheme catalogue is its own `schemes` section below tools (§schemes-directory),
|
|
1076
|
-
// NOT appended here: grammar 0.49+ is scheme-agnostic, so the service advertises
|
|
1077
|
-
// the scheme set at packet-time (grammar#239 item 7) via SchemeRegistry.teach().
|
|
1078
|
-
const system_definition = byRole("system");
|
|
1079
|
-
// the prompt section sources from the loop's most recent prompt entry first
|
|
1080
|
-
// (plurnk:///prompt/<loop_id>/<N> for the highest N written to date).
|
|
1081
|
-
// This is what inject + the turn-1 foist write into. Falls back to
|
|
1082
|
-
// the runLoop caller's messages.user for tests that bypass the
|
|
1083
|
-
// foist mechanism entirely.
|
|
1084
|
-
const latestPromptRow = await this.#db.drain_get_latest_prompt_body_for_loop.get({ pattern: `/prompt/${loopId}/%` });
|
|
1085
|
-
const promptCap = Number.parseInt(process.env.PLURNK_PROMPT_PREVIEW_CHARS ?? "", 10);
|
|
1086
|
-
const prompt = (latestPromptRow !== undefined && typeof latestPromptRow.content === "string" && latestPromptRow.content.length > 0)
|
|
1087
|
-
? PacketWire.previewPrompt(latestPromptRow.content, renderAddress("plurnk", latestPromptRow.pathname), Number.isInteger(promptCap) ? promptCap : -1)
|
|
1088
|
-
: byRole("user");
|
|
1089
|
-
// Requirements is engine-sourced, NOT threaded from callers — that threading is
|
|
1090
|
-
// exactly how it went missing (callers read the sysprompt but never the
|
|
1091
|
-
// requirements). Read Paths.defaultRequirements (PLURNK_REQUIREMENTS env →
|
|
1092
|
-
// requirements.md) fresh each build so edits take effect; a non-empty param wins.
|
|
1093
|
-
const baseRequirements = requirements.length > 0 ? requirements : await readFile(Paths.defaultRequirements, "utf8");
|
|
1094
|
-
// No injected syntax line: the grammar already headlines the system definition (§Syntax) and
|
|
1095
|
-
// leads requirements.md, so a third copy here was pure duplication in the model's packet. PLAN
|
|
1096
|
-
// is mandated unconditionally by plurnk.md §Imperatives (grammar 0.70 requires every turn to
|
|
1097
|
-
// lead with PLAN), so the service injects no separate plan directive either (the former
|
|
1098
|
-
// PLURNK_PLAN gating is retired — PLURNK_PLAN is no longer a flag).
|
|
1099
|
-
const log = await this.#buildLog(runId);
|
|
1100
|
-
const telemetryErrors = presetTelemetry ?? await this.#buildTelemetryErrors(loopId, currentTurnSeq);
|
|
1101
|
-
const countTokens = (t) => provider.countTokens(t); // §provider-surface-counttokens
|
|
1102
|
-
const tools = this.#collectTools();
|
|
1103
|
-
// Budget readout (SPEC.md §tokenomics). Two-pass: render the budget from
|
|
1104
|
-
// the structured log's subtotals with a {{tokensFree}} placeholder, build
|
|
1105
|
-
// the section list, measure the assembled total, resolve free, substitute.
|
|
1106
|
-
// Subtotals come from the real log render — meta and fences included — not
|
|
1107
|
-
// a serialized approximation. ceiling is the provider's window ×
|
|
1108
|
-
// PLURNK_BUDGET_CEILING (null when no window is reported → headline
|
|
1109
|
-
// omitted, section lines still shown). §tokenomics-render-weight-budget
|
|
1110
|
-
const ceiling = _a.computeCeiling(provider.contextSize, this.#budgetCeiling);
|
|
1111
|
-
const budgetReadout = this.#renderBudget(PacketWire.measureLogBudget(log, countTokens), ceiling);
|
|
1112
|
-
// The default packet: an ordered list of addressable sections (§packet-construction).
|
|
1113
|
-
// `slot` is a TRUST boundary (and the prompt-cache boundary): system holds only
|
|
1114
|
-
// framework-authored, non-injectable sections — the static head (definition/tools/
|
|
1115
|
-
// schemes/policy) forms the cached prefix, then the volatile-but-trusted tail of
|
|
1116
|
-
// errors/git/budget; user holds injectable content (the log, the operator prompt) plus
|
|
1117
|
-
// the requirements footer. The budget section carries its {{tokensFree}} placeholders
|
|
1118
|
-
// here; they resolve below once the assembled total is known.
|
|
1119
|
-
const inject = await readPacketInject(); // #240 — operator section, per-turn, fail-hard on a broken path
|
|
1120
|
-
const sessionRoot = (await this.#db.envelope_get_session.get({ id: sessionId }))?.project_root ?? null;
|
|
1121
|
-
const systemPolicy = await readSystemPolicy(); // ~/.plurnk/AGENTS.md (or PLURNK_POLICY)
|
|
1122
|
-
const projectPolicy = await readProjectPolicy(sessionRoot); // <projectRoot>/AGENTS.md (or PLURNK_PROJECT)
|
|
1123
|
-
const defaults = [
|
|
1124
|
-
{ name: "definition", slot: "system", header: null, content: system_definition, tokens: 0 },
|
|
1125
|
-
{ name: "tools", slot: "system", header: null, content: tools.join("\n"), tokens: 0 }, // titleless — the examples flow on from plurnk.md (definition) directly above
|
|
1126
|
-
{ name: "schemes", slot: "system", header: "Plurnk System Schemes", content: this.#schemes.teach(), tokens: 0 },
|
|
1127
|
-
...(inject !== null ? [{ name: "inject", slot: "system", header: "Plurnk Operator Notes", content: inject, tokens: 0 }] : []),
|
|
1128
|
-
// policy: the client's privileged rules — ~/.plurnk/AGENTS.md (system) then <root>/AGENTS.md (project) — below grammar/tools/schemes, above budget-the-law. AGENTS is POLICY here, never a curatable READable entry. Empty content ⇒ section omitted.
|
|
1129
|
-
{ name: "system-policy", slot: "system", header: "Plurnk System Policy", content: systemPolicy ?? "", tokens: 0 },
|
|
1130
|
-
{ name: "project-policy", slot: "system", header: "Project Policy", content: projectPolicy ?? "", tokens: 0 },
|
|
1131
|
-
// The packet split is a TRUST boundary: system carries only framework-authored, non-injectable
|
|
1132
|
-
// sections; anything that could carry attacker-reachable text (a READ result, exec output, the
|
|
1133
|
-
// model's own mirrored bytes) stays in user. errors + git are framework status — the errors
|
|
1134
|
-
// section is uri+status POINTERS (the error item + body live in the log), git is counts — so
|
|
1135
|
-
// neither is an injection surface; both sit at the bottom of system, just above budget-the-law.
|
|
1136
|
-
{ name: "errors", slot: "system", header: "Plurnk System Errors", content: PacketWire.renderErrors(telemetryErrors), tokens: 0 },
|
|
1137
|
-
{ name: "git", slot: "system", header: "Plurnk System Git Status", content: PacketWire.renderGit(gitStatus), tokens: 0 },
|
|
1138
|
-
// budget is the very last system line — LAW (a hard ceiling the model must obey), the final word before the model acts.
|
|
1139
|
-
{ name: "budget", slot: "system", header: "Plurnk System Budget", content: budgetReadout, tokens: 0 },
|
|
1140
|
-
{ name: "prompt", slot: "user", header: "Plurnk System User Prompt", content: prompt, tokens: 0 },
|
|
1141
|
-
// log in the user slot: injectable content (READ results, exec output, the model's own mirror) — data, never rules — kept at the action point so the model consults its history.
|
|
1142
|
-
{ name: "log", slot: "user", header: "Plurnk System Log", content: PacketWire.renderLog(log, countTokens), tokens: 0 },
|
|
1143
|
-
// requirements renders LAST — the user-slot footer, the syntax contract closest to the model's turn (a recency carve-out for weak models).
|
|
1144
|
-
{ name: "requirements", slot: "user", header: "Plurnk System Requirements", content: baseRequirements, tokens: 0 },
|
|
1145
|
-
];
|
|
1146
|
-
// Plugin packet control (§packet-construction): trusted schemes rewrite the
|
|
1147
|
-
// default list — add, remove, reorder — in-process, before measurement.
|
|
1148
|
-
const sections = await this.#schemes.transformSections(defaults);
|
|
1149
|
-
// Pass 1: measure the assembled total with the placeholder budget in
|
|
1150
|
-
// place, resolve free/percent, substitute into the budget section.
|
|
1151
|
-
const total = countTokens(PacketWire.renderSlot(sections, "system")) + countTokens(PacketWire.renderSlot(sections, "user"));
|
|
1152
|
-
const tokensFree = ceiling === null ? null : Math.max(0, ceiling - total); // free floors at 0 on overshoot — §tokenomics-over-budget-floor
|
|
1153
|
-
const percent = ceiling === null ? null : Math.round((total / ceiling) * 100); // usage as % of the ceiling — §tokenomics-context-percent
|
|
1154
|
-
if (tokensFree !== null) {
|
|
1155
|
-
const budgetSec = sections.find((s) => s.name === "budget"); // a plugin may have removed it
|
|
1156
|
-
if (budgetSec) {
|
|
1157
|
-
budgetSec.content = budgetSec.content
|
|
1158
|
-
.replace(TOKEN_USAGE_PLACEHOLDER, String(total))
|
|
1159
|
-
.replace(TOKEN_PERCENT_PLACEHOLDER, percent === 0 && total > 0 ? "<1" : String(percent))
|
|
1160
|
-
.replace(TOKENS_FREE_PLACEHOLDER, String(tokensFree));
|
|
1161
|
-
}
|
|
1162
|
-
}
|
|
1163
|
-
// Pass 2: per-section render-weight + the assembled packet total (post
|
|
1164
|
-
// substitution — the placeholder/number length delta is negligible).
|
|
1165
|
-
for (const s of sections)
|
|
1166
|
-
s.tokens = countTokens(PacketWire.renderSection(s));
|
|
1167
|
-
const packetTokens = countTokens(PacketWire.renderSlot(sections, "system")) + countTokens(PacketWire.renderSlot(sections, "user"));
|
|
1168
|
-
return { tokens: packetTokens, sections, telemetryErrors };
|
|
1169
|
-
}
|
|
1170
|
-
// Budget readout body, rendered into the `## Plurnk System Budget` section.
|
|
1171
|
-
// Headline `ceiling/free` only when a ceiling exists; section lines for the
|
|
1172
|
-
// curatable index/log weight the model can FOLD back. tokensFree is a
|
|
1173
|
-
// placeholder here — buildSystem substitutes it after measuring the packet.
|
|
1174
|
-
#renderBudget(log, ceiling) {
|
|
1175
|
-
const lines = [];
|
|
1176
|
-
if (ceiling !== null)
|
|
1177
|
-
lines.push(`Token Ceiling ${ceiling} · Token Usage ${TOKEN_USAGE_PLACEHOLDER} (${TOKEN_PERCENT_PLACEHOLDER}%) · Tokens Free ${TOKENS_FREE_PLACEHOLDER}`);
|
|
1178
|
-
if (log.entries > 0) {
|
|
1179
|
-
if (lines.length > 0)
|
|
1180
|
-
lines.push("");
|
|
1181
|
-
lines.push(`Log entries: ${log.entries} entries, ${log.tokens} tokens`);
|
|
1182
|
-
// Per-turn weight — the grinder's rollback unit, oldest first: the
|
|
1183
|
-
// model sees what's first to go (§tokenomics {§tokenomics-turn-totals}).
|
|
1184
|
-
if (log.byTurn.length > 0) {
|
|
1185
|
-
lines.push("", "Turns:", "| turn | tokens |", "|---|--:|");
|
|
1186
|
-
for (const t of log.byTurn)
|
|
1187
|
-
lines.push(`| ${t.turn} | ${t.tokens} |`);
|
|
1188
|
-
}
|
|
1189
|
-
// The heaviest individual log items — the FOLD targets behind the weight
|
|
1190
|
-
// (§tokenomics {§tokenomics-largest-entries}). "items", not "entries": the readout
|
|
1191
|
-
// lists log:/// rows (log items), distinct from catalog entries (plurnk.md: "EDIT
|
|
1192
|
-
// is only for entries. Do not attempt to edit log items.").
|
|
1193
|
-
if (log.largest.length > 0) {
|
|
1194
|
-
lines.push("", "Heaviest items:", "| item | tokens |", "|---|--:|");
|
|
1195
|
-
for (const e of log.largest)
|
|
1196
|
-
lines.push(`| ${e.path} | ${e.tokens} |`);
|
|
1197
|
-
}
|
|
1198
|
-
}
|
|
1199
|
-
return lines.join("\n");
|
|
1200
|
-
}
|
|
1201
|
-
// The ## Plurnk System Tools capability sheet (SPEC §tools). A hook: each enabled
|
|
1202
|
-
// capability contributes one line, rendered above Requirements so the model sees what
|
|
1203
|
-
// it can do before the rules. Each available executor tag contributes its self-documenting
|
|
1204
|
-
// example (plurnk-execs#7), retiring the blind EXEC.
|
|
1205
|
-
// The capability sheet — the live tool surface (wired executor tags). §tools-capability-sheet
|
|
1206
|
-
#collectTools() {
|
|
1207
|
-
const tools = [];
|
|
1208
|
-
// Each available runtime tag contributes its self-documenting example —
|
|
1209
|
-
// the example carries syntax + purpose, so there's no prose line. Tags
|
|
1210
|
-
// with no example (sh/node, covered by the core prompt) contribute
|
|
1211
|
-
// nothing; available-only, so the model never sees an unusable tag. `* `
|
|
1212
|
-
// bullets + bare op forms match the packet's list/op rendering (no `- `,
|
|
1213
|
-
// no backticks — see packet-wire.ts).
|
|
1214
|
-
if (this.#executors !== undefined) {
|
|
1215
|
-
const excluded = docsExcludeSet();
|
|
1216
|
-
for (const tag of this.#executors.availableRuntimes()) {
|
|
1217
|
-
if (excluded.has(tag))
|
|
1218
|
-
continue; // #240 — PLURNK_DOCS_EXCLUDE drops the oneliner + the doc
|
|
1219
|
-
const entry = this.#executors.entry(tag);
|
|
1220
|
-
// #240 — identical treatment with the scheme directory: the example IS the oneliner,
|
|
1221
|
-
// the fuller doc (materialized at plurnk://docs/<tag>.md) rides an inline link whose
|
|
1222
|
-
// token cost lives on that manifest entry. No example → no line (like a provisional scheme).
|
|
1223
|
-
if (entry?.example)
|
|
1224
|
-
tools.push(teachingLine(entry.example));
|
|
1225
|
-
}
|
|
1226
|
-
}
|
|
1227
|
-
return tools;
|
|
1228
|
-
}
|
|
1229
963
|
// #note12 — the daughter-provided reference docs (schemes' + execs' `documentation`),
|
|
1230
|
-
// materialized at plurnk:///docs/<name>.md by loop_run (like operator docs)
|
|
1231
|
-
// 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).
|
|
1232
965
|
docEntries() {
|
|
1233
|
-
|
|
1234
|
-
if (this.#executors !== undefined) {
|
|
1235
|
-
const excluded = docsExcludeSet();
|
|
1236
|
-
for (const tag of this.#executors.availableRuntimes()) {
|
|
1237
|
-
if (excluded.has(tag))
|
|
1238
|
-
continue; // #240 — exec docs honor the same exclude
|
|
1239
|
-
const doc = this.#executors.entry(tag)?.documentation;
|
|
1240
|
-
if (doc !== undefined && doc.length > 0)
|
|
1241
|
-
out.push({ name: tag, content: doc });
|
|
1242
|
-
}
|
|
1243
|
-
}
|
|
1244
|
-
return out;
|
|
1245
|
-
}
|
|
1246
|
-
// SPEC §grinder — the budget grinder. Runs pre-LLM (in runTurn, after the packet
|
|
1247
|
-
// is built, before provider.generate); fires only on actual overflow. Two
|
|
1248
|
-
// passes, re-measuring between. Folds (never deletes) — the prior turn's logs,
|
|
1249
|
-
// then the catalog except the manifest lifeline. The strike it raises and the
|
|
1250
|
-
// hard-stop it can signal are returned to runLoop, which owns abandonment.
|
|
1251
|
-
// §grinder-overflow-only — fires only on actual overflow, never speculatively
|
|
1252
|
-
async #enforceBudget({ packet, provider, runId, loopId, turnId, sessionId, turnNumber, rebuild }) {
|
|
1253
|
-
const ceiling = _a.computeCeiling(provider.contextSize, this.#budgetCeiling);
|
|
1254
|
-
const measure = (p) => p.tokens;
|
|
1255
|
-
if (ceiling === null || measure(packet) <= ceiling)
|
|
1256
|
-
return { packet, fit: true, struck: false };
|
|
1257
|
-
const folded = new Map();
|
|
1258
|
-
const note = (scheme) => { folded.set(scheme, (folded.get(scheme) ?? 0) + 1); };
|
|
1259
|
-
// Pass 1 — prior-turn rollback: fold the latest emissions (the ones that
|
|
1260
|
-
// pushed it over). No prior turn (turn 1, env overflow) → no-op → pass 2.
|
|
1261
|
-
const priorLogs = await this.#db.engine_grinder_prior_turn_logs.all({ loop_id: loopId, turn_id: turnId }); // prior-turn rollback folds the latest emissions — §grinder-layer1-rollback
|
|
1262
|
-
for (const le of priorLogs)
|
|
1263
|
-
note(le.scheme ?? "log");
|
|
1264
|
-
if (priorLogs.length > 0)
|
|
1265
|
-
await this.#db.engine_grinder_fold_prior_turn_logs.run({ loop_id: loopId, turn_id: turnId });
|
|
1266
|
-
const errors = packet.telemetryErrors;
|
|
1267
|
-
let current = priorLogs.length > 0 ? await rebuild(errors) : packet;
|
|
1268
|
-
if (measure(current) <= ceiling) {
|
|
1269
|
-
this.#emitBudgetOverflow(sessionId, loopId, folded);
|
|
1270
|
-
return { packet: current, fit: true, struck: turnNumber > 1 }; // turn 0/1 overflow is the environment, never a strike — §grinder-soft-turn-0-1
|
|
1271
|
-
}
|
|
1272
|
-
// Prior-turn rollback is the only budget lever now: entries don't render
|
|
1273
|
-
// (no index), so there is no catalog to collapse. If pass 1 didn't fit,
|
|
1274
|
-
// the packet is over and the caller hard-413s. §grinder-hard-413-abort
|
|
1275
|
-
this.#emitBudgetOverflow(sessionId, loopId, folded);
|
|
1276
|
-
return { packet: current, fit: measure(current) <= ceiling, struck: turnNumber > 1 };
|
|
1277
|
-
}
|
|
1278
|
-
// The model-facing budget event (SPEC §grinder, §telemetry): which entries left the
|
|
1279
|
-
// window, by scheme — the model's own terms, no mechanism vocabulary. The
|
|
1280
|
-
// strike this overflow triggers stays engine-internal (gamification policy).
|
|
1281
|
-
// §grinder-event-model-terms — model-facing terms only; the strike stays engine-internal
|
|
1282
|
-
#emitBudgetOverflow(sessionId, loopId, folded) {
|
|
1283
|
-
if (folded.size === 0)
|
|
1284
|
-
return;
|
|
1285
|
-
this.#pushTelemetry(sessionId, loopId, {
|
|
1286
|
-
source: "engine:rail",
|
|
1287
|
-
kind: "budget_overflow",
|
|
1288
|
-
folded: [...folded.entries()].map(([scheme, count]) => ({ scheme, count })),
|
|
1289
|
-
level: "warn",
|
|
1290
|
-
});
|
|
1291
|
-
}
|
|
1292
|
-
// Wire projection lives in ./packet-wire.ts so Engine and
|
|
1293
|
-
// bin/digest.ts import the exact same function — structurally one
|
|
1294
|
-
// implementation, no drift between wire and digest possible.
|
|
1295
|
-
// Format: markdown (user pick over rummy's XML alternative, 2026-05-22).
|
|
1296
|
-
#packetToWireMessages(packet) {
|
|
1297
|
-
return PacketWire.packetToWireMessages(packet);
|
|
1298
|
-
}
|
|
1299
|
-
// Complete the packet by adding the model's response. After this the
|
|
1300
|
-
// packet matches Packet.json fully and is ready for storage.
|
|
1301
|
-
#completePacket(requestPacket, assistant, assistantRaw, provider) {
|
|
1302
|
-
const assistantTokens = provider.countTokens(assistant.content);
|
|
1303
|
-
return {
|
|
1304
|
-
tokens: requestPacket.tokens + assistantTokens,
|
|
1305
|
-
sections: requestPacket.sections,
|
|
1306
|
-
telemetryErrors: requestPacket.telemetryErrors,
|
|
1307
|
-
assistant,
|
|
1308
|
-
assistantRaw,
|
|
1309
|
-
};
|
|
1310
|
-
}
|
|
1311
|
-
// Render-time mimetype invocation (SPEC §mimetype {§mimetype-handlers-fire-render-time},
|
|
1312
|
-
// §per-entry-channels {§per-entry-channels-preview-is-handler-output}). For each (run, entry, channel)
|
|
1313
|
-
// with expanded=1, pass the channel's current content through
|
|
1314
|
-
// mimetype.preview(content, budget). State is included verbatim — engine
|
|
1315
|
-
// does NOT branch on it (§channel-state {§channel-state-engine-does-not-branch-on-state}).
|
|
1316
|
-
// SPEC §telemetry: model-facing alert surface.
|
|
1317
|
-
// Two sources, merged on each packet build:
|
|
1318
|
-
// 1. Previous-turn action-bound failures (status_rx >= 400 on log_entries).
|
|
1319
|
-
// 2. Engine-buffered actionless failures (no_send, parse, watchdog, rails).
|
|
1320
|
-
// Buffer drains on read — each error appears in exactly one packet.
|
|
1321
|
-
async #buildTelemetryErrors(loopId, currentTurnSeq) {
|
|
1322
|
-
const rows = await this.#db.engine_render_telemetry_errors.all({ loop_id: loopId, current_turn_seq: currentTurnSeq });
|
|
1323
|
-
const actionFailures = rows.map((r) => {
|
|
1324
|
-
const target = r.scheme !== null
|
|
1325
|
-
? `${r.scheme}://${r.pathname ?? ""}`
|
|
1326
|
-
: (r.pathname ?? null);
|
|
1327
|
-
const parsedRx = r.mimetype_rx === "application/json" ? JSON.parse(r.rx) : r.rx;
|
|
1328
|
-
return {
|
|
1329
|
-
kind: "action_failure",
|
|
1330
|
-
level: "error", // the derived error pointer is always an error — clients color off level (#276)
|
|
1331
|
-
coordinate: `${r.loop_seq}/${r.turn_seq}/${r.sequence}`,
|
|
1332
|
-
op: r.op,
|
|
1333
|
-
target,
|
|
1334
|
-
status: r.status_rx,
|
|
1335
|
-
error: typeof parsedRx === "object" && parsedRx !== null && "error" in parsedRx
|
|
1336
|
-
? parsedRx.error
|
|
1337
|
-
: typeof parsedRx === "string" ? parsedRx : "",
|
|
1338
|
-
};
|
|
1339
|
-
});
|
|
1340
|
-
return [...this.#drainTelemetry(loopId), ...actionFailures];
|
|
1341
|
-
}
|
|
1342
|
-
// SPEC §packet the log section — chronological action-entries for the loop.
|
|
1343
|
-
// Snapshot is taken at packet build (pre-dispatch this turn), so it
|
|
1344
|
-
// reflects "what has happened before this turn." Each row carries a
|
|
1345
|
-
// log:///<loop_seq>/<turn_seq>/<sequence> coordinate the model can READ.
|
|
1346
|
-
async #buildLog(runId) {
|
|
1347
|
-
// SPEC §packet-terms: runs own log entries — log is the run's history,
|
|
1348
|
-
// not the loop's. Span all loops in the run so the model sees
|
|
1349
|
-
// earlier loops' work as conversational memory.
|
|
1350
|
-
//
|
|
1351
|
-
// User prompts are first-class log entries: runTurn writes a
|
|
1352
|
-
// client-origin SEND[200] row at sequence=0 of each new
|
|
1353
|
-
// turn-1. Prompts thus surface naturally in this query — no
|
|
1354
|
-
// synthetic / shim layer.
|
|
1355
|
-
const rows = await this.#db.engine_render_log.all({ run_id: runId });
|
|
1356
|
-
return rows.map((r) => ({
|
|
1357
|
-
coordinate: `${r.loop_seq}/${r.turn_seq}/${r.sequence}`,
|
|
1358
|
-
origin: r.origin,
|
|
1359
|
-
op: r.op,
|
|
1360
|
-
suffix: r.suffix,
|
|
1361
|
-
signal: r.signal === null ? null : JSON.parse(r.signal),
|
|
1362
|
-
target: {
|
|
1363
|
-
scheme: r.scheme,
|
|
1364
|
-
username: r.username, password: r.password,
|
|
1365
|
-
hostname: r.hostname, port: r.port,
|
|
1366
|
-
pathname: r.pathname,
|
|
1367
|
-
params: r.params === null ? null : JSON.parse(r.params),
|
|
1368
|
-
fragment: r.fragment,
|
|
1369
|
-
},
|
|
1370
|
-
status: r.status_rx,
|
|
1371
|
-
rx: r.mimetype_rx === "application/json" ? JSON.parse(r.rx) : r.rx,
|
|
1372
|
-
mimetype_rx: r.mimetype_rx,
|
|
1373
|
-
tx: r.mimetype_tx === "application/json" ? JSON.parse(r.tx) : r.tx,
|
|
1374
|
-
mimetype_tx: r.mimetype_tx,
|
|
1375
|
-
folded: r.expanded === 0,
|
|
1376
|
-
source: r.source,
|
|
1377
|
-
attrs: r.attrs === null ? null : JSON.parse(r.attrs),
|
|
1378
|
-
}));
|
|
966
|
+
return this.#packets.docEntries();
|
|
1379
967
|
}
|
|
1380
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
|
|
1381
969
|
// run last looked. No per-run snapshot (§machine-processes "a run is its log"): every
|
|
@@ -1494,241 +1082,27 @@ class Engine {
|
|
|
1494
1082
|
}
|
|
1495
1083
|
}
|
|
1496
1084
|
async dispatch(context) {
|
|
1497
|
-
|
|
1498
|
-
const schemeCtx = this.#buildSchemeCtx({ sessionId, runId, loopId, turnId, origin });
|
|
1499
|
-
let result;
|
|
1500
|
-
let denial = this.#checkWritable(statement, origin);
|
|
1501
|
-
if (denial === null)
|
|
1502
|
-
denial = await this.#checkFlagsGate(statement, loopId);
|
|
1503
|
-
if (denial !== null) {
|
|
1504
|
-
result = denial;
|
|
1505
|
-
}
|
|
1506
|
-
else if (_a.#readFansOut(statement)) {
|
|
1507
|
-
// READ honors FIND: a glob/folder scope or a matcher fans out to one log row per MATCH
|
|
1508
|
-
// (its own writeLogs), returning early. A READ never proposes, so it bypasses the
|
|
1509
|
-
// single-row path below. A bare entry, body-less, falls through to the direct read. #286
|
|
1510
|
-
return await this.#handleReadFanout(statement, schemeCtx, { runId, loopId, turnId, sequence, origin, onDispatch });
|
|
1511
|
-
}
|
|
1512
|
-
else {
|
|
1513
|
-
// SPEC §scheme-surface + plurnk-schemes#1: action-entry-as-outcome. Scheme-handler
|
|
1514
|
-
// exceptions become the action-entry's outcome (status 500), not a
|
|
1515
|
-
// thrown bubble. The log_entry is the durable record; engine never
|
|
1516
|
-
// skips it. Logging failures (#writeLog throws) are NOT caught —
|
|
1517
|
-
// those are system failures.
|
|
1518
|
-
try {
|
|
1519
|
-
if (statement.op === "SEND" && statement.target === null) {
|
|
1520
|
-
result = await this.#handleSendBroadcast(statement, loopId);
|
|
1521
|
-
}
|
|
1522
|
-
else if (statement.op === "COPY") {
|
|
1523
|
-
result = await this.#handleCopy(statement, schemeCtx);
|
|
1524
|
-
}
|
|
1525
|
-
else if (statement.op === "MOVE") {
|
|
1526
|
-
result = await this.#handleMove(statement, schemeCtx);
|
|
1527
|
-
}
|
|
1528
|
-
else if (statement.op === "KILL") {
|
|
1529
|
-
result = await this.#handleKill(statement, schemeCtx);
|
|
1530
|
-
}
|
|
1531
|
-
else if (statement.op === "PLAN") {
|
|
1532
|
-
result = this.#handlePlan(statement);
|
|
1533
|
-
}
|
|
1534
|
-
else if (statement.op === "EXEC") {
|
|
1535
|
-
// EXEC's target slot is `cwd`, not a scheme address.
|
|
1536
|
-
// Per plurnk.md the op routes unconditionally to the
|
|
1537
|
-
// exec scheme; the scheme handler reads runtime
|
|
1538
|
-
// (signal), cwd (target), and command (body).
|
|
1539
|
-
result = await this.#run("exec", statement, schemeCtx);
|
|
1540
|
-
}
|
|
1541
|
-
else {
|
|
1542
|
-
result = await this.#run(this.#schemeNameOf(statement.target), statement, schemeCtx); // §op-methods-op-dispatch
|
|
1543
|
-
}
|
|
1544
|
-
}
|
|
1545
|
-
catch (err) { // a scheme exception becomes the op's 500 outcome — §scheme-surface-exception-500
|
|
1546
|
-
result = {
|
|
1547
|
-
status: 500,
|
|
1548
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1549
|
-
};
|
|
1550
|
-
}
|
|
1551
|
-
}
|
|
1552
|
-
const logEntryId = await this.#writeLog({ statement, result, runId, loopId, turnId, sequence, origin });
|
|
1553
|
-
onDispatch?.(logEntryId);
|
|
1554
|
-
// Proposal lifecycle (SPEC.md §engine-rails + §methods loop.resolve; §proposal-202-pauses). When a
|
|
1555
|
-
// side-effecting op returns status 202 (a broadcast SEND[202] park is model
|
|
1556
|
-
// speech, not a proposal — #isProposal, #255), the entry is written
|
|
1557
|
-
// state='proposed'; dispatch then PAUSES on a per-entry waiter until
|
|
1558
|
-
// resolution arrives via Engine.resolveProposal (from the loop/resolve RPC,
|
|
1559
|
-
// YOLO listener, or timeout). The post-resolution status replaces 202 in the
|
|
1560
|
-
// result the caller sees, so runTurn never branches on a pending state.
|
|
1561
|
-
if (_a.#isProposal(statement, result)) {
|
|
1562
|
-
// Effect-gated auto-run (read/pure runtimes, plurnk-service#182):
|
|
1563
|
-
// no human gate, no loop/proposal notification. Accept + apply
|
|
1564
|
-
// in-process; the model sees the outcome directly, never a review.
|
|
1565
|
-
if (result.attrs?.inline === true) {
|
|
1566
|
-
const effective = await this.#runApplyResolution(statement, result, { decision: "accept" }, { sessionId, runId, loopId, turnId });
|
|
1567
|
-
return this.#applyResolution(logEntryId, effective);
|
|
1568
|
-
}
|
|
1569
|
-
// Register the resolution waiter SYNCHRONOUSLY before any await
|
|
1570
|
-
// yields. A same-tick resolveProposal() (e.g. from a test that
|
|
1571
|
-
// awaits the onDispatch callback and immediately resolves) must
|
|
1572
|
-
// find the waiter registered — adding an await between insert
|
|
1573
|
-
// and waiter-registration would open a race window.
|
|
1574
|
-
const resolutionPromise = this.#awaitResolution(logEntryId);
|
|
1575
|
-
// Notify external listeners (Daemon broadcasts loop/proposal;
|
|
1576
|
-
// YOLO listener auto-resolves) BEFORE awaiting — they may
|
|
1577
|
-
// resolve synchronously inside their handlers.
|
|
1578
|
-
const target = this.#extractTarget(statement.target);
|
|
1579
|
-
const flags = await this.#loadLoopFlags(loopId); // the loop/proposal notification carries flags (yolo) — §dual-yolo-proposal-carries-flags
|
|
1580
|
-
// #note10 — if the target diverged on disk this turn, the model's EDIT is based
|
|
1581
|
-
// on a stale read; flag it so a YOLO auto-accept rejects instead of clobbering.
|
|
1582
|
-
const diverged = await this.#db.engine_target_diverged_this_turn.get({ run_id: runId, turn_id: turnId, scheme: target.scheme, pathname: target.pathname });
|
|
1583
|
-
const event = {
|
|
1584
|
-
logEntryId, sessionId, runId, loopId, turnId,
|
|
1585
|
-
op: statement.op,
|
|
1586
|
-
target: { scheme: target.scheme, pathname: target.pathname },
|
|
1587
|
-
body: typeof result.body === "string" ? result.body : "",
|
|
1588
|
-
attrs: (result.attrs ?? {}),
|
|
1589
|
-
flags,
|
|
1590
|
-
staleClobberRisk: diverged !== undefined,
|
|
1591
|
-
};
|
|
1592
|
-
for (const listener of this.#proposalPendingListeners) {
|
|
1593
|
-
try {
|
|
1594
|
-
listener(event);
|
|
1595
|
-
}
|
|
1596
|
-
catch (_) { /* listener errors don't break dispatch */ }
|
|
1597
|
-
}
|
|
1598
|
-
const resolution = await resolutionPromise;
|
|
1599
|
-
// Run the scheme's applyResolution hook on accept (writes the
|
|
1600
|
-
// file, spawns the process, etc.). If applyResolution returns a
|
|
1601
|
-
// 4xx/5xx or throws, the resolution is downgraded to a reject
|
|
1602
|
-
// with the failure outcome — engine treats it like a client
|
|
1603
|
-
// rejection.
|
|
1604
|
-
const effective = await this.#runApplyResolution(statement, result, resolution, { sessionId, runId, loopId, turnId });
|
|
1605
|
-
// MOVE into a proposed dest: the deferred source-delete fires ONLY now,
|
|
1606
|
-
// after the dest write landed (accept). On reject the source survives.
|
|
1607
|
-
if (effective.decision === "accept") {
|
|
1608
|
-
const moveSource = result.attrs?.moveSource;
|
|
1609
|
-
if (moveSource !== undefined) {
|
|
1610
|
-
const srcHandler = this.#schemes.get(moveSource.scheme);
|
|
1611
|
-
if (srcHandler !== undefined && typeof srcHandler.deleteEntry === "function")
|
|
1612
|
-
await srcHandler.deleteEntry(moveSource.pathname, schemeCtx);
|
|
1613
|
-
}
|
|
1614
|
-
}
|
|
1615
|
-
const post = await this.#applyResolution(logEntryId, effective);
|
|
1616
|
-
return post;
|
|
1617
|
-
}
|
|
1618
|
-
return result;
|
|
1085
|
+
return this.#dispatcher.dispatch(context);
|
|
1619
1086
|
}
|
|
1620
1087
|
// op.look (#283) — resolve a READ and return its content WITHOUT writing a
|
|
1621
|
-
// log_entries row: the client's off-run inspection primitive
|
|
1622
|
-
// invisible to the model). READ never mutates and never proposes, so this is
|
|
1623
|
-
// dispatch's resolve path minus #writeLog. Runs on the client loop, so the
|
|
1624
|
-
// 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}
|
|
1625
1089
|
async look(context) {
|
|
1626
|
-
|
|
1627
|
-
if (statement.op !== "READ")
|
|
1628
|
-
throw new Error(`look resolves READ only; got ${statement.op}`);
|
|
1629
|
-
// turnId is a write-time FK only — a look writes no row, so 0 (no turn) is inert.
|
|
1630
|
-
const schemeCtx = this.#buildSchemeCtx({ sessionId, runId, loopId, turnId: 0, origin });
|
|
1631
|
-
const denial = await this.#checkFlagsGate(statement, loopId);
|
|
1632
|
-
if (denial !== null)
|
|
1633
|
-
return denial;
|
|
1634
|
-
return this.#run(this.#schemeNameOf(statement.target), statement, schemeCtx);
|
|
1635
|
-
}
|
|
1636
|
-
#buildSchemeCtx(ids) {
|
|
1637
|
-
const { sessionId, runId, loopId, turnId, origin } = ids;
|
|
1638
|
-
return {
|
|
1639
|
-
db: this.#db,
|
|
1640
|
-
sessionId, runId, loopId, turnId,
|
|
1641
|
-
writer: origin,
|
|
1642
|
-
signal: this.#loopAborts.get(loopId)?.signal,
|
|
1643
|
-
streamEventNotify: this.#streamEventNotify,
|
|
1644
|
-
wakeRunNotify: this.#wakeRunNotify,
|
|
1645
|
-
injectRun: this.#injectRun,
|
|
1646
|
-
mimetypes: this.#mimetypes,
|
|
1647
|
-
tokenize: this.#tokenize,
|
|
1648
|
-
pushTelemetry: (event) => this.#pushTelemetry(sessionId, loopId, event),
|
|
1649
|
-
executors: this.#executors,
|
|
1650
|
-
};
|
|
1651
|
-
}
|
|
1652
|
-
// On accept, run the scheme's applyResolution — File writes disk, Exec spawns. §proposal-accept-applies
|
|
1653
|
-
async #runApplyResolution(statement, originalResult, resolution, ids) {
|
|
1654
|
-
const { sessionId, runId, loopId, turnId } = ids;
|
|
1655
|
-
if (resolution.decision !== "accept")
|
|
1656
|
-
return resolution;
|
|
1657
|
-
// EXEC routes to the exec scheme regardless of target (cwd, not
|
|
1658
|
-
// a scheme address). All other ops resolve their handler from
|
|
1659
|
-
// statement.target's scheme.
|
|
1660
|
-
// COPY/MOVE write the DEST (statement.body), not the source (target): the
|
|
1661
|
-
// accept must reach the dest scheme's applyResolution (File writes disk).
|
|
1662
|
-
const schemeName = statement.op === "EXEC" ? "exec"
|
|
1663
|
-
: (statement.op === "COPY" || statement.op === "MOVE") ? this.#schemeNameOf(statement.body)
|
|
1664
|
-
: this.#schemeNameOf(statement.target);
|
|
1665
|
-
if (schemeName === null)
|
|
1666
|
-
return resolution;
|
|
1667
|
-
const handler = this.#schemes.get(schemeName);
|
|
1668
|
-
if (handler === undefined || typeof handler.applyResolution !== "function")
|
|
1669
|
-
return resolution;
|
|
1670
|
-
try {
|
|
1671
|
-
// Build a ctx for the scheme's applyResolution. The proposal
|
|
1672
|
-
// was raised inside a specific (session, run, loop, turn);
|
|
1673
|
-
// the scheme uses ctx to write the entry that makes the
|
|
1674
|
-
// operation's artifact visible in the next packet's index.
|
|
1675
|
-
const applyCtx = {
|
|
1676
|
-
db: this.#db, sessionId, runId, loopId, turnId,
|
|
1677
|
-
writer: "model", signal: this.#loopAborts.get(loopId)?.signal,
|
|
1678
|
-
streamEventNotify: this.#streamEventNotify,
|
|
1679
|
-
wakeRunNotify: this.#wakeRunNotify,
|
|
1680
|
-
tokenize: this.#tokenize,
|
|
1681
|
-
pushTelemetry: (event) => this.#pushTelemetry(sessionId, loopId, event),
|
|
1682
|
-
executors: this.#executors,
|
|
1683
|
-
};
|
|
1684
|
-
const applyResult = await handler.applyResolution({
|
|
1685
|
-
attrs: (originalResult.attrs ?? {}),
|
|
1686
|
-
body: resolution.body,
|
|
1687
|
-
}, applyCtx);
|
|
1688
|
-
if (applyResult.status >= 400) {
|
|
1689
|
-
return {
|
|
1690
|
-
decision: "reject",
|
|
1691
|
-
outcome: applyResult.outcome ?? "apply_failed",
|
|
1692
|
-
body: applyResult.body,
|
|
1693
|
-
};
|
|
1694
|
-
}
|
|
1695
|
-
// Propagate applyResolution.outcome onto the accepted resolution
|
|
1696
|
-
// (operational metadata, e.g. exec's "exit_N") AND its body — an
|
|
1697
|
-
// inline (read/pure) run returns its output as the body, which has
|
|
1698
|
-
// to reach the model-facing result this turn, not just stream to
|
|
1699
|
-
// the entry. Host accepts carry no body (fire-and-forget).
|
|
1700
|
-
const withOutcome = applyResult.outcome !== undefined && resolution.outcome === undefined
|
|
1701
|
-
? { ...resolution, outcome: applyResult.outcome }
|
|
1702
|
-
: resolution;
|
|
1703
|
-
return applyResult.body === undefined ? withOutcome : { ...withOutcome, body: applyResult.body };
|
|
1704
|
-
}
|
|
1705
|
-
catch (err) {
|
|
1706
|
-
return {
|
|
1707
|
-
decision: "reject",
|
|
1708
|
-
outcome: "apply_threw",
|
|
1709
|
-
body: err instanceof Error ? err.message : String(err),
|
|
1710
|
-
};
|
|
1711
|
-
}
|
|
1090
|
+
return this.#dispatcher.look(context);
|
|
1712
1091
|
}
|
|
1713
|
-
//
|
|
1714
|
-
//
|
|
1715
|
-
// the in-tree YOLO listener (Phase E.3), or the timeout watcher. Throws
|
|
1716
|
-
// when the logEntryId has no pending waiter — duplicate resolutions, IDs
|
|
1717
|
-
// for non-proposed entries, or entries already-resolved are caller
|
|
1718
|
-
// 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.
|
|
1719
1094
|
resolveProposal(logEntryId, resolution) {
|
|
1720
|
-
|
|
1721
|
-
if (waiter === undefined) {
|
|
1722
|
-
throw new Error(`Engine.resolveProposal: no pending proposal for log_entry ${logEntryId}`);
|
|
1723
|
-
}
|
|
1724
|
-
clearTimeout(waiter.timeoutHandle);
|
|
1725
|
-
this.#pendingProposals.delete(logEntryId);
|
|
1726
|
-
waiter.resolve(resolution);
|
|
1095
|
+
this.#proposals.resolve(logEntryId, resolution);
|
|
1727
1096
|
}
|
|
1728
|
-
// Snapshot of pending proposals (for diagnostic / RPC listings).
|
|
1729
|
-
// the log entry IDs currently awaiting resolution.
|
|
1097
|
+
// Snapshot of pending proposals (for diagnostic / RPC listings).
|
|
1730
1098
|
pendingProposalIds() {
|
|
1731
|
-
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);
|
|
1732
1106
|
}
|
|
1733
1107
|
// Used by wake-on-completion (daemon side): "is there any loop in this
|
|
1734
1108
|
// run still accepting turns?" If yes, skip the wake — the active loop
|
|
@@ -1738,6 +1112,27 @@ class Engine {
|
|
|
1738
1112
|
const row = await this.#db.engine_count_active_loops_for_run.get({ run_id: runId });
|
|
1739
1113
|
return (row?.n ?? 0) > 0;
|
|
1740
1114
|
}
|
|
1115
|
+
// #290 — run the derivation pump (deep channels: symbols/refs/FTS +
|
|
1116
|
+
// embeddings, deep_hash-gated) at SESSION-SCOPE, off the per-turn path, so a freshly-created
|
|
1117
|
+
// session's corpus warms DURING the client's startup window instead of freezing the first
|
|
1118
|
+
// loop.run. session.create fires this and returns immediately; embed_progress live-fans-out as it
|
|
1119
|
+
// runs. Idempotent + deep_hash-gated, so turn 1's pump finds the work done (or harmlessly re-runs);
|
|
1120
|
+
// a no-embedder build derives the cheap symbols/refs/FTS channels and skips the embed pass. Has no
|
|
1121
|
+
// loop yet — telemetry fans out live only (loopId 0), never buffered to a loop that never drains.
|
|
1122
|
+
async warmSessionDerivations(sessionId) {
|
|
1123
|
+
const ctx = {
|
|
1124
|
+
db: this.#db, sessionId, runId: 0, loopId: 0, turnId: 0,
|
|
1125
|
+
writer: "plurnk",
|
|
1126
|
+
signal: undefined,
|
|
1127
|
+
streamEventNotify: this.#streamEventNotify,
|
|
1128
|
+
wakeRunNotify: this.#wakeRunNotify,
|
|
1129
|
+
tokenize: this.#tokenize,
|
|
1130
|
+
mimetypes: this.#mimetypes,
|
|
1131
|
+
defaultChannelFor: (s) => this.#schemes.defaultChannelFor(s),
|
|
1132
|
+
pushTelemetry: (event) => this.#telemetry.notify(sessionId, 0, event),
|
|
1133
|
+
};
|
|
1134
|
+
await EntryManifest.maintainDerivations(ctx);
|
|
1135
|
+
}
|
|
1741
1136
|
// Inject a prompt into the run's currently-executing loop. Writes a
|
|
1742
1137
|
// plurnk:///prompt/<loop_id>/<next-turn> entry whose body becomes the
|
|
1743
1138
|
// prompt section at the next turn boundary. Last-wins: if two
|
|
@@ -1760,7 +1155,7 @@ class Engine {
|
|
|
1760
1155
|
const sessionRow = await this.#db.drain_get_run_session.get({ run_id: runId });
|
|
1761
1156
|
if (sessionRow === undefined)
|
|
1762
1157
|
throw new Error(`Engine.inject: run ${runId} not found`);
|
|
1763
|
-
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
|
|
1764
1159
|
const ctx = {
|
|
1765
1160
|
db: this.#db, sessionId: sessionRow.session_id, runId, loopId,
|
|
1766
1161
|
turnId: 0, // no turn open at inject time; entries don't pin turnId
|
|
@@ -1769,7 +1164,7 @@ class Engine {
|
|
|
1769
1164
|
streamEventNotify: this.#streamEventNotify,
|
|
1770
1165
|
wakeRunNotify: this.#wakeRunNotify,
|
|
1771
1166
|
tokenize: this.#tokenize,
|
|
1772
|
-
pushTelemetry: (event) => this.#
|
|
1167
|
+
pushTelemetry: (event) => this.#telemetry.push(sessionRow.session_id, loopId, event),
|
|
1773
1168
|
};
|
|
1774
1169
|
const entry = {
|
|
1775
1170
|
channels: { body: { content: prompt, mimetype: "text/markdown" } },
|
|
@@ -1778,650 +1173,35 @@ class Engine {
|
|
|
1778
1173
|
await EntryCrud.writeEntry(pathname, entry, ctx, "plurnk");
|
|
1779
1174
|
return { loopId, turnSeq };
|
|
1780
1175
|
}
|
|
1781
|
-
//
|
|
1782
|
-
//
|
|
1783
|
-
// (
|
|
1784
|
-
//
|
|
1785
|
-
//
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
return DEFAULT_LOOP_FLAGS;
|
|
1797
|
-
try {
|
|
1798
|
-
const parsed = JSON.parse(row.flags);
|
|
1799
|
-
return { ...DEFAULT_LOOP_FLAGS, ...parsed };
|
|
1800
|
-
}
|
|
1801
|
-
catch {
|
|
1802
|
-
return DEFAULT_LOOP_FLAGS;
|
|
1803
|
-
}
|
|
1804
|
-
}
|
|
1805
|
-
#awaitResolution(logEntryId) {
|
|
1806
|
-
const timeoutMs = readProposalTimeoutMs();
|
|
1807
|
-
return new Promise((resolve) => {
|
|
1808
|
-
const timeoutHandle = setTimeout(() => {
|
|
1809
|
-
// Timeout: synthesize a cancel resolution and feed it back
|
|
1810
|
-
// through the same path as any other resolution. State
|
|
1811
|
-
// transitions to cancelled with outcome='timeout'.
|
|
1812
|
-
if (this.#pendingProposals.has(logEntryId)) {
|
|
1813
|
-
this.#pendingProposals.delete(logEntryId);
|
|
1814
|
-
resolve({ decision: "cancel", outcome: "timeout" }); // §proposal-timeout-cancels
|
|
1815
|
-
}
|
|
1816
|
-
}, timeoutMs);
|
|
1817
|
-
this.#pendingProposals.set(logEntryId, { resolve, timeoutHandle });
|
|
1818
|
-
});
|
|
1819
|
-
}
|
|
1820
|
-
async #applyResolution(logEntryId, resolution) {
|
|
1821
|
-
// Map decision → terminal state + HTTP-aligned status:
|
|
1822
|
-
// accept → state='resolved', status=200
|
|
1823
|
-
// reject → state='failed', status=400, outcome='rejected' (default) §proposal-reject-fails
|
|
1824
|
-
// cancel → state='cancelled',status=499, outcome='loop_aborted' (default) §proposal-cancel-aborts
|
|
1825
|
-
// resolution.outcome wins over the default when supplied; this is how
|
|
1826
|
-
// veto filters (Phase E.2 proposal.accepting) can specify a more
|
|
1827
|
-
// precise outcome string like 'policy_veto' or 'timeout'.
|
|
1828
|
-
const decision = resolution.decision;
|
|
1829
|
-
const state = decision === "accept" ? "resolved"
|
|
1830
|
-
: decision === "reject" ? "failed"
|
|
1831
|
-
: "cancelled";
|
|
1832
|
-
const status = decision === "accept" ? 200
|
|
1833
|
-
: decision === "reject" ? 400
|
|
1834
|
-
: 499;
|
|
1835
|
-
const defaultOutcome = decision === "accept" ? null
|
|
1836
|
-
: decision === "reject" ? "rejected"
|
|
1837
|
-
: "loop_aborted";
|
|
1838
|
-
const outcome = resolution.outcome ?? defaultOutcome;
|
|
1839
|
-
// rx is the model-facing operation result. Status always; outcome is
|
|
1840
|
-
// operational (stays on log_entries for forensics, never model-facing).
|
|
1841
|
-
// Body is normally dropped — the propose preview was an input echo —
|
|
1842
|
-
// EXCEPT an inline auto-run (read/pure) carries its run output AS the
|
|
1843
|
-
// body, which is exactly the "what happened" the model needs this turn.
|
|
1844
|
-
// Per AGENTS.md "Operational hygiene on what the model sees."
|
|
1845
|
-
const rx = (decision === "accept" && resolution.body !== undefined)
|
|
1846
|
-
? JSON.stringify({ status, body: resolution.body })
|
|
1847
|
-
: JSON.stringify({ status });
|
|
1848
|
-
await this.#db.engine_resolve_log_entry.run({
|
|
1849
|
-
id: logEntryId, state, outcome, status_rx: status, rx,
|
|
1850
|
-
});
|
|
1851
|
-
return { status, outcome, body: resolution.body };
|
|
1852
|
-
}
|
|
1853
|
-
// SPEC §scheme-surface: engine rejects writes whose origin is outside the target
|
|
1854
|
-
// scheme's manifest.writableBy.
|
|
1855
|
-
// - Read-side ops (READ, FIND, OPEN, FOLD) are not gated.
|
|
1856
|
-
// - SEND broadcast (path=null) has no target scheme; not gated.
|
|
1857
|
-
// - COPY: dst scheme writableBy applies.
|
|
1858
|
-
// - MOVE: both src (delete) and dst (write) schemes' writableBy apply.
|
|
1859
|
-
// - Schemes without a manifest are not gated (legacy / future allowance).
|
|
1860
|
-
#checkWritable(statement, origin) {
|
|
1861
|
-
if (!MUTATING_OPS.has(statement.op))
|
|
1862
|
-
return null;
|
|
1863
|
-
if (statement.op === "SEND" && statement.target === null)
|
|
1864
|
-
return null;
|
|
1865
|
-
// EXEC's target slot is `cwd`, not a scheme address. The op's
|
|
1866
|
-
// authority always belongs to the exec scheme regardless of cwd.
|
|
1867
|
-
if (statement.op === "EXEC") {
|
|
1868
|
-
return this.#denyIfDisallowed("exec", origin);
|
|
1869
|
-
}
|
|
1870
|
-
// A run-fork (COPY src=run://) is gated by run://'s writableBy — its body
|
|
1871
|
-
// is a fork prompt, not a dst path, so the entry-COPY dst-parse below
|
|
1872
|
-
// doesn't apply. §machine-processes
|
|
1873
|
-
if (this.#isRunFork(statement))
|
|
1874
|
-
return this.#denyIfDisallowed("run", origin);
|
|
1875
|
-
if (statement.op === "COPY" || statement.op === "MOVE") {
|
|
1876
|
-
const dst = statement.op === "COPY" ? (statement.body === null ? null : parsePath(statement.body)) : statement.body;
|
|
1877
|
-
const dstScheme = this.#schemeNameOf(dst);
|
|
1878
|
-
const dstDenial = this.#denyIfDisallowed(dstScheme, origin);
|
|
1879
|
-
if (dstDenial !== null)
|
|
1880
|
-
return dstDenial;
|
|
1881
|
-
if (statement.op === "MOVE") {
|
|
1882
|
-
const srcScheme = this.#schemeNameOf(statement.target);
|
|
1883
|
-
if (srcScheme !== dstScheme) {
|
|
1884
|
-
const srcDenial = this.#denyIfDisallowed(srcScheme, origin);
|
|
1885
|
-
if (srcDenial !== null)
|
|
1886
|
-
return srcDenial;
|
|
1887
|
-
}
|
|
1888
|
-
}
|
|
1889
|
-
return null;
|
|
1890
|
-
}
|
|
1891
|
-
const target = this.#schemeNameOf(statement.target);
|
|
1892
|
-
return this.#denyIfDisallowed(target, origin);
|
|
1893
|
-
}
|
|
1894
|
-
#denyIfDisallowed(schemeName, origin) {
|
|
1895
|
-
if (schemeName === null)
|
|
1896
|
-
return null;
|
|
1897
|
-
const handler = this.#schemes.get(schemeName);
|
|
1898
|
-
if (handler === undefined)
|
|
1899
|
-
return null;
|
|
1900
|
-
const manifest = handler.constructor.manifest;
|
|
1901
|
-
if (manifest === undefined)
|
|
1902
|
-
return null;
|
|
1903
|
-
if (manifest.writableBy.includes(origin))
|
|
1904
|
-
return null;
|
|
1905
|
-
return { status: 403, error: `writer '${origin}' is not in writableBy for scheme '${schemeName}'` }; // §scheme-surface-writableby-403
|
|
1906
|
-
}
|
|
1907
|
-
// Per-loop flag gating. Schemes self-declare their flag affinity in
|
|
1908
|
-
// their manifest (excludedInAsk / requiresWeb /
|
|
1909
|
-
// requiresInteraction); SchemeRegistry.resolveForLoop returns the
|
|
1910
|
-
// active set under the loop's persisted flags. Anything outside the
|
|
1911
|
-
// set returns 403 — action-entry-as-outcome carries the rejection.
|
|
1912
|
-
async #checkFlagsGate(statement, loopId) {
|
|
1913
|
-
// Broadcast SEND has no scheme to gate.
|
|
1914
|
-
if (statement.op === "SEND" && statement.target === null)
|
|
1915
|
-
return null;
|
|
1916
|
-
const flags = await this.#loadLoopFlags(loopId);
|
|
1917
|
-
// Fast path: default flags gate nothing. (yolo never gates.)
|
|
1918
|
-
if (!flags.noWeb && !flags.noInteraction && flags.mode === "act")
|
|
1919
|
-
return null;
|
|
1920
|
-
const active = this.#schemes.resolveForLoop(flags);
|
|
1921
|
-
const check = (target) => {
|
|
1922
|
-
const scheme = this.#schemeNameOf(target);
|
|
1923
|
-
if (scheme === null)
|
|
1924
|
-
return null;
|
|
1925
|
-
if (active.has(scheme))
|
|
1926
|
-
return null;
|
|
1927
|
-
return { status: 403, error: `scheme '${scheme}' is inactive under current loop flags` };
|
|
1928
|
-
};
|
|
1929
|
-
if (this.#isRunFork(statement))
|
|
1930
|
-
return check(statement.target); // body is a fork prompt, not a dst path
|
|
1931
|
-
if (statement.op === "COPY" || statement.op === "MOVE") {
|
|
1932
|
-
return check(statement.target) ?? check(statement.op === "COPY" ? (statement.body === null ? null : parsePath(statement.body)) : statement.body);
|
|
1933
|
-
}
|
|
1934
|
-
return check(statement.target);
|
|
1935
|
-
}
|
|
1936
|
-
// A COPY whose SOURCE is run:// is a run-fork, not an entry-copy — its body
|
|
1937
|
-
// is the fork's seed prompt, not a destination path. The COPY gates and
|
|
1938
|
-
// #handleCopy branch on this so they never parse the prompt as a dst path.
|
|
1939
|
-
#isRunFork(statement) {
|
|
1940
|
-
return statement.op === "COPY" && this.#schemeNameOf(statement.target) === "run";
|
|
1941
|
-
}
|
|
1942
|
-
// COPY(run://<src>):prompt — fork: deep-copy the source run's log into a new
|
|
1943
|
-
// run (Fork), then start it with the prompt (ctx.injectRun). Source `run://self` =
|
|
1944
|
-
// self (ctx.runId); a name resolves within the session (404 if absent). §run-scheme,
|
|
1945
|
-
// §machine-processes-fork-copies-the-log
|
|
1946
|
-
async #handleRunFork(statement, ctx) {
|
|
1947
|
-
const target = statement.target;
|
|
1948
|
-
if (target === null)
|
|
1949
|
-
return { status: 400, error: "run:// fork requires a source run" };
|
|
1950
|
-
const name = target.kind === "url" ? (target.hostname ?? "") : ""; // §run-scheme — run is the AUTHORITY (run://<name>), not the path
|
|
1951
|
-
if (name === "")
|
|
1952
|
-
return { status: 400, error: "run:// fork requires a source run name or 'self' (run://self)" };
|
|
1953
|
-
let srcRunId = ctx.runId;
|
|
1954
|
-
if (name !== "self") {
|
|
1955
|
-
const row = await this.#db.run_resolve_by_name.get({ session_id: ctx.sessionId, name });
|
|
1956
|
-
if (row === undefined)
|
|
1957
|
-
return { status: 404, error: `run://${name} not found in this session` };
|
|
1958
|
-
srcRunId = row.id;
|
|
1959
|
-
}
|
|
1960
|
-
if (ctx.injectRun === undefined)
|
|
1961
|
-
throw new Error("run fork: injectRun capability absent");
|
|
1962
|
-
const denied = await RunCap.deny(this.#db, ctx.sessionId);
|
|
1963
|
-
if (denied !== null)
|
|
1964
|
-
return denied;
|
|
1965
|
-
const branchRunId = await Fork.fork(this.#db, srcRunId);
|
|
1966
|
-
const branch = await this.#db.fork_get_run.get({ id: branchRunId });
|
|
1967
|
-
await ctx.injectRun({ sessionId: ctx.sessionId, runId: branchRunId, prompt: typeof statement.body === "string" ? statement.body : "" });
|
|
1968
|
-
return { status: 200, body: branch?.name ?? "" };
|
|
1969
|
-
}
|
|
1970
|
-
async #handleCopy(statement, ctx) {
|
|
1971
|
-
if (statement.op !== "COPY")
|
|
1972
|
-
throw new Error("unreachable");
|
|
1973
|
-
if (this.#isRunFork(statement))
|
|
1974
|
-
return await this.#handleRunFork(statement, ctx);
|
|
1975
|
-
const srcPath = statement.target;
|
|
1976
|
-
// COPY's body is an opaque raw string (grammar §COPY: a dest path OR a run-fork
|
|
1977
|
-
// prompt); parse it to the dest path. Non-path bodies (run:// fork prompts) are
|
|
1978
|
-
// not yet handled and surface as a 400.
|
|
1979
|
-
const dstPath = statement.body === null ? null : parsePath(statement.body);
|
|
1980
|
-
if (srcPath === null)
|
|
1981
|
-
return { status: 400, error: "COPY requires source path" };
|
|
1982
|
-
if (dstPath === null)
|
|
1983
|
-
return { status: 400, error: "COPY destination must be a parseable path in the body slot" };
|
|
1984
|
-
return await this.#copyOrchestration({ statement, srcPath, dstPath, ctx });
|
|
1985
|
-
}
|
|
1986
|
-
async #handleMove(statement, ctx) {
|
|
1987
|
-
if (statement.op !== "MOVE")
|
|
1988
|
-
throw new Error("unreachable");
|
|
1989
|
-
const srcPath = statement.target;
|
|
1990
|
-
const dstPath = statement.body;
|
|
1991
|
-
if (srcPath === null)
|
|
1992
|
-
return { status: 400, error: "MOVE requires source path" };
|
|
1993
|
-
// MOVE is relocation only — deletion is KILL's job (§move, §move-dev-null-not-special). The /dev/null
|
|
1994
|
-
// and null-body delete-by-MOVE back-compat is retired: no silent debt.
|
|
1995
|
-
if (dstPath === null)
|
|
1996
|
-
return { status: 400, error: "MOVE requires a destination; use KILL to delete" }; // §move-null-body-400
|
|
1997
|
-
const srcSchemeName = this.#schemeNameOf(srcPath);
|
|
1998
|
-
if (srcSchemeName === null)
|
|
1999
|
-
return { status: 400, error: "MOVE source must be a URL path with a scheme" };
|
|
2000
|
-
const srcHandler = this.#schemes.get(srcSchemeName);
|
|
2001
|
-
if (srcHandler === undefined || typeof srcHandler.deleteEntry !== "function")
|
|
2002
|
-
return { status: 501 };
|
|
2003
|
-
// Relocation: COPY then DELETE source (§move-relocation-deletes-source).
|
|
2004
|
-
const copyResult = await this.#copyOrchestration({ statement, srcPath, dstPath, ctx });
|
|
2005
|
-
if (copyResult.status >= 400)
|
|
2006
|
-
return copyResult;
|
|
2007
|
-
const srcPathname = pathnameFromPath(srcPath);
|
|
2008
|
-
// If the dest write is a pending proposal (file dest → §membership review), the
|
|
2009
|
-
// source-delete MUST wait until the dest actually lands — a rejected
|
|
2010
|
-
// proposal would otherwise lose the source. Thread it into the resolution:
|
|
2011
|
-
// dispatch deletes the source AFTER the dest applies on accept.
|
|
2012
|
-
if (copyResult.status === 202) {
|
|
2013
|
-
return { ...copyResult, attrs: { ...copyResult.attrs, moveSource: { scheme: srcSchemeName, pathname: srcPathname } } };
|
|
2014
|
-
}
|
|
2015
|
-
const delResult = await srcHandler.deleteEntry(srcPathname, ctx);
|
|
2016
|
-
if (delResult.status >= 400)
|
|
2017
|
-
return { status: delResult.status };
|
|
2018
|
-
return copyResult;
|
|
2019
|
-
}
|
|
2020
|
-
// KILL — scheme-polymorphic destroy (plurnk-grammar#203 / 0.28.0). Entry-KILL
|
|
2021
|
-
// permanently deletes the entry: the canonical delete now, MOVE→/dev/null
|
|
2022
|
-
// retired from the model's vocabulary. Process-KILL (exec:///) aborts the
|
|
2023
|
-
// running spawn's controller (the same teardown loop.cancel rides), addressed
|
|
2024
|
-
// by coordinate pathname (#203). The KILL body is an opaque
|
|
2025
|
-
// annotation with no runtime meaning; it survives into the log row's tx for
|
|
2026
|
-
// free via the statement serialization. Status: 200 killed · 404 unknown ·
|
|
2027
|
-
// 405 log:/// (append-only) · 403 writableBy (the #checkWritable gate, KILL ∈
|
|
2028
|
-
// MUTATING_OPS) · 200/410/304/404 exec (killed / killed-earlier / exited / unknown) · 501 no-kill/delete scheme.
|
|
2029
|
-
async #handleKill(statement, ctx) {
|
|
2030
|
-
if (statement.op !== "KILL")
|
|
2031
|
-
throw new Error("unreachable");
|
|
2032
|
-
const path = statement.target;
|
|
2033
|
-
if (path === null)
|
|
2034
|
-
return { status: 400, error: "KILL requires a target path" };
|
|
2035
|
-
const schemeName = this.#schemeNameOf(path);
|
|
2036
|
-
if (schemeName === null)
|
|
2037
|
-
return { status: 400, error: "KILL target must be a URL path with a scheme" };
|
|
2038
|
-
// KILL on log:/// erases the log row(s) — the model's DB-storage curation lever
|
|
2039
|
-
// (plurnk.md:36, :98), routed to Log.kill below via the killable.kill path. The old
|
|
2040
|
-
// "append-only" 405 forbade what the grammar requires; FOLD only collapses the render.
|
|
2041
|
-
// Process-KILL: any scheme whose handler exposes kill() aborts a live stream — the
|
|
2042
|
-
// exec handler, registered as "exec" + under every runtime tag (sh/node), so a tag-
|
|
2043
|
-
// addressed stream (sh:///l/t/s) routes here, not to deleteEntry. §exec
|
|
2044
|
-
const killable = this.#schemes.get(schemeName);
|
|
2045
|
-
if (killable !== undefined && typeof killable.kill === "function") {
|
|
2046
|
-
return await killable.kill(pathnameFromPath(path), statement.signal, ctx);
|
|
2047
|
-
}
|
|
2048
|
-
if (schemeName === "run") {
|
|
2049
|
-
// Entry-path present → KILL a run-scope scratch ENTRY (delete it), self-only —
|
|
2050
|
-
// NOT run cancellation. The authority (hostname) names the owner, the pathname the
|
|
2051
|
-
// entry; only the path-ABSENT form (run://<name>) terminates the run-as-actor. §run-scheme
|
|
2052
|
-
const entryPath = path.kind === "url" ? (path.pathname ?? "") : "";
|
|
2053
|
-
if (entryPath !== "" && entryPath !== "/") {
|
|
2054
|
-
const runHandler = this.#schemes.get("run");
|
|
2055
|
-
return await runHandler.deleteEntry(statement, ctx);
|
|
2056
|
-
}
|
|
2057
|
-
// terminate — abort any run by address; whoever holds it may end it.
|
|
2058
|
-
// `run://self` = self. cancelRun (→ Daemon.cancelDrain) aborts the run's signal
|
|
2059
|
-
// (its loop closes 499); an idle run is a no-op-200, a missing run 404.
|
|
2060
|
-
const name = path.kind === "url" ? (path.hostname ?? "") : ""; // §run-scheme — run is the AUTHORITY
|
|
2061
|
-
if (name === "")
|
|
2062
|
-
return { status: 400, error: "run:// kill requires a run name or 'self' (run://<name>)" };
|
|
2063
|
-
let runId = ctx.runId;
|
|
2064
|
-
if (name !== "self") {
|
|
2065
|
-
const row = await this.#db.run_resolve_by_name.get({ session_id: ctx.sessionId, name });
|
|
2066
|
-
if (row === undefined)
|
|
2067
|
-
return { status: 404, error: `run://${name} not found in this session` };
|
|
2068
|
-
runId = row.id;
|
|
2069
|
-
}
|
|
2070
|
-
if (this.#cancelRun === undefined)
|
|
2071
|
-
throw new Error("run kill: cancelRun capability absent");
|
|
2072
|
-
this.#cancelRun(runId);
|
|
2073
|
-
return { status: 200 };
|
|
2074
|
-
}
|
|
2075
|
-
const handler = this.#schemes.get(schemeName);
|
|
2076
|
-
if (handler === undefined || typeof handler.deleteEntry !== "function")
|
|
2077
|
-
return { status: 501 };
|
|
2078
|
-
const delResult = await handler.deleteEntry(pathnameFromPath(path), ctx);
|
|
2079
|
-
return { status: delResult.status };
|
|
2080
|
-
}
|
|
2081
|
-
// Multi-file READ fan-out (SPEC §matcher-result — "the companion to FIND's survey"). A glob
|
|
2082
|
-
// READ target resolves to MANY files; READ returns one log row per file that matches, each
|
|
2083
|
-
// holding that file's matching lines. The matched SET is exactly FIND's survey (which files
|
|
2084
|
-
// + where — matchLines), so we reuse the scheme's own find, then READ each matched file. One
|
|
2085
|
-
// model command, N log rows — each row addresses its concrete file, so it folds/kills/re-READs
|
|
2086
|
-
// on its own. The running sequence counter in runTurn advances by rowsWritten.
|
|
2087
|
-
// A READ fans out (honors FIND) when it resolves to more than the single exact entry: a glob
|
|
2088
|
-
// or folder scope, OR a matcher (which selects per-match within whatever the target resolved).
|
|
2089
|
-
// A bare entry, body-less, is the one direct read. #286
|
|
2090
|
-
static #readFansOut(statement) {
|
|
2091
|
-
if (statement.op !== "READ")
|
|
2092
|
-
return false;
|
|
2093
|
-
if ("body" in statement && statement.body !== null)
|
|
2094
|
-
return true; // a matcher → per-match fan-out
|
|
2095
|
-
const t = statement.target;
|
|
2096
|
-
const p = t === null ? "" : (t.kind === "url" ? t.pathname : t.raw);
|
|
2097
|
-
return p.includes("*") || p.endsWith("/"); // glob/folder scope → fan out its contents
|
|
2098
|
-
}
|
|
2099
|
-
// Clone the READ onto one concrete match — the FIND already matched, so the per-match READ
|
|
2100
|
-
// delivers content at the span: strip the body (no re-match) and set <L> to the span. A null
|
|
2101
|
-
// span (body-less folder/glob fan-out) reads the whole entry. #286
|
|
2102
|
-
static #retargetRead(statement, pathname, span) {
|
|
2103
|
-
const t = statement.target;
|
|
2104
|
-
const target = t !== null && t.kind === "url"
|
|
2105
|
-
? { ...t, pathname, raw: `${t.scheme}://${pathname}` }
|
|
2106
|
-
: { ...t, raw: pathname };
|
|
2107
|
-
const lineMarker = span !== null ? { marks: [span.lineStart, span.lineEnd] } : null;
|
|
2108
|
-
return { ...statement, target: target, lineMarker, body: null };
|
|
2109
|
-
}
|
|
2110
|
-
async #handleReadFanout(statement, ctx, ids) {
|
|
2111
|
-
const { runId, loopId, turnId, sequence, origin, onDispatch } = ids;
|
|
2112
|
-
const schemeName = this.#schemeNameOf(statement.target);
|
|
2113
|
-
const found = await this.#run(schemeName, { ...statement, op: "FIND" }, ctx);
|
|
2114
|
-
const matches = found.matches ?? [];
|
|
2115
|
-
// Find-less scheme, a matcher/scope error, or zero matches → a single row carrying the
|
|
2116
|
-
// status, exactly like a non-fanned READ. The model sees the empty/failed result, not silence.
|
|
2117
|
-
if (found.status !== 200 || matches.length === 0) {
|
|
2118
|
-
const result = { status: found.status === 200 ? 204 : found.status };
|
|
2119
|
-
const id = await this.#writeLog({ statement, result, runId, loopId, turnId, sequence, origin });
|
|
2120
|
-
onDispatch?.(id);
|
|
2121
|
-
return { ...result, rowsWritten: 1 };
|
|
2122
|
-
}
|
|
2123
|
-
// One READ row per MATCH — the span's source lines (or the whole entry for a body-less
|
|
2124
|
-
// folder/glob). The match span is SOURCE LINES, so deliver via a raw line-slice — NOT the
|
|
2125
|
-
// scheme's <L> (which is item-index for application/json, structural for xml). Read each
|
|
2126
|
-
// distinct entry's content once, then line-slice per match. #286
|
|
2127
|
-
const wholeByPath = new Map();
|
|
2128
|
-
const fannedStatuses = [];
|
|
2129
|
-
let written = 0;
|
|
2130
|
-
for (const m of matches) {
|
|
2131
|
-
let whole = wholeByPath.get(m.pathname);
|
|
2132
|
-
if (whole === undefined) {
|
|
2133
|
-
whole = await this.#run(schemeName, _a.#retargetRead(statement, m.pathname, null), ctx);
|
|
2134
|
-
wholeByPath.set(m.pathname, whole);
|
|
2135
|
-
}
|
|
2136
|
-
const result = _a.#sliceMatch(whole, m.span);
|
|
2137
|
-
const id = await this.#writeLog({ statement: _a.#retargetRead(statement, m.pathname, m.span), result, runId, loopId, turnId, sequence: sequence + written, origin });
|
|
2138
|
-
onDispatch?.(id);
|
|
2139
|
-
fannedStatuses.push(result.status);
|
|
2140
|
-
written++;
|
|
2141
|
-
}
|
|
2142
|
-
return { status: 200, rowsWritten: written, fannedStatuses };
|
|
2143
|
-
}
|
|
2144
|
-
// Deliver one match: the whole entry (body-less, span null) or the source lines at the span —
|
|
2145
|
-
// a RAW line-slice, so a structural mimetype (json item-index / xml) doesn't mis-slice a span
|
|
2146
|
-
// that is, by construction, source line numbers (#286).
|
|
2147
|
-
static #sliceMatch(whole, span) {
|
|
2148
|
-
if (whole.status !== 200 || span === null)
|
|
2149
|
-
return whole;
|
|
2150
|
-
const sliced = LineMarkerOps.sliceLines(typeof whole.content === "string" ? whole.content : "", { marks: [span.lineStart, span.lineEnd] });
|
|
2151
|
-
if (sliced.status !== 200)
|
|
2152
|
-
return { status: sliced.status, error: sliced.error };
|
|
2153
|
-
return { status: 200, content: sliced.text ?? "", mimetype: "text/markdown", startLine: sliced.startLine ?? span.lineStart };
|
|
2154
|
-
}
|
|
2155
|
-
// §model-entry — mirror a verbatim model emission back as an actionless `model` log row, so
|
|
2156
|
-
// the model can finally SEE its own prior output (and reason through its own syntax errors).
|
|
2157
|
-
// Born FOLDED by default (budget-neutral until OPENed); the turn-0 exemplar passes folded:false
|
|
2158
|
-
// (born open — the one worked example the model orients on, thinning the grammar). text/vnd.plurnk.
|
|
2159
|
-
async #writeModelEntry({ verbatim, runId, loopId, turnId, sequence, folded, origin = "model" }) {
|
|
2160
|
-
const row = await this.#db.engine_insert_log_entry.get({
|
|
2161
|
-
run_id: runId, loop_id: loopId, turn_id: turnId, sequence,
|
|
2162
|
-
origin, source: null, op: "model", suffix: "", signal: null,
|
|
2163
|
-
scheme: null, username: null, password: null, hostname: null, port: null,
|
|
2164
|
-
pathname: null, params: null, fragment: null, lineMarker: null,
|
|
2165
|
-
tx: "", mimetype_tx: "text/vnd.plurnk",
|
|
2166
|
-
rx: JSON.stringify({ content: verbatim, mimetype: "text/vnd.plurnk" }),
|
|
2167
|
-
mimetype_rx: "application/json",
|
|
2168
|
-
status_rx: 200, tokens: this.#tokenize(verbatim), state: "resolved", outcome: null, attrs: "{}",
|
|
2169
|
-
});
|
|
2170
|
-
if (row === undefined)
|
|
2171
|
-
throw new Error("Engine.#writeModelEntry: insert returned no row");
|
|
2172
|
-
if (folded)
|
|
2173
|
-
await this.#db.engine_fold_log_entry.run({ id: row.id });
|
|
2174
|
-
return row.id;
|
|
2175
|
-
}
|
|
2176
|
-
// PLAN — the model's reasoning op (the 11th op). An ordinary op: dispatched like any
|
|
2177
|
-
// other, logged, and broadcast to the client as a log entry — but a pure no-op for
|
|
2178
|
-
// state (PLAN ∉ MUTATING_OPS); its body serializes into the log row's tx, no effect.
|
|
2179
|
-
#handlePlan(statement) {
|
|
2180
|
-
if (statement.op !== "PLAN")
|
|
2181
|
-
throw new Error("unreachable");
|
|
2182
|
-
return { status: 200 };
|
|
2183
|
-
}
|
|
2184
|
-
// Same- and cross-scheme COPY share one orchestrator — §copy-cross-scheme-copy §move-cross-scheme-move
|
|
2185
|
-
async #copyOrchestration({ statement, srcPath, dstPath, ctx }) {
|
|
2186
|
-
const srcSchemeName = this.#schemeNameOf(srcPath);
|
|
2187
|
-
const dstSchemeName = this.#schemeNameOf(dstPath);
|
|
2188
|
-
if (srcSchemeName === null || dstSchemeName === null)
|
|
2189
|
-
return { status: 400, error: "COPY/MOVE require URL paths with schemes" };
|
|
2190
|
-
const srcHandler = this.#schemes.get(srcSchemeName);
|
|
2191
|
-
const dstHandler = this.#schemes.get(dstSchemeName);
|
|
2192
|
-
if (srcHandler === undefined || dstHandler === undefined)
|
|
2193
|
-
return { status: 501 };
|
|
2194
|
-
if (typeof srcHandler.readEntry !== "function" || typeof dstHandler.writeEntry !== "function")
|
|
2195
|
-
return { status: 501 };
|
|
2196
|
-
const srcPathname = pathnameFromPath(srcPath);
|
|
2197
|
-
const dstPathname = pathnameFromPath(dstPath);
|
|
2198
|
-
const srcResult = await srcHandler.readEntry(srcPathname, ctx);
|
|
2199
|
-
if (srcResult.status !== 200 || srcResult.entry === null)
|
|
2200
|
-
return { status: 404, error: `COPY/MOVE source not found: ${srcSchemeName}://${srcPathname}` }; // §copy-missing-source-404 §move-missing-source-404
|
|
2201
|
-
const entry = srcResult.entry;
|
|
2202
|
-
// Destination read — the conflict/no-op verdict is deferred until the
|
|
2203
|
-
// to-be-written content is known (after <L> slice + tag resolution below),
|
|
2204
|
-
// so an identical re-copy resolves to 304 instead of a phantom 409.
|
|
2205
|
-
const dstExisting = typeof dstHandler.readEntry === "function"
|
|
2206
|
-
? await dstHandler.readEntry(dstPathname, ctx)
|
|
2207
|
-
: null;
|
|
2208
|
-
// Mimetype compatibility check against the destination scheme's manifest
|
|
2209
|
-
const dstManifest = dstHandler.constructor.manifest;
|
|
2210
|
-
const dstChannels = dstManifest?.channels ?? {};
|
|
2211
|
-
for (const [channelName, channelData] of Object.entries(entry.channels)) {
|
|
2212
|
-
const expectedMimetype = dstChannels[channelName];
|
|
2213
|
-
if (expectedMimetype !== undefined && expectedMimetype !== channelData.mimetype) {
|
|
2214
|
-
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
|
|
2215
|
-
}
|
|
2216
|
-
}
|
|
2217
|
-
// `<L>` source range slicing per SPEC.md §op-invariants (symmetric with READ
|
|
2218
|
-
// `<L>` — source range, no line-number prefix).
|
|
2219
|
-
// Applied to every channel of the source entry. Binary channels return
|
|
2220
|
-
// 415 since line semantics don't apply.
|
|
2221
|
-
const lineMarker = statement.lineMarker ?? null;
|
|
2222
|
-
let channels = entry.channels;
|
|
2223
|
-
if (lineMarker !== null) {
|
|
2224
|
-
const sliced = {};
|
|
2225
|
-
for (const [channelName, channelData] of Object.entries(entry.channels)) {
|
|
2226
|
-
if (MimetypeBinary.isBinaryMimetype(channelData.mimetype)) {
|
|
2227
|
-
return { status: 415, error: `cannot slice <L> on binary channel '${channelName}' (${channelData.mimetype})` };
|
|
2228
|
-
}
|
|
2229
|
-
const r = LineMarkerOps.sliceLinesRaw(channelData.content ?? "", lineMarker);
|
|
2230
|
-
if (r.status !== 200)
|
|
2231
|
-
return { status: r.status, error: r.error };
|
|
2232
|
-
sliced[channelName] = { ...channelData, content: r.text ?? "" };
|
|
2233
|
-
}
|
|
2234
|
-
channels = sliced;
|
|
2235
|
-
}
|
|
2236
|
-
// Tag resolution: signal = replace (§copy-signal-replaces-source-tags); absent/empty = carry from source (§copy-no-signal-carries-source-tags)
|
|
2237
|
-
const tags = (Array.isArray(statement.signal) && statement.signal.length > 0)
|
|
2238
|
-
? statement.signal
|
|
2239
|
-
: entry.tags;
|
|
2240
|
-
// 304/409 on an existing destination (SPEC §copy): a re-copy that would write
|
|
2241
|
-
// exactly what's already there — same channel contents, same tags — is a no-op
|
|
2242
|
-
// (304), mirroring EDIT's 304-on-noop (§edit). A divergent destination is a real
|
|
2243
|
-
// collision (409); COPY/MOVE never clobbers.
|
|
2244
|
-
if (dstExisting !== null && dstExisting.status === 200 && dstExisting.entry !== null) {
|
|
2245
|
-
const dstChannels = dstExisting.entry.channels;
|
|
2246
|
-
const writeNames = Object.keys(channels).sort();
|
|
2247
|
-
const dstNames = Object.keys(dstChannels).sort();
|
|
2248
|
-
const sameContent = writeNames.length === dstNames.length
|
|
2249
|
-
&& writeNames.every((n, i) => n === dstNames[i] && (channels[n]?.content ?? "") === (dstChannels[n]?.content ?? ""));
|
|
2250
|
-
const sameTags = [...tags].sort().join("") === [...dstExisting.entry.tags].sort().join("");
|
|
2251
|
-
if (sameContent && sameTags)
|
|
2252
|
-
return { status: 304 }; // identical → §copy-noop-304
|
|
2253
|
-
return { status: 409, error: `COPY/MOVE destination exists: ${dstSchemeName}://${dstPathname}` }; // §copy-conflict-409
|
|
2254
|
-
}
|
|
2255
|
-
const writeResult = await dstHandler.writeEntry(dstPathname, { channels, tags }, ctx);
|
|
2256
|
-
// A file dest returns 202 (disk write → §membership review): propagate the
|
|
2257
|
-
// proposal so dispatch runs the gate + routes applyResolution to the dest.
|
|
2258
|
-
if (writeResult.status === 202)
|
|
2259
|
-
return { status: 202, attrs: writeResult.attrs, body: writeResult.body };
|
|
2260
|
-
return { status: writeResult.status, entryId: writeResult.entryId, created: writeResult.created };
|
|
2261
|
-
}
|
|
2262
|
-
async #handleSendBroadcast(statement, loopId) {
|
|
2263
|
-
if (statement.op !== "SEND")
|
|
2264
|
-
throw new Error("unreachable");
|
|
2265
|
-
const status = statement.signal;
|
|
2266
|
-
if (status === null)
|
|
2267
|
-
return { status: 400 };
|
|
2268
|
-
if (status === 200 || status === 202 || status === 499) {
|
|
2269
|
-
// The broadcast terminals (200 done, 202 parked-async, 499 cancelled) advance
|
|
2270
|
-
// the loop; each carries its body as the loop's terminal message — the deliverable.
|
|
2271
|
-
const body = statement.body;
|
|
2272
|
-
const message = body === null ? null : typeof body === "string" ? body : body.raw;
|
|
2273
|
-
await this.#db.engine_loop_set_status.run({ status, loop_id: loopId, message });
|
|
2274
|
-
}
|
|
2275
|
-
return { status };
|
|
2276
|
-
}
|
|
2277
|
-
async #run(schemeName, statement, ctx) {
|
|
2278
|
-
if (schemeName === null)
|
|
2279
|
-
return { status: 400 };
|
|
2280
|
-
const handler = this.#schemes.get(schemeName);
|
|
2281
|
-
if (handler === undefined)
|
|
2282
|
-
return { status: 501 };
|
|
2283
|
-
const methodName = statement.op.toLowerCase();
|
|
2284
|
-
const method = handler[methodName];
|
|
2285
|
-
if (typeof method !== "function")
|
|
2286
|
-
return { status: 501 };
|
|
2287
|
-
// External @plurnk/plurnk-schemes-* siblings receive the DB-free SchemeCtx
|
|
2288
|
-
// (caps), never the raw PlurnkSchemeContext (schemes SPEC §channels). The dynamic
|
|
2289
|
-
// dispatch is typed for in-tree schemes; the cast bridges the ctx shapes —
|
|
2290
|
-
// the sibling reads caps, the in-tree handler reads db.
|
|
2291
|
-
if (this.#schemes.isExternal(schemeName)) {
|
|
2292
|
-
return method.call(handler, statement, new SchemeCtxImpl(ctx, schemeName));
|
|
2293
|
-
}
|
|
2294
|
-
return method.call(handler, statement, ctx);
|
|
2295
|
-
}
|
|
2296
|
-
// Bare paths default to the file scheme per plurnk.md (grammar sysprompt):
|
|
2297
|
-
// "Bare paths (no scheme) default to local relative project file paths."
|
|
2298
|
-
// file:/// remains an optional explicit form for absolute paths.
|
|
2299
|
-
#schemeNameOf(path) {
|
|
2300
|
-
if (path === null)
|
|
2301
|
-
return null;
|
|
2302
|
-
// http + https are one scheme — the http sibling owns both prefixes (#195).
|
|
2303
|
-
if (path.kind === "url")
|
|
2304
|
-
return path.scheme === "https" ? "http" : path.scheme;
|
|
2305
|
-
return "file"; // local (bare) → file
|
|
2306
|
-
}
|
|
2307
|
-
// A status-202 result is a reviewable PROPOSAL (a side-effecting op — EDIT/EXEC/
|
|
2308
|
-
// directed write — paused for client resolution) UNLESS it is a broadcast SEND.
|
|
2309
|
-
// A broadcast SEND[202] is the model PARKING the loop (a terminal disposition,
|
|
2310
|
-
// plurnk.md), never a side-effect — #255: gating the propose/await path on the
|
|
2311
|
-
// bare 202 surfaced model speech as a loop/proposal and froze clients. The 202
|
|
2312
|
-
// is overloaded (proposal-pause vs parked-terminal); the op disambiguates it.
|
|
2313
|
-
static #isProposal(statement, result) {
|
|
2314
|
-
return result.status === 202 && !(statement.op === "SEND" && statement.target === null);
|
|
2315
|
-
}
|
|
2316
|
-
async #writeLog({ statement, result, runId, loopId, turnId, sequence, origin, }) {
|
|
2317
|
-
const target = this.#extractTarget(statement.target);
|
|
2318
|
-
const lineMarkerJson = "lineMarker" in statement && statement.lineMarker !== null
|
|
2319
|
-
? JSON.stringify(statement.lineMarker)
|
|
2320
|
-
: null;
|
|
2321
|
-
// A proposal (status 202 from a side-effecting op) is written to the log in
|
|
2322
|
-
// state='proposed' until the proposal lifecycle resolves it; attrs holds the
|
|
2323
|
-
// scheme-supplied payload (file diff, exec command, etc.) the client renders
|
|
2324
|
-
// for review and the scheme consumes on accept. A broadcast SEND[202] is a
|
|
2325
|
-
// parked-terminal, NOT a proposal (#isProposal / #255) → state='resolved'.
|
|
2326
|
-
const isProposed = _a.#isProposal(statement, result);
|
|
2327
|
-
let attrsObj = (result.attrs !== undefined && result.attrs !== null)
|
|
2328
|
-
? { ...result.attrs }
|
|
2329
|
-
: {};
|
|
2330
|
-
// EXEC produces a stream entry addressed by RUNTIME TAG as authority (§exec): it lives
|
|
2331
|
-
// at <runtime>:///<loop_seq>/<turn_seq>/<sequence> (e.g. sh:///1/1/2). That address is a
|
|
2332
|
-
// SEPARATE `stream` link in attrs — NOT an overload of `target`, which stays faithful to
|
|
2333
|
-
// the EXEC's own slot (the cwd, or the path to the executable). The log:/// coordinate
|
|
2334
|
-
// shares the trailing <loop>/<turn>/<seq>, so the op still correlates to its stream.
|
|
2335
|
-
// Runtime comes from statement.signal (EXEC's runtime slot), resolvable for failed execs
|
|
2336
|
-
// too; empty/absent = the default shell.
|
|
2337
|
-
if (statement.op === "EXEC") {
|
|
2338
|
-
const seqs = await this.#db.engine_loop_turn_seqs.get({
|
|
2339
|
-
loop_id: loopId, turn_id: turnId,
|
|
2340
|
-
});
|
|
2341
|
-
if (seqs === undefined)
|
|
2342
|
-
throw new Error(`Engine.#writeLog: loop_turn_seqs returned no row for loop=${loopId} turn=${turnId}`);
|
|
2343
|
-
const runtime = (typeof statement.signal === "string" && statement.signal.length > 0) ? statement.signal : "sh";
|
|
2344
|
-
const coordPathname = `/${seqs.loop_seq}/${seqs.turn_seq}/${sequence}`;
|
|
2345
|
-
attrsObj.pathname = coordPathname;
|
|
2346
|
-
attrsObj.stream = `${runtime}://${coordPathname}`;
|
|
2347
|
-
// Mutate the in-memory result.attrs too: the dispatch path
|
|
2348
|
-
// hands originalResult.attrs to handler.applyResolution after
|
|
2349
|
-
// proposal accept (see #acceptResolution). Both views — the
|
|
2350
|
-
// stored row AND the in-memory proposal — need the same
|
|
2351
|
-
// pathname so applyResolution writes the entry at the same URI.
|
|
2352
|
-
if (result.attrs !== undefined && result.attrs !== null) {
|
|
2353
|
-
result.attrs.pathname = coordPathname;
|
|
2354
|
-
}
|
|
2355
|
-
}
|
|
2356
|
-
const attrs = JSON.stringify(attrsObj);
|
|
2357
|
-
const txJson = JSON.stringify(statement);
|
|
2358
|
-
const rxJson = JSON.stringify(result);
|
|
2359
|
-
const row = await this.#db.engine_insert_log_entry.get({
|
|
2360
|
-
run_id: runId,
|
|
2361
|
-
loop_id: loopId,
|
|
2362
|
-
turn_id: turnId,
|
|
2363
|
-
sequence: sequence,
|
|
2364
|
-
origin,
|
|
2365
|
-
source: null, // dispatch entries are self-authored; §env-delta deltas set this
|
|
2366
|
-
op: statement.op,
|
|
2367
|
-
suffix: statement.suffix,
|
|
2368
|
-
signal: this.#signalToJson(statement.signal),
|
|
2369
|
-
scheme: target.scheme,
|
|
2370
|
-
username: target.username,
|
|
2371
|
-
password: target.password,
|
|
2372
|
-
hostname: target.hostname,
|
|
2373
|
-
port: target.port,
|
|
2374
|
-
pathname: target.pathname,
|
|
2375
|
-
params: target.params,
|
|
2376
|
-
fragment: target.fragment,
|
|
2377
|
-
lineMarker: lineMarkerJson,
|
|
2378
|
-
tx: txJson,
|
|
2379
|
-
mimetype_tx: "application/json",
|
|
2380
|
-
rx: rxJson,
|
|
2381
|
-
mimetype_rx: "application/json",
|
|
2382
|
-
status_rx: result.status,
|
|
2383
|
-
tokens: this.#tokenize(txJson) + this.#tokenize(rxJson),
|
|
2384
|
-
state: isProposed ? "proposed" : "resolved",
|
|
2385
|
-
outcome: null,
|
|
2386
|
-
attrs,
|
|
2387
|
-
});
|
|
2388
|
-
if (row === undefined)
|
|
2389
|
-
throw new Error("Engine.#writeLog: INSERT ... RETURNING produced no row");
|
|
2390
|
-
return row.id;
|
|
2391
|
-
}
|
|
2392
|
-
// Normalize a parsed path for storage. The `file` scheme is a routing
|
|
2393
|
-
// internal — never stored, never rendered to the model. Both bare paths
|
|
2394
|
-
// and `file:///...` inputs collapse to scheme=null at this boundary, so
|
|
2395
|
-
// entries.scheme / log_entries.scheme never carry the string "file".
|
|
2396
|
-
#extractTarget(path) {
|
|
2397
|
-
if (path === null)
|
|
2398
|
-
return { scheme: null, username: null, password: null, hostname: null, port: null, pathname: null, params: null, fragment: null };
|
|
2399
|
-
// `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.
|
|
2400
|
-
if (path.kind === "regex")
|
|
2401
|
-
return { scheme: null, username: null, password: null, hostname: null, port: null, pathname: path.raw, params: null, fragment: null }; // regex source — no decode
|
|
2402
|
-
if (path.kind === "local")
|
|
2403
|
-
return { scheme: null, username: null, password: null, hostname: null, port: null, pathname: decodePathParens(path.raw), params: null, fragment: null }; // #239 item 4
|
|
2404
|
-
const scheme = path.scheme === "file" ? null : path.scheme;
|
|
2405
|
-
// Every registered (plurnk-namespace) scheme uses its authority as a namespace segment — fold
|
|
2406
|
-
// it into the canonical pathname so known://x ≡ known:///x ≡ /x and the log keys identically to
|
|
2407
|
-
// the entry (/prompt/<loop>, /docs/x.md). A foreign web host (http://, unregistered) is NOT a
|
|
2408
|
-
// namespace: keep it in hostname. run:// is the one registered EXCEPTION — its authority IS the
|
|
2409
|
-
// run selector (§run-scheme), and run://self must stay distinct from run://name, so Run.ts
|
|
2410
|
-
// folds the owner into the storage path itself, never here.
|
|
2411
|
-
const foldNs = scheme !== null && scheme !== "run" && this.#schemes.has(scheme);
|
|
2412
|
-
return {
|
|
2413
|
-
scheme, username: path.username, password: path.password,
|
|
2414
|
-
hostname: foldNs ? null : path.hostname, port: path.port,
|
|
2415
|
-
pathname: decodePathParens(foldNs ? foldAuthorityIntoPath(path.hostname, path.pathname) : path.pathname), // #239 item 4
|
|
2416
|
-
params: JSON.stringify(path.params), fragment: path.fragment,
|
|
2417
|
-
};
|
|
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");
|
|
2418
1191
|
}
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
1192
|
+
// A run "holds a live thing" iff it has an open stream/spawn (subscription registry or an
|
|
1193
|
+
// exec spawn) OR a non-terminal child run — the structured-concurrency invariant a terminal
|
|
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).
|
|
1196
|
+
async #runHoldsLiveThing(runId) {
|
|
1197
|
+
const openSubs = await this.#db.find_open_subscriptions_for_run.all({ run_id: runId });
|
|
1198
|
+
if (openSubs.length > 0)
|
|
1199
|
+
return true;
|
|
1200
|
+
const execHandler = this.#schemes.get("exec");
|
|
1201
|
+
if (execHandler?.hasActiveSpawns?.(runId) === true)
|
|
1202
|
+
return true;
|
|
1203
|
+
const liveChild = await this.#db.engine_run_has_live_child.get({ run_id: runId });
|
|
1204
|
+
return liveChild !== undefined;
|
|
2423
1205
|
}
|
|
2424
1206
|
}
|
|
2425
|
-
_a = Engine;
|
|
2426
|
-
export default Engine;
|
|
2427
1207
|
//# sourceMappingURL=Engine.js.map
|