@plurnk/plurnk-service 1.0.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.defaults +208 -0
- package/INSTALL.md +69 -0
- package/LICENSE +21 -0
- package/README.md +46 -0
- package/SPEC.md +1511 -0
- package/dist/Paths.d.ts +13 -0
- package/dist/Paths.d.ts.map +1 -0
- package/dist/Paths.js +60 -0
- package/dist/Paths.js.map +1 -0
- package/dist/content/edited-span.d.ts +2 -0
- package/dist/content/edited-span.d.ts.map +1 -0
- package/dist/content/edited-span.js +33 -0
- package/dist/content/edited-span.js.map +1 -0
- package/dist/content/index.d.ts +10 -0
- package/dist/content/index.d.ts.map +1 -0
- package/dist/content/index.js +11 -0
- package/dist/content/index.js.map +1 -0
- package/dist/content/line-marker.d.ts +15 -0
- package/dist/content/line-marker.d.ts.map +1 -0
- package/dist/content/line-marker.js +21 -0
- package/dist/content/line-marker.js.map +1 -0
- package/dist/content/matcher.d.ts +44 -0
- package/dist/content/matcher.d.ts.map +1 -0
- package/dist/content/matcher.js +126 -0
- package/dist/content/matcher.js.map +1 -0
- package/dist/content/mimetype-binary.d.ts +8 -0
- package/dist/content/mimetype-binary.d.ts.map +1 -0
- package/dist/content/mimetype-binary.js +17 -0
- package/dist/content/mimetype-binary.js.map +1 -0
- package/dist/content/path-mimetype.d.ts +5 -0
- package/dist/content/path-mimetype.d.ts.map +1 -0
- package/dist/content/path-mimetype.js +15 -0
- package/dist/content/path-mimetype.js.map +1 -0
- package/dist/content/read-resolve.d.ts +20 -0
- package/dist/content/read-resolve.d.ts.map +1 -0
- package/dist/content/read-resolve.js +68 -0
- package/dist/content/read-resolve.js.map +1 -0
- package/dist/core/ChannelWrite.d.ts +100 -0
- package/dist/core/ChannelWrite.d.ts.map +1 -0
- package/dist/core/ChannelWrite.js +112 -0
- package/dist/core/ChannelWrite.js.map +1 -0
- package/dist/core/ChannelWrite.sql +84 -0
- package/dist/core/Db.d.ts +14 -0
- package/dist/core/Db.d.ts.map +1 -0
- package/dist/core/Db.js +7 -0
- package/dist/core/Db.js.map +1 -0
- package/dist/core/Dispatcher.d.ts +67 -0
- package/dist/core/Dispatcher.d.ts.map +1 -0
- package/dist/core/Dispatcher.js +1021 -0
- package/dist/core/Dispatcher.js.map +1 -0
- package/dist/core/Engine.d.ts +113 -0
- package/dist/core/Engine.d.ts.map +1 -0
- package/dist/core/Engine.js +1410 -0
- package/dist/core/Engine.js.map +1 -0
- package/dist/core/Engine.sql +538 -0
- package/dist/core/EnvFlags.d.ts +13 -0
- package/dist/core/EnvFlags.d.ts.map +1 -0
- package/dist/core/EnvFlags.js +84 -0
- package/dist/core/EnvFlags.js.map +1 -0
- package/dist/core/ExecutorRegistry.d.ts +45 -0
- package/dist/core/ExecutorRegistry.d.ts.map +1 -0
- package/dist/core/ExecutorRegistry.js +128 -0
- package/dist/core/ExecutorRegistry.js.map +1 -0
- package/dist/core/PacketBuilder.d.ts +67 -0
- package/dist/core/PacketBuilder.d.ts.map +1 -0
- package/dist/core/PacketBuilder.js +524 -0
- package/dist/core/PacketBuilder.js.map +1 -0
- package/dist/core/PluginLoader.d.ts +17 -0
- package/dist/core/PluginLoader.d.ts.map +1 -0
- package/dist/core/PluginLoader.js +97 -0
- package/dist/core/PluginLoader.js.map +1 -0
- package/dist/core/ProposalLifecycle.d.ts +59 -0
- package/dist/core/ProposalLifecycle.d.ts.map +1 -0
- package/dist/core/ProposalLifecycle.js +215 -0
- package/dist/core/ProposalLifecycle.js.map +1 -0
- package/dist/core/ProviderInstantiate.d.ts +9 -0
- package/dist/core/ProviderInstantiate.d.ts.map +1 -0
- package/dist/core/ProviderInstantiate.js +156 -0
- package/dist/core/ProviderInstantiate.js.map +1 -0
- package/dist/core/SchemeRegistry.d.ts +26 -0
- package/dist/core/SchemeRegistry.d.ts.map +1 -0
- package/dist/core/SchemeRegistry.js +205 -0
- package/dist/core/SchemeRegistry.js.map +1 -0
- package/dist/core/StrikeRail.d.ts +28 -0
- package/dist/core/StrikeRail.d.ts.map +1 -0
- package/dist/core/StrikeRail.js +165 -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/TokenGauge.d.ts +16 -0
- package/dist/core/TokenGauge.d.ts.map +1 -0
- package/dist/core/TokenGauge.js +58 -0
- package/dist/core/TokenGauge.js.map +1 -0
- package/dist/core/caps/CapsResolve.d.ts +5 -0
- package/dist/core/caps/CapsResolve.d.ts.map +1 -0
- package/dist/core/caps/CapsResolve.js +13 -0
- package/dist/core/caps/CapsResolve.js.map +1 -0
- package/dist/core/caps/DbChannelCaps.d.ts +16 -0
- package/dist/core/caps/DbChannelCaps.d.ts.map +1 -0
- package/dist/core/caps/DbChannelCaps.js +41 -0
- package/dist/core/caps/DbChannelCaps.js.map +1 -0
- package/dist/core/caps/DbEntryCaps.d.ts +19 -0
- package/dist/core/caps/DbEntryCaps.d.ts.map +1 -0
- package/dist/core/caps/DbEntryCaps.js +28 -0
- package/dist/core/caps/DbEntryCaps.js.map +1 -0
- package/dist/core/caps/DbNotifyCaps.d.ts +8 -0
- package/dist/core/caps/DbNotifyCaps.d.ts.map +1 -0
- package/dist/core/caps/DbNotifyCaps.js +35 -0
- package/dist/core/caps/DbNotifyCaps.js.map +1 -0
- package/dist/core/caps/DbSubscriptionCaps.d.ts +10 -0
- package/dist/core/caps/DbSubscriptionCaps.d.ts.map +1 -0
- package/dist/core/caps/DbSubscriptionCaps.js +86 -0
- package/dist/core/caps/DbSubscriptionCaps.js.map +1 -0
- package/dist/core/caps/DbTagCaps.d.ts +17 -0
- package/dist/core/caps/DbTagCaps.d.ts.map +1 -0
- package/dist/core/caps/DbTagCaps.js +37 -0
- package/dist/core/caps/DbTagCaps.js.map +1 -0
- package/dist/core/caps/SchemeCtxImpl.d.ts +18 -0
- package/dist/core/caps/SchemeCtxImpl.d.ts.map +1 -0
- package/dist/core/caps/SchemeCtxImpl.js +42 -0
- package/dist/core/caps/SchemeCtxImpl.js.map +1 -0
- package/dist/core/env-defaults.d.ts +19 -0
- package/dist/core/env-defaults.d.ts.map +1 -0
- package/dist/core/env-defaults.js +89 -0
- package/dist/core/env-defaults.js.map +1 -0
- package/dist/core/fork.d.ts +6 -0
- package/dist/core/fork.d.ts.map +1 -0
- package/dist/core/fork.js +84 -0
- package/dist/core/fork.js.map +1 -0
- package/dist/core/fork.sql +85 -0
- package/dist/core/git-membership.d.ts +23 -0
- package/dist/core/git-membership.d.ts.map +1 -0
- package/dist/core/git-membership.js +366 -0
- package/dist/core/git-membership.js.map +1 -0
- package/dist/core/git-state.d.ts +15 -0
- package/dist/core/git-state.d.ts.map +1 -0
- package/dist/core/git-state.js +67 -0
- package/dist/core/git-state.js.map +1 -0
- package/dist/core/packet-inject.d.ts +5 -0
- package/dist/core/packet-inject.d.ts.map +1 -0
- package/dist/core/packet-inject.js +48 -0
- package/dist/core/packet-inject.js.map +1 -0
- package/dist/core/packet-wire.d.ts +46 -0
- package/dist/core/packet-wire.d.ts.map +1 -0
- package/dist/core/packet-wire.js +453 -0
- package/dist/core/packet-wire.js.map +1 -0
- package/dist/core/path-decode.d.ts +2 -0
- package/dist/core/path-decode.d.ts.map +1 -0
- package/dist/core/path-decode.js +8 -0
- package/dist/core/path-decode.js.map +1 -0
- package/dist/core/plugin-attribution.d.ts +5 -0
- package/dist/core/plugin-attribution.d.ts.map +1 -0
- package/dist/core/plugin-attribution.js +39 -0
- package/dist/core/plugin-attribution.js.map +1 -0
- package/dist/core/plurnk-uri.d.ts +7 -0
- package/dist/core/plurnk-uri.d.ts.map +1 -0
- package/dist/core/plurnk-uri.js +52 -0
- package/dist/core/plurnk-uri.js.map +1 -0
- package/dist/core/resolveForLoop.d.ts +5 -0
- package/dist/core/resolveForLoop.d.ts.map +1 -0
- package/dist/core/resolveForLoop.js +12 -0
- package/dist/core/resolveForLoop.js.map +1 -0
- package/dist/core/results.d.ts +12 -0
- package/dist/core/results.d.ts.map +1 -0
- package/dist/core/results.js +27 -0
- package/dist/core/results.js.map +1 -0
- package/dist/core/run-cap.d.ts +8 -0
- package/dist/core/run-cap.d.ts.map +1 -0
- package/dist/core/run-cap.js +20 -0
- package/dist/core/run-cap.js.map +1 -0
- package/dist/core/run-ops.sql +43 -0
- package/dist/core/scheme-types.d.ts +45 -0
- package/dist/core/scheme-types.d.ts.map +1 -0
- package/dist/core/scheme-types.js +6 -0
- package/dist/core/scheme-types.js.map +1 -0
- package/dist/core/session-settings.d.ts +23 -0
- package/dist/core/session-settings.d.ts.map +1 -0
- package/dist/core/session-settings.js +55 -0
- package/dist/core/session-settings.js.map +1 -0
- package/dist/core/teaching.d.ts +3 -0
- package/dist/core/teaching.d.ts.map +1 -0
- package/dist/core/teaching.js +13 -0
- package/dist/core/teaching.js.map +1 -0
- package/dist/core/types.d.ts +3 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +2 -0
- package/dist/core/types.js.map +1 -0
- package/dist/digest/Digest.d.ts +20 -0
- package/dist/digest/Digest.d.ts.map +1 -0
- package/dist/digest/Digest.js +451 -0
- package/dist/digest/Digest.js.map +1 -0
- package/dist/digest/Digest.sql +1 -0
- package/dist/digest/digest.sql +55 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/schemes/EffectPolicy.d.ts +6 -0
- package/dist/schemes/EffectPolicy.d.ts.map +1 -0
- package/dist/schemes/EffectPolicy.js +18 -0
- package/dist/schemes/EffectPolicy.js.map +1 -0
- package/dist/schemes/Exec.d.ts +39 -0
- package/dist/schemes/Exec.d.ts.map +1 -0
- package/dist/schemes/Exec.js +510 -0
- package/dist/schemes/Exec.js.map +1 -0
- package/dist/schemes/ExecOutputScheme.d.ts +20 -0
- package/dist/schemes/ExecOutputScheme.d.ts.map +1 -0
- package/dist/schemes/ExecOutputScheme.js +39 -0
- package/dist/schemes/ExecOutputScheme.js.map +1 -0
- package/dist/schemes/File.d.ts +42 -0
- package/dist/schemes/File.d.ts.map +1 -0
- package/dist/schemes/File.js +356 -0
- package/dist/schemes/File.js.map +1 -0
- package/dist/schemes/Known.d.ts +17 -0
- package/dist/schemes/Known.d.ts.map +1 -0
- package/dist/schemes/Known.js +40 -0
- package/dist/schemes/Known.js.map +1 -0
- package/dist/schemes/Log.d.ts +22 -0
- package/dist/schemes/Log.d.ts.map +1 -0
- package/dist/schemes/Log.js +269 -0
- package/dist/schemes/Log.js.map +1 -0
- package/dist/schemes/Log.sql +59 -0
- package/dist/schemes/Plurnk.d.ts +17 -0
- package/dist/schemes/Plurnk.d.ts.map +1 -0
- package/dist/schemes/Plurnk.js +46 -0
- package/dist/schemes/Plurnk.js.map +1 -0
- package/dist/schemes/Run.d.ts +25 -0
- package/dist/schemes/Run.d.ts.map +1 -0
- package/dist/schemes/Run.js +172 -0
- package/dist/schemes/Run.js.map +1 -0
- package/dist/schemes/Skill.d.ts +17 -0
- package/dist/schemes/Skill.d.ts.map +1 -0
- package/dist/schemes/Skill.js +40 -0
- package/dist/schemes/Skill.js.map +1 -0
- package/dist/schemes/Unknown.d.ts +17 -0
- package/dist/schemes/Unknown.d.ts.map +1 -0
- package/dist/schemes/Unknown.js +39 -0
- package/dist/schemes/Unknown.js.map +1 -0
- package/dist/schemes/_entry-chunk.d.ts +11 -0
- package/dist/schemes/_entry-chunk.d.ts.map +1 -0
- package/dist/schemes/_entry-chunk.js +100 -0
- package/dist/schemes/_entry-chunk.js.map +1 -0
- package/dist/schemes/_entry-crud.d.ts +31 -0
- package/dist/schemes/_entry-crud.d.ts.map +1 -0
- package/dist/schemes/_entry-crud.js +77 -0
- package/dist/schemes/_entry-crud.js.map +1 -0
- package/dist/schemes/_entry-crud.sql +101 -0
- package/dist/schemes/_entry-find.d.ts +32 -0
- package/dist/schemes/_entry-find.d.ts.map +1 -0
- package/dist/schemes/_entry-find.js +226 -0
- package/dist/schemes/_entry-find.js.map +1 -0
- package/dist/schemes/_entry-find.sql +54 -0
- package/dist/schemes/_entry-graph.d.ts +16 -0
- package/dist/schemes/_entry-graph.d.ts.map +1 -0
- package/dist/schemes/_entry-graph.js +94 -0
- package/dist/schemes/_entry-graph.js.map +1 -0
- package/dist/schemes/_entry-graph.sql +52 -0
- package/dist/schemes/_entry-manifest.d.ts +25 -0
- package/dist/schemes/_entry-manifest.d.ts.map +1 -0
- package/dist/schemes/_entry-manifest.js +266 -0
- package/dist/schemes/_entry-manifest.js.map +1 -0
- package/dist/schemes/_entry-ops.d.ts +32 -0
- package/dist/schemes/_entry-ops.d.ts.map +1 -0
- package/dist/schemes/_entry-ops.js +216 -0
- package/dist/schemes/_entry-ops.js.map +1 -0
- package/dist/schemes/_entry-ops.sql +31 -0
- package/dist/schemes/_entry-semantic.d.ts +38 -0
- package/dist/schemes/_entry-semantic.d.ts.map +1 -0
- package/dist/schemes/_entry-semantic.js +202 -0
- package/dist/schemes/_entry-semantic.js.map +1 -0
- package/dist/schemes/_entry-semantic.sql +95 -0
- package/dist/schemes/_entry-send.d.ts +11 -0
- package/dist/schemes/_entry-send.d.ts.map +1 -0
- package/dist/schemes/_entry-send.js +66 -0
- package/dist/schemes/_entry-send.js.map +1 -0
- package/dist/schemes/cosine.d.ts +4 -0
- package/dist/schemes/cosine.d.ts.map +1 -0
- package/dist/schemes/cosine.js +9 -0
- package/dist/schemes/cosine.js.map +1 -0
- package/dist/schemes/exec-abort.d.ts +15 -0
- package/dist/schemes/exec-abort.d.ts.map +1 -0
- package/dist/schemes/exec-abort.js +29 -0
- package/dist/schemes/exec-abort.js.map +1 -0
- package/dist/schemes/exec-env.d.ts +5 -0
- package/dist/schemes/exec-env.d.ts.map +1 -0
- package/dist/schemes/exec-env.js +34 -0
- package/dist/schemes/exec-env.js.map +1 -0
- package/dist/server/Daemon.d.ts +244 -0
- package/dist/server/Daemon.d.ts.map +1 -0
- package/dist/server/Daemon.js +1119 -0
- package/dist/server/Daemon.js.map +1 -0
- package/dist/server/client-input.d.ts +11 -0
- package/dist/server/client-input.d.ts.map +1 -0
- package/dist/server/client-input.js +145 -0
- package/dist/server/client-input.js.map +1 -0
- package/dist/server/clientTurn.d.ts +6 -0
- package/dist/server/clientTurn.d.ts.map +1 -0
- package/dist/server/clientTurn.js +23 -0
- package/dist/server/clientTurn.js.map +1 -0
- package/dist/server/clientTurn.sql +10 -0
- package/dist/server/dispatch-as-plurnk.d.ts +7 -0
- package/dist/server/dispatch-as-plurnk.d.ts.map +1 -0
- package/dist/server/dispatch-as-plurnk.js +23 -0
- package/dist/server/dispatch-as-plurnk.js.map +1 -0
- package/dist/server/drain.sql +124 -0
- package/dist/server/envelope.d.ts +53 -0
- package/dist/server/envelope.d.ts.map +1 -0
- package/dist/server/envelope.js +172 -0
- package/dist/server/envelope.js.map +1 -0
- package/dist/server/envelope.sql +76 -0
- package/dist/server/logEntry.d.ts +35 -0
- package/dist/server/logEntry.d.ts.map +1 -0
- package/dist/server/logEntry.js +49 -0
- package/dist/server/logEntry.js.map +1 -0
- package/dist/server/logEntry.sql +10 -0
- package/dist/server/loopDocs.d.ts +6 -0
- package/dist/server/loopDocs.d.ts.map +1 -0
- package/dist/server/loopDocs.js +22 -0
- package/dist/server/loopDocs.js.map +1 -0
- package/dist/server/noProposals.d.ts +6 -0
- package/dist/server/noProposals.d.ts.map +1 -0
- package/dist/server/noProposals.js +37 -0
- package/dist/server/noProposals.js.map +1 -0
- package/dist/server/seam-entry-read.sql +21 -0
- package/dist/server/seam-log-read.sql +21 -0
- package/dist/server/seam-loop.sql +9 -0
- package/dist/server/seam-proposal-list.sql +13 -0
- package/dist/server/version-info.d.ts +14 -0
- package/dist/server/version-info.d.ts.map +1 -0
- package/dist/server/version-info.js +69 -0
- package/dist/server/version-info.js.map +1 -0
- package/dist/server/yolo.d.ts +6 -0
- package/dist/server/yolo.d.ts.map +1 -0
- package/dist/server/yolo.js +64 -0
- package/dist/server/yolo.js.map +1 -0
- package/dist/service.d.ts +6 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +309 -0
- package/dist/service.js.map +1 -0
- package/migrations/0000-00-00.01_schema.sql +500 -0
- package/package.json +86 -0
|
@@ -0,0 +1,1410 @@
|
|
|
1
|
+
import { PlurnkParser, PlurnkParseError } from "@plurnk/plurnk-grammar";
|
|
2
|
+
import { Mimetypes, emptyRegistry } from "@plurnk/plurnk-mimetypes";
|
|
3
|
+
import EntryCrud from "../schemes/_entry-crud.js";
|
|
4
|
+
import EntryManifest from "../schemes/_entry-manifest.js";
|
|
5
|
+
import { markTerminal } from "../schemes/Run.js";
|
|
6
|
+
import TokenGauge from "./TokenGauge.js";
|
|
7
|
+
import GitMembership from "./git-membership.js";
|
|
8
|
+
import GitState from "./git-state.js";
|
|
9
|
+
import SessionSettings from "./session-settings.js";
|
|
10
|
+
import { editedSpan } from "../content/index.js";
|
|
11
|
+
import { promptPathname } from "./plurnk-uri.js";
|
|
12
|
+
import { readFile } from "node:fs/promises";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
15
|
+
// Shared module imported by both Engine and bin/digest.ts, so wire
|
|
16
|
+
// projection and digest projection are structurally one function — no
|
|
17
|
+
// drift between wire and digest possible.
|
|
18
|
+
// Format: markdown (user pick over rummy's XML alternative, 2026-05-22).
|
|
19
|
+
import PacketWire from "./packet-wire.js";
|
|
20
|
+
// The engine's collaborators — each owns one machine; Engine owns the loop/turn
|
|
21
|
+
// lifecycle and wires them together as the public facade.
|
|
22
|
+
import TelemetryChannel from "./TelemetryChannel.js";
|
|
23
|
+
import StrikeRail from "./StrikeRail.js";
|
|
24
|
+
import PacketBuilder from "./PacketBuilder.js";
|
|
25
|
+
import ProposalLifecycle from "./ProposalLifecycle.js";
|
|
26
|
+
import Dispatcher from "./Dispatcher.js";
|
|
27
|
+
const DEFAULT_MAX_STRIKES = 3;
|
|
28
|
+
const DEFAULT_MAX_COMMANDS = 99;
|
|
29
|
+
// The foisted prompt EDIT/READ target — run-qualified storage (§prompt-auto-read, #382 fault-1)
|
|
30
|
+
// rendered in the plurnk:// authority form (hostname carries the namespace, plurnk-uri folds it
|
|
31
|
+
// back into the storage key on dispatch).
|
|
32
|
+
const promptTarget = (runId, loopSeq, turnSeq) => {
|
|
33
|
+
const storage = promptPathname(runId, loopSeq, turnSeq);
|
|
34
|
+
return {
|
|
35
|
+
kind: "url", raw: `plurnk://${storage.slice(1)}`,
|
|
36
|
+
scheme: "plurnk", username: null, password: null,
|
|
37
|
+
hostname: "prompt", port: null,
|
|
38
|
+
pathname: storage.slice("/prompt".length), params: {}, fragment: null,
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
const readMaxStrikes = () => {
|
|
42
|
+
const raw = process.env.PLURNK_SERVICE_MAX_STRIKES;
|
|
43
|
+
if (raw === undefined || raw.length === 0)
|
|
44
|
+
return DEFAULT_MAX_STRIKES;
|
|
45
|
+
const n = Number.parseInt(raw, 10);
|
|
46
|
+
if (!Number.isFinite(n) || n < 0)
|
|
47
|
+
return DEFAULT_MAX_STRIKES;
|
|
48
|
+
return n;
|
|
49
|
+
};
|
|
50
|
+
const readMaxCommands = () => {
|
|
51
|
+
const raw = process.env.PLURNK_SERVICE_MAX_COMMANDS;
|
|
52
|
+
if (raw === undefined || raw.length === 0)
|
|
53
|
+
return DEFAULT_MAX_COMMANDS;
|
|
54
|
+
const n = Number.parseInt(raw, 10);
|
|
55
|
+
if (!Number.isFinite(n) || n < 1)
|
|
56
|
+
return DEFAULT_MAX_COMMANDS;
|
|
57
|
+
return n;
|
|
58
|
+
};
|
|
59
|
+
// PLURNK_SERVICE_FILES_ITEMS — the turn-0 manifest preview. null = off (no foist);
|
|
60
|
+
// -1 = the full manifest; positive N = the first N items. 0 / unset = off.
|
|
61
|
+
const normalizeFilesItems = (n) => (!Number.isFinite(n) || n === 0 ? null : n < 0 ? -1 : n);
|
|
62
|
+
const readFilesItems = () => {
|
|
63
|
+
const raw = process.env.PLURNK_SERVICE_FILES_ITEMS;
|
|
64
|
+
if (raw === undefined || raw.length === 0)
|
|
65
|
+
return null;
|
|
66
|
+
return normalizeFilesItems(Number.parseInt(raw, 10));
|
|
67
|
+
};
|
|
68
|
+
import { ProviderError, scopeEnvToAlias, resolveActiveAlias } from "@plurnk/plurnk-providers";
|
|
69
|
+
import ProviderInstantiate from "./ProviderInstantiate.js";
|
|
70
|
+
// Default turn.status when ops were emitted but no SEND. Model is implicitly
|
|
71
|
+
// continuing; loop.status stays 102 either way (only SEND broadcast advances
|
|
72
|
+
// loop terminal). No strike, no telemetry.
|
|
73
|
+
const TURN_STATUS_IMPLICIT_CONTINUE = 102;
|
|
74
|
+
// Status assigned to a turn that emitted NO ops at all. Strike-worthy; the
|
|
75
|
+
// action routes through telemetry.errors[] (§telemetry, §telemetry-no-error-scheme — never an error:// scheme).
|
|
76
|
+
const TURN_STATUS_NO_OPS = 422;
|
|
77
|
+
const DEFAULT_MIN_CYCLES = 3;
|
|
78
|
+
const DEFAULT_MAX_CYCLE_PERIOD = 4;
|
|
79
|
+
const readPositiveInt = (envVar, fallback) => {
|
|
80
|
+
const raw = process.env[envVar];
|
|
81
|
+
if (raw === undefined || raw.length === 0)
|
|
82
|
+
return fallback;
|
|
83
|
+
const n = Number.parseInt(raw, 10);
|
|
84
|
+
if (!Number.isFinite(n) || n < 1)
|
|
85
|
+
return fallback;
|
|
86
|
+
return n;
|
|
87
|
+
};
|
|
88
|
+
// §operator-config-loop-timeout — the loop's wall-clock budget (PLURNK_SERVICE_LOOP_TIMEOUT).
|
|
89
|
+
const DEFAULT_LOOP_TIMEOUT_MS = 86400000;
|
|
90
|
+
const readLoopTimeoutMs = () => readPositiveInt("PLURNK_SERVICE_LOOP_TIMEOUT", DEFAULT_LOOP_TIMEOUT_MS);
|
|
91
|
+
// The wall's abort reason — runLoop branches a mid-turn teardown to the 504 terminal on it.
|
|
92
|
+
const LOOP_TIMEOUT_REASON = "loop_timeout";
|
|
93
|
+
export default class Engine {
|
|
94
|
+
static fingerprintTurn(ops) {
|
|
95
|
+
return StrikeRail.fingerprintTurn(ops);
|
|
96
|
+
}
|
|
97
|
+
static detectCycle(history, minCycles, maxCyclePeriod) {
|
|
98
|
+
return StrikeRail.detectCycle(history, minCycles, maxCyclePeriod);
|
|
99
|
+
}
|
|
100
|
+
#db;
|
|
101
|
+
#schemes;
|
|
102
|
+
#mimetypes;
|
|
103
|
+
// Write-time tokenizer (SPEC §tokenomics). Synchronous per the provider
|
|
104
|
+
// contract (§provider-surface). Populated from the active provider's countTokens via
|
|
105
|
+
// the Daemon; a divisor tripwire stands in only for bare/standalone
|
|
106
|
+
// construction before a provider is wired (same boot affordance as
|
|
107
|
+
// Mimetypes, §mimetype-surface). Real counts come from provider.countTokens.
|
|
108
|
+
#tokenize;
|
|
109
|
+
// Boot-discovered runtime executors. Daemon builds + sets via
|
|
110
|
+
// setExecutors at start(); undefined until then (and in bare tests).
|
|
111
|
+
#executors;
|
|
112
|
+
// §send-premature-terminate/[102]<T> — park deadlines by loopId, written at dispatch (the
|
|
113
|
+
// marker's seconds; -1 = indefinite), consumed by the daemon's drain park-exit to schedule
|
|
114
|
+
// the deadline wake. In-memory: a daemon restart drops pending deadlines (documented).
|
|
115
|
+
parkDeadlines = new Map();
|
|
116
|
+
// §join-blocking-collect (#354) — loops whose turn issued a READ(run://running-child): the
|
|
117
|
+
// blocking join. The dispatcher arms this on the READ; the turn's bare SEND[102] parks on it
|
|
118
|
+
// (indefinite) instead of continuing, and any SEND clears it. Twin of parkDeadlines.
|
|
119
|
+
joinTargets = new Set();
|
|
120
|
+
// The collaborators. Engine constructs them (they share its deps via
|
|
121
|
+
// thunks where the value is late-injected — executors, loop signals)
|
|
122
|
+
// and fronts their public surface.
|
|
123
|
+
#telemetry;
|
|
124
|
+
#strikes;
|
|
125
|
+
// §grinder-hard-413-recovery — loops granted their ONE over-ceiling recovery turn. Cleared on a
|
|
126
|
+
// fitting turn (the model curated; a LATER overflow earns a fresh recovery) and at loop cleanup.
|
|
127
|
+
#hardOverflowRecovery = new Set();
|
|
128
|
+
#packets;
|
|
129
|
+
#proposals;
|
|
130
|
+
#dispatcher;
|
|
131
|
+
// Per-loop AbortController for cancellation propagation into scheme
|
|
132
|
+
// ctx.signal. runLoop creates one at entry, cleans up at end. Engine
|
|
133
|
+
// cancellation paths (strikes, max_turns, external) abort it.
|
|
134
|
+
// Streaming schemes (exec) chain their per-spawn controllers off
|
|
135
|
+
// ctx.signal so cancelled loops tear down their background spawns.
|
|
136
|
+
#loopAborts = new Map();
|
|
137
|
+
// §tokenomics-ceiling-calibrates-to-usage (#311) — the loop's observed real/measured token
|
|
138
|
+
// ratio (provider usage.prompt ÷ our measured packet tokens), monotone max so the ceiling
|
|
139
|
+
// only tightens. A chars/4 heuristic ruler undercounts escaped-JSON logs ~1.5×; calibrating
|
|
140
|
+
// against the provider's own count makes a real context overflow unreachable past turn 1.
|
|
141
|
+
#tokenRatios = new Map();
|
|
142
|
+
// §send-premature-terminate — loops owed one idle-grace turn after a retrieval-only 409
|
|
143
|
+
// (the steer's own advice is to wait; in-memory, fail-open on restart).
|
|
144
|
+
#retrievalRefusalGrace = new Set();
|
|
145
|
+
// §derivation-off-hot-path — the background derivation chain: the per-turn pump and the
|
|
146
|
+
// session warm ride it instead of the turn (a 2-CPU container CPU-embedding a 335-entry
|
|
147
|
+
// ingest starved every loop for ~28min, #316). Serialized (never two pumps interleaved),
|
|
148
|
+
// drained at daemon stop (never racing db close), failures logged (never swallowed).
|
|
149
|
+
// A turn never waits on an embedding; a ~query warms its own candidate slice inline
|
|
150
|
+
// (§semantic-cold-query-full-fidelity), so cold sessions still get full-fidelity search.
|
|
151
|
+
#derivationChain = Promise.resolve();
|
|
152
|
+
#queueDerivation(job) {
|
|
153
|
+
const run = this.#derivationChain.then(job).catch((err) => {
|
|
154
|
+
process.stderr.write(`plurnk-engine: background derivation failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
155
|
+
});
|
|
156
|
+
this.#derivationChain = run;
|
|
157
|
+
return run;
|
|
158
|
+
}
|
|
159
|
+
// Awaited by Daemon.stop before the db closes.
|
|
160
|
+
async drainDerivations() {
|
|
161
|
+
await this.#derivationChain;
|
|
162
|
+
}
|
|
163
|
+
#streamEventNotify;
|
|
164
|
+
#wakeRunNotify;
|
|
165
|
+
// Cached plurnk GBNF — read once on the first constrained generate (#189).
|
|
166
|
+
#gbnfCache = new Map(); // variant name -> GBNF text (per-alias selection, #353)
|
|
167
|
+
constructor({ db, schemes, mimetypes, streamEventNotify, wakeRunNotify, injectRun, cancelRun, telemetryEventNotify, tokenize }) {
|
|
168
|
+
this.#db = db;
|
|
169
|
+
this.#schemes = schemes;
|
|
170
|
+
this.#streamEventNotify = streamEventNotify;
|
|
171
|
+
this.#wakeRunNotify = wakeRunNotify;
|
|
172
|
+
// Default to empty discovery — standalone Engine construction (in
|
|
173
|
+
// tests) gets no handlers, and content flows through the framework's
|
|
174
|
+
// raw-content fitContent fallback. Daemon-managed Engine receives a
|
|
175
|
+
// production-configured Mimetypes via the constructor arg.
|
|
176
|
+
this.#mimetypes = mimetypes ?? new Mimetypes({
|
|
177
|
+
discovery: { registry: emptyRegistry(), handlers: new Map() },
|
|
178
|
+
});
|
|
179
|
+
// Tripwire default matches the Mimetypes boot affordance (SPEC §mimetype-surface):
|
|
180
|
+
// the divisor stands in only until the provider-backed tokenizer is
|
|
181
|
+
// wired by the Daemon. Real counts come from provider.countTokens.
|
|
182
|
+
this.#tokenize = tokenize ?? ((text) => Math.ceil(text.length / 4));
|
|
183
|
+
const executors = () => this.#executors;
|
|
184
|
+
const loopSignal = (loopId) => this.#loopAborts.get(loopId)?.signal;
|
|
185
|
+
this.#telemetry = new TelemetryChannel({ db, notify: telemetryEventNotify });
|
|
186
|
+
this.#strikes = new StrikeRail();
|
|
187
|
+
this.#packets = new PacketBuilder({ db, schemes, telemetry: this.#telemetry, executors });
|
|
188
|
+
this.#proposals = new ProposalLifecycle({
|
|
189
|
+
db, schemes, telemetry: this.#telemetry,
|
|
190
|
+
streamEventNotify, wakeRunNotify,
|
|
191
|
+
tokenize: this.#tokenize, mimetypes: this.#mimetypes, executors, loopSignal,
|
|
192
|
+
});
|
|
193
|
+
this.#dispatcher = new Dispatcher({
|
|
194
|
+
db, schemes, mimetypes: this.#mimetypes,
|
|
195
|
+
tokenize: this.#tokenize,
|
|
196
|
+
telemetry: this.#telemetry, proposals: this.#proposals,
|
|
197
|
+
executors, loopSignal,
|
|
198
|
+
streamEventNotify, wakeRunNotify, injectRun, cancelRun,
|
|
199
|
+
parkDeadlines: this.parkDeadlines,
|
|
200
|
+
joinTargets: this.joinTargets,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
// Late injection: the executor registry is async-built at daemon start()
|
|
204
|
+
// (discover + probe), after Engine construction.
|
|
205
|
+
setExecutors(executors) {
|
|
206
|
+
this.#executors = executors;
|
|
207
|
+
}
|
|
208
|
+
// Runtime hotload (#289) — register an executor TAG live, after boot (the /mcp route: an MCP
|
|
209
|
+
// server connected at runtime becomes EXEC[<server>]). Registers on BOTH registries the boot path
|
|
210
|
+
// wires: the ExecutorRegistry (dispatch resolves the tag; the tools sheet, rebuilt per packet, then
|
|
211
|
+
// offers it to the model) and the SchemeRegistry face (the tag's READ/FIND/KILL scheme), sharing the
|
|
212
|
+
// same reserved/cross-family arbitration boot uses. Fail-hard if the registry isn't wired yet — a
|
|
213
|
+
// hotload before daemon start() is a caller bug, not a silent no-op.
|
|
214
|
+
hotloadRuntime(tag, entry) {
|
|
215
|
+
if (this.#executors === undefined)
|
|
216
|
+
throw new Error("hotloadRuntime: executor registry not wired yet (call after daemon start)");
|
|
217
|
+
// Scheme face FIRST — it is the arbitration gate (reserved / cross-family collision, #240) and
|
|
218
|
+
// throws before we mutate the executor registry, so a rejected tag leaves neither registry
|
|
219
|
+
// half-written. A brand-new tag registers on both; a reserved/claimed tag throws here untouched.
|
|
220
|
+
this.#schemes.registerRuntimeScheme(tag, entry.executor);
|
|
221
|
+
this.#executors.register(tag, entry);
|
|
222
|
+
}
|
|
223
|
+
// Grammar-constrained sampling (#189): when PLURNK_PROVIDERS_GBNF is enabled
|
|
224
|
+
// (the only knob — default-on in .env.defaults), hand the provider the plurnk
|
|
225
|
+
// GBNF (the full shipped multi-op root, read once + cached). The provider
|
|
226
|
+
// attaches it iff the backend supports it and silently drops it otherwise —
|
|
227
|
+
// capability is providers' concern, not ours. Pure plumbing grammar→provider.
|
|
228
|
+
async #grammarConstraint(provider) {
|
|
229
|
+
// PLURNK_PROVIDERS_GBNF SELECTS the GBNF variant to constrain sampling to (#225):
|
|
230
|
+
// a bare name (`plurnk-strict.gbnf` | `plurnk.gbnf`) is a variant shipped by
|
|
231
|
+
// @plurnk/plurnk-grammar; an absolute/relative path is a BYO grammar. Empty or "0"
|
|
232
|
+
// disables — unconstrained generation.
|
|
233
|
+
//
|
|
234
|
+
// PER ALIAS (#353): resolved PLURNK_PROVIDERS_GBNF_<alias> over the bare fallback (providers'
|
|
235
|
+
// scopeEnvToAlias), scoped by the alias that built this provider. GBNF only helps backends
|
|
236
|
+
// that constrain sampling (llama-server, response_format backends); a cloud model that IGNORES the grammar
|
|
237
|
+
// gets a filter-mode divergence event every turn for nothing. So the bare default is OFF
|
|
238
|
+
// and the GBNF-capable aliases opt IN via a PLURNK_PROVIDERS_GBNF_<alias> suffix.
|
|
239
|
+
const alias = ProviderInstantiate.aliasOf(provider) ?? resolveActiveAlias(process.env)?.alias ?? "";
|
|
240
|
+
const variant = scopeEnvToAlias(process.env, alias, ["PLURNK_PROVIDERS_GBNF"]).PLURNK_PROVIDERS_GBNF;
|
|
241
|
+
if (variant === undefined || variant === "" || variant === "0")
|
|
242
|
+
return undefined;
|
|
243
|
+
const hit = this.#gbnfCache.get(variant);
|
|
244
|
+
if (hit !== undefined)
|
|
245
|
+
return hit;
|
|
246
|
+
const path = variant.startsWith("/") || variant.startsWith(".")
|
|
247
|
+
? variant
|
|
248
|
+
: fileURLToPath(import.meta.resolve(`@plurnk/plurnk-grammar/${variant}`));
|
|
249
|
+
const text = await readFile(path, "utf8");
|
|
250
|
+
this.#gbnfCache.set(variant, text);
|
|
251
|
+
return text;
|
|
252
|
+
}
|
|
253
|
+
// Per-loop usage totals (#197): SUM the loop's turns (usage is stored per
|
|
254
|
+
// turn, §tokenomics). Surfaced on loop.run + loop/terminated so clients render real
|
|
255
|
+
// token/cost numbers. costPico is the stored pico-dollar unit.
|
|
256
|
+
// #345 — the client-facing budget denominator, ONE meaning on every surface: the prompt
|
|
257
|
+
// budget the packet actually lives under (effective window minus the partition reserves),
|
|
258
|
+
// the same number loop-usage stores per turn. providers.list advertised the raw KV and the
|
|
259
|
+
// client's gauge rendered a window the model can never fill.
|
|
260
|
+
promptBudgetFor(provider) {
|
|
261
|
+
return this.#packets.promptBudgetFor(provider);
|
|
262
|
+
}
|
|
263
|
+
async loopUsage(loopId) {
|
|
264
|
+
const row = await this.#db.engine_loop_usage.get({ loop_id: loopId });
|
|
265
|
+
return {
|
|
266
|
+
promptTokens: row?.prompt ?? 0,
|
|
267
|
+
completionTokens: row?.completion ?? 0,
|
|
268
|
+
costPico: row?.cost_pico ?? 0,
|
|
269
|
+
// #263 — the last turn's prompt tokens = current window occupancy (gauge numerator), NOT the
|
|
270
|
+
// summed promptTokens above, which overcounts a context that grows across turns.
|
|
271
|
+
contextTokens: row?.context ?? 0,
|
|
272
|
+
// #274 — the last turn's model window (denominator); null when the provider reports none.
|
|
273
|
+
contextSize: row?.context_size ?? null,
|
|
274
|
+
// #252 — the latest turn's opaque provider blob, parsed for the wire. Empty {} when the
|
|
275
|
+
// provider returned no meta. The service forwards it; it never reads a field within.
|
|
276
|
+
meta: JSON.parse(row?.meta ?? "{}"),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
// A @plurnk/gbnf divergence position (providers#24) is a CODE-POINT offset into the
|
|
280
|
+
// model's content; the snippet/telemetry surface speaks 1-based line + 0-based column.
|
|
281
|
+
// Convert over code points (not UTF-16 units) so an astral char doesn't skew the line,
|
|
282
|
+
// clamping out-of-range offsets to the content's end.
|
|
283
|
+
#offsetToLineColumn(content, offset) {
|
|
284
|
+
const cps = Array.from(content);
|
|
285
|
+
const clamped = Math.max(0, Math.min(offset, cps.length));
|
|
286
|
+
let line = 1;
|
|
287
|
+
let column = 0;
|
|
288
|
+
for (let i = 0; i < clamped; i++) {
|
|
289
|
+
if (cps[i] === "\n") {
|
|
290
|
+
line++;
|
|
291
|
+
column = 0;
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
column++;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return { line, column };
|
|
298
|
+
}
|
|
299
|
+
async runLoop({ provider, messages, requirements = "", sessionId, runId, loopId, maxTurns = 50, maxStrikes = readMaxStrikes(), minCycles = readPositiveInt("PLURNK_SERVICE_MIN_CYCLES", DEFAULT_MIN_CYCLES), maxCyclePeriod = readPositiveInt("PLURNK_SERVICE_MAX_CYCLE_PERIOD", DEFAULT_MAX_CYCLE_PERIOD), origin = "model", signal, onDispatch, }) {
|
|
300
|
+
const turnIds = [];
|
|
301
|
+
const suddenDeathThreshold = maxTurns - maxStrikes;
|
|
302
|
+
// Per-loop AbortController for scheme-side cancellation propagation.
|
|
303
|
+
// Chained from the caller's `signal` so an external abort cascades.
|
|
304
|
+
const loopAbort = new AbortController();
|
|
305
|
+
if (signal !== undefined) {
|
|
306
|
+
if (signal.aborted)
|
|
307
|
+
loopAbort.abort(signal.reason);
|
|
308
|
+
else
|
|
309
|
+
signal.addEventListener("abort", () => loopAbort.abort(signal.reason), { once: true });
|
|
310
|
+
}
|
|
311
|
+
this.#loopAborts.set(loopId, loopAbort);
|
|
312
|
+
// §operator-config-loop-timeout — the wall-clock budget. Expiry aborts the loop signal, so a
|
|
313
|
+
// mid-flight provider call (generate rides this signal) and in-flight spawns tear down; the
|
|
314
|
+
// loop terminates 504 (kin to the exec <T> reap's 504, §exec-timeout) — a legible engine
|
|
315
|
+
// terminal, never an outside kill. unref'd: the wall never holds the process open.
|
|
316
|
+
const wall = setTimeout(() => loopAbort.abort(LOOP_TIMEOUT_REASON), readLoopTimeoutMs());
|
|
317
|
+
wall.unref();
|
|
318
|
+
const timedOut = () => loopAbort.signal.aborted && loopAbort.signal.reason === LOOP_TIMEOUT_REASON;
|
|
319
|
+
const ruleTimeout = async () => {
|
|
320
|
+
await this.#db.engine_loop_set_status.run({ loop_id: loopId, status: 504, message: "loop_timeout" });
|
|
321
|
+
cleanup("forceful", "loop_timeout");
|
|
322
|
+
return { turnIds, finalStatus: 504, hitMaxTurns: false, reason: "loop_timeout" };
|
|
323
|
+
};
|
|
324
|
+
// Cleanup splits by termination kind:
|
|
325
|
+
// - "graceful" (SEND[202] Accepted): in-flight streaming-scheme spawns
|
|
326
|
+
// are ALLOWED to outlive the loop — they complete naturally, write final
|
|
327
|
+
// channel state, and wake-on-completion (E.4) opens a fresh loop. 202 is
|
|
328
|
+
// the only terminal that means "keep my async work."
|
|
329
|
+
// - "forceful" (SEND[200] done, max_turns, strike, cancel, budget, 4xx/5xx):
|
|
330
|
+
// fire the loop-level abort so leftover spawns tear down. "Done" reaps.
|
|
331
|
+
const cleanup = (kind, reason) => {
|
|
332
|
+
clearTimeout(wall);
|
|
333
|
+
if (kind === "forceful" && !loopAbort.signal.aborted) {
|
|
334
|
+
loopAbort.abort(reason ?? "loop_forceful_termination");
|
|
335
|
+
}
|
|
336
|
+
this.#loopAborts.delete(loopId);
|
|
337
|
+
this.#strikes.delete(loopId);
|
|
338
|
+
this.#hardOverflowRecovery.delete(loopId);
|
|
339
|
+
this.#telemetry.delete(loopId);
|
|
340
|
+
this.#tokenRatios.delete(loopId);
|
|
341
|
+
};
|
|
342
|
+
while (true) {
|
|
343
|
+
// The wall fired between turns — rule 504 before anything else reads the loop.
|
|
344
|
+
if (timedOut())
|
|
345
|
+
return await ruleTimeout();
|
|
346
|
+
signal?.throwIfAborted();
|
|
347
|
+
const row = await this.#db.engine_loop_status.get({ loop_id: loopId });
|
|
348
|
+
if (row === undefined)
|
|
349
|
+
throw new Error(`Engine.runLoop: loop ${loopId} not found`);
|
|
350
|
+
if (row.status === 100) {
|
|
351
|
+
// NOT a terminal — a wake re-queued this loop while its own live drain was
|
|
352
|
+
// between turns (a child concluded in the gap between our 202 write and this
|
|
353
|
+
// check, §run-lifecycle-wake-requeue-not-terminal). The wake's intent is KEEP
|
|
354
|
+
// RUNNING: re-claim atomically and continue — the injected prompt is already
|
|
355
|
+
// this loop's next turn. Returning it as "external" broadcast a QUEUED loop
|
|
356
|
+
// as loop/terminated {finalStatus: 100} — the delegation-flags flake.
|
|
357
|
+
await this.#db.engine_reclaim_queued_loop.run({ loop_id: loopId });
|
|
358
|
+
continue; // claimed (or a racer flipped it first — the re-read decides)
|
|
359
|
+
}
|
|
360
|
+
if (row.status !== 102) {
|
|
361
|
+
// Only 202 (Accepted) lets spawns outlive — it IS the async wake
|
|
362
|
+
// contract (E.4). Every other terminal, 200 included, reaps: "done"
|
|
363
|
+
// must not leak running execs. Trust the code's declared intent.
|
|
364
|
+
cleanup(row.status === 202 ? "graceful" : "forceful", `loop_terminal_${row.status}`);
|
|
365
|
+
return { turnIds, finalStatus: row.status, hitMaxTurns: false, reason: "external" };
|
|
366
|
+
}
|
|
367
|
+
if (maxTurns >= 0 && turnIds.length >= maxTurns) {
|
|
368
|
+
// §loop-terminals — the turn ceiling is exhausted: 429 Too Many Requests
|
|
369
|
+
// (kin to the soft sudden-death 429 warnings that precede it).
|
|
370
|
+
await this.#db.engine_loop_set_status.run({ loop_id: loopId, status: 429, message: "max_turns" });
|
|
371
|
+
cleanup("forceful", "max_turns");
|
|
372
|
+
return { turnIds, finalStatus: 429, hitMaxTurns: true, reason: "max_turns" };
|
|
373
|
+
}
|
|
374
|
+
// PLURNK_SERVICE_EXEC_WAIT_MS — a post-EXEC breath: if a spawn from the prior turn
|
|
375
|
+
// is still in flight, give it a tunable beat to land in THIS turn's packet
|
|
376
|
+
// before we assemble it. A fixed grace beat, never a wait-for-completion;
|
|
377
|
+
// 0/unset = off. Abortable with the loop signal.
|
|
378
|
+
const execHandler = this.#schemes.get("exec");
|
|
379
|
+
// §exec-hold-until-concluded — the turn-hold exception (owner ruling): for runtimes in
|
|
380
|
+
// the operator's HOLD set (the search family — streams we know and control: one final
|
|
381
|
+
// JSON digest, seconds-bounded), the cycle PAUSES here until the stream concludes, so
|
|
382
|
+
// the model never gets a turn it can only waste asking "are we there yet". Bounded by
|
|
383
|
+
// PLURNK_SERVICE_EXEC_HOLD_MS and FAIL-OPEN: at the cap the standard cycle resumes
|
|
384
|
+
// (the stream stays live; parks/wakes/polls all still apply). Zero grammar or teaching
|
|
385
|
+
// change — the model emits EXEC + SEND[102] as ever; the next packet simply contains
|
|
386
|
+
// the finished digest, open and final.
|
|
387
|
+
const holdSet = new Set((process.env.PLURNK_SERVICE_EXEC_HOLD ?? "").split(",").map((x) => x.trim()).filter((x) => x.length > 0));
|
|
388
|
+
const holdCapMs = Number(process.env.PLURNK_SERVICE_EXEC_HOLD_MS ?? "300000");
|
|
389
|
+
if (holdSet.size > 0 && holdCapMs > 0 && execHandler?.hasActiveHoldSpawns !== undefined) {
|
|
390
|
+
const holdStart = Date.now();
|
|
391
|
+
while (execHandler.hasActiveHoldSpawns(runId, holdSet) && Date.now() - holdStart < holdCapMs) {
|
|
392
|
+
await delay(150, undefined, { signal });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const execWaitMs = Number(process.env.PLURNK_SERVICE_EXEC_WAIT_MS ?? "0");
|
|
396
|
+
if (execWaitMs > 0) {
|
|
397
|
+
if (execHandler?.hasActiveSpawns?.(runId) === true)
|
|
398
|
+
await delay(execWaitMs, undefined, { signal });
|
|
399
|
+
}
|
|
400
|
+
let turn;
|
|
401
|
+
try {
|
|
402
|
+
turn = await this.runTurn({
|
|
403
|
+
provider, messages, requirements, sessionId, runId, loopId, origin, signal, onDispatch,
|
|
404
|
+
turnNumber: turnIds.length + 1, maxTurns,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
catch (err) {
|
|
408
|
+
// The wall fired mid-turn — the abort tore the turn down (generate rides the loop
|
|
409
|
+
// signal); rule the legible 504, never a generic drain error.
|
|
410
|
+
if (timedOut())
|
|
411
|
+
return await ruleTimeout();
|
|
412
|
+
throw err;
|
|
413
|
+
}
|
|
414
|
+
turnIds.push(turn.turnId);
|
|
415
|
+
// SPEC §grinder: budget hard-stop — packet won't fit even collapsed → abandon.
|
|
416
|
+
if (turn.budgetHardStop) {
|
|
417
|
+
// §loop-terminals — the packet won't fit even collapsed: 413 Content Too Large.
|
|
418
|
+
await this.#db.engine_loop_set_status.run({ loop_id: loopId, status: 413, message: "budget_overflow" });
|
|
419
|
+
cleanup("forceful", "budget_overflow");
|
|
420
|
+
return { turnIds, finalStatus: 413, hitMaxTurns: false, reason: "budget_overflow" };
|
|
421
|
+
}
|
|
422
|
+
// Rails #38/#39 — per-turn strike accounting (cycle detection, the
|
|
423
|
+
// grinder/steer coupling, hard-failure statuses). StrikeRail owns the
|
|
424
|
+
// bookkeeping; runLoop owns abandonment.
|
|
425
|
+
const verdict = this.#strikes.assess(loopId, {
|
|
426
|
+
fingerprint: turn.fingerprint,
|
|
427
|
+
statuses: turn.statuses,
|
|
428
|
+
noOps: turn.status === TURN_STATUS_NO_OPS,
|
|
429
|
+
budgetStruck: turn.budgetStruck,
|
|
430
|
+
steerStruck: turn.steerStruck,
|
|
431
|
+
minCycles, maxCyclePeriod, maxStrikes,
|
|
432
|
+
});
|
|
433
|
+
if (verdict.thresholdCrossed) {
|
|
434
|
+
// §loop-terminals — a cycle-driven strike is the model spinning in place
|
|
435
|
+
// (508 Loop Detected); a failure/no-op strike is the model failing (500
|
|
436
|
+
// Internal Server Error). The straw that crossed the threshold picks it.
|
|
437
|
+
const status = verdict.cycleDetected ? 508 : 500;
|
|
438
|
+
await this.#db.engine_loop_set_status.run({ loop_id: loopId, status, message: "strike_threshold" });
|
|
439
|
+
cleanup("forceful", "strike_threshold");
|
|
440
|
+
return { turnIds, finalStatus: status, hitMaxTurns: false, reason: "strike_threshold" };
|
|
441
|
+
}
|
|
442
|
+
// Sudden-death threshold is engine-internal — abandonment
|
|
443
|
+
// happens at maxTurns regardless. Per gamification policy:
|
|
444
|
+
// we don't warn the model that it's nearing our limit.
|
|
445
|
+
if (turnIds.length >= suddenDeathThreshold && turnIds.length < maxTurns) {
|
|
446
|
+
// Threshold tripped; engine bookkeeping only.
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
async runTurn({ provider, messages, requirements = "", sessionId, runId, loopId, origin = "model", signal, onDispatch, turnNumber = 1, maxTurns = 50, }) {
|
|
451
|
+
// === Turn-as-container model ===
|
|
452
|
+
//
|
|
453
|
+
// Turn rows are created at runTurn OPEN (status=102, placeholder
|
|
454
|
+
// packet) so things can be written into the turn before the model
|
|
455
|
+
// is called: the user prompt on turn 1; later, system signals or
|
|
456
|
+
// injected telemetry events on any turn. The turn is CLOSED at
|
|
457
|
+
// the end with the final packet + status + usage stats.
|
|
458
|
+
//
|
|
459
|
+
// sequence is "ordinal of stuff in this turn." Pre-model
|
|
460
|
+
// writes consume low indices; model ops continue from there.
|
|
461
|
+
const seqRow = await this.#db.engine_next_turn_sequence.get({ loop_id: loopId });
|
|
462
|
+
const seq = seqRow.next;
|
|
463
|
+
// #269 — loops.sequence is the loop's ordinal WITHIN the run. Turn-0 foists that belong to the
|
|
464
|
+
// RUN (manifest preview, AGENTS, operator docs) gate on the run's FIRST loop, not every loop's
|
|
465
|
+
// first turn; per-loop foists (the prompt, @file) still fire each loop. Read once, turn-1 only.
|
|
466
|
+
const loopRow = seq === 1
|
|
467
|
+
? await this.#db.engine_get_loop_prompt.get({ loop_id: loopId })
|
|
468
|
+
: undefined;
|
|
469
|
+
const runFirstLoop = (loopRow?.sequence ?? 0) === 1;
|
|
470
|
+
const openRow = await this.#db.engine_open_turn.get({
|
|
471
|
+
loop_id: loopId, sequence: seq,
|
|
472
|
+
});
|
|
473
|
+
if (openRow === undefined)
|
|
474
|
+
throw new Error("Engine.runTurn: turn open returned no row");
|
|
475
|
+
const turnId = openRow.id;
|
|
476
|
+
// Pre-model writes. Each turn opens with a system-origin EDIT
|
|
477
|
+
// against `plurnk://prompt/<run>/<loop>/<seq>` IF there's a prompt
|
|
478
|
+
// for THIS turn the model hasn't seen yet:
|
|
479
|
+
// - Turn 1: loop.prompt is the initial user prompt.
|
|
480
|
+
// - Turn N>1: only if Engine.inject (or wake-on-completion via
|
|
481
|
+
// daemon.inject) wrote a prompt entry for this turn slot
|
|
482
|
+
// between turn N-1 and N. Inject writes directly to entries;
|
|
483
|
+
// we DON'T re-foist here for N>1.
|
|
484
|
+
// The log records the EDIT for forensics. Model ops dispatch
|
|
485
|
+
// from sequence=2 onward on prompt-foisted turns; 1 onward
|
|
486
|
+
// otherwise.
|
|
487
|
+
let nextActionIndex = 1;
|
|
488
|
+
// §model-entry — the run's first turn opens with the model's own turn-0, mirrored OPEN: a
|
|
489
|
+
// worked turn PLAN → the environment FINDs the foist ACTUALLY dispatches → SEND[102]. Built
|
|
490
|
+
// from the real ops below (not a static print — we lean into the genuine echo paradigm) and
|
|
491
|
+
// written at sequence 1, so it reads first as the emission with the foisted results following.
|
|
492
|
+
const turnZeroMoves = [];
|
|
493
|
+
if (seq === 1) {
|
|
494
|
+
if (runFirstLoop)
|
|
495
|
+
nextActionIndex = 2; // reserve sequence 1 for the turn-0 echo
|
|
496
|
+
// Operator doc READs (PLURNK_SERVICE_MD_<ALIAS>, §actor-boundary-doc-injection). The docs were materialized
|
|
497
|
+
// as plurnk:///<entry> entries by the plurnk run (loop_run, via the
|
|
498
|
+
// §actor-boundary keystone); foist a READ of each into THIS turn-0 so the model
|
|
499
|
+
// reads them inline. It sees only the READ — the materializing EDIT
|
|
500
|
+
// lives in the plurnk run's log, never the model's.
|
|
501
|
+
// #231 — env docs (PLURNK_SERVICE_MD_*) UNION the session's client docs; foist a READ of
|
|
502
|
+
// each materialized plurnk:///<alias>.md (loop_run materialized the same set).
|
|
503
|
+
const { mdDocs } = await SessionSettings.read(this.#db, sessionId);
|
|
504
|
+
// #269 — operator docs are run-once; foist them only on the run's first loop.
|
|
505
|
+
for (const doc of runFirstLoop ? await SessionSettings.resolveDocs(mdDocs) : []) {
|
|
506
|
+
const docTarget = {
|
|
507
|
+
kind: "url", raw: `plurnk:///${doc.entryName}`, scheme: "plurnk",
|
|
508
|
+
username: null, password: null, hostname: null, port: null,
|
|
509
|
+
pathname: `/${doc.entryName}`, params: {}, fragment: null,
|
|
510
|
+
};
|
|
511
|
+
const docRead = {
|
|
512
|
+
op: "READ", suffix: "", signal: null, target: docTarget,
|
|
513
|
+
lineMarker: null, body: null, position: { line: 1, column: 1 },
|
|
514
|
+
};
|
|
515
|
+
await this.dispatch({
|
|
516
|
+
statement: docRead, sessionId, runId, loopId, turnId,
|
|
517
|
+
sequence: nextActionIndex, origin: "plurnk", onDispatch,
|
|
518
|
+
});
|
|
519
|
+
nextActionIndex++;
|
|
520
|
+
}
|
|
521
|
+
const promptRow = loopRow; // #269 — already read above (per-loop; fires every loop's turn 1)
|
|
522
|
+
if (promptRow !== undefined && typeof promptRow.prompt === "string" && promptRow.prompt.length > 0) {
|
|
523
|
+
const promptLoopSeq = promptRow.sequence; // the loop's PER-RUN sequence — model-facing, matching log coordinates (owner: the db id read as prompt/2/1)
|
|
524
|
+
const promptPath = promptTarget(runId, promptLoopSeq, seq);
|
|
525
|
+
const promptStmt = {
|
|
526
|
+
op: "EDIT", suffix: "", signal: null,
|
|
527
|
+
target: promptPath, lineMarker: null,
|
|
528
|
+
body: promptRow.prompt, position: { line: 1, column: 1 },
|
|
529
|
+
};
|
|
530
|
+
let promptLogId;
|
|
531
|
+
await this.dispatch({
|
|
532
|
+
statement: promptStmt, sessionId, runId, loopId, turnId,
|
|
533
|
+
sequence: nextActionIndex, origin: "plurnk",
|
|
534
|
+
onDispatch: (id) => { promptLogId = id; onDispatch?.(id); },
|
|
535
|
+
});
|
|
536
|
+
// §prompt-fold: the prompt EDIT's row is folded — the body reaches the model
|
|
537
|
+
// through the auto-READ below; the EDIT stays forensic, re-OPENable.
|
|
538
|
+
if (promptLogId !== undefined)
|
|
539
|
+
await this.#db.engine_fold_log_entry.run({ id: promptLogId });
|
|
540
|
+
nextActionIndex++;
|
|
541
|
+
// §prompt-auto-read (owner): the prompt's body reaches the model as a foisted
|
|
542
|
+
// READ of its own entry — first 12 lines (<1,12>), or the whole prompt (<1,-1>)
|
|
543
|
+
// when it runs fewer than 12 (whole-read form doubles as teaching). Prior prompts
|
|
544
|
+
// stay listed by path in the system packet's User Prompts section — reachable,
|
|
545
|
+
// never silently lost.
|
|
546
|
+
const promptLineCount = promptRow.prompt.split("\n").length;
|
|
547
|
+
const promptRead = {
|
|
548
|
+
op: "READ", suffix: "", signal: null, target: promptPath,
|
|
549
|
+
lineMarker: { marks: promptLineCount >= 12 ? [1, 12] : [1, -1] },
|
|
550
|
+
body: null, position: { line: 1, column: 1 },
|
|
551
|
+
};
|
|
552
|
+
await this.dispatch({
|
|
553
|
+
statement: promptRead, sessionId, runId, loopId, turnId,
|
|
554
|
+
sequence: nextActionIndex, origin: "plurnk", onDispatch,
|
|
555
|
+
});
|
|
556
|
+
nextActionIndex++;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
// §prompt-auto-read, the mid-loop half (owner): an inject writes a prompt entry for
|
|
560
|
+
// THIS turn's slot between turns — foist the same auto-READ so an injected prompt
|
|
561
|
+
// arrives exactly like the first one (first 12 lines, or whole when fewer).
|
|
562
|
+
if (seq > 1) {
|
|
563
|
+
const loopSeqRow = await this.#db.engine_loop_sequence.get({ loop_id: loopId });
|
|
564
|
+
const loopSeq = loopSeqRow?.sequence ?? loopId;
|
|
565
|
+
const injected = await this.#db.drain_get_all_prompt_bodies_for_loop.all({ pattern: promptPathname(runId, loopSeq, seq) });
|
|
566
|
+
const injectedRow = injected.find((r) => typeof r.content === "string" && r.content.length > 0);
|
|
567
|
+
if (injectedRow !== undefined) {
|
|
568
|
+
const lineCount = injectedRow.content.split("\n").length;
|
|
569
|
+
const injTarget = promptTarget(runId, loopSeq, seq);
|
|
570
|
+
const injRead = {
|
|
571
|
+
op: "READ", suffix: "", signal: null, target: injTarget,
|
|
572
|
+
lineMarker: { marks: lineCount >= 12 ? [1, 12] : [1, -1] },
|
|
573
|
+
body: null, position: { line: 1, column: 1 },
|
|
574
|
+
};
|
|
575
|
+
await this.dispatch({
|
|
576
|
+
statement: injRead, sessionId, runId, loopId, turnId,
|
|
577
|
+
sequence: nextActionIndex, origin: "plurnk", onDispatch,
|
|
578
|
+
});
|
|
579
|
+
nextActionIndex++;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
// The per-turn derivation pump (_entry-manifest.maintainDerivations) — refreshes
|
|
583
|
+
// every entry's deep channels (symbols/refs/embeddings/FTS, deep_hash-gated) so the
|
|
584
|
+
// catalog and FIND read current data. NOT an action: no log entry, no sequence slot,
|
|
585
|
+
// not dispatched. There is no plurnk:///manifest.json entry — the catalog is served
|
|
586
|
+
// on demand by FIND(scheme:///**), foisted into the run's first turn below.
|
|
587
|
+
// #312 — the turn's token gauge: the ACTIVE provider's tokenizer identity + exact counter
|
|
588
|
+
// (mimetypes seam; provider upper bound surfaced as tokenizer_unavailable when inexact).
|
|
589
|
+
// Threaded per turn — never engine state — so concurrent loops on different providers
|
|
590
|
+
// each read their own honest numbers.
|
|
591
|
+
const gauge = await TokenGauge.resolve(this.#mimetypes, provider, (event) => this.#telemetry.push(sessionId, loopId, event));
|
|
592
|
+
const systemCtx = {
|
|
593
|
+
db: this.#db, sessionId, runId, loopId, turnId,
|
|
594
|
+
writer: "plurnk",
|
|
595
|
+
signal: this.#loopAborts.get(loopId)?.signal,
|
|
596
|
+
streamEventNotify: this.#streamEventNotify,
|
|
597
|
+
wakeRunNotify: this.#wakeRunNotify,
|
|
598
|
+
tokenize: this.#tokenize,
|
|
599
|
+
gauge,
|
|
600
|
+
mimetypes: this.#mimetypes,
|
|
601
|
+
defaultChannelFor: (s) => this.#schemes.defaultChannelFor(s),
|
|
602
|
+
pushTelemetry: (event) => this.#telemetry.push(sessionId, loopId, event),
|
|
603
|
+
};
|
|
604
|
+
// SPEC §membership D4/D5 — git-ls-files workspace membership, resolved at
|
|
605
|
+
// prompt-composition (EMI is eager + exhaustive — git is the only bound). When the
|
|
606
|
+
// session's project_root is a git working tree, tracked files are
|
|
607
|
+
// members without a client `add`; active members are materialized
|
|
608
|
+
// (disk → body channel) so they appear in the catalog. No-ops
|
|
609
|
+
// on headless / non-git sessions. Runs BEFORE the derivation pump so
|
|
610
|
+
// this turn's packet reflects them.
|
|
611
|
+
const fsDivergences = await GitMembership.indexGitMembership(systemCtx);
|
|
612
|
+
await this.#logFsFictions(sessionId, fsDivergences);
|
|
613
|
+
this.#queueDerivation(() => EntryManifest.maintainDerivations(systemCtx)); // §derivation-off-hot-path — the turn proceeds; ~queries warm their own slice
|
|
614
|
+
// Turn-0 catalog preview (PLURNK_SERVICE_FILES_ITEMS, §actor-boundary-catalog-preview):
|
|
615
|
+
// one FIND(scheme:///**) per scheme that holds entries, foisted into the run's first
|
|
616
|
+
// model turn so it opens with its catalog (the per-scheme arrays that replaced the
|
|
617
|
+
// single manifest.json). -1 → each scheme's whole catalog; N → its first N rows
|
|
618
|
+
// (clamped to the scheme's count so FIND's strict <L> never 416s); off by default.
|
|
619
|
+
if (seq === 1) {
|
|
620
|
+
// #231 — a session's client-chosen filesItems REPLACES the env default outright.
|
|
621
|
+
const { filesItems: sessionMI } = await SessionSettings.read(this.#db, sessionId);
|
|
622
|
+
const filesItems = sessionMI !== null ? normalizeFilesItems(sessionMI) : readFilesItems();
|
|
623
|
+
if (filesItems !== null && runFirstLoop) { // #269 — catalog preview is run-once
|
|
624
|
+
// engine_scheme_catalog_summary is the scheme source: session-scoped, ordered,
|
|
625
|
+
// one row per scheme that has entries (scheme=null → file). log:// is absent —
|
|
626
|
+
// it lives in log_entries, not the catalog (present-mode, the # Log section).
|
|
627
|
+
const catalogSchemes = await this.#db.engine_scheme_catalog_summary.all({ session_id: sessionId });
|
|
628
|
+
// known:/// + unknown:/// + file ALWAYS foist, even at zero entries — else the
|
|
629
|
+
// model burns a turn running the FIND itself, assuming the catalog is merely
|
|
630
|
+
// being withheld. An empty FIND(**) is orienting, not noise (owner): it tells
|
|
631
|
+
// the model NOT to look there. Other schemes keep the with-entries default.
|
|
632
|
+
const foistSchemes = [...catalogSchemes];
|
|
633
|
+
for (const always of ["known", "unknown", null]) {
|
|
634
|
+
if (!foistSchemes.some((c) => c.scheme === always))
|
|
635
|
+
foistSchemes.push({ scheme: always, entries: 0 });
|
|
636
|
+
}
|
|
637
|
+
for (const { scheme, entries } of foistSchemes) {
|
|
638
|
+
const schemeName = scheme ?? "file";
|
|
639
|
+
// plurnk → its docs subtree (FIND(plurnk://docs/**), uncapped) — the self-
|
|
640
|
+
// documenting surface. The prompt is shown in # Prompt, so the plurnk catalog
|
|
641
|
+
// the model orients on IS the docs; doc links are no longer rendered inline (#270).
|
|
642
|
+
const isPlurnk = schemeName === "plurnk";
|
|
643
|
+
const isFile = schemeName === "file";
|
|
644
|
+
// Only the FILE list is cappable (PLURNK_SERVICE_FILES_ITEMS first-N): the tracked-file
|
|
645
|
+
// tree is external and arbitrarily large. Every other scheme — known/unknown
|
|
646
|
+
// (memory), run (scratch), plurnk (docs) — foists FULL, never truncated: a partial
|
|
647
|
+
// view of the model's own memory reads as withheld. file at -1, or any non-file
|
|
648
|
+
// scheme → no cap. (file in this loop always has entries>0, so no degenerate <1,0>.)
|
|
649
|
+
const cap = isFile && filesItems > 0 ? Math.min(filesItems, entries) : null;
|
|
650
|
+
// The file survey foists as the BARE relative glob — the path shape plurnk.md
|
|
651
|
+
// teaches (`src/**`, `**/notes.md`; bare = project-relative) — so the turn-0
|
|
652
|
+
// exemplar and the log rows the model reads never train a leading-slash or
|
|
653
|
+
// file:/// habit the rest of the teaching contradicts.
|
|
654
|
+
const catalogFind = {
|
|
655
|
+
op: "FIND", suffix: "", signal: null,
|
|
656
|
+
target: isFile ? { kind: "local", raw: "**" } : {
|
|
657
|
+
kind: "url",
|
|
658
|
+
raw: isPlurnk ? "plurnk://docs/**" : `${schemeName}:///**`,
|
|
659
|
+
scheme: schemeName,
|
|
660
|
+
username: null, password: null, hostname: null, port: null,
|
|
661
|
+
pathname: isPlurnk ? "/docs/**" : "/**",
|
|
662
|
+
params: {}, fragment: null,
|
|
663
|
+
},
|
|
664
|
+
body: null,
|
|
665
|
+
lineMarker: cap === null ? null : { marks: [1, cap] },
|
|
666
|
+
position: { line: 1, column: 1 },
|
|
667
|
+
};
|
|
668
|
+
await this.dispatch({
|
|
669
|
+
statement: catalogFind, sessionId, runId, loopId, turnId,
|
|
670
|
+
sequence: nextActionIndex, origin: "plurnk", onDispatch,
|
|
671
|
+
});
|
|
672
|
+
nextActionIndex++;
|
|
673
|
+
// §model-entry — the same FIND, rendered back to DSL for the turn-0 echo (the model's
|
|
674
|
+
// own survey, mirrored OPEN). The <L> cap rides as `<1,N>`, exactly as the model would type it.
|
|
675
|
+
turnZeroMoves.push(`<<FIND(${isPlurnk ? "plurnk://docs/**" : isFile ? "**" : `${schemeName}:///**`})${cap === null ? "" : `<1,${cap}>`}::FIND`);
|
|
676
|
+
}
|
|
677
|
+
// §run-scheme — Manifest(run) = session-scope ∪ THIS run's run-scope. Foist the
|
|
678
|
+
// building run's OWN scratch (run://self/**, uncapped — a run needs the full view to
|
|
679
|
+
// manage its private workspace) so it's catalogued in ITS perspective alone; other
|
|
680
|
+
// runs reach it only via explicit FIND(run://<name>/**). A run with no scratch foists nothing.
|
|
681
|
+
const selfRun = await this.#db.run_name_by_id.get({ run_id: runId });
|
|
682
|
+
const scratch = selfRun === undefined ? 0 : (await this.#db.engine_run_scratch_count.get({ session_id: sessionId, owner_prefix: `/${selfRun.name}/*` }))?.entries ?? 0;
|
|
683
|
+
if (scratch > 0) {
|
|
684
|
+
const runFind = {
|
|
685
|
+
op: "FIND", suffix: "", signal: null,
|
|
686
|
+
target: { kind: "url", raw: "run://self/**", scheme: "run", username: null, password: null, hostname: "self", port: null, pathname: "/**", params: {}, fragment: null },
|
|
687
|
+
body: null, lineMarker: null, position: { line: 1, column: 1 },
|
|
688
|
+
};
|
|
689
|
+
await this.dispatch({ statement: runFind, sessionId, runId, loopId, turnId, sequence: nextActionIndex, origin: "plurnk", onDispatch });
|
|
690
|
+
nextActionIndex++;
|
|
691
|
+
turnZeroMoves.push("<<FIND(run://self/**)::FIND"); // §model-entry — the run-scope survey, into the turn-0 echo
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
// #260 — foist a turn-0 READ of each client-passed @file path so its content sits in front
|
|
695
|
+
// of the model. Daemon owns the workspace → a normal file:/// member READ; a missing or
|
|
696
|
+
// non-member path surfaces its READ outcome (4xx) in the log, visible to the model.
|
|
697
|
+
const openPathsRow = await this.#db.engine_get_loop_open_paths.get({ loop_id: loopId });
|
|
698
|
+
for (const raw of JSON.parse(openPathsRow?.open_paths ?? "[]")) {
|
|
699
|
+
const pathname = raw.startsWith("/") ? raw : `/${raw}`;
|
|
700
|
+
const fileRead = {
|
|
701
|
+
op: "READ", suffix: "", signal: null, lineMarker: null,
|
|
702
|
+
target: {
|
|
703
|
+
kind: "url", raw: `file://${pathname}`, scheme: "file",
|
|
704
|
+
username: null, password: null, hostname: null, port: null,
|
|
705
|
+
pathname, params: {}, fragment: null,
|
|
706
|
+
},
|
|
707
|
+
body: null, position: { line: 1, column: 1 },
|
|
708
|
+
};
|
|
709
|
+
await this.dispatch({
|
|
710
|
+
statement: fileRead, sessionId, runId, loopId, turnId,
|
|
711
|
+
sequence: nextActionIndex, origin: "plurnk", onDispatch,
|
|
712
|
+
});
|
|
713
|
+
nextActionIndex++;
|
|
714
|
+
}
|
|
715
|
+
// §model-entry — mirror the model's turn-0 OPEN at sequence 1: PLAN → the FINDs actually
|
|
716
|
+
// foisted above (real, their results already in the log) → SEND[102]. Dynamic — it reflects
|
|
717
|
+
// the true survey, never a frozen print — and OPEN: the worked example the model orients on,
|
|
718
|
+
// so the grammar can stay thin. Subsequent turns mirror the model's real output, folded.
|
|
719
|
+
if (runFirstLoop) {
|
|
720
|
+
const emission = ["<<PLAN:Initialize:PLAN", ...turnZeroMoves, "<<SEND[102]:Initialized:SEND"].join("\n");
|
|
721
|
+
await this.#dispatcher.writeModelEntry({ verbatim: emission, runId, loopId, turnId, sequence: 1, folded: false, origin: "plurnk" });
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
// §env-delta — pre-seed the run's ambient observations (what changed since
|
|
725
|
+
// it last looked) as foisted rows before the packet composes; advance the action index
|
|
726
|
+
// past them so model ops continue after. Two instances of one machine: env-delta (sibling
|
|
727
|
+
// edits · timestamp cursor · always folded) and exec streams (channel bytes · byte cursor ·
|
|
728
|
+
// terminal delta opens). §env-delta §exec-stream
|
|
729
|
+
// §exec-poll — EXEC `<0>` is turn-scoped: reap the run's open turn-scoped streams (necessarily
|
|
730
|
+
// from a prior turn — this runs before the turn's own spawns) so a `<0>` never survives into
|
|
731
|
+
// the subsequent turn. The terminal output then surfaces born-OPEN via the stream-delta path.
|
|
732
|
+
await this.#reapTurnScopedStreams(runId);
|
|
733
|
+
nextActionIndex += await this.#materializeEnvironmentDeltas({ sessionId, runId, loopId, turnId, fromSequence: nextActionIndex });
|
|
734
|
+
nextActionIndex += await this.#materializeStreamDeltas({ runId, loopId, turnId, fromSequence: nextActionIndex });
|
|
735
|
+
// SPEC §telemetry — git working-tree state for the telemetry section, read once
|
|
736
|
+
// (a service-side `git status` shell-out) and threaded into the budget
|
|
737
|
+
// rebuild too so it isn't re-shelled on overflow.
|
|
738
|
+
const gitStatus = await GitState.status(this.#db, sessionId, this.#loopAborts.get(loopId)?.signal);
|
|
739
|
+
// Build the spec'd packet (Packet.json) request half. The log build
|
|
740
|
+
// queries log_entries scoped to the run — the prompt entry just
|
|
741
|
+
// written (if turn 1) is part of that query result.
|
|
742
|
+
const tokenRatio = this.#tokenRatios.get(loopId) ?? 1;
|
|
743
|
+
let requestPacket = await this.#packets.buildRequestPacket({
|
|
744
|
+
initialMessages: messages, requirements, sessionId, runId, loopId,
|
|
745
|
+
currentTurnSeq: seq, provider, gitStatus, tokenRatio,
|
|
746
|
+
});
|
|
747
|
+
// SPEC §grinder — budget grinder, pre-LLM: reclaim window on actual overflow.
|
|
748
|
+
const enforced = await this.#packets.enforceBudget({
|
|
749
|
+
packet: requestPacket, provider, runId, loopId, turnId, tokenRatio,
|
|
750
|
+
// The overflow error row is minted at the turn's running sequence (nextActionIndex), pre-generate;
|
|
751
|
+
// runTurn advances the counter past it below so the post-generate dispatch rows never collide.
|
|
752
|
+
mintSequence: nextActionIndex,
|
|
753
|
+
// No preset telemetry — the rebuild RE-DERIVES the errors section from log≥400 so the
|
|
754
|
+
// overflow row just minted surfaces THIS turn (§grinder-overflow-error-row). Safe: the
|
|
755
|
+
// ephemeral buffer is empty pre-generate (events drain on the next turn's build).
|
|
756
|
+
rebuild: () => this.#packets.buildRequestPacket({
|
|
757
|
+
initialMessages: messages, requirements, sessionId, runId, loopId,
|
|
758
|
+
currentTurnSeq: seq, provider, gitStatus, tokenRatio,
|
|
759
|
+
}),
|
|
760
|
+
});
|
|
761
|
+
if (enforced.struck)
|
|
762
|
+
nextActionIndex += 1; // the budget-overflow error row consumed a sequence
|
|
763
|
+
requestPacket = enforced.packet;
|
|
764
|
+
if (!enforced.fit) {
|
|
765
|
+
// §grinder-hard-413-recovery (Q4, owner ruling: recoverable strike, NO margin) — the
|
|
766
|
+
// overflow lives in foldable HISTORY the model owns, and the grinder won't touch
|
|
767
|
+
// history (§grinder-layer1-rollback doctrine). So the first hard overflow is a
|
|
768
|
+
// RECOVERY TURN, not death: the packet is over the POLICY ceiling but usually well
|
|
769
|
+
// within PHYSICS (the jumbo pin: ceiling 13k, real window 49k) — send it once, with a
|
|
770
|
+
// minted steer naming the remedy, and a strike (budgetStruck). The model curates →
|
|
771
|
+
// next turn fits → the grant clears. It declines → the second consecutive hard
|
|
772
|
+
// overflow terminates 413 — death only after the model was told. Physically
|
|
773
|
+
// unsendable (over the provider's real window too) → 413 immediately; physics
|
|
774
|
+
// doesn't negotiate. The pointer stays at 100% of budget — a margin would mask it.
|
|
775
|
+
const physicallySendable = provider.contextSize === null
|
|
776
|
+
? true
|
|
777
|
+
: requestPacket.tokens * Math.max(tokenRatio, 1) <= provider.contextSize - this.#packets.decodeBudget(provider);
|
|
778
|
+
if (physicallySendable && !this.#hardOverflowRecovery.has(loopId)) {
|
|
779
|
+
this.#hardOverflowRecovery.add(loopId);
|
|
780
|
+
await this.#db.engine_insert_log_entry.get({
|
|
781
|
+
run_id: runId, loop_id: loopId, turn_id: turnId, sequence: nextActionIndex++,
|
|
782
|
+
origin: "model", source: "engine", op: "error", suffix: "", signal: null,
|
|
783
|
+
scheme: null, username: null, password: null, hostname: null, port: null,
|
|
784
|
+
pathname: null, params: null, fragment: null, lineMarker: null,
|
|
785
|
+
tx: "", mimetype_tx: "text/plain",
|
|
786
|
+
rx: JSON.stringify({ status: 413, kind: "budget_overflow", message: "the packet exceeds the budget even after the newest turn folded — this is your ONE recovery turn: KILL or FOLD history items now (the budget table lists the heaviest) to reclaim room; a second consecutive overflow terminates the loop" }),
|
|
787
|
+
mimetype_rx: "application/json", status_rx: 413, tokens: 0, state: "failed", outcome: "budget_overflow",
|
|
788
|
+
attrs: "{}",
|
|
789
|
+
});
|
|
790
|
+
// Rebuild so the recovery-steer row just minted renders in THIS packet's log +
|
|
791
|
+
// errors sections (the same re-derive contract the soft grind uses).
|
|
792
|
+
nextActionIndex += 1;
|
|
793
|
+
requestPacket = await this.#packets.buildRequestPacket({
|
|
794
|
+
initialMessages: messages, requirements, sessionId, runId, loopId,
|
|
795
|
+
currentTurnSeq: seq, provider, gitStatus, tokenRatio,
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
else {
|
|
799
|
+
// Hard 413: physically unsendable, or the model already declined its recovery turn.
|
|
800
|
+
// Skip the LLM, close the turn, and let runLoop abandon.
|
|
801
|
+
const hardPacket = this.#packets.completePacket(requestPacket, { content: "", ops: [], reasoning: null }, null, provider);
|
|
802
|
+
await this.#db.engine_close_turn.run({
|
|
803
|
+
id: turnId, status: 413, packet: JSON.stringify(hardPacket),
|
|
804
|
+
usage_prompt: 0, usage_completion: 0, usage_reasoning: 0, usage_cached: 0, usage_cost_pico: 0,
|
|
805
|
+
usage_context_size: this.#packets.promptBudgetFor(provider), // #274 — the PROMPT BUDGET (window − reserves), even on a hard-413 turn: the gauge denominator the packet actually lives under
|
|
806
|
+
finish_reason: "budget_hard_stop", model: provider.model, meta: "{}",
|
|
807
|
+
});
|
|
808
|
+
return { turnId, status: 413, statuses: [], fingerprint: "", budgetStruck: enforced.struck, budgetHardStop: true, steerStruck: false };
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
else {
|
|
812
|
+
// A fitting turn clears the recovery grant — the model curated; a later overflow
|
|
813
|
+
// earns a fresh recovery turn (chronic overflow still strikes out via the rail).
|
|
814
|
+
this.#hardOverflowRecovery.delete(loopId);
|
|
815
|
+
}
|
|
816
|
+
const modelMessages = PacketWire.packetToWireMessages(requestPacket);
|
|
817
|
+
// No decode cap. Our budget governs the TRANSMISSION packet (the grinder folds
|
|
818
|
+
// the input under the ceiling); the model's decode — reasoning + emission — is
|
|
819
|
+
// out of band, owned by the provider's own context window. Deriving a maxTokens
|
|
820
|
+
// from our budget conflated the two and guillotined a reasoning model's
|
|
821
|
+
// out-of-band thinking as the packet filled (`ceiling - packet` → near-zero
|
|
822
|
+
// decode → finish=length mid-reasoning → no emission → strike spiral). The
|
|
823
|
+
// provider enforces its physical wall on its own.
|
|
824
|
+
let response;
|
|
825
|
+
// #249 — plugin attribution tags onto the per-turn generate() wire. Value is the
|
|
826
|
+
// active-plugin set (placeholder); real per-turn grounding is deferred.
|
|
827
|
+
const attributions = [...new Set([...this.#schemes.attributions(), ...(this.#executors?.attributions() ?? [])])].toSorted();
|
|
828
|
+
// #249 — tag the loop (the activity) with its active plugins' attribution tags, write-once.
|
|
829
|
+
if (attributions.length > 0)
|
|
830
|
+
await this.#db.engine_tag_loop_attributions.run({ loop_id: loopId, attributions: JSON.stringify(attributions) });
|
|
831
|
+
// #249 — session-stable frontend id, forwarded as Plurnk-Client by the plurnk provider only.
|
|
832
|
+
const { client } = await SessionSettings.read(this.#db, sessionId);
|
|
833
|
+
try {
|
|
834
|
+
// §turn-lifecycle (#301) — the provider call is the long, opaque window (submit → first
|
|
835
|
+
// committed op is provider latency + a full first-turn generation, ~70s local): a static
|
|
836
|
+
// screen there is indistinguishable from a hang. Bracket generate() with two telemetry beats
|
|
837
|
+
// so a client can show "awaiting model" the instant the turn starts and flip to "parsing" when
|
|
838
|
+
// ops are about to land. Base telemetry/event channel (the embed_progress precedent, §tokenomics
|
|
839
|
+
// clients already render it unconditionally); the abort guard keeps a cancelled loop silent.
|
|
840
|
+
if (!signal?.aborted)
|
|
841
|
+
this.#telemetry.push(sessionId, loopId, { source: "engine:turn", kind: "turn_awaiting_model", level: "info", message: "awaiting model response" });
|
|
842
|
+
// generate rides the LOOP signal (already chained from the caller's), so a loop-level
|
|
843
|
+
// abort — the §operator-config-loop-timeout wall — cancels a stuck provider call, not
|
|
844
|
+
// just the schemes. Bare runTurn (no runLoop) has no loop entry → the caller's signal.
|
|
845
|
+
response = await provider.generate({ messages: modelMessages, runId: String(runId), signal: this.#loopAborts.get(loopId)?.signal ?? signal, grammar: await this.#grammarConstraint(provider), maxTokens: this.#packets.decodeBudget(provider), strikes: this.#strikes.streak(loopId), attributions: attributions.length > 0 ? attributions : undefined, client: client ?? undefined }); // strikes: first-party routing signal, 0 sent explicitly (#313) // §provider-surface-generate §provider-guarantees-single-call §provider-guarantees-signal-wired §attribution-plurnk-namespace-reserved §client-telemetry
|
|
846
|
+
if (!signal?.aborted)
|
|
847
|
+
this.#telemetry.push(sessionId, loopId, { source: "engine:turn", kind: "turn_generated", level: "info", message: "parsing model response" });
|
|
848
|
+
}
|
|
849
|
+
catch (err) {
|
|
850
|
+
// §turn-never-blank — a ProviderError is an INFRASTRUCTURE failure (auth, network
|
|
851
|
+
// beyond retries, rate limit): no completed exchange exists, so no turn exists —
|
|
852
|
+
// telemetry the cause and DIE legibly (the drain writes the loop terminal 500 with
|
|
853
|
+
// the message). Grammar conformance never arrives here: providers 0.32 retired the
|
|
854
|
+
// constrained-path throw — a completed exchange ALWAYS returns, bytes in assistant,
|
|
855
|
+
// the conformance verdict riding response.telemetry as an OBSERVATION (the engine's
|
|
856
|
+
// ANTLR parse is the judge; the provider transports and observes, never adjudicates).
|
|
857
|
+
// The old fallback fabricated an empty emission here and laundered a provider
|
|
858
|
+
// adjudication into a model-behavior 422 — a state the system otherwise forbids,
|
|
859
|
+
// a record that lied, and days of forensics pointed at the wrong suspect.
|
|
860
|
+
if (err instanceof ProviderError) {
|
|
861
|
+
this.#telemetry.push(sessionId, loopId, { source: "provider", kind: err.kind, message: err.message, level: "error" });
|
|
862
|
+
}
|
|
863
|
+
throw err;
|
|
864
|
+
}
|
|
865
|
+
// Engine splits wire-level response: emission (content, reasoning,
|
|
866
|
+
// parsed ops) → packet.assistant per Packet.json assistant section;
|
|
867
|
+
// call-metadata (usage, finishReason, model) → Turn columns per
|
|
868
|
+
// Turn.json. Mixing the two on packet.assistant was the wrong layer.
|
|
869
|
+
const { packetAssistant, callMetadata, parseErrors } = this.#splitResponse(response); // raw assistant content is opaque — split, never interpreted — §provider-guarantees-assistantraw-opaque
|
|
870
|
+
// §tokenomics-ceiling-calibrates-to-usage — learn the loop's real/measured ratio from the
|
|
871
|
+
// provider's OWN prompt count (ground truth for the whole wire request, template overhead
|
|
872
|
+
// included). Monotone max within the loop. An EXACT ruler floors at 1 (never expands);
|
|
873
|
+
// a certified upper-bound ruler calibrates to observed truth in BOTH directions — the
|
|
874
|
+
// worst-observed packing wins, and expansion toward ground truth cannot overshoot the
|
|
875
|
+
// window (owner-ruled; run24: the unconditional floor halved gbuild's effective budget).
|
|
876
|
+
if (callMetadata.usage.prompt > 0 && requestPacket.tokens > 0) {
|
|
877
|
+
const observed = callMetadata.usage.prompt / requestPacket.tokens;
|
|
878
|
+
const prior = this.#tokenRatios.get(loopId) ?? (gauge.exact ? 1 : observed);
|
|
879
|
+
if (observed >= prior)
|
|
880
|
+
this.#tokenRatios.set(loopId, observed);
|
|
881
|
+
}
|
|
882
|
+
// Surface parse errors to the model's NEXT packet so it can self-
|
|
883
|
+
// correct. Without this, malformed emissions (e.g. a READ matcher
|
|
884
|
+
// body starting with `//` being interpreted as xpath) silently
|
|
885
|
+
// drop, the model sees zero ops dispatched, strike-rail fires,
|
|
886
|
+
// model has no feedback on WHY its emission didn't take effect.
|
|
887
|
+
//
|
|
888
|
+
// Envelope per @plurnk/plurnk-grammar 0.17.0 TelemetryEvent:
|
|
889
|
+
// { source, kind, message, position: { type: "content-offset", line, column } }
|
|
890
|
+
// Plus a `snippet` field (additionalProperties) carrying ±N lines
|
|
891
|
+
// of the assistant's own content around the error line. Without
|
|
892
|
+
// the snippet, the model sees "invalid xpath at 1:0" but can't
|
|
893
|
+
// connect that to what IT wrote — and tends to regenerate the
|
|
894
|
+
// same broken emission. See edit-todo demo for the canonical case.
|
|
895
|
+
// Parse errors are LOG ITEMS now (§telemetry — one budget surface): each failed-to-parse
|
|
896
|
+
// emission records an actionless `error` row below, after the turn's dispatched ops are
|
|
897
|
+
// sequenced (see the parse-error log write past the dispatch loop). The errors section
|
|
898
|
+
// derives a pointer to it from log≥400, uniform with action_failure.
|
|
899
|
+
// providers#24 / #275: non-fatal provider telemetry on a SUCCESSFUL turn. In GBNF-filter
|
|
900
|
+
// mode the provider no longer THROWS grammar_unenforced — it returns the model's bytes
|
|
901
|
+
// (here, packetAssistant.content) and attaches the conflict as a telemetry event carrying
|
|
902
|
+
// the divergence code-point position. Forward each event with a content-offset `line:col`;
|
|
903
|
+
// the model resolves it against its own emission — READ the folded `model` mirror row at the
|
|
904
|
+
// cited lines (§model-entry) — not an embedded snippet that would duplicate the emission.
|
|
905
|
+
for (const event of response.telemetry ?? []) {
|
|
906
|
+
const located = typeof event.position === "number"
|
|
907
|
+
? this.#offsetToLineColumn(packetAssistant.content, event.position)
|
|
908
|
+
: null;
|
|
909
|
+
this.#telemetry.push(sessionId, loopId, {
|
|
910
|
+
source: event.source,
|
|
911
|
+
kind: event.kind,
|
|
912
|
+
message: event.message ?? "",
|
|
913
|
+
level: event.level ?? "warn", // forward the producer's severity; default for a producer predating the field
|
|
914
|
+
...(located !== null
|
|
915
|
+
? { position: { type: "content-offset", line: located.line, column: located.column } }
|
|
916
|
+
: {}),
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
const opsCount = packetAssistant.ops.length;
|
|
920
|
+
// PLAN (reasoning) and informational SEND[103] are no-ops, not actions: both are
|
|
921
|
+
// excluded from the real-op count so a PLAN-only or prose-only turn still strikes
|
|
922
|
+
// as no-ops, and the terminal scan ignores 1xx so they never set turnStatus.
|
|
923
|
+
const realOpsCount = packetAssistant.ops.filter((op) => op.op !== "PLAN" && !(op.op === "SEND" && op.signal === 103 && op.target === null)).length;
|
|
924
|
+
const sendOp = packetAssistant.ops.findLast((op) => op.op === "SEND" && typeof op.signal === "number" && op.signal >= 200);
|
|
925
|
+
// §send the terminal contract — two engine error states verify a terminal claim against run
|
|
926
|
+
// state, never trusting the model's code. Both strike via turn.steerStruck (turnErrors,
|
|
927
|
+
// §grinder-strike-coupling): the loop continues, the model sees the steering hint not the strike
|
|
928
|
+
// count, and a non-resolver spins out to the engine's 500.
|
|
929
|
+
let steerStruck = false;
|
|
930
|
+
// Engine errors raised this turn, minted as op='error' log rows after dispatch (they share the
|
|
931
|
+
// post-dispatch sequence counter). §telemetry-uniform-error-channel
|
|
932
|
+
const pendingEngineErrors = [];
|
|
933
|
+
// Terminal adjudication moved to the DISPATCHER (§send-premature-terminate, the unified
|
|
934
|
+
// pending set): the terminal SEND is judged AT ITS OWN DISPATCH — after the emission's
|
|
935
|
+
// earlier ops executed — so a same-turn KILL+[200] repairs in one turn and a same-turn
|
|
936
|
+
// WORK+[200] is caught. A refused terminal (409) strikes via the dispatch-loop check below.
|
|
937
|
+
// Rail #41 (revised): the per-turn requirement is "emit at least one op," not "emit a terminal
|
|
938
|
+
// SEND." SEND is purely a signal verb; many turns pass without one. An empty op list strikes.
|
|
939
|
+
// Provisional here — a terminal REFUSED at dispatch (the pending-set 409, only knowable
|
|
940
|
+
// post-dispatch) demotes the turn back to a continue below: the SEND's signal stays on the
|
|
941
|
+
// row (the un-erased record), but the loop never went terminal, so the turn didn't either.
|
|
942
|
+
let turnStatus = sendOp !== undefined
|
|
943
|
+
? sendOp.signal
|
|
944
|
+
: realOpsCount === 0 ? TURN_STATUS_NO_OPS : TURN_STATUS_IMPLICIT_CONTINUE;
|
|
945
|
+
// Idle turn: an implicit-continue (102) that did no WORK — its ops are only PLAN/SEND, no mid op.
|
|
946
|
+
// The model continued with nothing to do. (Skipped when premature already steered this turn.)
|
|
947
|
+
const midOpsCount = packetAssistant.ops.filter((op) => op.op !== "PLAN" && op.op !== "SEND").length;
|
|
948
|
+
if (!steerStruck && turnStatus === TURN_STATUS_IMPLICIT_CONTINUE && midOpsCount === 0) {
|
|
949
|
+
// One grace turn after a retrieval-only 409 (admins specimen): the refusal steer says
|
|
950
|
+
// "continuing in order to receive results" — a model that obediently waits one bare
|
|
951
|
+
// [102] turn is following OUR advice, and the idle rail was executing it for that.
|
|
952
|
+
// The grace is exactly one turn; a second consecutive idle strikes as ever.
|
|
953
|
+
if (this.#retrievalRefusalGrace.delete(loopId)) {
|
|
954
|
+
// graced — the wait the steer asked for
|
|
955
|
+
}
|
|
956
|
+
else {
|
|
957
|
+
steerStruck = true;
|
|
958
|
+
pendingEngineErrors.push("idle_turn");
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
else {
|
|
962
|
+
this.#retrievalRefusalGrace.delete(loopId); // a working turn consumes any pending grace
|
|
963
|
+
}
|
|
964
|
+
// Close the turn with the final packet, status, and usage stats.
|
|
965
|
+
const packet = this.#packets.completePacket(requestPacket, packetAssistant, response.assistantRaw, provider);
|
|
966
|
+
const { usage, finishReason, model } = callMetadata;
|
|
967
|
+
await this.#db.engine_close_turn.run({
|
|
968
|
+
id: turnId,
|
|
969
|
+
status: turnStatus,
|
|
970
|
+
packet: JSON.stringify(packet),
|
|
971
|
+
usage_prompt: usage.prompt,
|
|
972
|
+
usage_completion: usage.completion,
|
|
973
|
+
usage_reasoning: usage.reasoning,
|
|
974
|
+
usage_cached: usage.cached,
|
|
975
|
+
usage_cost_pico: provider.costFor(usage), // §provider-surface-costfor
|
|
976
|
+
usage_context_size: this.#packets.promptBudgetFor(provider), // #274 — the PROMPT BUDGET (window − reserves): the raw n_ctx overstated usable room by the reserve total
|
|
977
|
+
finish_reason: finishReason,
|
|
978
|
+
model,
|
|
979
|
+
// #252 — opaque provider→client metadata passthrough (e.g. balancePico the
|
|
980
|
+
// provider normalized). Stored verbatim, unenforced; the service never reads a field.
|
|
981
|
+
meta: JSON.stringify(response.meta ?? {}),
|
|
982
|
+
});
|
|
983
|
+
// Dispatch model ops starting at nextActionIndex (continues the
|
|
984
|
+
// turn's running counter after any pre-model writes).
|
|
985
|
+
//
|
|
986
|
+
// Max-commands cap: a single emission with more than `maxCommands`
|
|
987
|
+
// ops is the runaway-loop fingerprint observed in pathological cases
|
|
988
|
+
// (html-attrs demo: 635 ops in one turn). Cap dispatches at the
|
|
989
|
+
// configured limit; overflow ops are dropped without per-op log
|
|
990
|
+
// entries (avoids bloating forensics with hundreds of identical refusals)
|
|
991
|
+
// and the model gets a single telemetry signal next packet so it knows
|
|
992
|
+
// its emission was truncated.
|
|
993
|
+
// #232 — a session's maxCommands is a tighten-only ceiling: min() the env ceiling.
|
|
994
|
+
const maxCommands = Math.min(readMaxCommands(), (await SessionSettings.read(this.#db, sessionId)).maxCommands ?? Number.POSITIVE_INFINITY);
|
|
995
|
+
// PLAN (reasoning) and a terminal SEND (signal ≥ 200, the conclusion) are not
|
|
996
|
+
// actions — they always dispatch and never count against the cap. maxCommands
|
|
997
|
+
// bounds real actions only; maxCommands:0 still admits a plan and a conclusion
|
|
998
|
+
// (the PLAN/SEND ops, zero actions), which is its only coherent meaning.
|
|
999
|
+
let realCommands = 0;
|
|
1000
|
+
const opsToDispatch = packetAssistant.ops.filter((op) => op.op === "PLAN"
|
|
1001
|
+
|| (op.op === "SEND" && typeof op.signal === "number" && op.signal >= 200)
|
|
1002
|
+
|| realCommands++ < maxCommands);
|
|
1003
|
+
const droppedCount = opsCount - opsToDispatch.length;
|
|
1004
|
+
const statuses = [];
|
|
1005
|
+
// Running counter — a multi-file READ writes N rows from one statement (rowsWritten),
|
|
1006
|
+
// so the next op's sequence picks up after them. Collapses to nextActionIndex+i when
|
|
1007
|
+
// every op writes one row (the common case).
|
|
1008
|
+
let rowSeq = nextActionIndex;
|
|
1009
|
+
for (const statement of opsToDispatch) {
|
|
1010
|
+
const result = await this.dispatch({
|
|
1011
|
+
statement, sessionId, runId, loopId, turnId,
|
|
1012
|
+
sequence: rowSeq,
|
|
1013
|
+
origin, onDispatch, gauge,
|
|
1014
|
+
// §send-200-failed-ops — parse errors mint as rows AFTER this loop; the terminal
|
|
1015
|
+
// gate needs them NOW, so the count rides the dispatch context.
|
|
1016
|
+
turnParseErrors: parseErrors?.length ?? 0,
|
|
1017
|
+
});
|
|
1018
|
+
statuses.push(result.status);
|
|
1019
|
+
// A refused terminal (the pending-set 409) demotes the turn to a continue: the loop
|
|
1020
|
+
// never went terminal, so the turn didn't either (the close persisted the provisional
|
|
1021
|
+
// status BEFORE dispatch — run20's T3 stored 200 with a 409-refused SEND). Whether it
|
|
1022
|
+
// ALSO strikes is kind-specific (owner ruling): a retrievals-only refusal teaches
|
|
1023
|
+
// without striking — atomic-turn-pretrained models pair fetch-and-answer by habit,
|
|
1024
|
+
// the refusal is correct each time, and maxTurns bounds the walk; striking executed
|
|
1025
|
+
// converging behavior (jumbo/admins specimens: 3 correct refusals → 500 mid-adapt).
|
|
1026
|
+
// Streams/children refusals keep the strike — discarding live work stays serious.
|
|
1027
|
+
if (statement === sendOp && result.status === 409) {
|
|
1028
|
+
if (result.attrs?.retrievalOnly !== true)
|
|
1029
|
+
steerStruck = true;
|
|
1030
|
+
else
|
|
1031
|
+
this.#retrievalRefusalGrace.add(loopId); // the steer says "continuing to receive" — the NEXT turn's obedient wait must not idle-strike
|
|
1032
|
+
turnStatus = TURN_STATUS_IMPLICIT_CONTINUE;
|
|
1033
|
+
await this.#db.engine_demote_turn_status.run({ id: turnId, status: turnStatus });
|
|
1034
|
+
}
|
|
1035
|
+
// A [300] question resolves through the proposal system (#346) — whatever the
|
|
1036
|
+
// resolution (answer/reject/timeout), the LOOP continues to the turn where the model
|
|
1037
|
+
// reads it; the turn record is a continue, never a 300 terminal.
|
|
1038
|
+
if (statement === sendOp && sendOp.signal === 300 && result.status !== 409) {
|
|
1039
|
+
turnStatus = TURN_STATUS_IMPLICIT_CONTINUE;
|
|
1040
|
+
await this.#db.engine_demote_turn_status.run({ id: turnId, status: turnStatus });
|
|
1041
|
+
}
|
|
1042
|
+
rowSeq += result.rowsWritten ?? 1;
|
|
1043
|
+
}
|
|
1044
|
+
// §telemetry-uniform-error-channel — every engine + parse failure mints as an op='error'
|
|
1045
|
+
// log row at the turn's next free sequence (after every dispatched row, incl. a multi-file
|
|
1046
|
+
// READ's fan-out). One channel: the errors section derives a LogCoordinate pointer from log≥400.
|
|
1047
|
+
let errSeq = rowSeq;
|
|
1048
|
+
// max_commands_exceeded IS model-facing: dropped ops the model emitted that didn't run.
|
|
1049
|
+
if (droppedCount > 0)
|
|
1050
|
+
pendingEngineErrors.push("max_commands_exceeded");
|
|
1051
|
+
for (const kind of pendingEngineErrors)
|
|
1052
|
+
await this.#telemetry.mintEngineError(kind, { runId, loopId, turnId, sequence: errSeq++ });
|
|
1053
|
+
// §log-row-self-explains (Q2, owner-clarified) — a model-op failure is the MODEL'S OWN op
|
|
1054
|
+
// result: the op row carries its failure message on its meta line (packet-wire), and the
|
|
1055
|
+
// errors section points at the row. No separate minted item (the retired action_failure
|
|
1056
|
+
// mint dressed op results as source:"engine" faults — the jumbo model chased a phantom
|
|
1057
|
+
// "engine run 400 error" off a message-less item). Genuine engine-internal faults CRASH
|
|
1058
|
+
// (fail-hard, §turn-never-blank) and never mint model-facing rows.
|
|
1059
|
+
// §tokenomics-output-truncated (#351) — packet honesty at the completion cap: a
|
|
1060
|
+
// finish=length turn was GUILLOTINED mid-emission, and its parse errors are truncation
|
|
1061
|
+
// ARTIFACTS. This one MINTS (actionless — no op row exists to self-explain): the cause
|
|
1062
|
+
// must lead the artifact rows it explains, or the model fixes syntax forever.
|
|
1063
|
+
if (finishReason === "length" && (parseErrors?.length ?? 0) > 0) {
|
|
1064
|
+
await this.#db.engine_insert_log_entry.get({
|
|
1065
|
+
run_id: runId, loop_id: loopId, turn_id: turnId, sequence: errSeq++,
|
|
1066
|
+
origin: "model", source: "engine", op: "error", suffix: "", signal: null,
|
|
1067
|
+
scheme: null, username: null, password: null, hostname: null, port: null,
|
|
1068
|
+
pathname: null, params: null, fragment: null, lineMarker: null,
|
|
1069
|
+
tx: "", mimetype_tx: "text/plain",
|
|
1070
|
+
rx: JSON.stringify({ status: 413, kind: "output_truncated", message: `output truncated at the completion cap (${this.#packets.decodeBudget(provider)} tokens) mid-emission — the parse errors below are truncation artifacts; emit fewer ops per turn and continue` }),
|
|
1071
|
+
mimetype_rx: "application/json", status_rx: 413, tokens: 0, state: "failed", outcome: "output_truncated",
|
|
1072
|
+
attrs: "{}",
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
// Parse errors carry the parser message + a content-offset line:col (a ContentOffset position),
|
|
1076
|
+
// resolved against the model's folded mirror row (§model-entry) — origin 'model', not engine.
|
|
1077
|
+
for (const { message, line, column, source } of parseErrors ?? []) {
|
|
1078
|
+
await this.#db.engine_insert_log_entry.get({
|
|
1079
|
+
run_id: runId, loop_id: loopId, turn_id: turnId, sequence: errSeq++,
|
|
1080
|
+
origin: "model", source: "grammar", op: "error", suffix: "", signal: null,
|
|
1081
|
+
scheme: null, username: null, password: null, hostname: null, port: null,
|
|
1082
|
+
pathname: null, params: null, fragment: null, lineMarker: null,
|
|
1083
|
+
tx: "", mimetype_tx: "text/plain",
|
|
1084
|
+
// The error carries the parser message + a content-offset `line:col`; the model READs
|
|
1085
|
+
// its own folded mirror row (§model-entry) at the cited lines, so no snippet is
|
|
1086
|
+
// embedded. The derived errors-section pointer stays minimal (status + coordinate).
|
|
1087
|
+
rx: JSON.stringify({ message, position: { type: "content-offset", line, column }, parserSource: source }),
|
|
1088
|
+
mimetype_rx: "application/json",
|
|
1089
|
+
status_rx: 400, tokens: 0, state: "resolved", outcome: null, attrs: "{}",
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
// §model-entry — mirror this turn's verbatim emission back as a `model` row, so the NEXT
|
|
1093
|
+
// packet shows the model exactly what it last produced. ALWAYS born FOLDED — the old
|
|
1094
|
+
// born-OPEN-on-error auto-trigger was conditional helpfulness that bred its own hazards
|
|
1095
|
+
// (a 24k-char ramble mirrored open re-injects itself into the next packet: cost,
|
|
1096
|
+
// contamination, pressure feedback). An error's line:col resolves the same way anything
|
|
1097
|
+
// else does: the model that cares READs the folded row at the lines it wants — and can
|
|
1098
|
+
// introspect any prior emission of its own the same way. Empty emissions (a struck/
|
|
1099
|
+
// silent turn) write nothing — no prior output to mirror.
|
|
1100
|
+
if (packetAssistant.content.trim().length > 0) {
|
|
1101
|
+
await this.#dispatcher.writeModelEntry({ verbatim: packetAssistant.content, runId, loopId, turnId, sequence: errSeq++, folded: true });
|
|
1102
|
+
}
|
|
1103
|
+
// Zero ops is NOT an error to report — the model knows it emitted
|
|
1104
|
+
// nothing. Strike accounting (engine-internal) treats it as a
|
|
1105
|
+
// struck turn; the model just sees an empty packet next turn.
|
|
1106
|
+
// Per SPEC §telemetry gamification policy.
|
|
1107
|
+
return { turnId, status: turnStatus, statuses, fingerprint: StrikeRail.fingerprintTurn(packetAssistant.ops), budgetStruck: enforced.struck, budgetHardStop: false, steerStruck };
|
|
1108
|
+
}
|
|
1109
|
+
// Split the wire-level ProviderResponse into the two destinations:
|
|
1110
|
+
// packet.assistant gets the model's emission (content, ops, reasoning);
|
|
1111
|
+
// Turn columns get the call-metadata (usage, finishReason, model).
|
|
1112
|
+
// SPEC §provider-surface / plurnk-providers#1: text-fragment scraping policy lives
|
|
1113
|
+
// here — engine owns the parse and the scraping rule, providers stay
|
|
1114
|
+
// grammar-unaware.
|
|
1115
|
+
//
|
|
1116
|
+
// Test-fixture escape hatch: the Mock provider may pre-supply `ops` on
|
|
1117
|
+
// its assistant payload to skip the parse roundtrip. The wire Provider
|
|
1118
|
+
// contract has no `ops` field; only Mock exposes one. Real providers
|
|
1119
|
+
// always take the parse path because their `assistant.ops` is undefined.
|
|
1120
|
+
#splitResponse(response) {
|
|
1121
|
+
const { assistant } = response;
|
|
1122
|
+
const preParsedOps = assistant.ops;
|
|
1123
|
+
const ops = [];
|
|
1124
|
+
// PLAN is an ordinary op — emitted by the model, dispatched, and passed to the
|
|
1125
|
+
// client as a log entry. No special hoisting into the reasoning field (that
|
|
1126
|
+
// legacy paradigm is abandoned). Interstitial free text is DROPPED — the prior
|
|
1127
|
+
// #free-text-capture synthesis of SEND[103] log ops was retired as tech debt
|
|
1128
|
+
// (grammar 0.70 forbids free text between ops, so a prose-only turn strikes 422).
|
|
1129
|
+
// Full PlurnkParseError context (line/column/source) is preserved
|
|
1130
|
+
// here so runTurn can build TelemetryEvent envelopes per the
|
|
1131
|
+
// grammar 0.17.0 protocol — model needs position info to locate
|
|
1132
|
+
// its own offending content on the next turn.
|
|
1133
|
+
const parseErrors = [];
|
|
1134
|
+
if (preParsedOps !== undefined) {
|
|
1135
|
+
ops.push(...preParsedOps);
|
|
1136
|
+
}
|
|
1137
|
+
else {
|
|
1138
|
+
const parsed = PlurnkParser.parse(assistant.content);
|
|
1139
|
+
for (const item of parsed.items) {
|
|
1140
|
+
if (item.kind === "statement") {
|
|
1141
|
+
ops.push(item.statement);
|
|
1142
|
+
}
|
|
1143
|
+
// Free text (kind "text") is dropped — #free-text-capture retired (above).
|
|
1144
|
+
else if (item.kind === "error") {
|
|
1145
|
+
const err = item.error;
|
|
1146
|
+
if (err instanceof PlurnkParseError) {
|
|
1147
|
+
parseErrors.push({ message: err.message, line: err.line, column: err.column, source: err.source });
|
|
1148
|
+
}
|
|
1149
|
+
else {
|
|
1150
|
+
const msg = err?.message ?? "parse error";
|
|
1151
|
+
parseErrors.push({ message: msg, line: 0, column: 0, source: "parser" });
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
// The grammar also reports an `unparsedTail` when input ends
|
|
1156
|
+
// mid-statement (a body opened but never closed): its `reason`
|
|
1157
|
+
// names the op AND the fix ("…never closed — add `:READ`"), where
|
|
1158
|
+
// the item-level error only says "expected close tag" for a tag the
|
|
1159
|
+
// model thinks it already wrote. Surface it — phenomenal messages
|
|
1160
|
+
// the model can self-correct from are the whole point of the DSL.
|
|
1161
|
+
const tail = parsed.unparsedTail;
|
|
1162
|
+
if (tail !== undefined) {
|
|
1163
|
+
parseErrors.push({ message: tail.reason, line: tail.from.line, column: tail.from.column, source: "grammar" });
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
const reasoning = assistant.reasoning ?? null;
|
|
1167
|
+
return {
|
|
1168
|
+
packetAssistant: { content: assistant.content, ops, reasoning },
|
|
1169
|
+
callMetadata: { usage: assistant.usage, finishReason: assistant.finishReason, model: assistant.model },
|
|
1170
|
+
parseErrors,
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
// #note12 — the daughter-provided reference docs (schemes' + execs' `documentation`),
|
|
1174
|
+
// materialized at plurnk:///docs/<name>.md by loop_run (like operator docs).
|
|
1175
|
+
docEntries(sessionId) {
|
|
1176
|
+
return this.#packets.docEntries(sessionId);
|
|
1177
|
+
}
|
|
1178
|
+
// §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
|
|
1179
|
+
// run last looked. No per-run snapshot (§machine-processes "a run is its log"): every
|
|
1180
|
+
// edit is already a span-carrying log row, so PULL other actors' EDITs on shared
|
|
1181
|
+
// entries since this run's prior turn — real cross-run edits and the plurnk run's
|
|
1182
|
+
// fs-sync fictions — and materialize each as a FOLDED delta reusing the row's span +
|
|
1183
|
+
// cause. Returns the count so the caller advances nextActionIndex past the deltas.
|
|
1184
|
+
async #materializeEnvironmentDeltas(args) {
|
|
1185
|
+
const { sessionId, runId, loopId, turnId, fromSequence } = args;
|
|
1186
|
+
const boundary = await this.#db.engine_run_prior_turn_time.get({ run_id: runId, turn_id: turnId });
|
|
1187
|
+
const since = boundary?.since ?? null;
|
|
1188
|
+
if (since === null)
|
|
1189
|
+
return 0; // first turn — nothing prior; the model reads current state fresh
|
|
1190
|
+
const rows = await this.#db.engine_pull_env_deltas.all({ session_id: sessionId, run_id: runId, since });
|
|
1191
|
+
let written = 0;
|
|
1192
|
+
for (const r of rows) {
|
|
1193
|
+
// source: the originating run (a real cross-run edit) or 'file' (an fs fiction);
|
|
1194
|
+
// rx reuses the originating row's result span — the edit as it looked then.
|
|
1195
|
+
await this.#db.engine_insert_env_delta.run({
|
|
1196
|
+
run_id: runId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
|
|
1197
|
+
source: r.source ?? String(r.run_id), scheme: r.scheme, pathname: r.pathname, rx: r.rx,
|
|
1198
|
+
});
|
|
1199
|
+
written++;
|
|
1200
|
+
}
|
|
1201
|
+
// §run-scheme — loop-terminations: a sibling's loop reaching terminal surfaces the
|
|
1202
|
+
// same way an entry-change does, carrying its deliverable (the SEND body) or the
|
|
1203
|
+
// abandonment reason. Folded, attributed to the terminated run.
|
|
1204
|
+
const terms = await this.#db.engine_pull_loop_terminations.all({ session_id: sessionId, run_id: runId, since });
|
|
1205
|
+
for (const t of terms) {
|
|
1206
|
+
await this.#db.engine_insert_loop_termination_delta.run({
|
|
1207
|
+
run_id: runId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
|
|
1208
|
+
source: String(t.run_id), pathname: `/${t.run_name}`,
|
|
1209
|
+
rx: markTerminal(t.terminated_by, t.terminal_message) ?? `loop "${t.prompt}" ended (${t.status})`,
|
|
1210
|
+
status: t.status,
|
|
1211
|
+
});
|
|
1212
|
+
written++;
|
|
1213
|
+
}
|
|
1214
|
+
return written;
|
|
1215
|
+
}
|
|
1216
|
+
// §exec-poll — EXEC `<0>` is turn-scoped: abort the run's open turn-scoped streams via their
|
|
1217
|
+
// owning scheme (the same registry-routed abort the total reap uses). Called at each pre-turn
|
|
1218
|
+
// before the turn's own spawns, so every open turn-scoped sub here is from a prior turn — it
|
|
1219
|
+
// never survives into the subsequent turn. Fire-and-forget: the spawn finalizes async and its
|
|
1220
|
+
// terminal output surfaces born-OPEN through the stream-delta path (§exec-stream).
|
|
1221
|
+
async #reapTurnScopedStreams(runId) {
|
|
1222
|
+
const open = await this.#db.find_open_turn_scoped_subscriptions_for_run.all({ run_id: runId });
|
|
1223
|
+
for (const { id, scheme } of open) {
|
|
1224
|
+
const handler = this.#schemes.get(scheme);
|
|
1225
|
+
handler?.abortSubscription?.(id);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
// §env-delta — exec streams as an instance of the ambient-observe machine:
|
|
1229
|
+
// each turn, emit each owned channel's unshown byte-delta as a foisted READ@200 row. Folded
|
|
1230
|
+
// while the channel streams; the terminal delta (channel closed) auto-OPENs. The cursor is the
|
|
1231
|
+
// streamEnd recorded on the channel's prior delta — no exec-specific surfacing, just the
|
|
1232
|
+
// env-observe loop with a byte cursor where env-delta uses a timestamp. §exec-stream
|
|
1233
|
+
async #materializeStreamDeltas(args) {
|
|
1234
|
+
const { runId, loopId, turnId, fromSequence } = args;
|
|
1235
|
+
const channels = await this.#db.engine_run_stream_channels.all({ run_id: runId });
|
|
1236
|
+
let written = 0;
|
|
1237
|
+
for (const ch of channels) {
|
|
1238
|
+
const prior = await this.#db.engine_stream_cursor.get({
|
|
1239
|
+
run_id: runId, scheme: ch.runtime, pathname: ch.coord, fragment: ch.channel,
|
|
1240
|
+
});
|
|
1241
|
+
const priorAttrs = prior !== undefined ? JSON.parse(prior.attrs) : {};
|
|
1242
|
+
const cursor = priorAttrs.streamEnd ?? 0;
|
|
1243
|
+
const closed = ch.state === "closed" || ch.state === "errored";
|
|
1244
|
+
if (ch.content.length <= cursor) {
|
|
1245
|
+
// The cursor-terminal race (owner's dogfood find): a channel written in one final
|
|
1246
|
+
// burst gets fully shown FOLDED while still active; the close then has zero new
|
|
1247
|
+
// bytes and the auto-OPEN terminal delta never fired — the model was never shown
|
|
1248
|
+
// the conclusion of a stream whose result it already holds folded. Emit the
|
|
1249
|
+
// terminal marker ONCE: open, terse, carrying the close status; the content is a
|
|
1250
|
+
// pointer to the already-delivered bytes, never a re-send (§tokenomics-fetch-fits-free).
|
|
1251
|
+
if (closed && priorAttrs.terminal !== true && cursor > 0) {
|
|
1252
|
+
await this.#db.engine_insert_stream_delta.run({
|
|
1253
|
+
run_id: runId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
|
|
1254
|
+
scheme: ch.runtime, pathname: ch.coord, fragment: ch.channel,
|
|
1255
|
+
rx: JSON.stringify({ status: ch.close_status ?? 200, content: `[ stream closed (${ch.close_status ?? 200}) — full output already delivered above; READ ${ch.runtime}://${ch.coord}#${ch.channel} to revisit ]`, mimetype: "text/stream" }),
|
|
1256
|
+
attrs: JSON.stringify({ streamEnd: ch.content.length, terminal: true }),
|
|
1257
|
+
expanded: 1,
|
|
1258
|
+
});
|
|
1259
|
+
written++;
|
|
1260
|
+
}
|
|
1261
|
+
continue;
|
|
1262
|
+
}
|
|
1263
|
+
// startLine continues the line count across turns: a multi-turn stream's deltas number
|
|
1264
|
+
// into one sequence (lines N..M, then M+1..), not N independent "1:" restarts. §exec-stream
|
|
1265
|
+
const startLine = (ch.content.slice(0, cursor).match(/\n/g)?.length ?? 0) + 1;
|
|
1266
|
+
await this.#db.engine_insert_stream_delta.run({
|
|
1267
|
+
run_id: runId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
|
|
1268
|
+
scheme: ch.runtime, pathname: ch.coord, fragment: ch.channel,
|
|
1269
|
+
rx: JSON.stringify({ status: 200, content: ch.content.slice(cursor), mimetype: "text/stream", startLine }),
|
|
1270
|
+
attrs: JSON.stringify({ streamEnd: ch.content.length, terminal: closed }),
|
|
1271
|
+
expanded: closed ? 1 : 0, // §exec-stream — terminal delta auto-OPENs; ongoing folds
|
|
1272
|
+
});
|
|
1273
|
+
written++;
|
|
1274
|
+
}
|
|
1275
|
+
return written;
|
|
1276
|
+
}
|
|
1277
|
+
// §env-delta — the filesystem as an actor. Ambient disk divergences detected at
|
|
1278
|
+
// pre-turn (git membership re-read) are logged as the plurnk run's source=file EDIT
|
|
1279
|
+
// "fictions": no op happened, but EDIT is the only grammar the model has for "your
|
|
1280
|
+
// world changed," so the fiction keeps its perspective aligned with what its tooling
|
|
1281
|
+
// would show. The fiction lives in the plurnk run's log; every other run pulls it
|
|
1282
|
+
// through the one delta path, exactly like a sibling's real edit.
|
|
1283
|
+
// §membership-emi-divergence-signal — disk divergences logged as the plurnk run's source=file EDIT fictions
|
|
1284
|
+
async #logFsFictions(sessionId, divergences) {
|
|
1285
|
+
if (divergences.length === 0)
|
|
1286
|
+
return;
|
|
1287
|
+
const run = await this.#db.envelope_get_run_by_name.get({ session_id: sessionId, name: "plurnk" })
|
|
1288
|
+
?? await this.#db.envelope_insert_run.get({ session_id: sessionId, name: "plurnk", origin: "plurnk" });
|
|
1289
|
+
if (run === undefined)
|
|
1290
|
+
throw new Error("logFsFictions: plurnk run resolution returned no row");
|
|
1291
|
+
const loop = await this.#db.envelope_insert_client_loop.get({ run_id: run.id });
|
|
1292
|
+
if (loop === undefined)
|
|
1293
|
+
throw new Error("logFsFictions: loop insert returned no row");
|
|
1294
|
+
const seq = await this.#db.client_turn_next_sequence.get({ loop_id: loop.id });
|
|
1295
|
+
const turn = await this.#db.client_turn_insert.get({ loop_id: loop.id, sequence: seq?.next ?? 1, packet: "{}" });
|
|
1296
|
+
if (turn === undefined)
|
|
1297
|
+
throw new Error("logFsFictions: turn insert returned no row");
|
|
1298
|
+
let sequence = 1;
|
|
1299
|
+
for (const d of divergences) {
|
|
1300
|
+
const span = editedSpan(d.before, d.after);
|
|
1301
|
+
await this.#db.engine_insert_log_entry.get({
|
|
1302
|
+
run_id: run.id, loop_id: loop.id, turn_id: turn.id, sequence: sequence++,
|
|
1303
|
+
origin: "plurnk", source: "file", op: "EDIT", suffix: "", signal: null,
|
|
1304
|
+
scheme: d.scheme, username: null, password: null, hostname: null, port: null,
|
|
1305
|
+
pathname: d.pathname, params: null, fragment: null, lineMarker: null,
|
|
1306
|
+
tx: "", mimetype_tx: "text/plain",
|
|
1307
|
+
rx: JSON.stringify({ status: 200, entryId: d.entryId, channel: d.channel, span }), mimetype_rx: "application/json",
|
|
1308
|
+
status_rx: 200, tokens: 0, state: "resolved", outcome: null, attrs: "{}",
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
async dispatch(context) {
|
|
1313
|
+
return this.#dispatcher.dispatch(context);
|
|
1314
|
+
}
|
|
1315
|
+
// op.look (#283) — resolve a READ and return its content WITHOUT writing a
|
|
1316
|
+
// log_entries row: the client's off-run inspection primitive. {§op-look}
|
|
1317
|
+
async look(context) {
|
|
1318
|
+
return this.#dispatcher.look(context);
|
|
1319
|
+
}
|
|
1320
|
+
// External API to feed a resolution into a pending proposal — the loop/resolve
|
|
1321
|
+
// RPC handler, the in-tree YOLO listener, or the timeout watcher.
|
|
1322
|
+
// Shutdown lane: settle every pending proposal with a cancel so a stopped world can never
|
|
1323
|
+
// deadlock the stop (§proposal-cancel-aborts; the #344 wedge class).
|
|
1324
|
+
cancelAllProposals(outcome) {
|
|
1325
|
+
this.#proposals.cancelAll(outcome);
|
|
1326
|
+
}
|
|
1327
|
+
resolveProposal(logEntryId, resolution) {
|
|
1328
|
+
this.#proposals.resolve(logEntryId, resolution);
|
|
1329
|
+
}
|
|
1330
|
+
// Snapshot of pending proposals (for diagnostic / RPC listings).
|
|
1331
|
+
pendingProposalIds() {
|
|
1332
|
+
return this.#proposals.pendingIds();
|
|
1333
|
+
}
|
|
1334
|
+
// Subscribe to proposal-pending events. Daemon registers a listener
|
|
1335
|
+
// that broadcasts the loop/proposal WS notification; YOLO listener
|
|
1336
|
+
// registers one that auto-resolves.
|
|
1337
|
+
onProposalPending(listener) {
|
|
1338
|
+
this.#proposals.onPending(listener);
|
|
1339
|
+
}
|
|
1340
|
+
// Used by wake-on-completion (daemon side): "is there any loop in this
|
|
1341
|
+
// run still accepting turns?" If yes, skip the wake — the active loop
|
|
1342
|
+
// will pick up the channel transition at its next turn boundary. If no,
|
|
1343
|
+
// the daemon opens a fresh loop with the wake prompt.
|
|
1344
|
+
async hasActiveLoopForRun(runId) {
|
|
1345
|
+
const row = await this.#db.engine_count_active_loops_for_run.get({ run_id: runId });
|
|
1346
|
+
return (row?.n ?? 0) > 0;
|
|
1347
|
+
}
|
|
1348
|
+
// #290 — run the derivation pump (deep channels: symbols/refs/FTS +
|
|
1349
|
+
// embeddings, deep_hash-gated) at SESSION-SCOPE, off the per-turn path, so a freshly-created
|
|
1350
|
+
// session's corpus warms DURING the client's startup window instead of freezing the first
|
|
1351
|
+
// loop.run. session.create fires this and returns immediately; embed_progress live-fans-out as it
|
|
1352
|
+
// runs. Idempotent + deep_hash-gated, so turn 1's pump finds the work done (or harmlessly re-runs);
|
|
1353
|
+
// a no-embedder build derives the cheap symbols/refs/FTS channels and skips the embed pass. Has no
|
|
1354
|
+
// loop yet — telemetry fans out live only (loopId 0), never buffered to a loop that never drains.
|
|
1355
|
+
async warmSessionDerivations(sessionId) {
|
|
1356
|
+
const ctx = {
|
|
1357
|
+
db: this.#db, sessionId, runId: 0, loopId: 0, turnId: 0,
|
|
1358
|
+
writer: "plurnk",
|
|
1359
|
+
signal: undefined,
|
|
1360
|
+
streamEventNotify: this.#streamEventNotify,
|
|
1361
|
+
wakeRunNotify: this.#wakeRunNotify,
|
|
1362
|
+
tokenize: this.#tokenize,
|
|
1363
|
+
mimetypes: this.#mimetypes,
|
|
1364
|
+
defaultChannelFor: (s) => this.#schemes.defaultChannelFor(s),
|
|
1365
|
+
pushTelemetry: (event) => this.#telemetry.notify(sessionId, 0, event),
|
|
1366
|
+
};
|
|
1367
|
+
await this.#queueDerivation(() => EntryManifest.maintainDerivations(ctx)); // §derivation-off-hot-path
|
|
1368
|
+
}
|
|
1369
|
+
// Inject a prompt into the run's currently-executing loop. Writes a
|
|
1370
|
+
// plurnk://prompt/<run>/<loop>/<next-turn> entry whose body becomes the
|
|
1371
|
+
// prompt section at the next turn boundary. Last-wins: if two
|
|
1372
|
+
// injects target the same next-turn slot, the second overwrites the
|
|
1373
|
+
// first.
|
|
1374
|
+
//
|
|
1375
|
+
// Returns null when no loop in the run is currently active (status=102).
|
|
1376
|
+
// The daemon-side inject path then enqueues a fresh loop with this
|
|
1377
|
+
// prompt; engine doesn't open loops itself.
|
|
1378
|
+
//
|
|
1379
|
+
// Rummy parallel: AgentLoop.inject(). The "active drain → write
|
|
1380
|
+
// prompt entry, return immediately" branch.
|
|
1381
|
+
async inject(runId, prompt) {
|
|
1382
|
+
const loopRow = await this.#db.drain_current_loop_for_run.get({ run_id: runId });
|
|
1383
|
+
if (loopRow === undefined)
|
|
1384
|
+
return null;
|
|
1385
|
+
const loopId = loopRow.id;
|
|
1386
|
+
const turnRow = await this.#db.drain_next_turn_seq_for_loop.get({ loop_id: loopId });
|
|
1387
|
+
const turnSeq = turnRow?.next ?? 1;
|
|
1388
|
+
const sessionRow = await this.#db.drain_get_run_session.get({ run_id: runId });
|
|
1389
|
+
if (sessionRow === undefined)
|
|
1390
|
+
throw new Error(`Engine.inject: run ${runId} not found`);
|
|
1391
|
+
const pathname = promptPathname(runId, loopRow.sequence, turnSeq); // canonical storage form, run-qualified loop-SEQ coordinates matching the turn-1 foist
|
|
1392
|
+
const ctx = {
|
|
1393
|
+
db: this.#db, sessionId: sessionRow.session_id, runId, loopId,
|
|
1394
|
+
turnId: 0, // no turn open at inject time; entries don't pin turnId
|
|
1395
|
+
writer: "plurnk",
|
|
1396
|
+
signal: this.#loopAborts.get(loopId)?.signal,
|
|
1397
|
+
streamEventNotify: this.#streamEventNotify,
|
|
1398
|
+
wakeRunNotify: this.#wakeRunNotify,
|
|
1399
|
+
tokenize: this.#tokenize,
|
|
1400
|
+
pushTelemetry: (event) => this.#telemetry.push(sessionRow.session_id, loopId, event),
|
|
1401
|
+
};
|
|
1402
|
+
const entry = {
|
|
1403
|
+
channels: { body: { content: prompt, mimetype: "text/markdown" } },
|
|
1404
|
+
tags: [],
|
|
1405
|
+
};
|
|
1406
|
+
await EntryCrud.writeEntry(pathname, entry, ctx, "plurnk");
|
|
1407
|
+
return { loopId, turnSeq };
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
//# sourceMappingURL=Engine.js.map
|