@plurnk/plurnk-service 1.5.0 → 1.6.1
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 +22 -27
- package/INSTALL.md +22 -17
- package/README.md +1 -1
- package/SPEC.md +505 -362
- package/dist/build-info.json +1 -1
- package/dist/content/edit-collision.d.ts +5 -0
- package/dist/content/edit-collision.d.ts.map +1 -0
- package/dist/content/edit-collision.js +14 -0
- package/dist/content/edit-collision.js.map +1 -0
- package/dist/content/edit-receipt.d.ts +3 -2
- package/dist/content/edit-receipt.d.ts.map +1 -1
- package/dist/content/edit-receipt.js +58 -13
- package/dist/content/edit-receipt.js.map +1 -1
- package/dist/content/index.d.ts +4 -1
- package/dist/content/index.d.ts.map +1 -1
- package/dist/content/index.js +3 -1
- package/dist/content/index.js.map +1 -1
- package/dist/content/line-anchors.d.ts +41 -0
- package/dist/content/line-anchors.d.ts.map +1 -0
- package/dist/content/line-anchors.js +191 -0
- package/dist/content/line-anchors.js.map +1 -0
- package/dist/content/read-projector.d.ts +7 -1
- package/dist/content/read-projector.d.ts.map +1 -1
- package/dist/content/read-projector.js +46 -10
- package/dist/content/read-projector.js.map +1 -1
- package/dist/content/read-resolve.js +3 -3
- package/dist/content/read-resolve.js.map +1 -1
- package/dist/core/BudgetReadout.d.ts.map +1 -1
- package/dist/core/BudgetReadout.js +9 -2
- package/dist/core/BudgetReadout.js.map +1 -1
- package/dist/core/ChannelWrite.js +1 -1
- package/dist/core/ChannelWrite.js.map +1 -1
- package/dist/core/ChannelWrite.sql +3 -3
- package/dist/core/CoreSchemeServices.d.ts +1 -1
- package/dist/core/CoreSchemeServices.d.ts.map +1 -1
- package/dist/core/CoreSchemeServices.js +1 -1
- package/dist/core/CoreSchemeServices.js.map +1 -1
- package/dist/core/Dispatcher.d.ts +15 -5
- package/dist/core/Dispatcher.d.ts.map +1 -1
- package/dist/core/Dispatcher.js +220 -978
- package/dist/core/Dispatcher.js.map +1 -1
- package/dist/core/DurableStatement.d.ts.map +1 -1
- package/dist/core/DurableStatement.js +2 -0
- package/dist/core/DurableStatement.js.map +1 -1
- package/dist/core/Engine.d.ts +18 -43
- package/dist/core/Engine.d.ts.map +1 -1
- package/dist/core/Engine.js +90 -1554
- package/dist/core/Engine.js.map +1 -1
- package/dist/core/Engine.sql +192 -112
- package/dist/core/ExecutableTools.d.ts +10 -0
- package/dist/core/ExecutableTools.d.ts.map +1 -0
- package/dist/core/ExecutableTools.js +41 -0
- package/dist/core/ExecutableTools.js.map +1 -0
- package/dist/core/ExecutorRegistry.d.ts +3 -3
- package/dist/core/ExecutorRegistry.d.ts.map +1 -1
- package/dist/core/ExecutorRegistry.js +2 -2
- package/dist/core/ExecutorRegistry.js.map +1 -1
- package/dist/core/LogBody.d.ts +3 -0
- package/dist/core/LogBody.d.ts.map +1 -1
- package/dist/core/LogBody.js +21 -5
- package/dist/core/LogBody.js.map +1 -1
- package/dist/core/LogEntryProjection.d.ts +14 -0
- package/dist/core/LogEntryProjection.d.ts.map +1 -0
- package/dist/core/LogEntryProjection.js +38 -0
- package/dist/core/LogEntryProjection.js.map +1 -0
- package/dist/core/ModelCall.d.ts +27 -0
- package/dist/core/ModelCall.d.ts.map +1 -0
- package/dist/core/ModelCall.js +127 -0
- package/dist/core/ModelCall.js.map +1 -0
- package/dist/core/PacketBuilder.d.ts +14 -25
- package/dist/core/PacketBuilder.d.ts.map +1 -1
- package/dist/core/PacketBuilder.js +170 -244
- package/dist/core/PacketBuilder.js.map +1 -1
- package/dist/core/ProblemLog.d.ts +1 -1
- package/dist/core/ProblemLog.d.ts.map +1 -1
- package/dist/core/ProblemLog.js +15 -3
- package/dist/core/ProblemLog.js.map +1 -1
- package/dist/core/ProposalLifecycle.d.ts +2 -2
- package/dist/core/ProposalLifecycle.d.ts.map +1 -1
- package/dist/core/ProposalLifecycle.js +17 -9
- package/dist/core/ProposalLifecycle.js.map +1 -1
- package/dist/core/ProviderInstantiate.d.ts.map +1 -1
- package/dist/core/ProviderInstantiate.js +25 -8
- package/dist/core/ProviderInstantiate.js.map +1 -1
- package/dist/core/ResourceMutations.d.ts +73 -0
- package/dist/core/ResourceMutations.d.ts.map +1 -0
- package/dist/core/ResourceMutations.js +1142 -0
- package/dist/core/ResourceMutations.js.map +1 -0
- package/dist/core/SchemeRegistry.js +10 -10
- package/dist/core/SchemeRegistry.js.map +1 -1
- package/dist/core/StoredPacket.d.ts +2 -2
- package/dist/core/StoredPacket.js +7 -7
- package/dist/core/StrikeRail.d.ts +0 -1
- package/dist/core/StrikeRail.d.ts.map +1 -1
- package/dist/core/StrikeRail.js +6 -4
- package/dist/core/StrikeRail.js.map +1 -1
- package/dist/core/TurnRunner.d.ts +72 -0
- package/dist/core/TurnRunner.d.ts.map +1 -0
- package/dist/core/TurnRunner.js +1730 -0
- package/dist/core/TurnRunner.js.map +1 -0
- package/dist/core/caps/CapsResolve.js +1 -1
- package/dist/core/caps/CapsResolve.js.map +1 -1
- package/dist/core/caps/DbChannelCaps.js +4 -4
- package/dist/core/caps/DbChannelCaps.js.map +1 -1
- package/dist/core/caps/DbEntryCaps.d.ts +2 -1
- package/dist/core/caps/DbEntryCaps.d.ts.map +1 -1
- package/dist/core/caps/DbEntryCaps.js +8 -5
- package/dist/core/caps/DbEntryCaps.js.map +1 -1
- package/dist/core/caps/DbProjectionCaps.d.ts +1 -0
- package/dist/core/caps/DbProjectionCaps.d.ts.map +1 -1
- package/dist/core/caps/DbProjectionCaps.js +29 -0
- package/dist/core/caps/DbProjectionCaps.js.map +1 -1
- package/dist/core/caps/SchemeCtxImpl.d.ts +5 -2
- package/dist/core/caps/SchemeCtxImpl.d.ts.map +1 -1
- package/dist/core/caps/SchemeCtxImpl.js +6 -4
- package/dist/core/caps/SchemeCtxImpl.js.map +1 -1
- package/dist/core/content-hash.js +2 -2
- package/dist/core/content-hash.js.map +1 -1
- package/dist/core/content-weight.d.ts +2 -0
- package/dist/core/content-weight.d.ts.map +1 -0
- package/dist/core/content-weight.js +4 -0
- package/dist/core/content-weight.js.map +1 -0
- package/dist/core/content_weight.d.ts +4 -0
- package/dist/core/content_weight.d.ts.map +1 -0
- package/dist/core/content_weight.js +6 -0
- package/dist/core/content_weight.js.map +1 -0
- package/dist/core/fork.d.ts.map +1 -1
- package/dist/core/fork.js +23 -7
- package/dist/core/fork.js.map +1 -1
- package/dist/core/fork.sql +38 -17
- package/dist/core/git-membership.d.ts.map +1 -1
- package/dist/core/git-membership.js +1 -2
- package/dist/core/git-membership.js.map +1 -1
- package/dist/core/git-state.js +1 -1
- package/dist/core/git-state.js.map +1 -1
- package/dist/core/packet-wire.d.ts +6 -3
- package/dist/core/packet-wire.d.ts.map +1 -1
- package/dist/core/packet-wire.js +257 -99
- package/dist/core/packet-wire.js.map +1 -1
- package/dist/core/provider-accounting.d.ts +24 -0
- package/dist/core/provider-accounting.d.ts.map +1 -0
- package/dist/core/provider-accounting.js +76 -0
- package/dist/core/provider-accounting.js.map +1 -0
- package/dist/core/scheme-types.d.ts +1 -1
- package/dist/core/scheme-types.d.ts.map +1 -1
- package/dist/core/search-gate.d.ts +2 -2
- package/dist/core/search-gate.d.ts.map +1 -1
- package/dist/core/search-gate.js +8 -8
- package/dist/core/search-gate.js.map +1 -1
- package/dist/core/turn-scheduler.js +1 -1
- package/dist/core/turn-scheduler.js.map +1 -1
- package/dist/digest/Digest.d.ts.map +1 -1
- package/dist/digest/Digest.js +221 -110
- package/dist/digest/Digest.js.map +1 -1
- package/dist/digest/digest.sql +40 -18
- package/dist/schemes/Exec.d.ts.map +1 -1
- package/dist/schemes/Exec.js +136 -110
- package/dist/schemes/Exec.js.map +1 -1
- package/dist/schemes/ExecOutputScheme.js +1 -1
- package/dist/schemes/ExecOutputScheme.js.map +1 -1
- package/dist/schemes/File.d.ts +5 -4
- package/dist/schemes/File.d.ts.map +1 -1
- package/dist/schemes/File.js +25 -23
- package/dist/schemes/File.js.map +1 -1
- package/dist/schemes/Log.d.ts +11 -0
- package/dist/schemes/Log.d.ts.map +1 -1
- package/dist/schemes/Log.js +172 -107
- package/dist/schemes/Log.js.map +1 -1
- package/dist/schemes/Log.sql +48 -32
- package/dist/schemes/Prompt.d.ts +3 -3
- package/dist/schemes/Prompt.d.ts.map +1 -1
- package/dist/schemes/Prompt.js +4 -1
- package/dist/schemes/Prompt.js.map +1 -1
- package/dist/schemes/Skill.d.ts +4 -4
- package/dist/schemes/Skill.d.ts.map +1 -1
- package/dist/schemes/Skill.js +3 -0
- package/dist/schemes/Skill.js.map +1 -1
- package/dist/schemes/Worker.d.ts +4 -4
- package/dist/schemes/Worker.d.ts.map +1 -1
- package/dist/schemes/Worker.js +11 -7
- package/dist/schemes/Worker.js.map +1 -1
- package/dist/schemes/_entry-crud.d.ts +0 -1
- package/dist/schemes/_entry-crud.d.ts.map +1 -1
- package/dist/schemes/_entry-crud.js +19 -13
- package/dist/schemes/_entry-crud.js.map +1 -1
- package/dist/schemes/_entry-crud.sql +4 -16
- package/dist/schemes/_entry-find.d.ts +17 -16
- package/dist/schemes/_entry-find.d.ts.map +1 -1
- package/dist/schemes/_entry-find.js +58 -52
- package/dist/schemes/_entry-find.js.map +1 -1
- package/dist/schemes/_entry-find.sql +2 -21
- package/dist/schemes/_entry-graph.sql +1 -1
- package/dist/schemes/_entry-manifest.d.ts +5 -17
- package/dist/schemes/_entry-manifest.d.ts.map +1 -1
- package/dist/schemes/_entry-manifest.js +32 -28
- package/dist/schemes/_entry-manifest.js.map +1 -1
- package/dist/schemes/_entry-ops.d.ts +5 -4
- package/dist/schemes/_entry-ops.d.ts.map +1 -1
- package/dist/schemes/_entry-ops.js +89 -49
- package/dist/schemes/_entry-ops.js.map +1 -1
- package/dist/schemes/_entry-ops.sql +30 -4
- package/dist/schemes/_entry-send.js +4 -4
- package/dist/schemes/_entry-send.js.map +1 -1
- package/dist/schemes/_search-index.d.ts.map +1 -1
- package/dist/schemes/_search-index.js +10 -2
- package/dist/schemes/_search-index.js.map +1 -1
- package/dist/schemes/exec-abort.js +2 -2
- package/dist/schemes/exec-abort.js.map +1 -1
- package/dist/server/Daemon.d.ts +7 -42
- package/dist/server/Daemon.d.ts.map +1 -1
- package/dist/server/Daemon.js +139 -900
- package/dist/server/Daemon.js.map +1 -1
- package/dist/server/DrainSupervisor.d.ts +117 -0
- package/dist/server/DrainSupervisor.d.ts.map +1 -0
- package/dist/server/DrainSupervisor.js +802 -0
- package/dist/server/DrainSupervisor.js.map +1 -0
- package/dist/server/drain.sql +2 -2
- package/dist/server/envelope.d.ts +0 -2
- package/dist/server/envelope.d.ts.map +1 -1
- package/dist/server/envelope.js.map +1 -1
- package/dist/server/envelope.sql +2 -2
- package/dist/server/lifecycle-recovery.sql +28 -15
- package/dist/server/logEntry.d.ts +2 -1
- package/dist/server/logEntry.d.ts.map +1 -1
- package/dist/server/logEntry.js +14 -2
- package/dist/server/logEntry.js.map +1 -1
- package/dist/server/logEntry.sql +5 -1
- package/dist/server/seam-entry-read.sql +2 -2
- package/dist/service.js +1 -1
- package/dist/service.js.map +1 -1
- package/migrations/001_schema.sql +622 -216
- package/package.json +39 -34
- package/dist/core/BudgetOverflow.d.ts +0 -20
- package/dist/core/BudgetOverflow.d.ts.map +0 -1
- package/dist/core/BudgetOverflow.js +0 -49
- package/dist/core/BudgetOverflow.js.map +0 -1
- package/dist/core/caps/DbTagCaps.d.ts +0 -10
- package/dist/core/caps/DbTagCaps.d.ts.map +0 -1
- package/dist/core/caps/DbTagCaps.js +0 -46
- package/dist/core/caps/DbTagCaps.js.map +0 -1
- package/dist/core/ruler_count.d.ts +0 -4
- package/dist/core/ruler_count.d.ts.map +0 -1
- package/dist/core/ruler_count.js +0 -7
- package/dist/core/ruler_count.js.map +0 -1
- package/dist/core/token-ruler.d.ts +0 -2
- package/dist/core/token-ruler.d.ts.map +0 -1
- package/dist/core/token-ruler.js +0 -5
- package/dist/core/token-ruler.js.map +0 -1
package/dist/core/Engine.js
CHANGED
|
@@ -1,96 +1,28 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { RuntimeTag } from "@plurnk/plurnk-execs";
|
|
3
|
-
import Owner from "./Owner.js";
|
|
4
|
-
const TERMINAL_SEND_SIGNALS = new Set([102, 200, 202, 300, 499]);
|
|
5
|
-
const comparePosition = (a, b) => a.line - b.line || a.column - b.column;
|
|
1
|
+
import { RuntimeInvocation, RuntimeTag } from "@plurnk/plurnk-execs";
|
|
6
2
|
import { Mimetypes, emptyRegistry } from "@plurnk/plurnk-mimetypes";
|
|
7
3
|
import Meta from "@plurnk/plurnk-meta";
|
|
8
4
|
import EntryCrud from "../schemes/_entry-crud.js";
|
|
9
5
|
import SearchIndex from "../schemes/_search-index.js";
|
|
10
6
|
import GitMembership from "./git-membership.js";
|
|
11
|
-
import
|
|
12
|
-
import
|
|
13
|
-
import { editedSpan } from "../content/index.js";
|
|
14
|
-
import { promptPathname, promptLoopPrefix, renderTarget } from "./plurnk-uri.js";
|
|
15
|
-
import { rulerCount } from "./token-ruler.js";
|
|
7
|
+
import { promptPathname, promptLoopPrefix } from "./plurnk-uri.js";
|
|
8
|
+
import { contentWeight } from "./content-weight.js";
|
|
16
9
|
import SearchGate from "./search-gate.js";
|
|
17
10
|
import LiveSubscriptions from "./LiveSubscriptions.js";
|
|
18
11
|
import LoopLifecycle from "./LoopLifecycle.js";
|
|
19
|
-
import { readFile } from "node:fs/promises";
|
|
20
|
-
import { fileURLToPath } from "node:url";
|
|
21
12
|
import { setTimeout as delay } from "node:timers/promises";
|
|
22
|
-
|
|
23
|
-
// projection and digest projection are structurally one function — no
|
|
24
|
-
// drift between wire and digest possible.
|
|
25
|
-
import PacketWire from "./packet-wire.js";
|
|
26
|
-
import Results, { OperationFailureError } from "./results.js";
|
|
27
|
-
import BranchReceipt from "./BranchReceipt.js";
|
|
28
|
-
import TerminalResult from "./TerminalResult.js";
|
|
29
|
-
import BudgetOverflow from "./BudgetOverflow.js";
|
|
30
|
-
import WorkerControlAddress from "./WorkerControlAddress.js";
|
|
31
|
-
import JournalTurn from "./JournalTurn.js";
|
|
13
|
+
import Results from "./results.js";
|
|
32
14
|
// The engine's collaborators — each owns one machine; Engine owns the loop/turn
|
|
33
15
|
// lifecycle and wires them together as the public facade.
|
|
34
16
|
import NoticeChannel from "./NoticeChannel.js";
|
|
35
17
|
import ProblemLog from "./ProblemLog.js";
|
|
36
18
|
import StrikeRail from "./StrikeRail.js";
|
|
37
19
|
import PacketBuilder from "./PacketBuilder.js";
|
|
38
|
-
import StoredPacket from "./StoredPacket.js";
|
|
39
20
|
import ProposalLifecycle from "./ProposalLifecycle.js";
|
|
40
21
|
import Dispatcher from "./Dispatcher.js";
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
43
|
-
import {
|
|
44
|
-
|
|
45
|
-
const DEFAULT_MAX_STRIKES = 3;
|
|
46
|
-
const RECORD_STREAM_MIMETYPES = new Set(["application/jsonl", "application/x-ndjson"]);
|
|
47
|
-
const baseMimetype = (mimetype) => mimetype.split(";", 1)[0].trim().toLowerCase();
|
|
48
|
-
// {§exec-stream} Active streams publish only independently meaningful units.
|
|
49
|
-
// Atomic documents wait for close; JSONL stops after its last complete record.
|
|
50
|
-
const streamPublicationEnd = (content, mimetype, cursor, closed) => {
|
|
51
|
-
const type = baseMimetype(mimetype);
|
|
52
|
-
if (closed || type.startsWith("text/"))
|
|
53
|
-
return content.length;
|
|
54
|
-
if (!RECORD_STREAM_MIMETYPES.has(type))
|
|
55
|
-
return cursor;
|
|
56
|
-
return Math.max(cursor, content.lastIndexOf("\n") + 1);
|
|
57
|
-
};
|
|
58
|
-
const ENGINE_PROBLEMS = Object.freeze({
|
|
59
|
-
max_commands_exceeded: {
|
|
60
|
-
status: 429,
|
|
61
|
-
code: "max-commands-exceeded",
|
|
62
|
-
detail: "Later operations were not executed because the turn exceeded its operation limit.",
|
|
63
|
-
},
|
|
64
|
-
idle_turn: {
|
|
65
|
-
status: 409,
|
|
66
|
-
code: "idle-turn",
|
|
67
|
-
detail: "SEND[102] was emitted without an operation to continue from.",
|
|
68
|
-
},
|
|
69
|
-
});
|
|
70
|
-
// The prompt entry target - prompt:///<loop>/<N>, self-only ({§prompt-self-only}):
|
|
71
|
-
// the owner rides the owner_id column, the address carries only the loop coordinate.
|
|
72
|
-
const promptTarget = (loopSeq, turnSeq) => {
|
|
73
|
-
const storage = promptPathname(loopSeq, turnSeq);
|
|
74
|
-
return {
|
|
75
|
-
kind: "url", raw: `prompt://${storage}`,
|
|
76
|
-
scheme: "prompt", username: null, password: null,
|
|
77
|
-
hostname: null, port: null,
|
|
78
|
-
pathname: storage, query: null, fragment: null,
|
|
79
|
-
};
|
|
80
|
-
};
|
|
81
|
-
const assertOpenPaths = (value, source) => {
|
|
82
|
-
if (!Array.isArray(value) || value.some((path) => typeof path !== "string" || path.length === 0)) {
|
|
83
|
-
throw new TypeError(`${source}: expected an array of non-empty strings`);
|
|
84
|
-
}
|
|
85
|
-
return value;
|
|
86
|
-
};
|
|
87
|
-
const parsePromptAttributes = (encoded, source) => {
|
|
88
|
-
const attributes = JSON.parse(encoded);
|
|
89
|
-
if (attributes === null || typeof attributes !== "object" || Array.isArray(attributes)) {
|
|
90
|
-
throw new TypeError(`${source}: expected a JSON object`);
|
|
91
|
-
}
|
|
92
|
-
return attributes;
|
|
93
|
-
};
|
|
22
|
+
import TurnRunner, { LOOP_TIMEOUT_REASON } from "./TurnRunner.js";
|
|
23
|
+
import { observed } from "../observe/spans.js";
|
|
24
|
+
import { providerRequestFromStorageRow, } from "./provider-accounting.js";
|
|
25
|
+
const DEFAULT_MAX_STRIKES = 6;
|
|
94
26
|
const readMaxStrikes = () => {
|
|
95
27
|
const raw = process.env.PLURNK_SERVICE_MAX_STRIKES;
|
|
96
28
|
if (raw === undefined || raw.length === 0)
|
|
@@ -100,37 +32,7 @@ const readMaxStrikes = () => {
|
|
|
100
32
|
return DEFAULT_MAX_STRIKES;
|
|
101
33
|
return n;
|
|
102
34
|
};
|
|
103
|
-
|
|
104
|
-
// every generated op dispatches. Runaway degeneration is a sampler concern (repetition penalty),
|
|
105
|
-
// not grounds to drop already-generated work. A positive value is an operator ceiling a
|
|
106
|
-
// workspace's maxCommands may tighten (min wins), never widen
|
|
107
|
-
// ({§operator-config-workspace-max-commands}).
|
|
108
|
-
const readMaxCommands = () => {
|
|
109
|
-
const raw = process.env.PLURNK_SERVICE_MAX_COMMANDS;
|
|
110
|
-
if (raw === undefined || raw.length === 0)
|
|
111
|
-
return Number.POSITIVE_INFINITY;
|
|
112
|
-
const n = Number.parseInt(raw, 10);
|
|
113
|
-
if (!Number.isFinite(n) || n < 1)
|
|
114
|
-
return Number.POSITIVE_INFINITY;
|
|
115
|
-
return n;
|
|
116
|
-
};
|
|
117
|
-
// PLURNK_SERVICE_FILES_ITEMS — the turn-0 catalog preview. null = off;
|
|
118
|
-
// -1 = the ordinary markerless page; positive N explicitly caps file rows. 0 / unset = off.
|
|
119
|
-
const normalizeFilesItems = (n) => (!Number.isFinite(n) || n === 0 ? null : n < 0 ? -1 : n);
|
|
120
|
-
const readFilesItems = () => {
|
|
121
|
-
const raw = process.env.PLURNK_SERVICE_FILES_ITEMS;
|
|
122
|
-
if (raw === undefined || raw.length === 0)
|
|
123
|
-
return null;
|
|
124
|
-
return normalizeFilesItems(Number.parseInt(raw, 10));
|
|
125
|
-
};
|
|
126
|
-
import { ProviderError, providerCostFor, providerCostUsd, validateProviderCost, scopeEnvToAlias, resolveActiveAlias } from "@plurnk/plurnk-providers";
|
|
127
|
-
import { validateGbnf } from "@plurnk/gbnf";
|
|
128
|
-
import ProviderInstantiate from "./ProviderInstantiate.js";
|
|
129
|
-
// Runtime normalization for a disposition the engine refuses or resolves as a
|
|
130
|
-
// continue after dispatch ({§send}). Every admitted emission itself ends in an
|
|
131
|
-
// explicit disposition SEND ({§emission-admission}).
|
|
132
|
-
const TURN_STATUS_IMPLICIT_CONTINUE = 102;
|
|
133
|
-
const INVALID_EMISSION_RECOVERY_MESSAGE = "Your previous response contained an unrecoverable syntax error. No operations were performed. Try again.";
|
|
35
|
+
import { aggregateProviderAccounting } from "@plurnk/plurnk-providers";
|
|
134
36
|
const DEFAULT_MIN_CYCLES = 3;
|
|
135
37
|
const DEFAULT_MAX_CYCLE_PERIOD = 4;
|
|
136
38
|
const readPositiveInt = (envVar, fallback) => {
|
|
@@ -142,19 +44,10 @@ const readPositiveInt = (envVar, fallback) => {
|
|
|
142
44
|
return fallback;
|
|
143
45
|
return n;
|
|
144
46
|
};
|
|
145
|
-
const readEmissionAttempts = () => {
|
|
146
|
-
const raw = process.env.PLURNK_SERVICE_EMISSION_ATTEMPTS;
|
|
147
|
-
const value = Number.parseInt(raw ?? "", 10);
|
|
148
|
-
if (!Number.isInteger(value) || value < 1) {
|
|
149
|
-
throw new Error(`PLURNK_SERVICE_EMISSION_ATTEMPTS must be a positive integer; got ${raw}`);
|
|
150
|
-
}
|
|
151
|
-
return value;
|
|
152
|
-
};
|
|
153
47
|
// {§operator-config-loop-timeout} — the loop's wall-clock budget (PLURNK_SERVICE_LOOP_TIMEOUT).
|
|
154
48
|
const DEFAULT_LOOP_TIMEOUT_MS = 86400000;
|
|
155
49
|
const readLoopTimeoutMs = () => readPositiveInt("PLURNK_SERVICE_LOOP_TIMEOUT", DEFAULT_LOOP_TIMEOUT_MS);
|
|
156
50
|
// The wall's abort reason — runLoop branches a mid-turn teardown to the 504 terminal on it.
|
|
157
|
-
const LOOP_TIMEOUT_REASON = "loop_timeout";
|
|
158
51
|
export default class Engine {
|
|
159
52
|
static fingerprintTurn(ops) {
|
|
160
53
|
return StrikeRail.fingerprintTurn(ops);
|
|
@@ -168,11 +61,11 @@ export default class Engine {
|
|
|
168
61
|
#mimetypes;
|
|
169
62
|
// {§tokenomics-agnostic-ruler} — the stable model-independent ruler used
|
|
170
63
|
// for write-time, catalog, receipt, and packet weights.
|
|
171
|
-
#
|
|
64
|
+
#weighContent;
|
|
172
65
|
// Boot-discovered runtime executors. Daemon builds + sets via
|
|
173
66
|
// setExecutors at start(); undefined until then (and in bare tests).
|
|
174
67
|
#executors;
|
|
175
|
-
// {§send-premature-terminate}/SEND
|
|
68
|
+
// {§send-premature-terminate}/scoped SEND signal 202 — park deadlines by loopId, written at dispatch (the
|
|
176
69
|
// marker's seconds; -1 = indefinite), consumed by the daemon's drain park-exit to schedule
|
|
177
70
|
// the deadline wake. In-memory: a daemon restart drops pending deadlines (documented).
|
|
178
71
|
parkDeadlines = new Map();
|
|
@@ -184,13 +77,11 @@ export default class Engine {
|
|
|
184
77
|
#notices;
|
|
185
78
|
#problems;
|
|
186
79
|
#strikes;
|
|
187
|
-
// {§grinder-hard-413-recovery} - loops granted their one over-ceiling recovery turn. Cleared on a
|
|
188
|
-
// fitting turn so a later independent overflow can earn a fresh recovery, and at loop cleanup.
|
|
189
|
-
#hardOverflowRecovery = new Set();
|
|
190
80
|
#packets;
|
|
191
81
|
searchGate = new SearchGate();
|
|
192
82
|
#proposals;
|
|
193
83
|
#dispatcher;
|
|
84
|
+
#turnRunner;
|
|
194
85
|
#liveSubscriptions = new LiveSubscriptions();
|
|
195
86
|
// Per-loop AbortController for cancellation propagation into scheme
|
|
196
87
|
// ctx.signal. runLoop creates one at entry, cleans up at end. Engine
|
|
@@ -329,33 +220,7 @@ export default class Engine {
|
|
|
329
220
|
#wakeWorkerNotify;
|
|
330
221
|
#acquireWorkspaceTurn;
|
|
331
222
|
#workspaceTurnCompleted;
|
|
332
|
-
|
|
333
|
-
#gbnfCache = new Map();
|
|
334
|
-
// {§rail-truth-engine-verdict} — the verify GAP (a configured grammar @plurnk/gbnf can't
|
|
335
|
-
// parse): warn once per message, never per turn; the turn records railsVerdict "unverifiable".
|
|
336
|
-
static #railGapWarned = new Set();
|
|
337
|
-
static #warnRailVerdictGapOnce(message) {
|
|
338
|
-
if (Engine.#railGapWarned.has(message))
|
|
339
|
-
return;
|
|
340
|
-
Engine.#railGapWarned.add(message);
|
|
341
|
-
process.stderr.write(`plurnk-engine: rail verdict unavailable — the configured grammar did not parse in @plurnk/gbnf (${message})\n`);
|
|
342
|
-
}
|
|
343
|
-
static #requireGrammarEvidence(response) {
|
|
344
|
-
const evidence = response.grammarEvidence;
|
|
345
|
-
if (evidence === undefined) {
|
|
346
|
-
throw new Error("provider contract violation: configured GBNF response omitted grammar evidence");
|
|
347
|
-
}
|
|
348
|
-
const input = [...evidence.input];
|
|
349
|
-
if (!Number.isInteger(evidence.contentStart)
|
|
350
|
-
|| evidence.contentStart < 0
|
|
351
|
-
|| evidence.contentStart > input.length
|
|
352
|
-
|| typeof evidence.transported !== "boolean"
|
|
353
|
-
|| input.slice(evidence.contentStart).join("") !== response.assistant.content) {
|
|
354
|
-
throw new Error("provider contract violation: grammar evidence does not map exactly to assistant.content");
|
|
355
|
-
}
|
|
356
|
-
return evidence;
|
|
357
|
-
}
|
|
358
|
-
constructor({ db, schemes, mimetypes, streamEventNotify, wakeWorkerNotify, injectWorker, branchWorker, branchCompletionGate, cancelWorker, cancelDescendants, acquireWorkspaceTurn, workspaceTurnCompleted, noticeNotify, tokenize }) {
|
|
223
|
+
constructor({ db, schemes, mimetypes, streamEventNotify, wakeWorkerNotify, injectWorker, branchWorker, branchCompletionGate, cancelWorker, cancelDescendants, acquireWorkspaceTurn, workspaceTurnCompleted, noticeNotify, weigh }) {
|
|
359
224
|
this.#db = db;
|
|
360
225
|
this.#lifecycle = new LoopLifecycle(db);
|
|
361
226
|
this.#schemes = schemes;
|
|
@@ -371,28 +236,28 @@ export default class Engine {
|
|
|
371
236
|
discovery: { registry: emptyRegistry(), handlers: new Map(), skipped: [] },
|
|
372
237
|
});
|
|
373
238
|
// {§tokenomics-agnostic-ruler} — standalone construction and the daemon
|
|
374
|
-
// use the same default; provider counting
|
|
375
|
-
|
|
239
|
+
// use the same default; provider request counting remains confined to
|
|
240
|
+
// provider-owned capacity assessment.
|
|
241
|
+
this.#weighContent = weigh ?? contentWeight;
|
|
376
242
|
const executors = () => this.#executors;
|
|
377
243
|
const loopSignal = (loopId) => this.#loopAborts.get(loopId)?.signal;
|
|
378
244
|
this.#notices = new NoticeChannel({ notify: noticeNotify });
|
|
379
|
-
this.#problems = new ProblemLog(db);
|
|
245
|
+
this.#problems = new ProblemLog(db, this.#weighContent);
|
|
380
246
|
this.#strikes = new StrikeRail();
|
|
381
247
|
this.#packets = new PacketBuilder({
|
|
382
248
|
db,
|
|
383
249
|
schemes,
|
|
384
|
-
problems: this.#problems,
|
|
385
250
|
executors,
|
|
386
251
|
});
|
|
387
252
|
this.#proposals = new ProposalLifecycle({
|
|
388
253
|
db, schemes, notices: this.#notices,
|
|
389
254
|
streamEventNotify, wakeWorkerNotify,
|
|
390
|
-
|
|
255
|
+
weigh: this.#weighContent, mimetypes: this.#mimetypes, executors, loopSignal,
|
|
391
256
|
liveSubscriptions: this.#liveSubscriptions,
|
|
392
257
|
});
|
|
393
258
|
this.#dispatcher = new Dispatcher({ searchGate: this.searchGate,
|
|
394
259
|
db, schemes, mimetypes: this.#mimetypes,
|
|
395
|
-
|
|
260
|
+
weigh: this.#weighContent,
|
|
396
261
|
notices: this.#notices, proposals: this.#proposals,
|
|
397
262
|
executors, loopSignal,
|
|
398
263
|
streamEventNotify, wakeWorkerNotify, injectWorker, branchWorker, branchCompletionGate, cancelWorker, cancelDescendants,
|
|
@@ -400,11 +265,30 @@ export default class Engine {
|
|
|
400
265
|
joinTargets: this.joinTargets,
|
|
401
266
|
liveSubscriptions: this.#liveSubscriptions,
|
|
402
267
|
});
|
|
268
|
+
this.#turnRunner = new TurnRunner({
|
|
269
|
+
db,
|
|
270
|
+
schemes,
|
|
271
|
+
mimetypes: this.#mimetypes,
|
|
272
|
+
weigh: this.#weighContent,
|
|
273
|
+
notices: this.#notices,
|
|
274
|
+
problems: this.#problems,
|
|
275
|
+
strikes: this.#strikes,
|
|
276
|
+
packets: this.#packets,
|
|
277
|
+
dispatcher: this.#dispatcher,
|
|
278
|
+
liveSubscriptions: this.#liveSubscriptions,
|
|
279
|
+
streamEventNotify,
|
|
280
|
+
wakeWorkerNotify,
|
|
281
|
+
executors,
|
|
282
|
+
loopSignal,
|
|
283
|
+
warmWorkspace: (context, invalidate, materialize) => this.#queueWorkspaceWarm(context, invalidate, materialize),
|
|
284
|
+
dispatch: (context) => this.dispatch(context),
|
|
285
|
+
resolveWorkerProviderIdentity: (workerId) => this.resolveWorkerProviderIdentity(workerId),
|
|
286
|
+
});
|
|
403
287
|
schemes.bindCore({
|
|
404
288
|
db,
|
|
405
289
|
mimetypes: this.#mimetypes,
|
|
406
290
|
executors,
|
|
407
|
-
|
|
291
|
+
weigh: this.#weighContent,
|
|
408
292
|
streamEventNotify,
|
|
409
293
|
wakeWorkerNotify,
|
|
410
294
|
injectWorker,
|
|
@@ -426,38 +310,16 @@ export default class Engine {
|
|
|
426
310
|
if (this.#executors === undefined)
|
|
427
311
|
throw new Error("registerRuntime: executor registry not wired yet");
|
|
428
312
|
RuntimeTag.assert(tag, "module runtime");
|
|
313
|
+
const normalized = {
|
|
314
|
+
...entry,
|
|
315
|
+
invocation: RuntimeInvocation.assert(entry.invocation, entry.namespaceOwner.name, tag),
|
|
316
|
+
};
|
|
429
317
|
// Preflight both owners before either write; synchronous registration
|
|
430
318
|
// then cannot leave a half-claimed namespace. {§plugin-namespace-arbitration}
|
|
431
|
-
this.#executors.assertCanRegister(tag,
|
|
432
|
-
this.#schemes.assertRuntimeClaim(tag,
|
|
433
|
-
this.#schemes.registerRuntimeScheme(tag,
|
|
434
|
-
this.#executors.register(tag,
|
|
435
|
-
}
|
|
436
|
-
// Supply an explicitly configured local constraint; ANTLR remains the
|
|
437
|
-
// language authority. {§grammar-enforcement-verified-at-boot}
|
|
438
|
-
async #grammarConstraint(provider) {
|
|
439
|
-
// Resolve through the registered or active alias; ambiguity and load
|
|
440
|
-
// failures never degrade to unconstrained generation.
|
|
441
|
-
// {§grammar-enforcement-verified-at-boot}
|
|
442
|
-
const registered = ProviderInstantiate.aliasOf(provider);
|
|
443
|
-
const fallback = registered === undefined ? resolveActiveAlias(process.env)?.alias : undefined;
|
|
444
|
-
if (registered === undefined && fallback === undefined && Object.keys(process.env).some((k) => k.startsWith("PLURNK_PROVIDERS_GBNF_"))) {
|
|
445
|
-
throw new Error("GBNF constraint: provider has no registered alias and no active alias resolves, while per-alias PLURNK_PROVIDERS_GBNF_* constraints are configured");
|
|
446
|
-
}
|
|
447
|
-
const alias = registered ?? fallback ?? "";
|
|
448
|
-
const variant = scopeEnvToAlias(process.env, alias, ["PLURNK_PROVIDERS_GBNF"]).PLURNK_PROVIDERS_GBNF;
|
|
449
|
-
if (variant === undefined || variant === "" || variant === "0")
|
|
450
|
-
return undefined;
|
|
451
|
-
const hit = this.#gbnfCache.get(variant);
|
|
452
|
-
if (hit !== undefined)
|
|
453
|
-
return hit;
|
|
454
|
-
const path = variant.startsWith("/") || variant.startsWith(".")
|
|
455
|
-
? variant
|
|
456
|
-
: fileURLToPath(import.meta.resolve(`@plurnk/plurnk-contracts/${variant}`));
|
|
457
|
-
const text = await readFile(path, "utf8"); // unresolvable/unreadable throws — a configured rail never silently degrades
|
|
458
|
-
this.#gbnfCache.set(variant, text);
|
|
459
|
-
process.stderr.write(`plurnk-engine: GBNF constraint: ${alias || "(bare)"} → ${variant} (${text.length} chars)\n`);
|
|
460
|
-
return text;
|
|
319
|
+
this.#executors.assertCanRegister(tag, normalized.namespaceOwner);
|
|
320
|
+
this.#schemes.assertRuntimeClaim(tag, normalized.namespaceOwner);
|
|
321
|
+
this.#schemes.registerRuntimeScheme(tag, normalized.executor, normalized.namespaceOwner, scheme);
|
|
322
|
+
this.#executors.register(tag, normalized);
|
|
461
323
|
}
|
|
462
324
|
// A lineage's no-parent root; a root worker resolves to itself. Fail hard
|
|
463
325
|
// when corruption leaves a worker without one. {§worker-primary}
|
|
@@ -467,12 +329,18 @@ export default class Engine {
|
|
|
467
329
|
throw new Error(`resolveWorkerPrimary: worker ${workerId} has no lineage root — corrupt parent chain`);
|
|
468
330
|
return root.id;
|
|
469
331
|
}
|
|
470
|
-
|
|
471
|
-
|
|
332
|
+
async resolveWorkerProviderIdentity(workerId) {
|
|
333
|
+
const identity = await this.#db.engine_worker_provider_identity.get({ worker_id: workerId });
|
|
334
|
+
if (identity === undefined) {
|
|
335
|
+
throw new Error(`resolveWorkerProviderIdentity: worker ${workerId} has no lineage root — corrupt parent chain`);
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
workerId: identity.worker_id,
|
|
339
|
+
primaryWorkerId: identity.primary_worker_id,
|
|
340
|
+
};
|
|
472
341
|
}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
return [...tags];
|
|
342
|
+
curationBudgetFor(provider) {
|
|
343
|
+
return this.#packets.curationBudgetFor(provider);
|
|
476
344
|
}
|
|
477
345
|
// {§attribution} — reporting derives from exact provider-request evidence;
|
|
478
346
|
// malformed durable tags fail here instead of being silently filtered.
|
|
@@ -486,52 +354,29 @@ export default class Engine {
|
|
|
486
354
|
});
|
|
487
355
|
return [...Meta.composeAttributions(tags)];
|
|
488
356
|
}
|
|
489
|
-
//
|
|
490
|
-
// occupancy gauge. {§tokenomics-client-gauge},
|
|
357
|
+
// The request ledger is cardinal evidence; its aggregate and the latest-turn
|
|
358
|
+
// occupancy gauge are derived projections. {§tokenomics-client-gauge},
|
|
359
|
+
// {§notifications-loop-terminated}, {§provider-accounting}
|
|
491
360
|
async loopUsage(loopId) {
|
|
492
361
|
const row = await this.#db.engine_loop_usage.get({ loop_id: loopId });
|
|
493
362
|
if (row === undefined)
|
|
494
363
|
throw new Error(`loopUsage: loop ${loopId} does not exist`);
|
|
495
|
-
const
|
|
496
|
-
if (!Array.isArray(parsedCosts))
|
|
497
|
-
throw new TypeError(`loop ${loopId} monetary evidence is not an array`);
|
|
498
|
-
const costs = parsedCosts.map((cost) => validateProviderCost(cost));
|
|
364
|
+
const requests = await this.#db.engine_loop_provider_requests.all({ loop_id: loopId });
|
|
499
365
|
return {
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
promptBudget: row?.context_size ?? null,
|
|
366
|
+
accounting: aggregateProviderAccounting(requests.map(providerRequestFromStorageRow)),
|
|
367
|
+
// Latest assembled request's stable model-independent weight.
|
|
368
|
+
curationWeight: row.curation_weight,
|
|
369
|
+
// Latest provider-derived calibration used only for curation.
|
|
370
|
+
curationBudget: row.curation_budget,
|
|
371
|
+
// Latest emission call's last physical request, not the billed total.
|
|
372
|
+
contextTokens: row.context_tokens,
|
|
373
|
+
// Request-shaped physical capacity from that same latest emission call.
|
|
374
|
+
contextCapacity: row.context_capacity,
|
|
510
375
|
// Latest turn's opaque provider metadata. {§meta-passthrough}
|
|
511
|
-
meta: JSON.parse(row
|
|
376
|
+
meta: JSON.parse(row.meta ?? "{}"),
|
|
512
377
|
};
|
|
513
378
|
}
|
|
514
|
-
|
|
515
|
-
// the snippet/notices surface speaks the parser-point convention. {§parser-position}
|
|
516
|
-
// Convert over code points (not UTF-16 units) so an astral char doesn't skew the line,
|
|
517
|
-
// clamping out-of-range offsets to the content's end.
|
|
518
|
-
#offsetToLineColumn(content, offset) {
|
|
519
|
-
const cps = Array.from(content);
|
|
520
|
-
const clamped = Math.max(0, Math.min(offset, cps.length));
|
|
521
|
-
let line = 1;
|
|
522
|
-
let column = 0;
|
|
523
|
-
for (let i = 0; i < clamped; i++) {
|
|
524
|
-
if (cps[i] === "\n") {
|
|
525
|
-
line++;
|
|
526
|
-
column = 0;
|
|
527
|
-
}
|
|
528
|
-
else {
|
|
529
|
-
column++;
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
return { line, column };
|
|
533
|
-
}
|
|
534
|
-
async runLoop({ provider, messages, requirements = "", workspaceId, workerId, 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, }) {
|
|
379
|
+
async runLoop({ provider, childProvider = provider, messages, requirements = "", workspaceId, workerId, 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, }) {
|
|
535
380
|
// A 202 park suspends this durable loop and a later wake re-enters runLoop.
|
|
536
381
|
// Its ceiling therefore counts every prior turn, not merely this process-local
|
|
537
382
|
// execution segment.
|
|
@@ -567,11 +412,11 @@ export default class Engine {
|
|
|
567
412
|
return { turnIds, result, hitMaxTurns: false, reason: "loop_timeout" };
|
|
568
413
|
};
|
|
569
414
|
// Cleanup splits by termination kind:
|
|
570
|
-
// - "graceful" (SEND
|
|
415
|
+
// - "graceful" (SEND signal 202 Accepted): in-flight streaming-scheme spawns
|
|
571
416
|
// are ALLOWED to outlive the loop — they complete naturally, write final
|
|
572
417
|
// channel state, and wake-on-completion (E.4) opens a fresh loop. 202 is
|
|
573
418
|
// the only terminal that means "keep my async work."
|
|
574
|
-
// - "forceful" (SEND
|
|
419
|
+
// - "forceful" (SEND signal 200 done, max_turns, strike, cancel, context-envelope rejection, 4xx/5xx):
|
|
575
420
|
// fire the loop-level abort so leftover spawns tear down. "Done" reaps.
|
|
576
421
|
const cleanup = (kind, reason) => {
|
|
577
422
|
clearTimeout(wall);
|
|
@@ -581,7 +426,6 @@ export default class Engine {
|
|
|
581
426
|
this.#loopAborts.delete(loopId);
|
|
582
427
|
this.#strikes.delete(loopId);
|
|
583
428
|
this.searchGate.cleanup(loopId);
|
|
584
|
-
this.#hardOverflowRecovery.delete(loopId);
|
|
585
429
|
this.#notices.delete(loopId);
|
|
586
430
|
};
|
|
587
431
|
while (true) {
|
|
@@ -649,7 +493,7 @@ export default class Engine {
|
|
|
649
493
|
turn = await observed(// {§observability-boundary}
|
|
650
494
|
"loop.turn", { workerId, "loop.id": loopId }, async (span) => {
|
|
651
495
|
const t = await this.runTurn({
|
|
652
|
-
provider, messages, requirements, workspaceId, workerId, loopId, origin, signal, onDispatch,
|
|
496
|
+
provider, childProvider, messages, requirements, workspaceId, workerId, loopId, origin, signal, onDispatch,
|
|
653
497
|
turnNumber: turnIds.length + 1, maxTurns,
|
|
654
498
|
invalidEmissionRecoveryEntryId,
|
|
655
499
|
});
|
|
@@ -698,25 +542,23 @@ export default class Engine {
|
|
|
698
542
|
return { turnIds, result, hitMaxTurns: false, reason: "invalid_emission" };
|
|
699
543
|
}
|
|
700
544
|
invalidEmissionRecoveryEntryId = null;
|
|
701
|
-
// SPEC {§grinder}:
|
|
702
|
-
if (turn.
|
|
703
|
-
if (turn.
|
|
704
|
-
throw new Error("a
|
|
545
|
+
// SPEC {§grinder}: the provider rejected the changed request for capacity.
|
|
546
|
+
if (turn.capacityHardStop) {
|
|
547
|
+
if (turn.capacityFailure === undefined) {
|
|
548
|
+
throw new Error("a provider-capacity stop requires its exact failure");
|
|
705
549
|
}
|
|
706
|
-
const
|
|
707
|
-
const result = await this.#lifecycle.finish(loopId, failure);
|
|
550
|
+
const result = await this.#lifecycle.finish(loopId, turn.capacityFailure);
|
|
708
551
|
if (result === null)
|
|
709
|
-
throw new Error(`loop ${loopId} became terminal before
|
|
710
|
-
cleanup("forceful", "
|
|
711
|
-
return { turnIds, result, hitMaxTurns: false, reason: "
|
|
552
|
+
throw new Error(`loop ${loopId} became terminal before provider-capacity settlement`);
|
|
553
|
+
cleanup("forceful", "provider_capacity");
|
|
554
|
+
return { turnIds, result, hitMaxTurns: false, reason: "provider_capacity" };
|
|
712
555
|
}
|
|
713
|
-
// {§engine-rails} — per-turn strike accounting (cycle detection,
|
|
714
|
-
//
|
|
556
|
+
// {§engine-rails} — per-turn strike accounting (cycle detection,
|
|
557
|
+
// steer coupling, hard operation outcomes). StrikeRail owns the
|
|
715
558
|
// bookkeeping; runLoop owns abandonment.
|
|
716
559
|
const verdict = this.#strikes.assess(loopId, {
|
|
717
560
|
fingerprint: turn.fingerprint,
|
|
718
561
|
outcomes: turn.outcomes,
|
|
719
|
-
budgetStruck: turn.budgetStruck,
|
|
720
562
|
steerStruck: turn.steerStruck,
|
|
721
563
|
minCycles, maxCyclePeriod, maxStrikes,
|
|
722
564
|
});
|
|
@@ -739,1148 +581,15 @@ export default class Engine {
|
|
|
739
581
|
}
|
|
740
582
|
}
|
|
741
583
|
}
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
const transientOpenLogEntryId = typeof invalidEmissionRecoveryEntryId === "number"
|
|
745
|
-
? invalidEmissionRecoveryEntryId
|
|
746
|
-
: null;
|
|
747
|
-
// === Turn-as-container model ===
|
|
748
|
-
//
|
|
749
|
-
// Turn rows are created at runTurn OPEN (status=102, placeholder
|
|
750
|
-
// packet) so things can be written into the turn before the model
|
|
751
|
-
// is called: the user prompt on turn 1; later, system signals or
|
|
752
|
-
// injected Notices on any turn. The turn is CLOSED at
|
|
753
|
-
// the end with the final packet + status + usage stats.
|
|
754
|
-
//
|
|
755
|
-
// sequence is "ordinal of stuff in this turn." Pre-model
|
|
756
|
-
// writes consume low indices; model ops continue from there.
|
|
757
|
-
const seqRow = await this.#db.engine_next_turn_sequence.get({ loop_id: loopId });
|
|
758
|
-
const seq = seqRow.next;
|
|
759
|
-
// loops.sequence is the loop's ordinal within the worker. Turn-0 foists that belong to the
|
|
760
|
-
// WORKER (manifest preview, AGENTS, operator docs) gate on the worker's first loop, not every loop's
|
|
761
|
-
// first turn ({§actor-boundary-catalog-preview}); per-loop foists such as
|
|
762
|
-
// {§prompt-entry} still fire each loop. Read once, turn-1 only.
|
|
763
|
-
const loopRow = seq === 1
|
|
764
|
-
? await this.#db.engine_get_loop_prompt.get({ loop_id: loopId })
|
|
765
|
-
: undefined;
|
|
766
|
-
const workerFirstLoop = (loopRow?.sequence ?? 0) === 1;
|
|
767
|
-
const openRow = await this.#db.engine_open_turn.get({
|
|
768
|
-
loop_id: loopId, sequence: seq,
|
|
769
|
-
});
|
|
770
|
-
if (openRow === undefined)
|
|
771
|
-
throw new Error("Engine.runTurn: turn open returned no row");
|
|
772
|
-
const turnId = openRow.id;
|
|
773
|
-
// {§env-delta-log-pull} — establish a fresh worker's observation
|
|
774
|
-
// baseline immediately after its first turn opens. A fork already has
|
|
775
|
-
// its parent's cursor, so the NULL-guarded statement leaves it intact.
|
|
776
|
-
// Events committed after this statement belong to this or a later
|
|
777
|
-
// closed pull window; pre-existing state is read through the ordinary
|
|
778
|
-
// shared-world projections in this first packet.
|
|
779
|
-
await this.#db.engine_initialize_ambient_cursor.get({ workspace_id: workspaceId, worker_id: workerId });
|
|
780
|
-
// Threaded per turn, never engine state, so concurrent loops on
|
|
781
|
-
// different providers each read their own honest tokenizer values.
|
|
782
|
-
const systemCtx = {
|
|
783
|
-
db: this.#db, workspaceId, workerId, loopId, turnId,
|
|
784
|
-
writer: "plurnk",
|
|
785
|
-
signal: this.#loopAborts.get(loopId)?.signal,
|
|
786
|
-
streamEventNotify: this.#streamEventNotify,
|
|
787
|
-
wakeWorkerNotify: this.#wakeWorkerNotify,
|
|
788
|
-
tokenize: this.#tokenize,
|
|
789
|
-
mimetypes: this.#mimetypes,
|
|
790
|
-
defaultChannelFor: (s) => this.#schemes.defaultChannelFor(s),
|
|
791
|
-
pushNotice: (notice) => this.#notices.push(workspaceId, loopId, notice),
|
|
792
|
-
};
|
|
793
|
-
// Pre-model writes. Each prompt the model has not seen yet becomes an
|
|
794
|
-
// actionless `prompt` log row whose target is its durable prompt:// entry:
|
|
795
|
-
// - Turn 1: loop.prompt is the initial user prompt.
|
|
796
|
-
// - Turn N>1: only if Engine.inject (or wake-on-completion via
|
|
797
|
-
// daemon.inject) wrote a prompt entry for this turn slot
|
|
798
|
-
// between turn N-1 and N. Inject writes directly to entries;
|
|
799
|
-
// we DON'T re-foist here for N>1.
|
|
800
|
-
// Model ops dispatch after these pre-model rows.
|
|
801
|
-
let nextActionIndex = 1;
|
|
802
|
-
const turnOpenPaths = [];
|
|
803
|
-
// {§model-entry} — the worker's first turn opens with the model's own turn-0, mirrored OPEN: a
|
|
804
|
-
// worked turn PLAN → the environment FINDs the foist ACTUALLY dispatches → SEND[102]. Built
|
|
805
|
-
// from the real ops below (not a static print — we lean into the genuine echo paradigm) and
|
|
806
|
-
// written at sequence 1, so it reads first as the emission with the foisted results following.
|
|
807
|
-
const turnZeroMoves = [];
|
|
808
|
-
if (seq === 1) {
|
|
809
|
-
if (workerFirstLoop)
|
|
810
|
-
nextActionIndex = 2; // reserve sequence 1 for the turn-0 echo
|
|
811
|
-
// Operator doc READs (PLURNK_SERVICE_MD_<ALIAS>, {§actor-boundary-doc-injection}). The docs were materialized
|
|
812
|
-
// as worker://plurnk/<entry> entries by the plurnk worker (LoopDocs, via the
|
|
813
|
-
// {§actor-boundary} keystone); foist a READ of each into THIS turn-0 so the model
|
|
814
|
-
// reads them inline. It sees only the READ — the materializing EDIT
|
|
815
|
-
// lives in the plurnk worker's log, never the model's.
|
|
816
|
-
// {§operator-config-workspace-md-docs} — env docs union the workspace's client docs; foist a READ of
|
|
817
|
-
// each materialized worker://plurnk/<alias>.md (LoopDocs materialized the same set).
|
|
818
|
-
const { mdDocs } = await WorkspaceSettings.read(this.#db, workspaceId);
|
|
819
|
-
// {§actor-boundary-doc-injection} — operator docs appear on the worker's first loop.
|
|
820
|
-
for (const doc of workerFirstLoop ? await WorkspaceSettings.resolveDocs(mdDocs) : []) {
|
|
821
|
-
const docTarget = {
|
|
822
|
-
kind: "url", raw: `worker://plurnk/${doc.entryName}`, scheme: "worker",
|
|
823
|
-
username: null, password: null, hostname: "plurnk", port: null,
|
|
824
|
-
pathname: `/${doc.entryName}`, query: null, fragment: null,
|
|
825
|
-
};
|
|
826
|
-
const docRead = {
|
|
827
|
-
op: "READ", suffix: "", signal: null, target: docTarget,
|
|
828
|
-
lineMarker: null, body: null, position: UNKNOWN_POSITION,
|
|
829
|
-
};
|
|
830
|
-
await this.dispatch({
|
|
831
|
-
statement: docRead, workspaceId, workerId, loopId, turnId,
|
|
832
|
-
sequence: nextActionIndex, origin: "plurnk", onDispatch,
|
|
833
|
-
});
|
|
834
|
-
nextActionIndex++;
|
|
835
|
-
}
|
|
836
|
-
const promptRow = loopRow; // {§prompt-entry} — per-loop; fires every loop's turn 1
|
|
837
|
-
if (promptRow !== undefined && typeof promptRow.prompt === "string" && promptRow.prompt.length > 0) {
|
|
838
|
-
const openPaths = assertOpenPaths(JSON.parse(promptRow.open_paths), `Loop ${loopId} open_paths`);
|
|
839
|
-
const promptLoopSeq = promptRow.sequence; // the loop's per-worker sequence — model-facing, matching log coordinates (owner: the db id read as prompt/2/1)
|
|
840
|
-
const promptPath = promptTarget(promptLoopSeq, seq);
|
|
841
|
-
const entry = {
|
|
842
|
-
channels: { body: { content: promptRow.prompt, mimetype: "text/markdown" } },
|
|
843
|
-
tags: [],
|
|
844
|
-
attributes: { openPaths },
|
|
845
|
-
};
|
|
846
|
-
await EntryCrud.writeEntry(promptPath.pathname, entry, systemCtx, "prompt", workerId);
|
|
847
|
-
turnOpenPaths.push(...openPaths);
|
|
848
|
-
const promptLogId = await this.#writePromptLog({
|
|
849
|
-
workerId,
|
|
850
|
-
loopId,
|
|
851
|
-
turnId,
|
|
852
|
-
sequence: nextActionIndex,
|
|
853
|
-
target: promptPath,
|
|
854
|
-
content: promptRow.prompt,
|
|
855
|
-
});
|
|
856
|
-
onDispatch?.(promptLogId);
|
|
857
|
-
nextActionIndex++;
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
// {§prompt-loop-containment}: the loop contains every prompt that arrived
|
|
861
|
-
// while it ran. Publish each undelivered frame as a prompt row, oldest
|
|
862
|
-
// first, so rapid arrivals reach the model together exactly once.
|
|
863
|
-
{
|
|
864
|
-
const loopSeqRow = await this.#db.engine_loop_sequence.get({ loop_id: loopId });
|
|
865
|
-
const loopSeq = loopSeqRow?.sequence ?? loopId;
|
|
866
|
-
const prefix = promptLoopPrefix(loopSeq);
|
|
867
|
-
const undelivered = (await this.#db.drain_undelivered_prompts_for_loop.all({
|
|
868
|
-
owner_id: workerId,
|
|
869
|
-
pattern: `${prefix}%`,
|
|
870
|
-
prefix_len: prefix.length,
|
|
871
|
-
loop_id: loopId,
|
|
872
|
-
}))
|
|
873
|
-
.filter((r) => typeof r.content === "string" && r.content.length > 0);
|
|
874
|
-
for (const injectedRow of undelivered) {
|
|
875
|
-
const attributes = parsePromptAttributes(injectedRow.attributes, `Prompt ${injectedRow.pathname} attributes`);
|
|
876
|
-
const encodedOpenPaths = attributes.openPaths;
|
|
877
|
-
if (encodedOpenPaths !== undefined) {
|
|
878
|
-
turnOpenPaths.push(...assertOpenPaths(encodedOpenPaths, `Prompt ${injectedRow.pathname} openPaths`));
|
|
879
|
-
}
|
|
880
|
-
const ordinal = Number(injectedRow.pathname.split("/").filter(Boolean).at(-1));
|
|
881
|
-
const injTarget = promptTarget(loopSeq, ordinal);
|
|
882
|
-
const promptLogId = await this.#writePromptLog({
|
|
883
|
-
workerId,
|
|
884
|
-
loopId,
|
|
885
|
-
turnId,
|
|
886
|
-
sequence: nextActionIndex,
|
|
887
|
-
target: injTarget,
|
|
888
|
-
content: injectedRow.content,
|
|
889
|
-
});
|
|
890
|
-
onDispatch?.(promptLogId);
|
|
891
|
-
nextActionIndex++;
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
// The persistent search-index pass (_search-index.maintain) attaches
|
|
895
|
-
// every readable entry/log projection to complete graph/FTS/vector derivations.
|
|
896
|
-
// NOT an action: no log entry, no sequence slot,
|
|
897
|
-
// not dispatched. There is no materialized manifest entry — the catalog
|
|
898
|
-
// is served on demand by FIND: recursive when asked, shallow-mapped below.
|
|
899
|
-
// {§semantic-embed-dedup} — one pass-wide semantic plan binds every
|
|
900
|
-
// chunk counter to the derivation identity it produces.
|
|
901
|
-
// SPEC {§membership} D4/D5 — git-ls-files workspace membership, resolved at
|
|
902
|
-
// prompt-composition (EMI is eager + exhaustive — git is the only bound). When the
|
|
903
|
-
// workspace's project_root is a git working tree, tracked files are
|
|
904
|
-
// members without a client `add`; active members are materialized
|
|
905
|
-
// (disk → body channel) so they appear in the catalog. No-ops
|
|
906
|
-
// on headless / non-git workspaces. Runs BEFORE the derivation pump so
|
|
907
|
-
// this turn's packet reflects them.
|
|
908
|
-
// Workspace creation starts this eagerly. Joining here is the correctness
|
|
909
|
-
// boundary: the model never runs against partial graph/vector coverage.
|
|
910
|
-
await this.#queueWorkspaceWarm(systemCtx, false);
|
|
911
|
-
// The warm materialized membership before deriving. This second pass is the
|
|
912
|
-
// ordinary cheap change detector and captures any drift that landed meanwhile.
|
|
913
|
-
const fsDivergences = await GitMembership.indexGitMembership(systemCtx);
|
|
914
|
-
// {§packet-git-status} — one post-reconciliation snapshot supplies both
|
|
915
|
-
// the compact packet summary and each causal file event's exact XY state.
|
|
916
|
-
const gitStatus = await GitState.status(this.#db, workspaceId, this.#loopAborts.get(loopId)?.signal);
|
|
917
|
-
await this.#logFsFictions(workspaceId, fsDivergences, gitStatus);
|
|
918
|
-
// The refresh above may have changed bodies (including model/client edits
|
|
919
|
-
// since the startup warm). Re-derive to completion before packet/model
|
|
920
|
-
// construction. Membership is already current, so this pass does not
|
|
921
|
-
// consume the filesystem divergences a second time.
|
|
922
|
-
await this.#queueWorkspaceWarm(systemCtx, true, false);
|
|
923
|
-
// Turn-0 catalog preview (PLURNK_SERVICE_FILES_ITEMS, {§actor-boundary-catalog-preview}):
|
|
924
|
-
// FIND surveys foisted into the worker's first model turn so it opens with its catalog.
|
|
925
|
-
// Folder-capable surfaces reveal one level with `*`; each deeper directory is an
|
|
926
|
-
// actionable `dir/**` aggregate. The curated kernel docs remain recursive, so the
|
|
927
|
-
// opening packet demonstrates both navigation forms. Empty results are orientation.
|
|
928
|
-
if (seq === 1) {
|
|
929
|
-
// {§operator-config-workspace-files-items} — workspace filesItems replaces the env default.
|
|
930
|
-
const { filesItems: workspaceMI } = await WorkspaceSettings.read(this.#db, workspaceId);
|
|
931
|
-
const filesItems = workspaceMI !== null ? normalizeFilesItems(workspaceMI) : readFilesItems();
|
|
932
|
-
if (filesItems !== null && workerFirstLoop) { // {§actor-boundary-catalog-preview} — once per worker
|
|
933
|
-
// engine_scheme_catalog_summary is the workspace-bounded scheme source: ordered,
|
|
934
|
-
// one row per stored entry scheme. log:// is absent —
|
|
935
|
-
// it lives in log_entries, not the catalog (present-mode, the # Log section).
|
|
936
|
-
const catalogSchemes = await this.#db.engine_scheme_catalog_summary.all({ workspace_id: workspaceId });
|
|
937
|
-
// Entry-bearing plugin schemes foist alongside the four structural surveys below.
|
|
938
|
-
const foistSchemes = catalogSchemes
|
|
939
|
-
.filter((catalog) => catalog.scheme !== "prompt" && catalog.scheme !== "worker")
|
|
940
|
-
.map(({ scheme, shallow_items }) => ({ scheme, shallow_items }));
|
|
941
|
-
// Commons + project files always foist. An empty result establishes that the
|
|
942
|
-
// surface exists and currently contains nothing.
|
|
943
|
-
foistSchemes.push({
|
|
944
|
-
scheme: "worker",
|
|
945
|
-
shallow_items: catalogSchemes.find((c) => c.scheme === "worker")?.shallow_items ?? 0,
|
|
946
|
-
});
|
|
947
|
-
if (!foistSchemes.some((c) => c.scheme === "file"))
|
|
948
|
-
foistSchemes.push({ scheme: "file", shallow_items: 0 }); // Empty project surface still receives its orienting FIND.
|
|
949
|
-
for (const { scheme, shallow_items: shallowItems } of foistSchemes) {
|
|
950
|
-
const isFile = scheme === "file";
|
|
951
|
-
const pattern = this.#schemes.manifestFor(scheme)?.folderScopes === true ? "*" : "**";
|
|
952
|
-
// Only the file map takes PLURNK_SERVICE_FILES_ITEMS as an explicit first-N
|
|
953
|
-
// cap. Every other ordinary survey uses FIND's markerless first page.
|
|
954
|
-
const cap = isFile && filesItems > 0 && shallowItems > 0 ? Math.min(filesItems, shallowItems) : null;
|
|
955
|
-
const catalogMarker = cap === null ? null : { marks: [1, cap] };
|
|
956
|
-
// The file survey foists as the BARE relative glob — the path shape plurnk.md
|
|
957
|
-
// teaches (`*`, `src/**`, `**/notes.md`; bare = project-relative) — so the turn-0
|
|
958
|
-
// exemplar and the log rows the model reads never train a leading-slash or
|
|
959
|
-
// file:/// habit the rest of the teaching contradicts.
|
|
960
|
-
const catalogFind = {
|
|
961
|
-
op: "FIND", suffix: "", signal: null,
|
|
962
|
-
target: isFile ? { kind: "local", raw: pattern } : {
|
|
963
|
-
kind: "url",
|
|
964
|
-
raw: `${scheme}:///${pattern}`,
|
|
965
|
-
scheme,
|
|
966
|
-
username: null, password: null, hostname: null, port: null,
|
|
967
|
-
pathname: `/${pattern}`,
|
|
968
|
-
query: null, fragment: null,
|
|
969
|
-
},
|
|
970
|
-
body: null,
|
|
971
|
-
lineMarker: catalogMarker,
|
|
972
|
-
position: UNKNOWN_POSITION,
|
|
973
|
-
};
|
|
974
|
-
await this.dispatch({
|
|
975
|
-
statement: catalogFind, workspaceId, workerId, loopId, turnId,
|
|
976
|
-
sequence: nextActionIndex, origin: "plurnk", onDispatch,
|
|
977
|
-
});
|
|
978
|
-
nextActionIndex++;
|
|
979
|
-
// {§model-entry} — the same FIND, rendered back to DSL for the turn-0 echo
|
|
980
|
-
// (the model's own survey, mirrored OPEN). An explicit file cap rides as
|
|
981
|
-
// `<1,N>`; ordinary surveys teach the safe markerless default by example.
|
|
982
|
-
const target = isFile ? pattern : `${scheme}:///${pattern}`;
|
|
983
|
-
turnZeroMoves.push(`<<FIND(${target})${cap === null ? "" : `<1,${cap}>`}::FIND`);
|
|
984
|
-
}
|
|
985
|
-
// The kernel's self-documenting surface — FIND(worker://plurnk/docs/**), uncapped,
|
|
986
|
-
// always ({§schemes-directory}, published under {§entry-owner}).
|
|
987
|
-
await Owner.kernelId(this.#db, workspaceId); // the row exists even before docs materialize — the empty survey is orienting, never 404
|
|
988
|
-
const kernelDocsFind = {
|
|
989
|
-
op: "FIND", suffix: "", signal: null,
|
|
990
|
-
target: { kind: "url", raw: "worker://plurnk/docs/**", scheme: "worker", username: null, password: null, hostname: "plurnk", port: null, pathname: "/docs/**", query: null, fragment: null },
|
|
991
|
-
body: null, lineMarker: { marks: [1, -1] }, position: UNKNOWN_POSITION,
|
|
992
|
-
};
|
|
993
|
-
await this.dispatch({ statement: kernelDocsFind, workspaceId, workerId, loopId, turnId, sequence: nextActionIndex, origin: "plurnk", onDispatch });
|
|
994
|
-
nextActionIndex++;
|
|
995
|
-
turnZeroMoves.push("<<FIND(worker://plurnk/docs/**)<1,-1>::FIND");
|
|
996
|
-
// {§worker-scheme} — the building worker's own scratch gets the same markerless
|
|
997
|
-
// one-level survey in its perspective alone. It always executes: an empty private
|
|
998
|
-
// space is useful orientation, not grounds to hide the surface.
|
|
999
|
-
const ownFind = {
|
|
1000
|
-
op: "FIND", suffix: "", signal: null,
|
|
1001
|
-
target: { kind: "url", raw: "worker://~/*", scheme: "worker", username: null, password: null, hostname: "~", port: null, pathname: "/*", query: null, fragment: null },
|
|
1002
|
-
body: null, lineMarker: null, position: UNKNOWN_POSITION,
|
|
1003
|
-
};
|
|
1004
|
-
await this.dispatch({ statement: ownFind, workspaceId, workerId, loopId, turnId, sequence: nextActionIndex, origin: "plurnk", onDispatch });
|
|
1005
|
-
nextActionIndex++;
|
|
1006
|
-
turnZeroMoves.push("<<FIND(worker://~/*)::FIND"); // {§model-entry} — the own-space survey, into the turn-0 echo
|
|
1007
|
-
}
|
|
1008
|
-
// {§model-entry} — mirror the model's turn-0 OPEN at sequence 1: PLAN → the FINDs actually
|
|
1009
|
-
// foisted above (real, their results already in the log) → SEND[102]. Dynamic — it reflects
|
|
1010
|
-
// the true survey, never a frozen print — and OPEN: the worked example the model orients on,
|
|
1011
|
-
// so the grammar can stay thin. Subsequent turns mirror the model's real output, folded.
|
|
1012
|
-
if (workerFirstLoop) {
|
|
1013
|
-
const emission = ["<<PLAN:Survey context, then address the prompt.:PLAN", ...turnZeroMoves, "<<SEND[102]:Next, address the prompt using the survey.:SEND"].join("\n");
|
|
1014
|
-
await this.#dispatcher.writeModelEntry({ verbatim: emission, workerId, loopId, turnId, sequence: 1, folded: false, origin: "plurnk" });
|
|
1015
|
-
}
|
|
1016
|
-
}
|
|
1017
|
-
// {§methods-loop-run-open-paths}: selected workspace paths belong to
|
|
1018
|
-
// the prompt frame. Publish the frame, then dispatch ordinary core READs
|
|
1019
|
-
// in that same turn; missing/non-member paths retain their normal 4xx.
|
|
1020
|
-
for (const raw of turnOpenPaths) {
|
|
1021
|
-
const pathname = raw.startsWith("/") ? raw : `/${raw}`;
|
|
1022
|
-
const fileRead = {
|
|
1023
|
-
op: "READ", suffix: "", signal: null, lineMarker: null,
|
|
1024
|
-
target: {
|
|
1025
|
-
kind: "url", raw: `file://${pathname}`, scheme: "file",
|
|
1026
|
-
username: null, password: null, hostname: null, port: null,
|
|
1027
|
-
pathname, query: null, fragment: null,
|
|
1028
|
-
},
|
|
1029
|
-
body: null, position: UNKNOWN_POSITION,
|
|
1030
|
-
};
|
|
1031
|
-
await this.dispatch({
|
|
1032
|
-
statement: fileRead, workspaceId, workerId, loopId, turnId,
|
|
1033
|
-
sequence: nextActionIndex, origin: "plurnk", onDispatch,
|
|
1034
|
-
});
|
|
1035
|
-
nextActionIndex++;
|
|
1036
|
-
}
|
|
1037
|
-
// {§env-delta-log-pull} — materialize ambient observations before packet
|
|
1038
|
-
// composition and reserve their action indices. {§exec-stream} owns the
|
|
1039
|
-
// distinct byte-cursor path for this worker's streams.
|
|
1040
|
-
// {§exec-poll} — EXEC `<0>` is turn-scoped: reap the worker's open turn-scoped streams (necessarily
|
|
1041
|
-
// from a prior turn — this runs before the turn's own spawns) so a `<0>` never survives into
|
|
1042
|
-
// the subsequent turn. The terminal output then surfaces born-OPEN via the stream-delta path.
|
|
1043
|
-
await this.#reapTurnScopedStreams(workerId);
|
|
1044
|
-
nextActionIndex += await this.#materializeEnvironmentDeltas({ workspaceId, workerId, loopId, turnId, fromSequence: nextActionIndex });
|
|
1045
|
-
nextActionIndex += await this.#materializeStreamDeltas({ workerId, loopId, turnId, fromSequence: nextActionIndex });
|
|
1046
|
-
// The post-reconciliation Git snapshot above is threaded into the packet
|
|
1047
|
-
// and every budget rebuild; overflow never shells again.
|
|
1048
|
-
// Notices are non-terminal observations, never operation-failure truth.
|
|
1049
|
-
// Drain once and thread the same set through every grinder rebuild.
|
|
1050
|
-
const notices = this.#notices.drain(loopId)
|
|
1051
|
-
.filter((event) => event.level !== "info");
|
|
1052
|
-
// Build the model request packet ({§packet-stored-shape}). The log build
|
|
1053
|
-
// queries log_entries scoped to the worker — the prompt entry just
|
|
1054
|
-
// written (if turn 1) is part of that query result.
|
|
1055
|
-
let requestPacket = await this.#packets.buildRequestPacket({
|
|
1056
|
-
initialMessages: messages, requirements, workspaceId, workerId, loopId,
|
|
1057
|
-
currentTurnSeq: seq, provider, gitStatus, notices, transientOpenLogEntryId,
|
|
1058
|
-
});
|
|
1059
|
-
// SPEC {§grinder} — budget grinder, pre-LLM: reclaim window on actual overflow.
|
|
1060
|
-
const enforced = await this.#packets.enforceBudget({
|
|
1061
|
-
packet: requestPacket, provider, workerId, loopId, turnId,
|
|
1062
|
-
// The overflow error row is minted at the turn's running sequence (nextActionIndex), pre-generate;
|
|
1063
|
-
// runTurn advances the counter past it below so the post-generate dispatch rows never collide.
|
|
1064
|
-
mintSequence: nextActionIndex,
|
|
1065
|
-
// Rebuilds re-derive durable errors and retain the one drained notice
|
|
1066
|
-
// set; neither path can duplicate or swallow a product failure.
|
|
1067
|
-
rebuild: () => this.#packets.buildRequestPacket({
|
|
1068
|
-
initialMessages: messages, requirements, workspaceId, workerId, loopId,
|
|
1069
|
-
currentTurnSeq: seq, provider, gitStatus, notices, transientOpenLogEntryId,
|
|
1070
|
-
}),
|
|
1071
|
-
});
|
|
1072
|
-
if (enforced.recorded)
|
|
1073
|
-
nextActionIndex += 1; // a fold-to-fit Problem consumed the reserved sequence
|
|
1074
|
-
requestPacket = enforced.packet;
|
|
1075
|
-
if (!enforced.fit) {
|
|
1076
|
-
// {§grinder-hard-413-recovery}/{§grinder-hard-413-abort} — admit
|
|
1077
|
-
// one physically sendable informed recovery turn; a physical
|
|
1078
|
-
// overflow or consecutive policy overflow terminates immediately.
|
|
1079
|
-
let physicalAdmission = await this.#packets.physicalAdmission(requestPacket, provider, this.#loopAborts.get(loopId)?.signal);
|
|
1080
|
-
let recoveryAdmitted = false;
|
|
1081
|
-
if (physicalAdmission.admitted && !this.#hardOverflowRecovery.has(loopId)) {
|
|
1082
|
-
const ceiling = this.#packets.ceilingFor(provider);
|
|
1083
|
-
if (ceiling === null) {
|
|
1084
|
-
throw new Error("an unbounded prompt budget cannot enter budget recovery");
|
|
1085
|
-
}
|
|
1086
|
-
await this.#problems.record({
|
|
1087
|
-
workerId,
|
|
1088
|
-
loopId,
|
|
1089
|
-
turnId,
|
|
1090
|
-
sequence: nextActionIndex++,
|
|
1091
|
-
origin: "model",
|
|
1092
|
-
source: "engine",
|
|
1093
|
-
result: BudgetOverflow.result(requestPacket.tokens, ceiling, true),
|
|
1094
|
-
});
|
|
1095
|
-
// Rebuild so the recovery-steer row just minted renders in THIS packet's log +
|
|
1096
|
-
// errors sections (the same re-derive contract the soft grind uses).
|
|
1097
|
-
requestPacket = await this.#packets.buildRequestPacket({
|
|
1098
|
-
initialMessages: messages, requirements, workspaceId, workerId, loopId,
|
|
1099
|
-
currentTurnSeq: seq, provider, gitStatus, notices, transientOpenLogEntryId,
|
|
1100
|
-
});
|
|
1101
|
-
physicalAdmission = await this.#packets.physicalAdmission(requestPacket, provider, this.#loopAborts.get(loopId)?.signal);
|
|
1102
|
-
if (physicalAdmission.admitted) {
|
|
1103
|
-
this.#hardOverflowRecovery.add(loopId);
|
|
1104
|
-
recoveryAdmitted = true;
|
|
1105
|
-
}
|
|
1106
|
-
}
|
|
1107
|
-
if (!recoveryAdmitted) {
|
|
1108
|
-
// Hard 413: physically unsendable, or still over after the informed recovery turn.
|
|
1109
|
-
const ceiling = this.#packets.ceilingFor(provider);
|
|
1110
|
-
if (ceiling === null) {
|
|
1111
|
-
throw new Error("an unbounded prompt budget cannot hard-stop");
|
|
1112
|
-
}
|
|
1113
|
-
if (!enforced.recorded || !physicalAdmission.admitted) {
|
|
1114
|
-
await this.#problems.record({
|
|
1115
|
-
workerId,
|
|
1116
|
-
loopId,
|
|
1117
|
-
turnId,
|
|
1118
|
-
sequence: nextActionIndex++,
|
|
1119
|
-
origin: "plurnk",
|
|
1120
|
-
source: "engine",
|
|
1121
|
-
result: BudgetOverflow.result(requestPacket.tokens, ceiling, false, physicalAdmission.admitted
|
|
1122
|
-
? undefined
|
|
1123
|
-
: {
|
|
1124
|
-
reason: physicalAdmission.reason,
|
|
1125
|
-
detail: physicalAdmission.detail,
|
|
1126
|
-
capacity: physicalAdmission.capacity,
|
|
1127
|
-
tokens: physicalAdmission.measurement?.tokens,
|
|
1128
|
-
tokenKind: physicalAdmission.measurement?.kind,
|
|
1129
|
-
tokenSource: physicalAdmission.measurement?.source,
|
|
1130
|
-
}),
|
|
1131
|
-
});
|
|
1132
|
-
requestPacket = await this.#packets.buildRequestPacket({
|
|
1133
|
-
initialMessages: messages, requirements, workspaceId, workerId, loopId,
|
|
1134
|
-
currentTurnSeq: seq, provider, gitStatus, notices, transientOpenLogEntryId,
|
|
1135
|
-
});
|
|
1136
|
-
}
|
|
1137
|
-
// Skip the LLM, close the turn, and let runLoop abandon.
|
|
1138
|
-
await this.#db.engine_close_turn.run({
|
|
1139
|
-
id: turnId, status: 413, packet: StoredPacket.stringify(requestPacket),
|
|
1140
|
-
// The attempted turn retains its effective allowance even
|
|
1141
|
-
// when no provider exchange completed. {§tokenomics-client-gauge}
|
|
1142
|
-
usage_prompt_budget: this.#packets.promptBudgetFor(provider),
|
|
1143
|
-
finish_reason: "budget_hard_stop", model: provider.model, meta: "{}",
|
|
1144
|
-
});
|
|
1145
|
-
return {
|
|
1146
|
-
turnId,
|
|
1147
|
-
status: 413,
|
|
1148
|
-
outcomes: [],
|
|
1149
|
-
fingerprint: "",
|
|
1150
|
-
budgetStruck: enforced.struck,
|
|
1151
|
-
budgetHardStop: true,
|
|
1152
|
-
steerStruck: false,
|
|
1153
|
-
emissionAttempts: 0,
|
|
1154
|
-
emissionExhausted: false,
|
|
1155
|
-
budget: BudgetOverflow.measure(requestPacket.tokens, ceiling),
|
|
1156
|
-
};
|
|
1157
|
-
}
|
|
1158
|
-
}
|
|
1159
|
-
else {
|
|
1160
|
-
// A fitting turn clears the recovery grant; a later independent overflow can earn
|
|
1161
|
-
// a fresh recovery turn (chronic overflow still strikes out via the rail).
|
|
1162
|
-
this.#hardOverflowRecovery.delete(loopId);
|
|
1163
|
-
}
|
|
1164
|
-
const modelMessages = PacketWire.packetToWireMessages(requestPacket);
|
|
1165
|
-
// Packet pressure and provider generation are independent. The grinder governs
|
|
1166
|
-
// only the request packet; maxTokens comes only from the provider envelope and
|
|
1167
|
-
// never shrinks as the virtual prompt budget fills.
|
|
1168
|
-
let response;
|
|
1169
|
-
let splitResponse;
|
|
1170
|
-
let railGrammar;
|
|
1171
|
-
let railEvidence;
|
|
1172
|
-
let emissionAttempts = 0;
|
|
1173
|
-
let providerCallInFlight = false;
|
|
1174
|
-
let providerAttemptSequence = 0;
|
|
1175
|
-
let providerAttemptId = null;
|
|
1176
|
-
let providerAttemptAttributions = [];
|
|
1177
|
-
const providerSignal = this.#loopAborts.get(loopId)?.signal ?? signal;
|
|
1178
|
-
// {§client-metadata}
|
|
1179
|
-
const { client } = await WorkspaceSettings.read(this.#db, workspaceId);
|
|
1180
|
-
const observeProviderAttempt = async (id, attemptResponse) => {
|
|
1181
|
-
const attemptUsage = attemptResponse.assistant.usage;
|
|
1182
|
-
await this.#db.engine_observe_turn_attempt_response.run({
|
|
1183
|
-
id,
|
|
1184
|
-
response: JSON.stringify(attemptResponse),
|
|
1185
|
-
usage_prompt: attemptUsage.prompt,
|
|
1186
|
-
usage_completion: attemptUsage.completion,
|
|
1187
|
-
usage_reasoning: attemptUsage.reasoning,
|
|
1188
|
-
usage_cached: attemptUsage.cached,
|
|
1189
|
-
usage_cost: JSON.stringify({
|
|
1190
|
-
kind: "unknown",
|
|
1191
|
-
reason: "provider response retained before monetary classification",
|
|
1192
|
-
}),
|
|
1193
|
-
finish_reason: attemptResponse.assistant.finishReason,
|
|
1194
|
-
model: attemptResponse.assistant.model,
|
|
1195
|
-
});
|
|
1196
|
-
};
|
|
1197
|
-
const classifyProviderAttempt = async (id, attemptResponse, attemptSplit, sequence, accepted, failure = null) => {
|
|
1198
|
-
const attemptUsage = attemptSplit.callMetadata.usage;
|
|
1199
|
-
const attemptCost = providerCostFor(provider, attemptUsage, attemptResponse.charge);
|
|
1200
|
-
const attemptCostUsd = providerCostUsd(attemptCost);
|
|
1201
|
-
await this.#db.engine_classify_turn_attempt_response.run({
|
|
1202
|
-
id,
|
|
1203
|
-
accepted: accepted ? 1 : 0,
|
|
1204
|
-
parse_errors: JSON.stringify(attemptSplit.parseErrors),
|
|
1205
|
-
failure: failure === null ? null : JSON.stringify(failure),
|
|
1206
|
-
usage_cost: JSON.stringify(attemptCost),
|
|
1207
|
-
usage_cost_usd: attemptCostUsd,
|
|
1208
|
-
});
|
|
1209
|
-
emissionAttempts = sequence;
|
|
1210
|
-
};
|
|
1211
|
-
try {
|
|
1212
|
-
// {§turn-lifecycle}: bracket the complete provider-attempt window with liveness notices.
|
|
1213
|
-
if (!signal?.aborted)
|
|
1214
|
-
this.#notices.push(workspaceId, loopId, { source: "engine:turn", kind: "turn_awaiting_model", level: "info", message: "awaiting model response" });
|
|
1215
|
-
const loopSeq = (await this.#db.engine_loop_sequence.get({ loop_id: loopId }))?.sequence ?? loopId;
|
|
1216
|
-
railGrammar = await this.#grammarConstraint(provider);
|
|
1217
|
-
const primaryWorkerId = String(await this.resolveWorkerPrimary(workerId));
|
|
1218
|
-
const attemptLimit = readEmissionAttempts();
|
|
1219
|
-
const maxTokens = this.#packets.maxTokensFor(provider) ?? undefined;
|
|
1220
|
-
const strikeStreak = this.#strikes.streak(loopId);
|
|
1221
|
-
for (let attempt = 1; attempt <= attemptLimit; attempt++) {
|
|
1222
|
-
// Every attempt carries the exact same model packet, coordinates,
|
|
1223
|
-
// limits, and engine-strike state. Plugin-authored tags are pulled
|
|
1224
|
-
// for the attempt and do not alter the model messages. No failed
|
|
1225
|
-
// emission is appended and no new engine turn opens between calls.
|
|
1226
|
-
providerAttemptSequence = attempt;
|
|
1227
|
-
const attributionContext = Object.freeze({
|
|
1228
|
-
workspaceId: String(workspaceId),
|
|
1229
|
-
workerId: String(workerId),
|
|
1230
|
-
primaryWorkerId,
|
|
1231
|
-
loop: loopSeq,
|
|
1232
|
-
turn: seq,
|
|
1233
|
-
attempt,
|
|
1234
|
-
});
|
|
1235
|
-
providerAttemptAttributions = await this.#attemptAttributions(provider, attributionContext);
|
|
1236
|
-
requestPacket = { ...requestPacket, attributions: providerAttemptAttributions };
|
|
1237
|
-
const attemptRow = await this.#db.engine_open_turn_attempt.get({
|
|
1238
|
-
turn_id: turnId,
|
|
1239
|
-
sequence: attempt,
|
|
1240
|
-
attributions: JSON.stringify(providerAttemptAttributions),
|
|
1241
|
-
model: provider.model,
|
|
1242
|
-
});
|
|
1243
|
-
if (attemptRow === undefined) {
|
|
1244
|
-
throw new Error(`Engine.runTurn: provider attempt ${attempt} did not open`);
|
|
1245
|
-
}
|
|
1246
|
-
providerAttemptId = attemptRow.id;
|
|
1247
|
-
providerCallInFlight = true;
|
|
1248
|
-
const completedResponse = await observed(// {§observability-boundary}
|
|
1249
|
-
"provider.generate", { model: provider.model, attempt }, async (span) => {
|
|
1250
|
-
const generated = await provider.generate({
|
|
1251
|
-
messages: modelMessages,
|
|
1252
|
-
workerId: String(workerId),
|
|
1253
|
-
primaryWorkerId,
|
|
1254
|
-
signal: providerSignal,
|
|
1255
|
-
grammar: railGrammar,
|
|
1256
|
-
maxTokens,
|
|
1257
|
-
strikes: strikeStreak,
|
|
1258
|
-
attributions: providerAttemptAttributions.length > 0
|
|
1259
|
-
? providerAttemptAttributions
|
|
1260
|
-
: undefined,
|
|
1261
|
-
client: client ?? undefined,
|
|
1262
|
-
workspaceId: String(workspaceId),
|
|
1263
|
-
loop: loopSeq,
|
|
1264
|
-
turn: seq,
|
|
1265
|
-
}); // {§provider-surface-generate} {§provider-guarantees-signal-wired} {§provider-guarantees-serial-attempts} {§attribution} {§client-metadata}
|
|
1266
|
-
providerCallInFlight = false;
|
|
1267
|
-
recordCounter(PROVIDER_CALLS, {
|
|
1268
|
-
model: provider.model,
|
|
1269
|
-
attempt,
|
|
1270
|
-
status: "resolved",
|
|
1271
|
-
});
|
|
1272
|
-
span.setAttribute("status", "resolved");
|
|
1273
|
-
return generated;
|
|
1274
|
-
});
|
|
1275
|
-
response = completedResponse;
|
|
1276
|
-
await observeProviderAttempt(attemptRow.id, completedResponse);
|
|
1277
|
-
railEvidence = railGrammar === undefined
|
|
1278
|
-
? undefined
|
|
1279
|
-
: Engine.#requireGrammarEvidence(completedResponse);
|
|
1280
|
-
splitResponse = this.#splitResponse(completedResponse);
|
|
1281
|
-
await classifyProviderAttempt(attemptRow.id, completedResponse, splitResponse, attempt, splitResponse.emissionValid);
|
|
1282
|
-
if (splitResponse.emissionValid)
|
|
1283
|
-
break;
|
|
1284
|
-
}
|
|
1285
|
-
if (!signal?.aborted)
|
|
1286
|
-
this.#notices.push(workspaceId, loopId, { source: "engine:turn", kind: "turn_generated", level: "info", message: "parsing model response" });
|
|
1287
|
-
}
|
|
1288
|
-
catch (err) {
|
|
1289
|
-
// This handler owns only provider-call failures. Parser, cost, SQL,
|
|
1290
|
-
// and engine-contract failures retain their original source.
|
|
1291
|
-
if (!providerCallInFlight)
|
|
1292
|
-
throw err;
|
|
1293
|
-
providerCallInFlight = false;
|
|
1294
|
-
if (providerAttemptId === null) {
|
|
1295
|
-
throw new Error("provider call failed without a durable attempt identity", { cause: err });
|
|
1296
|
-
}
|
|
1297
|
-
const failure = err instanceof ProviderError
|
|
1298
|
-
? { status: err.problem.status, problem: err.problem }
|
|
1299
|
-
: providerSignal?.aborted === true
|
|
1300
|
-
? Results.failure("engine:provider", providerSignal.reason === LOOP_TIMEOUT_REASON ? "provider-call-timeout" : "provider-call-cancelled", providerSignal.reason === LOOP_TIMEOUT_REASON ? 504 : 499, providerSignal.reason === LOOP_TIMEOUT_REASON
|
|
1301
|
-
? "The provider call was interrupted by the loop deadline."
|
|
1302
|
-
: "The provider call was interrupted by loop cancellation.", {}, { stage: "provider-request", retryable: false })
|
|
1303
|
-
: (() => {
|
|
1304
|
-
console.error("Provider failed outside its Problem Details contract:", err);
|
|
1305
|
-
return Results.failure("engine:provider", "provider-contract-violation", 502, "The provider failed without returning its required Problem Details.", {}, {
|
|
1306
|
-
stage: "provider-request",
|
|
1307
|
-
retryable: false,
|
|
1308
|
-
});
|
|
1309
|
-
})();
|
|
1310
|
-
// {§provider-interrupted-attempt} — a provider-declared interruption
|
|
1311
|
-
// carries response evidence without becoming a completed exchange.
|
|
1312
|
-
// Persist it as an unaccepted attempt before settling the failure.
|
|
1313
|
-
if (err instanceof ProviderError && err.attempt !== undefined) {
|
|
1314
|
-
response = err.attempt;
|
|
1315
|
-
await observeProviderAttempt(providerAttemptId, response);
|
|
1316
|
-
splitResponse = this.#splitResponse(response);
|
|
1317
|
-
await classifyProviderAttempt(providerAttemptId, response, splitResponse, providerAttemptSequence, false, failure);
|
|
1318
|
-
}
|
|
1319
|
-
else {
|
|
1320
|
-
const unknownCost = {
|
|
1321
|
-
kind: "unknown",
|
|
1322
|
-
reason: "provider call failed without response-bearing charge evidence",
|
|
1323
|
-
};
|
|
1324
|
-
await this.#db.engine_fail_turn_attempt.run({
|
|
1325
|
-
id: providerAttemptId,
|
|
1326
|
-
failure: JSON.stringify(failure),
|
|
1327
|
-
usage_cost: JSON.stringify(unknownCost),
|
|
1328
|
-
});
|
|
1329
|
-
emissionAttempts = providerAttemptSequence;
|
|
1330
|
-
}
|
|
1331
|
-
// {§turn-never-blank} — a ProviderError means no completed exchange exists.
|
|
1332
|
-
// Persist its exact RFC 9457 result before propagating it. Grammar evidence
|
|
1333
|
-
// and its engine-owned verdict exist only on completed responses
|
|
1334
|
-
// ({§rail-truth-engine-verdict}). Cancellation is lifecycle truth, not a
|
|
1335
|
-
// provider failure. Close the
|
|
1336
|
-
// attempted turn without inventing an assistant response, then let
|
|
1337
|
-
// runLoop/Daemon settle the exact 504/499 loop result.
|
|
1338
|
-
if (providerSignal?.aborted) {
|
|
1339
|
-
await this.#db.engine_close_turn.run({
|
|
1340
|
-
id: turnId,
|
|
1341
|
-
status: providerSignal.reason === LOOP_TIMEOUT_REASON ? 504 : 499,
|
|
1342
|
-
packet: StoredPacket.stringify(requestPacket),
|
|
1343
|
-
usage_prompt_budget: this.#packets.promptBudgetFor(provider),
|
|
1344
|
-
finish_reason: splitResponse?.callMetadata.finishReason ?? null,
|
|
1345
|
-
model: splitResponse?.callMetadata.model ?? provider.model,
|
|
1346
|
-
meta: JSON.stringify(response?.meta ?? {}),
|
|
1347
|
-
});
|
|
1348
|
-
throw err;
|
|
1349
|
-
}
|
|
1350
|
-
const recorded = await this.#problems.record({
|
|
1351
|
-
workerId,
|
|
1352
|
-
loopId,
|
|
1353
|
-
turnId,
|
|
1354
|
-
sequence: nextActionIndex,
|
|
1355
|
-
origin: "plurnk",
|
|
1356
|
-
source: "provider",
|
|
1357
|
-
result: failure,
|
|
1358
|
-
});
|
|
1359
|
-
// The provider call was attempted, but no completed exchange exists.
|
|
1360
|
-
// Persist the exact request half and failure status; omitting assistant
|
|
1361
|
-
// is materially different from fabricating an empty model turn.
|
|
1362
|
-
await this.#db.engine_close_turn.run({
|
|
1363
|
-
id: turnId,
|
|
1364
|
-
status: recorded.result.status,
|
|
1365
|
-
packet: StoredPacket.stringify(requestPacket),
|
|
1366
|
-
usage_prompt_budget: this.#packets.promptBudgetFor(provider),
|
|
1367
|
-
finish_reason: splitResponse?.callMetadata.finishReason ?? null,
|
|
1368
|
-
model: splitResponse?.callMetadata.model ?? provider.model,
|
|
1369
|
-
meta: JSON.stringify(response?.meta ?? {}),
|
|
1370
|
-
});
|
|
1371
|
-
throw new OperationFailureError(recorded.result, { cause: err });
|
|
1372
|
-
}
|
|
1373
|
-
if (response === undefined || splitResponse === undefined) {
|
|
1374
|
-
throw new Error("provider attempt loop completed without a response");
|
|
1375
|
-
}
|
|
1376
|
-
if (!splitResponse.emissionValid) {
|
|
1377
|
-
// {§invalid-emission-attempts} The first consecutive exhaustion
|
|
1378
|
-
// publishes only the raw final response and generic recovery fact.
|
|
1379
|
-
let rejectedModelEntryId;
|
|
1380
|
-
if (allowInvalidEmissionRecovery) {
|
|
1381
|
-
rejectedModelEntryId = await this.#dispatcher.writeModelEntry({
|
|
1382
|
-
verbatim: splitResponse.packetAssistant.content,
|
|
1383
|
-
workerId,
|
|
1384
|
-
loopId,
|
|
1385
|
-
turnId,
|
|
1386
|
-
sequence: nextActionIndex,
|
|
1387
|
-
folded: true,
|
|
1388
|
-
admission: "rejected",
|
|
1389
|
-
});
|
|
1390
|
-
this.#notices.push(workspaceId, loopId, {
|
|
1391
|
-
source: "engine:grammar",
|
|
1392
|
-
kind: "invalid_emission",
|
|
1393
|
-
level: "error",
|
|
1394
|
-
message: INVALID_EMISSION_RECOVERY_MESSAGE,
|
|
1395
|
-
});
|
|
1396
|
-
}
|
|
1397
|
-
await this.#db.engine_close_turn.run({
|
|
1398
|
-
id: turnId,
|
|
1399
|
-
status: allowInvalidEmissionRecovery ? TURN_STATUS_IMPLICIT_CONTINUE : 500,
|
|
1400
|
-
packet: StoredPacket.stringify(requestPacket),
|
|
1401
|
-
usage_prompt_budget: this.#packets.promptBudgetFor(provider),
|
|
1402
|
-
finish_reason: splitResponse.callMetadata.finishReason,
|
|
1403
|
-
model: splitResponse.callMetadata.model,
|
|
1404
|
-
meta: JSON.stringify(response.meta ?? {}),
|
|
1405
|
-
});
|
|
1406
|
-
return {
|
|
1407
|
-
turnId,
|
|
1408
|
-
status: allowInvalidEmissionRecovery ? TURN_STATUS_IMPLICIT_CONTINUE : 500,
|
|
1409
|
-
outcomes: [],
|
|
1410
|
-
fingerprint: "",
|
|
1411
|
-
budgetStruck: enforced.struck,
|
|
1412
|
-
budgetHardStop: false,
|
|
1413
|
-
steerStruck: false,
|
|
1414
|
-
emissionAttempts,
|
|
1415
|
-
emissionExhausted: true,
|
|
1416
|
-
...(rejectedModelEntryId === undefined ? {} : { rejectedModelEntryId }),
|
|
1417
|
-
};
|
|
1418
|
-
}
|
|
1419
|
-
// {§packet-stored-shape} — admitted emission data extends the packet;
|
|
1420
|
-
// provider-call metadata remains on the Turn row.
|
|
1421
|
-
const { packetAssistant, callMetadata, parseNotices, recoverableParseErrors, } = splitResponse; // raw assistant content is opaque — split, never interpreted — {§provider-guarantees-assistantraw-opaque}
|
|
1422
|
-
for (const notice of parseNotices) {
|
|
1423
|
-
this.#notices.push(workspaceId, loopId, notice);
|
|
1424
|
-
}
|
|
1425
|
-
// Non-fatal provider transport notices on an accepted turn. Forward each
|
|
1426
|
-
// Notice with a content-offset `line:col`;
|
|
1427
|
-
// the model resolves it against its own emission — READ the folded model-emission row at the
|
|
1428
|
-
// cited lines ({§model-entry}) — not an embedded snippet that would duplicate the emission.
|
|
1429
|
-
for (const notice of response.notices ?? []) {
|
|
1430
|
-
const located = typeof notice.position === "number"
|
|
1431
|
-
? this.#offsetToLineColumn(packetAssistant.content, notice.position)
|
|
1432
|
-
: null;
|
|
1433
|
-
this.#notices.push(workspaceId, loopId, {
|
|
1434
|
-
source: notice.source,
|
|
1435
|
-
kind: notice.kind,
|
|
1436
|
-
message: notice.message ?? "",
|
|
1437
|
-
level: notice.level,
|
|
1438
|
-
...(located !== null
|
|
1439
|
-
? { position: { type: "content-offset", line: located.line, column: located.column } }
|
|
1440
|
-
: {}),
|
|
1441
|
-
});
|
|
1442
|
-
}
|
|
1443
|
-
// Grade configured local evidence independently. Endpoint-owned
|
|
1444
|
-
// constraints remain provider observations. {§rail-truth-engine-verdict}
|
|
1445
|
-
let railKeys;
|
|
1446
|
-
if (railGrammar !== undefined) {
|
|
1447
|
-
if (railEvidence === undefined)
|
|
1448
|
-
throw new Error("configured GBNF response has no final grammar evidence");
|
|
1449
|
-
let verdict = null;
|
|
1450
|
-
try {
|
|
1451
|
-
verdict = validateGbnf(railGrammar, railEvidence.input);
|
|
1452
|
-
}
|
|
1453
|
-
catch (cause) {
|
|
1454
|
-
Engine.#warnRailVerdictGapOnce(cause.message);
|
|
1455
|
-
}
|
|
1456
|
-
railKeys = {
|
|
1457
|
-
railsAttached: railEvidence.transported ? "client" : "withheld",
|
|
1458
|
-
railsVerdict: verdict?.status ?? "unverifiable",
|
|
1459
|
-
};
|
|
1460
|
-
if (verdict !== null && verdict.status !== "accept") {
|
|
1461
|
-
const contentPosition = verdict.pos >= railEvidence.contentStart
|
|
1462
|
-
? verdict.pos - railEvidence.contentStart
|
|
1463
|
-
: null;
|
|
1464
|
-
const located = contentPosition === null
|
|
1465
|
-
? null
|
|
1466
|
-
: this.#offsetToLineColumn(packetAssistant.content, contentPosition);
|
|
1467
|
-
this.#notices.push(workspaceId, loopId, {
|
|
1468
|
-
source: "engine:rails",
|
|
1469
|
-
kind: "grammar_unenforced",
|
|
1470
|
-
message: verdict.status === "reject"
|
|
1471
|
-
? `emission rejects the grammar at raw code point ${verdict.pos}`
|
|
1472
|
-
: `emission is an incomplete grammar sentence (ends at raw code point ${verdict.pos})`,
|
|
1473
|
-
level: "warn",
|
|
1474
|
-
...(located === null
|
|
1475
|
-
? {}
|
|
1476
|
-
: { position: { type: "content-offset", line: located.line, column: located.column } }),
|
|
1477
|
-
});
|
|
1478
|
-
}
|
|
1479
|
-
}
|
|
1480
|
-
const opsCount = packetAssistant.ops.length;
|
|
1481
|
-
const finalOp = packetAssistant.ops.at(-1);
|
|
1482
|
-
if (finalOp?.op !== "SEND") {
|
|
1483
|
-
// Text emissions cannot reach this point without a disposition;
|
|
1484
|
-
// this fail-hard guard also keeps Mock's trusted pre-parsed seam
|
|
1485
|
-
// from creating runtime states that production admission forbids.
|
|
1486
|
-
throw new Error("an admitted emission must end in a disposition SEND");
|
|
1487
|
-
}
|
|
1488
|
-
const dispositionSignal = finalOp.signal;
|
|
1489
|
-
if (typeof dispositionSignal !== "number" || !TERMINAL_SEND_SIGNALS.has(dispositionSignal)) {
|
|
1490
|
-
throw new Error("an admitted emission must end in a disposition SEND");
|
|
1491
|
-
}
|
|
1492
|
-
const sendOp = finalOp;
|
|
1493
|
-
// {§send} the terminal contract — engine error states verify a terminal claim against loop
|
|
1494
|
-
// state, never trusting the model's code. They strike via turn.steerStruck
|
|
1495
|
-
// ({§engine-rails}): the loop continues, the model sees the steering hint not the strike
|
|
1496
|
-
// count, and a non-resolver spins out to the engine's 500.
|
|
1497
|
-
let steerStruck = false;
|
|
1498
|
-
// Engine errors raised this turn, minted as op='error' log rows after dispatch (they share the
|
|
1499
|
-
// post-dispatch sequence counter). {§operation-result-uniform-error-channel}
|
|
1500
|
-
const pendingEngineErrors = [];
|
|
1501
|
-
// Terminal adjudication moved to the DISPATCHER ({§send-premature-terminate}, the unified
|
|
1502
|
-
// pending set): the terminal SEND is judged AT ITS OWN DISPATCH — after the emission's
|
|
1503
|
-
// earlier ops executed — so a same-turn KILL+[200] repairs in one turn and a same-turn
|
|
1504
|
-
// WORK+[200] is caught. A refused final disposition (409) strikes via
|
|
1505
|
-
// the dispatch-loop check below.
|
|
1506
|
-
let turnStatus = dispositionSignal;
|
|
1507
|
-
// Idle turn: an implicit-continue (102) that did no WORK — its ops are only PLAN/SEND, no mid op.
|
|
1508
|
-
// The model continued with nothing to do.
|
|
1509
|
-
const midOpsCount = packetAssistant.ops.filter((op) => op.op !== "PLAN" && op.op !== "SEND").length
|
|
1510
|
-
+ recoverableParseErrors.length;
|
|
1511
|
-
if (!steerStruck && turnStatus === TURN_STATUS_IMPLICIT_CONTINUE && midOpsCount === 0) {
|
|
1512
|
-
steerStruck = true;
|
|
1513
|
-
pendingEngineErrors.push("idle_turn");
|
|
1514
|
-
}
|
|
1515
|
-
// Close the turn with the final packet, status, and usage stats.
|
|
1516
|
-
const packet = StoredPacket.admit(requestPacket, packetAssistant, response.assistantRaw);
|
|
1517
|
-
await this.#db.engine_close_turn.run({
|
|
1518
|
-
id: turnId,
|
|
1519
|
-
status: turnStatus,
|
|
1520
|
-
packet: StoredPacket.stringify(packet),
|
|
1521
|
-
usage_prompt_budget: this.#packets.promptBudgetFor(provider), // {§tokenomics-client-gauge}
|
|
1522
|
-
finish_reason: callMetadata.finishReason,
|
|
1523
|
-
model: callMetadata.model,
|
|
1524
|
-
// Opaque provider metadata plus engine-authored rail keys.
|
|
1525
|
-
// {§meta-passthrough}, {§rail-truth-engine-verdict}
|
|
1526
|
-
meta: JSON.stringify({ ...(response.meta ?? {}), ...(railKeys ?? {}) }),
|
|
1527
|
-
});
|
|
1528
|
-
// Dispatch model ops starting at nextActionIndex (continues the
|
|
1529
|
-
// turn's running counter after any pre-model writes).
|
|
1530
|
-
//
|
|
1531
|
-
// Max-commands ceiling: OFF by default (unlimited) — every generated op dispatches.
|
|
1532
|
-
// A degenerate op-loop is a sampler failure guarded at generation, not by dropping
|
|
1533
|
-
// already-generated work post-hoc. The ceiling is an OPT-IN operator/client bound:
|
|
1534
|
-
// when set, overflow ops drop without per-op log entries (no forensics flood) and the
|
|
1535
|
-
// model gets one notices signal next packet.
|
|
1536
|
-
// {§operator-config-workspace-max-commands} — workspace maxCommands min()s the env ceiling.
|
|
1537
|
-
const maxCommands = Math.min(readMaxCommands(), (await WorkspaceSettings.read(this.#db, workspaceId)).maxCommands ?? Number.POSITIVE_INFINITY);
|
|
1538
|
-
// PLAN (intended goals) and the final disposition SEND are not actions —
|
|
1539
|
-
// they always dispatch and never count against the cap. maxCommands
|
|
1540
|
-
// bounds real actions only; maxCommands:0 still admits a plan and a disposition
|
|
1541
|
-
// (the PLAN/SEND ops, zero actions), which is its only coherent meaning.
|
|
1542
|
-
let realCommands = 0;
|
|
1543
|
-
const admittedOps = packetAssistant.ops.filter((op) => {
|
|
1544
|
-
return op.op === "PLAN"
|
|
1545
|
-
|| op === sendOp
|
|
1546
|
-
|| realCommands++ < maxCommands;
|
|
1547
|
-
});
|
|
1548
|
-
const opsToDispatch = scheduleTurnOps(admittedOps);
|
|
1549
|
-
await this.#dispatcher.prepareEditBatches(opsToDispatch.filter((statement) => statement.op === "EDIT"), {
|
|
1550
|
-
workspaceId, workerId, loopId, turnId,
|
|
1551
|
-
origin, onDispatch,
|
|
1552
|
-
});
|
|
1553
|
-
const droppedCount = opsCount - opsToDispatch.length;
|
|
1554
|
-
const outcomes = [];
|
|
1555
|
-
// Running counter — a multi-file READ writes N rows from one statement (rowsWritten),
|
|
1556
|
-
// so the next op's sequence picks up after them. Collapses to nextActionIndex+i when
|
|
1557
|
-
// every op writes one row (the common case).
|
|
1558
|
-
let rowSeq = nextActionIndex;
|
|
1559
|
-
let parseErrorsRecorded = false;
|
|
1560
|
-
const recordRecoverableParseErrors = async () => {
|
|
1561
|
-
if (parseErrorsRecorded)
|
|
1562
|
-
return;
|
|
1563
|
-
parseErrorsRecorded = true;
|
|
1564
|
-
for (const error of recoverableParseErrors) {
|
|
1565
|
-
const recorded = await this.#problems.record({
|
|
1566
|
-
workerId,
|
|
1567
|
-
loopId,
|
|
1568
|
-
turnId,
|
|
1569
|
-
sequence: rowSeq++,
|
|
1570
|
-
origin: "model",
|
|
1571
|
-
source: "grammar",
|
|
1572
|
-
result: Results.failure("grammar:parser", "invalid-operation-syntax", 400, error.message, {}, {
|
|
1573
|
-
line: error.line,
|
|
1574
|
-
column: error.column,
|
|
1575
|
-
source: error.source,
|
|
1576
|
-
stage: "parse",
|
|
1577
|
-
recovery: "Correct only the failed operation; sibling operations were retained.",
|
|
1578
|
-
retryable: false,
|
|
1579
|
-
}),
|
|
1580
|
-
});
|
|
1581
|
-
outcomes.push({ op: null, status: recorded.result.status });
|
|
1582
|
-
onDispatch?.(recorded.id);
|
|
1583
|
-
}
|
|
1584
|
-
};
|
|
1585
|
-
for (const statement of opsToDispatch) {
|
|
1586
|
-
if (statement.op === "SEND"
|
|
1587
|
-
&& typeof statement.signal === "number"
|
|
1588
|
-
&& TERMINAL_SEND_SIGNALS.has(statement.signal)) {
|
|
1589
|
-
await recordRecoverableParseErrors();
|
|
1590
|
-
// {§worker-optimistic-settlement} — SEND judges the refreshed
|
|
1591
|
-
// lifecycle after this turn's own fast streams receive one
|
|
1592
|
-
// bounded opportunity to conclude. This is not a sleep and
|
|
1593
|
-
// does not delay a later turn for older monitored streams.
|
|
1594
|
-
const execHandler = this.#schemes.get("exec");
|
|
1595
|
-
await execHandler?.settleTurnSpawns?.(workerId, turnId, readOptimisticSettlementMs(), providerSignal);
|
|
1596
|
-
}
|
|
1597
|
-
const result = await observed(// {§observability-boundary}
|
|
1598
|
-
"op.dispatch", { op: statement.op }, async (span) => {
|
|
1599
|
-
const dispatchResult = await this.#dispatcher.dispatch({
|
|
1600
|
-
statement, workspaceId, workerId, loopId, turnId,
|
|
1601
|
-
sequence: rowSeq,
|
|
1602
|
-
origin, onDispatch,
|
|
1603
|
-
});
|
|
1604
|
-
span.setAttribute("status", dispatchResult.status);
|
|
1605
|
-
recordCounter(OPS_DISPATCHED, { op: statement.op, status: dispatchResult.status });
|
|
1606
|
-
return dispatchResult;
|
|
1607
|
-
});
|
|
1608
|
-
outcomes.push({ op: statement.op, status: result.status });
|
|
1609
|
-
for (const normalization of result.scopeNormalizations ?? []) {
|
|
1610
|
-
this.#notices.push(workspaceId, loopId, {
|
|
1611
|
-
source: "engine:slicer",
|
|
1612
|
-
kind: "scope_normalized",
|
|
1613
|
-
level: "warn",
|
|
1614
|
-
message: `Scope <${normalization.requested.join(",")}> was normalized to <${normalization.canonical.join(",")}>.`,
|
|
1615
|
-
});
|
|
1616
|
-
}
|
|
1617
|
-
// {§engine-rails} — a refused final disposition leaves both loop
|
|
1618
|
-
// and turn continuing, and its 409 steering ruling strikes once.
|
|
1619
|
-
if (statement === sendOp && result.status === 409) {
|
|
1620
|
-
steerStruck = true;
|
|
1621
|
-
turnStatus = TURN_STATUS_IMPLICIT_CONTINUE;
|
|
1622
|
-
await this.#db.engine_reconcile_turn_status.run({ id: turnId, status: turnStatus });
|
|
1623
|
-
}
|
|
1624
|
-
// {§send-300-choices}: a question resolves through the proposal system; whatever the
|
|
1625
|
-
// resolution (answer/reject/timeout), the LOOP continues to the turn where the model
|
|
1626
|
-
// reads it; the turn record is a continue, never a 300 terminal.
|
|
1627
|
-
if (statement === sendOp && sendOp.signal === 300 && result.status !== 409) {
|
|
1628
|
-
turnStatus = TURN_STATUS_IMPLICIT_CONTINUE;
|
|
1629
|
-
await this.#db.engine_reconcile_turn_status.run({ id: turnId, status: turnStatus });
|
|
1630
|
-
}
|
|
1631
|
-
// A broadcast [202] is a conditional wait, not an unconditional turn status:
|
|
1632
|
-
// live work parks at 202; completed-but-unobserved work continues at 102; an
|
|
1633
|
-
// empty join completes at 200. Persist and return the dispatcher's actual ruling.
|
|
1634
|
-
if (statement === sendOp
|
|
1635
|
-
&& result.status !== 409
|
|
1636
|
-
&& sendOp.target === null
|
|
1637
|
-
&& sendOp.signal === 202
|
|
1638
|
-
&& result.status !== 202) {
|
|
1639
|
-
turnStatus = result.status;
|
|
1640
|
-
await this.#db.engine_reconcile_turn_status.run({ id: turnId, status: turnStatus });
|
|
1641
|
-
}
|
|
1642
|
-
rowSeq += result.rowsWritten ?? 1;
|
|
1643
|
-
}
|
|
1644
|
-
await recordRecoverableParseErrors();
|
|
1645
|
-
// Engine rail failures mint as op='error' log rows at the turn's next
|
|
1646
|
-
// free sequence. Bounded syntax failures were recorded in their
|
|
1647
|
-
// authored turn before its terminal disposition.
|
|
1648
|
-
let errSeq = rowSeq;
|
|
1649
|
-
// max_commands_exceeded IS model-facing: dropped ops the model emitted that didn't run.
|
|
1650
|
-
if (droppedCount > 0)
|
|
1651
|
-
pendingEngineErrors.push("max_commands_exceeded");
|
|
1652
|
-
for (const kind of pendingEngineErrors) {
|
|
1653
|
-
const problem = ENGINE_PROBLEMS[kind];
|
|
1654
|
-
const extensions = kind === "max_commands_exceeded"
|
|
1655
|
-
? {
|
|
1656
|
-
operationLimit: maxCommands,
|
|
1657
|
-
omittedOperations: droppedCount,
|
|
1658
|
-
stage: "dispatch-admission",
|
|
1659
|
-
recovery: "Continue with no more than the configured operation limit.",
|
|
1660
|
-
retryable: false,
|
|
1661
|
-
}
|
|
1662
|
-
: {
|
|
1663
|
-
stage: "turn",
|
|
1664
|
-
recovery: "Perform an operation before continuing with SEND[102].",
|
|
1665
|
-
retryable: false,
|
|
1666
|
-
};
|
|
1667
|
-
await this.#problems.record({
|
|
1668
|
-
workerId,
|
|
1669
|
-
loopId,
|
|
1670
|
-
turnId,
|
|
1671
|
-
sequence: errSeq++,
|
|
1672
|
-
origin: "plurnk",
|
|
1673
|
-
source: "rail",
|
|
1674
|
-
result: Results.failure("engine:rail", problem.code, problem.status, problem.detail, {}, extensions),
|
|
1675
|
-
});
|
|
1676
|
-
}
|
|
1677
|
-
// {§log-row-self-explains} — model-operation failures remain on their
|
|
1678
|
-
// own rows; genuine engine faults fail hard and mint no substitute row.
|
|
1679
|
-
// {§model-entry} — mirror this turn's verbatim emission back as a `model` row, so the NEXT
|
|
1680
|
-
// packet shows the model exactly what it last produced. ALWAYS born FOLDED — the old
|
|
1681
|
-
// born-OPEN-on-error auto-trigger was conditional helpfulness that bred its own hazards
|
|
1682
|
-
// (a 24k-char ramble mirrored open re-injects itself into the next packet: cost,
|
|
1683
|
-
// contamination, pressure feedback).
|
|
1684
|
-
// {§encrypted-reasoning-carrier} — core relays every provider-normalized
|
|
1685
|
-
// item unchanged.
|
|
1686
|
-
const reasoningItems = response.assistant.reasoningEncrypted?.length
|
|
1687
|
-
? response.assistant.reasoningEncrypted
|
|
1688
|
-
: undefined;
|
|
1689
|
-
if (packetAssistant.content.trim().length > 0 || reasoningItems !== undefined) {
|
|
1690
|
-
await this.#dispatcher.writeModelEntry({ verbatim: packetAssistant.content, workerId, loopId, turnId, sequence: errSeq++, folded: true, ...(reasoningItems !== undefined ? { reasoningItems } : {}) });
|
|
1691
|
-
}
|
|
1692
|
-
return {
|
|
1693
|
-
turnId,
|
|
1694
|
-
status: turnStatus,
|
|
1695
|
-
outcomes,
|
|
1696
|
-
fingerprint: StrikeRail.fingerprintTurn(packetAssistant.ops),
|
|
1697
|
-
budgetStruck: enforced.struck,
|
|
1698
|
-
budgetHardStop: false,
|
|
1699
|
-
steerStruck,
|
|
1700
|
-
emissionAttempts,
|
|
1701
|
-
emissionExhausted: false,
|
|
1702
|
-
};
|
|
1703
|
-
}
|
|
1704
|
-
// Split the wire-level ProviderResponse into the two destinations:
|
|
1705
|
-
// packet.assistant gets the model's emission (content, ops, reasoning);
|
|
1706
|
-
// Turn columns get the call-metadata (usage, finishReason, model).
|
|
1707
|
-
// {§provider-surface} Text-fragment scraping policy lives
|
|
1708
|
-
// here — engine owns the parse and the scraping rule, providers stay
|
|
1709
|
-
// grammar-unaware.
|
|
1710
|
-
//
|
|
1711
|
-
// Test-fixture escape hatch: the Mock provider may pre-supply `ops` on
|
|
1712
|
-
// its assistant payload to skip the parse roundtrip. The wire Provider
|
|
1713
|
-
// contract has no `ops` field; only Mock exposes one. Real providers
|
|
1714
|
-
// always take the parse path because their `assistant.ops` is undefined.
|
|
1715
|
-
#splitResponse(response) {
|
|
1716
|
-
const { assistant } = response;
|
|
1717
|
-
const preParsedOps = assistant.ops;
|
|
1718
|
-
const ops = [];
|
|
1719
|
-
// PLAN is an ordinary op — emitted by the model, dispatched, and passed to the
|
|
1720
|
-
// client as a log entry. No special hoisting into the reasoning field. Only
|
|
1721
|
-
// structured operations are executable; interstitial text is not an operation.
|
|
1722
|
-
// Full PlurnkParseError context is preserved on rejected attempt evidence;
|
|
1723
|
-
// warnings remain admissible Notices. {§parse-diagnostics}
|
|
1724
|
-
const parseErrors = [];
|
|
1725
|
-
let hasUnparsedTail = false;
|
|
1726
|
-
const parseNotices = [];
|
|
1727
|
-
if (preParsedOps !== undefined) {
|
|
1728
|
-
ops.push(...preParsedOps);
|
|
1729
|
-
}
|
|
1730
|
-
else {
|
|
1731
|
-
// {§observability-boundary} — the parse is observed without its input;
|
|
1732
|
-
// only the resulting statement count is attributable.
|
|
1733
|
-
const parsed = observedSync("contracts.parse", {}, (span) => {
|
|
1734
|
-
const result = PlurnkParser.parse(assistant.content);
|
|
1735
|
-
span.setAttribute("statements", result.items.filter((item) => item.kind === "statement").length);
|
|
1736
|
-
return result;
|
|
1737
|
-
});
|
|
1738
|
-
for (const item of parsed.items) {
|
|
1739
|
-
if (item.kind === "statement") {
|
|
1740
|
-
ops.push(item.statement);
|
|
1741
|
-
}
|
|
1742
|
-
else if (item.kind === "error") {
|
|
1743
|
-
const err = item.error;
|
|
1744
|
-
if (err instanceof PlurnkParseError) {
|
|
1745
|
-
if (err.severity === "warning") {
|
|
1746
|
-
parseNotices.push({
|
|
1747
|
-
source: "grammar",
|
|
1748
|
-
kind: "parse_advisory",
|
|
1749
|
-
level: "warn",
|
|
1750
|
-
message: err.message,
|
|
1751
|
-
position: {
|
|
1752
|
-
type: "content-offset",
|
|
1753
|
-
line: err.line,
|
|
1754
|
-
column: err.column,
|
|
1755
|
-
},
|
|
1756
|
-
parserSource: err.source,
|
|
1757
|
-
});
|
|
1758
|
-
}
|
|
1759
|
-
else {
|
|
1760
|
-
parseErrors.push({ message: err.message, line: err.line, column: err.column, source: err.source });
|
|
1761
|
-
}
|
|
1762
|
-
}
|
|
1763
|
-
else {
|
|
1764
|
-
const msg = err?.message ?? "parse error";
|
|
1765
|
-
parseErrors.push({ message: msg, line: 0, column: 0, source: "parser" });
|
|
1766
|
-
}
|
|
1767
|
-
}
|
|
1768
|
-
}
|
|
1769
|
-
// Boundary loss is the parser's one public fact from `unparsedTail.from` onward;
|
|
1770
|
-
// preserve it with the rejected forensic attempt. {§unparsed-tail-boundary}
|
|
1771
|
-
const tail = parsed.unparsedTail;
|
|
1772
|
-
if (tail !== undefined) {
|
|
1773
|
-
hasUnparsedTail = true;
|
|
1774
|
-
parseErrors.push({ message: tail.reason, line: tail.from.line, column: tail.from.column, source: "grammar" });
|
|
1775
|
-
}
|
|
1776
|
-
}
|
|
1777
|
-
const plan = ops[0]?.op === "PLAN" ? ops[0] : undefined;
|
|
1778
|
-
const finalOp = ops.at(-1);
|
|
1779
|
-
const terminalSend = finalOp?.op === "SEND"
|
|
1780
|
-
&& typeof finalOp.signal === "number"
|
|
1781
|
-
&& TERMINAL_SEND_SIGNALS.has(finalOp.signal)
|
|
1782
|
-
? finalOp
|
|
1783
|
-
: undefined;
|
|
1784
|
-
const recoverableParseErrors = plan !== undefined && terminalSend !== undefined && !hasUnparsedTail
|
|
1785
|
-
? parseErrors.filter((error) => comparePosition(error, plan.position) > 0
|
|
1786
|
-
&& comparePosition(error, terminalSend.position) < 0).toSorted(comparePosition)
|
|
1787
|
-
: [];
|
|
1788
|
-
const emissionValid = preParsedOps !== undefined
|
|
1789
|
-
|| (plan !== undefined
|
|
1790
|
-
&& terminalSend !== undefined
|
|
1791
|
-
&& !hasUnparsedTail
|
|
1792
|
-
&& recoverableParseErrors.length === parseErrors.length);
|
|
1793
|
-
const reasoning = assistant.reasoning ?? null;
|
|
1794
|
-
return {
|
|
1795
|
-
packetAssistant: { content: assistant.content, ops, reasoning },
|
|
1796
|
-
callMetadata: { usage: assistant.usage, finishReason: assistant.finishReason, model: assistant.model },
|
|
1797
|
-
parseErrors,
|
|
1798
|
-
recoverableParseErrors: emissionValid ? recoverableParseErrors : [],
|
|
1799
|
-
parseNotices,
|
|
1800
|
-
// The ANTLR model-turn parser is authoritative. A trustworthy
|
|
1801
|
-
// PLAN...SEND frame admits bounded interior statement failures so
|
|
1802
|
-
// they become durable operation results. Missing boundaries,
|
|
1803
|
-
// errors outside the frame, and an unparsed tail reject wholesale.
|
|
1804
|
-
// Pre-parsed ops are Mock's trusted test seam.
|
|
1805
|
-
emissionValid,
|
|
1806
|
-
};
|
|
584
|
+
runTurn(args) {
|
|
585
|
+
return this.#turnRunner.runTurn(args);
|
|
1807
586
|
}
|
|
1808
|
-
// #note12 — the plugin-provided reference docs (schemes' + execs' `documentation`),
|
|
1809
|
-
// materialized at worker://plurnk/docs/<name>.md by LoopDocs (like operator docs).
|
|
1810
587
|
docEntries(workspaceId) {
|
|
1811
588
|
return this.#packets.docEntries(workspaceId);
|
|
1812
589
|
}
|
|
1813
590
|
// {§env-delta-log-pull} — materialize one closed interval of the ambient
|
|
1814
591
|
// occurrence journal into this worker's self-contained log. #67 owns only
|
|
1815
592
|
// the remaining model-facing actor-name projection.
|
|
1816
|
-
async #materializeEnvironmentDeltas(args) {
|
|
1817
|
-
const { workspaceId, workerId, loopId, turnId, fromSequence } = args;
|
|
1818
|
-
const rows = await this.#db.engine_pull_ambient_events.all({ workspace_id: workspaceId, worker_id: workerId });
|
|
1819
|
-
const window = rows[0];
|
|
1820
|
-
if (window === undefined)
|
|
1821
|
-
throw new Error(`ambient pull: worker ${workerId} has no observation window`);
|
|
1822
|
-
let written = 0;
|
|
1823
|
-
for (const r of rows) {
|
|
1824
|
-
if (r.event_id === null || r.producer_worker_id === null || r.producer_worker_name === null || r.kind === null
|
|
1825
|
-
|| r.op === null || r.status_rx === null)
|
|
1826
|
-
continue;
|
|
1827
|
-
const termination = r.kind === "loop_termination";
|
|
1828
|
-
if (r.rx === null)
|
|
1829
|
-
throw new Error(`ambient event ${r.event_id} has no materializable result`);
|
|
1830
|
-
const terminal = termination
|
|
1831
|
-
? TerminalResult.parse(r.rx, `ambient loop-termination event ${r.event_id}`)
|
|
1832
|
-
: null;
|
|
1833
|
-
if (terminal !== null && terminal.status !== r.status_rx) {
|
|
1834
|
-
throw new Error(`ambient loop-termination event ${r.event_id} status ${r.status_rx} does not match its terminal result status ${terminal.status}`);
|
|
1835
|
-
}
|
|
1836
|
-
let attrs = r.attrs ?? "{}";
|
|
1837
|
-
if (terminal !== null) {
|
|
1838
|
-
const inherited = JSON.parse(attrs);
|
|
1839
|
-
if (inherited === null || typeof inherited !== "object" || Array.isArray(inherited)) {
|
|
1840
|
-
throw new TypeError(`ambient loop-termination event ${r.event_id} attrs must be an object`);
|
|
1841
|
-
}
|
|
1842
|
-
const receipt = await BranchReceipt.render(this.#db, r.producer_worker_id);
|
|
1843
|
-
attrs = JSON.stringify({
|
|
1844
|
-
...inherited,
|
|
1845
|
-
kind: "loop_termination",
|
|
1846
|
-
...(r.terminated_by === null ? {} : { terminatedBy: r.terminated_by }),
|
|
1847
|
-
...(receipt === null ? {} : { receipt }),
|
|
1848
|
-
});
|
|
1849
|
-
}
|
|
1850
|
-
const inserted = await this.#db.engine_insert_ambient_delta.get({
|
|
1851
|
-
worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
|
|
1852
|
-
event_id: r.event_id,
|
|
1853
|
-
source: r.source ?? WorkerControlAddress.render(r.producer_worker_name),
|
|
1854
|
-
op: r.op,
|
|
1855
|
-
scheme: r.scheme,
|
|
1856
|
-
hostname: r.hostname,
|
|
1857
|
-
pathname: r.pathname,
|
|
1858
|
-
rx: r.rx,
|
|
1859
|
-
mimetype_rx: "application/json",
|
|
1860
|
-
status: r.status_rx,
|
|
1861
|
-
expanded: terminal !== null && terminal.status >= 200 && terminal.status < 300 ? 1 : 0,
|
|
1862
|
-
attrs,
|
|
1863
|
-
});
|
|
1864
|
-
if (inserted !== undefined)
|
|
1865
|
-
written++;
|
|
1866
|
-
}
|
|
1867
|
-
await this.#db.engine_advance_ambient_cursor.get({
|
|
1868
|
-
workspace_id: workspaceId,
|
|
1869
|
-
worker_id: workerId,
|
|
1870
|
-
cursor: window.cursor,
|
|
1871
|
-
boundary: window.boundary,
|
|
1872
|
-
});
|
|
1873
|
-
return written;
|
|
1874
|
-
}
|
|
1875
|
-
// {§exec-poll} — EXEC `<0>` is turn-scoped: abort the worker's open turn-scoped streams via their
|
|
1876
|
-
// owning scheme (the same registry-routed abort the total reap uses). Called at each pre-turn
|
|
1877
|
-
// before the turn's own spawns, so every open turn-scoped sub here is from a prior turn — it
|
|
1878
|
-
// never survives into the subsequent turn. Fire-and-forget: the spawn finalizes async and its
|
|
1879
|
-
// terminal output surfaces born-OPEN through the stream-delta path ({§exec-stream}).
|
|
1880
|
-
async #reapTurnScopedStreams(workerId) {
|
|
1881
|
-
const open = await this.#db.find_open_turn_scoped_subscriptions_for_worker.all({ worker_id: workerId });
|
|
1882
|
-
await Promise.all(open.map(({ id }) => this.#liveSubscriptions.cancel(id)));
|
|
1883
|
-
}
|
|
1884
593
|
cancelSubscription(subscriptionId) {
|
|
1885
594
|
return this.#liveSubscriptions.cancel(subscriptionId);
|
|
1886
595
|
}
|
|
@@ -1889,141 +598,6 @@ export default class Engine {
|
|
|
1889
598
|
// 200 while the channel streams and preserves the exact terminal result when closed. Ongoing
|
|
1890
599
|
// observations fold; the terminal observation auto-OPENs. The cursor is the streamEnd recorded
|
|
1891
600
|
// on the channel's prior observation. {§exec-stream}
|
|
1892
|
-
async #materializeStreamDeltas(args) {
|
|
1893
|
-
const { workerId, loopId, turnId, fromSequence } = args;
|
|
1894
|
-
const channels = await this.#db.engine_worker_stream_channels.all({ worker_id: workerId });
|
|
1895
|
-
let written = 0;
|
|
1896
|
-
for (const ch of channels) {
|
|
1897
|
-
// Default channels are an implementation detail. Preserve the
|
|
1898
|
-
// channel internally on the entry/subscription, but present the
|
|
1899
|
-
// ordinary address to the model; only an explicitly non-default
|
|
1900
|
-
// channel earns a fragment in the log.
|
|
1901
|
-
const visibleFragment = ch.published_channel !== null
|
|
1902
|
-
&& ch.channel === this.#schemes.defaultChannelFor(ch.runtime)
|
|
1903
|
-
? null
|
|
1904
|
-
: ch.channel;
|
|
1905
|
-
const prior = await this.#db.engine_stream_cursor.get({
|
|
1906
|
-
worker_id: workerId, scheme: ch.runtime, pathname: ch.coord, fragment: visibleFragment,
|
|
1907
|
-
});
|
|
1908
|
-
const priorAttrs = prior !== undefined ? JSON.parse(prior.attrs) : {};
|
|
1909
|
-
const cursor = priorAttrs.streamEnd ?? 0;
|
|
1910
|
-
const closed = ch.state === "closed" || ch.state === "errored";
|
|
1911
|
-
const terminal = closed
|
|
1912
|
-
? Results.assert(JSON.parse(ch.close_result ?? "null"))
|
|
1913
|
-
: null;
|
|
1914
|
-
const terminalResult = async (fields, sequence) => {
|
|
1915
|
-
if (terminal === null)
|
|
1916
|
-
throw new Error(`closed subscription ${ch.subscription_id} has no terminal result`);
|
|
1917
|
-
const result = Results.assert({
|
|
1918
|
-
...terminal,
|
|
1919
|
-
...(terminal.problem === undefined ? {} : { problem: { ...terminal.problem } }),
|
|
1920
|
-
...fields,
|
|
1921
|
-
});
|
|
1922
|
-
if (result.problem !== undefined) {
|
|
1923
|
-
const seqs = await this.#db.engine_loop_turn_seqs.get({
|
|
1924
|
-
loop_id: loopId,
|
|
1925
|
-
turn_id: turnId,
|
|
1926
|
-
});
|
|
1927
|
-
if (seqs === undefined)
|
|
1928
|
-
throw new Error(`stream delta has no log coordinate for loop=${loopId} turn=${turnId}`);
|
|
1929
|
-
Results.attachInstance(result, `log:///${seqs.loop_seq}/${seqs.turn_seq}/${sequence}/READ`);
|
|
1930
|
-
}
|
|
1931
|
-
return result;
|
|
1932
|
-
};
|
|
1933
|
-
const publishEnd = streamPublicationEnd(ch.content, ch.mimetype, cursor, closed);
|
|
1934
|
-
if (publishEnd <= cursor) {
|
|
1935
|
-
// The cursor-terminal race: a channel written in one final
|
|
1936
|
-
// burst gets fully shown FOLDED while still active; the close then has zero new
|
|
1937
|
-
// content and the auto-OPEN terminal observation never fired — the model was never shown
|
|
1938
|
-
// the conclusion of a stream whose result it already holds folded. The same
|
|
1939
|
-
// observation is required when the channel produced no publishable content:
|
|
1940
|
-
// completion is information independently of payload. Text streams retain their
|
|
1941
|
-
// terse marker; structured channels emit a bodyless typed conclusion.
|
|
1942
|
-
if (closed && priorAttrs.terminal !== true) {
|
|
1943
|
-
const streamTarget = renderTarget({
|
|
1944
|
-
scheme: ch.runtime,
|
|
1945
|
-
pathname: ch.coord,
|
|
1946
|
-
fragment: visibleFragment,
|
|
1947
|
-
});
|
|
1948
|
-
if (streamTarget === null)
|
|
1949
|
-
throw new Error(`stream ${ch.subscription_id} has no renderable address`);
|
|
1950
|
-
const pointer = cursor > 0
|
|
1951
|
-
? `full output already delivered above; READ ${streamTarget} to revisit`
|
|
1952
|
-
: "stream produced no output";
|
|
1953
|
-
const sequence = fromSequence + written;
|
|
1954
|
-
const content = baseMimetype(ch.mimetype).startsWith("text/")
|
|
1955
|
-
? `[ stream closed (${ch.close_status ?? 200}) - ${pointer} ]`
|
|
1956
|
-
: "";
|
|
1957
|
-
await this.#db.engine_insert_stream_delta.run({
|
|
1958
|
-
worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence,
|
|
1959
|
-
scheme: ch.runtime, pathname: ch.coord, fragment: visibleFragment,
|
|
1960
|
-
rx: JSON.stringify(await terminalResult({
|
|
1961
|
-
content,
|
|
1962
|
-
mimetype: ch.mimetype,
|
|
1963
|
-
}, sequence)),
|
|
1964
|
-
status: terminal?.status ?? 200,
|
|
1965
|
-
attrs: JSON.stringify({ streamEnd: ch.content.length, terminal: true }),
|
|
1966
|
-
expanded: 1,
|
|
1967
|
-
});
|
|
1968
|
-
written++;
|
|
1969
|
-
}
|
|
1970
|
-
continue;
|
|
1971
|
-
}
|
|
1972
|
-
// startLine continues the line count across turns: a multi-turn stream's deltas number
|
|
1973
|
-
// into one sequence (lines N..M, then M+1..), not N independent "1:" restarts. {§exec-stream}
|
|
1974
|
-
const startLine = (ch.content.slice(0, cursor).match(/\n/g)?.length ?? 0) + 1;
|
|
1975
|
-
const sequence = fromSequence + written;
|
|
1976
|
-
const content = ch.content.slice(cursor, publishEnd);
|
|
1977
|
-
const terminalDelivery = closed && publishEnd === ch.content.length;
|
|
1978
|
-
const fields = { content, mimetype: ch.mimetype, startLine };
|
|
1979
|
-
const result = terminalDelivery
|
|
1980
|
-
? await terminalResult(fields, sequence)
|
|
1981
|
-
: { status: 200, ...fields };
|
|
1982
|
-
await this.#db.engine_insert_stream_delta.run({
|
|
1983
|
-
worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence,
|
|
1984
|
-
scheme: ch.runtime, pathname: ch.coord, fragment: visibleFragment,
|
|
1985
|
-
rx: JSON.stringify(result),
|
|
1986
|
-
status: result.status,
|
|
1987
|
-
attrs: JSON.stringify({ streamEnd: publishEnd, terminal: terminalDelivery }),
|
|
1988
|
-
expanded: terminalDelivery ? 1 : 0, // {§exec-stream} — terminal observation auto-OPENs; ongoing folds
|
|
1989
|
-
});
|
|
1990
|
-
written++;
|
|
1991
|
-
}
|
|
1992
|
-
return written;
|
|
1993
|
-
}
|
|
1994
|
-
// {§env-delta-filesystem-narration} {§membership-emi-divergence-signal}
|
|
1995
|
-
// — journal project-file divergence once through the reserved actor.
|
|
1996
|
-
async #logFsFictions(workspaceId, divergences, gitStatus) {
|
|
1997
|
-
if (divergences.length === 0)
|
|
1998
|
-
return;
|
|
1999
|
-
const gitByPath = new Map(gitStatus?.files.map(({ path, status }) => [path, status]) ?? []);
|
|
2000
|
-
const worker = await this.#db.envelope_get_worker_by_name.get({ workspace_id: workspaceId, name: "plurnk" })
|
|
2001
|
-
?? await this.#db.envelope_insert_worker.get({ workspace_id: workspaceId, name: "plurnk", origin: "plurnk" });
|
|
2002
|
-
if (worker === undefined)
|
|
2003
|
-
throw new Error("logFsFictions: plurnk worker resolution returned no row");
|
|
2004
|
-
const loop = await this.#db.envelope_insert_client_loop.get({ worker_id: worker.id });
|
|
2005
|
-
if (loop === undefined)
|
|
2006
|
-
throw new Error("logFsFictions: loop insert returned no row");
|
|
2007
|
-
const turn = await JournalTurn.insert(this.#db, loop.id);
|
|
2008
|
-
let sequence = 1;
|
|
2009
|
-
for (const d of divergences) {
|
|
2010
|
-
const span = editedSpan(d.before, d.after);
|
|
2011
|
-
await this.#db.engine_insert_log_entry.get({
|
|
2012
|
-
worker_id: worker.id, loop_id: loop.id, turn_id: turn.id, sequence: sequence++,
|
|
2013
|
-
origin: "plurnk", source: "file", op: "EDIT", suffix: "", signal: null,
|
|
2014
|
-
// Match Dispatcher.#extractTarget: a bare file address has NULL scheme
|
|
2015
|
-
// only in log target metadata; its entry identity remains `file`.
|
|
2016
|
-
scheme: null, username: null, password: null, hostname: null, port: null,
|
|
2017
|
-
pathname: d.pathname, query: null, fragment: null, lineMarker: null,
|
|
2018
|
-
tx: "", mimetype_tx: "text/plain",
|
|
2019
|
-
rx: JSON.stringify({ status: 200, entryId: d.entryId, channel: d.channel, span }), mimetype_rx: "application/json",
|
|
2020
|
-
status_rx: 200, tokens: 0, state: "resolved", outcome: null,
|
|
2021
|
-
attrs: gitByPath.has(d.pathname)
|
|
2022
|
-
? JSON.stringify({ git: gitByPath.get(d.pathname) })
|
|
2023
|
-
: "{}",
|
|
2024
|
-
});
|
|
2025
|
-
}
|
|
2026
|
-
}
|
|
2027
601
|
async dispatch(context) {
|
|
2028
602
|
return observed(// {§observability-boundary}
|
|
2029
603
|
"op.dispatch", { op: context.statement.op }, async (span) => {
|
|
@@ -2043,43 +617,6 @@ export default class Engine {
|
|
|
2043
617
|
async resolveEntryAddress(context) {
|
|
2044
618
|
return this.#dispatcher.resolveEntryAddress(context);
|
|
2045
619
|
}
|
|
2046
|
-
async #writePromptLog({ workerId, loopId, turnId, sequence, target, content, }) {
|
|
2047
|
-
const row = await this.#db.engine_insert_log_entry.get({
|
|
2048
|
-
worker_id: workerId,
|
|
2049
|
-
loop_id: loopId,
|
|
2050
|
-
turn_id: turnId,
|
|
2051
|
-
sequence,
|
|
2052
|
-
origin: "plurnk",
|
|
2053
|
-
source: null,
|
|
2054
|
-
op: "prompt",
|
|
2055
|
-
suffix: "",
|
|
2056
|
-
signal: null,
|
|
2057
|
-
scheme: target.scheme,
|
|
2058
|
-
username: target.username,
|
|
2059
|
-
password: target.password,
|
|
2060
|
-
hostname: target.hostname,
|
|
2061
|
-
port: target.port,
|
|
2062
|
-
pathname: target.pathname,
|
|
2063
|
-
query: target.query,
|
|
2064
|
-
fragment: target.fragment,
|
|
2065
|
-
lineMarker: null,
|
|
2066
|
-
tx: "",
|
|
2067
|
-
mimetype_tx: "text/plain",
|
|
2068
|
-
rx: JSON.stringify({ content, mimetype: "text/markdown" }),
|
|
2069
|
-
mimetype_rx: "application/json",
|
|
2070
|
-
status_rx: 200,
|
|
2071
|
-
tokens: this.#tokenize(content),
|
|
2072
|
-
state: "resolved",
|
|
2073
|
-
outcome: null,
|
|
2074
|
-
attrs: "{}",
|
|
2075
|
-
});
|
|
2076
|
-
if (row === undefined)
|
|
2077
|
-
throw new Error("Engine.#writePromptLog: INSERT ... RETURNING produced no row");
|
|
2078
|
-
return row.id;
|
|
2079
|
-
}
|
|
2080
|
-
// External API to feed a resolution into a pending proposal — the client-interface
|
|
2081
|
-
// seam, core-owned disposition, or the timeout watcher.
|
|
2082
|
-
// {§worker-lifecycle-total-reap}: release every stopped-world waiter before joining drains.
|
|
2083
620
|
cancelAllProposals(outcome) {
|
|
2084
621
|
this.#proposals.cancelAll(outcome);
|
|
2085
622
|
}
|
|
@@ -2117,7 +654,7 @@ export default class Engine {
|
|
|
2117
654
|
signal: undefined,
|
|
2118
655
|
streamEventNotify: this.#streamEventNotify,
|
|
2119
656
|
wakeWorkerNotify: this.#wakeWorkerNotify,
|
|
2120
|
-
|
|
657
|
+
weigh: this.#weighContent,
|
|
2121
658
|
mimetypes: this.#mimetypes,
|
|
2122
659
|
defaultChannelFor: (s) => this.#schemes.defaultChannelFor(s),
|
|
2123
660
|
pushNotice: (notice) => this.#notices.notify(workspaceId, 0, notice),
|
|
@@ -2172,12 +709,11 @@ export default class Engine {
|
|
|
2172
709
|
signal: this.#loopAborts.get(loopId)?.signal,
|
|
2173
710
|
streamEventNotify: this.#streamEventNotify,
|
|
2174
711
|
wakeWorkerNotify: this.#wakeWorkerNotify,
|
|
2175
|
-
|
|
712
|
+
weigh: this.#weighContent,
|
|
2176
713
|
pushNotice: (notice) => this.#notices.push(workspaceRow.workspace_id, loopId, notice),
|
|
2177
714
|
};
|
|
2178
715
|
const entry = {
|
|
2179
716
|
channels: { body: { content: prompt, mimetype: "text/markdown" } },
|
|
2180
|
-
tags: [],
|
|
2181
717
|
attributes: { openPaths },
|
|
2182
718
|
};
|
|
2183
719
|
await EntryCrud.writeEntry(pathname, entry, ctx, "prompt", workerId);
|