@plurnk/plurnk-service 1.0.4 → 1.0.6
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 +55 -31
- package/INSTALL.md +3 -3
- package/README.md +4 -4
- package/SPEC.md +205 -193
- package/dist/Paths.js +1 -1
- package/dist/Paths.js.map +1 -1
- package/dist/core/ChannelWrite.d.ts +18 -18
- package/dist/core/ChannelWrite.d.ts.map +1 -1
- package/dist/core/ChannelWrite.js +17 -17
- package/dist/core/ChannelWrite.js.map +1 -1
- package/dist/core/ChannelWrite.sql +11 -11
- package/dist/core/Dispatcher.d.ts +11 -16
- package/dist/core/Dispatcher.d.ts.map +1 -1
- package/dist/core/Dispatcher.js +122 -125
- package/dist/core/Dispatcher.js.map +1 -1
- package/dist/core/Engine.d.ts +20 -23
- package/dist/core/Engine.d.ts.map +1 -1
- package/dist/core/Engine.js +213 -210
- package/dist/core/Engine.js.map +1 -1
- package/dist/core/Engine.sql +108 -130
- package/dist/core/PacketBuilder.d.ts +10 -11
- package/dist/core/PacketBuilder.d.ts.map +1 -1
- package/dist/core/PacketBuilder.js +204 -128
- package/dist/core/PacketBuilder.js.map +1 -1
- package/dist/core/ProposalLifecycle.d.ts +8 -8
- package/dist/core/ProposalLifecycle.d.ts.map +1 -1
- package/dist/core/ProposalLifecycle.js +10 -10
- package/dist/core/ProposalLifecycle.js.map +1 -1
- package/dist/core/ProviderInstantiate.d.ts +1 -0
- package/dist/core/ProviderInstantiate.d.ts.map +1 -1
- package/dist/core/ProviderInstantiate.js +9 -3
- package/dist/core/ProviderInstantiate.js.map +1 -1
- package/dist/core/SchemeRegistry.d.ts +4 -1
- package/dist/core/SchemeRegistry.d.ts.map +1 -1
- package/dist/core/SchemeRegistry.js +18 -9
- package/dist/core/SchemeRegistry.js.map +1 -1
- package/dist/core/TelemetryChannel.d.ts +5 -5
- package/dist/core/TelemetryChannel.d.ts.map +1 -1
- package/dist/core/TelemetryChannel.js +9 -9
- package/dist/core/TelemetryChannel.js.map +1 -1
- package/dist/core/caps/CapsResolve.js +2 -2
- package/dist/core/caps/CapsResolve.js.map +1 -1
- package/dist/core/caps/DbNotifyCaps.js +2 -2
- package/dist/core/caps/DbNotifyCaps.js.map +1 -1
- package/dist/core/caps/DbSubscriptionCaps.js +7 -7
- package/dist/core/caps/DbSubscriptionCaps.js.map +1 -1
- package/dist/core/caps/SchemeCtxImpl.d.ts +2 -2
- package/dist/core/caps/SchemeCtxImpl.d.ts.map +1 -1
- package/dist/core/caps/SchemeCtxImpl.js +4 -4
- package/dist/core/caps/SchemeCtxImpl.js.map +1 -1
- package/dist/core/content-hash.d.ts +2 -0
- package/dist/core/content-hash.d.ts.map +1 -0
- package/dist/core/content-hash.js +6 -0
- package/dist/core/content-hash.js.map +1 -0
- package/dist/core/fork.d.ts +1 -1
- package/dist/core/fork.d.ts.map +1 -1
- package/dist/core/fork.js +31 -26
- package/dist/core/fork.js.map +1 -1
- package/dist/core/fork.sql +41 -32
- package/dist/core/git-env.d.ts.map +1 -1
- package/dist/core/git-env.js +23 -7
- package/dist/core/git-env.js.map +1 -1
- package/dist/core/git-iso.d.ts +9 -0
- package/dist/core/git-iso.d.ts.map +1 -0
- package/dist/core/git-iso.js +227 -0
- package/dist/core/git-iso.js.map +1 -0
- package/dist/core/git-membership.d.ts +2 -2
- package/dist/core/git-membership.d.ts.map +1 -1
- package/dist/core/git-membership.js +70 -50
- package/dist/core/git-membership.js.map +1 -1
- package/dist/core/git-state.d.ts +1 -1
- package/dist/core/git-state.d.ts.map +1 -1
- package/dist/core/git-state.js +21 -11
- package/dist/core/git-state.js.map +1 -1
- package/dist/core/packet-wire.d.ts.map +1 -1
- package/dist/core/packet-wire.js +81 -22
- package/dist/core/packet-wire.js.map +1 -1
- package/dist/core/plurnk-uri.d.ts +2 -2
- package/dist/core/plurnk-uri.d.ts.map +1 -1
- package/dist/core/plurnk-uri.js +19 -12
- package/dist/core/plurnk-uri.js.map +1 -1
- package/dist/core/run-ops.sql +26 -26
- package/dist/core/scheme-types.d.ts +6 -11
- package/dist/core/scheme-types.d.ts.map +1 -1
- package/dist/core/search-gate.js +0 -0
- package/dist/core/search-gate.js.map +1 -1
- package/dist/core/token-ruler.d.ts +2 -0
- package/dist/core/token-ruler.d.ts.map +1 -0
- package/dist/core/token-ruler.js +18 -0
- package/dist/core/token-ruler.js.map +1 -0
- package/dist/core/worker-cap.d.ts +8 -0
- package/dist/core/worker-cap.d.ts.map +1 -0
- package/dist/core/worker-cap.js +20 -0
- package/dist/core/worker-cap.js.map +1 -0
- package/dist/core/{session-settings.d.ts → workspace-settings.d.ts} +5 -5
- package/dist/core/workspace-settings.d.ts.map +1 -0
- package/dist/core/{session-settings.js → workspace-settings.js} +13 -13
- package/dist/core/workspace-settings.js.map +1 -0
- package/dist/digest/Digest.d.ts +3 -3
- package/dist/digest/Digest.d.ts.map +1 -1
- package/dist/digest/Digest.js +79 -73
- package/dist/digest/Digest.js.map +1 -1
- package/dist/digest/digest.sql +18 -18
- package/dist/schemes/Exec.d.ts +9 -2
- package/dist/schemes/Exec.d.ts.map +1 -1
- package/dist/schemes/Exec.js +121 -68
- package/dist/schemes/Exec.js.map +1 -1
- package/dist/schemes/ExecOutputScheme.js +3 -3
- package/dist/schemes/ExecOutputScheme.js.map +1 -1
- package/dist/schemes/File.js +23 -23
- package/dist/schemes/File.js.map +1 -1
- package/dist/schemes/Known.js +5 -5
- package/dist/schemes/Known.js.map +1 -1
- package/dist/schemes/Log.d.ts.map +1 -1
- package/dist/schemes/Log.js +63 -14
- package/dist/schemes/Log.js.map +1 -1
- package/dist/schemes/Log.sql +49 -8
- package/dist/schemes/Plurnk.js +5 -5
- package/dist/schemes/Plurnk.js.map +1 -1
- package/dist/schemes/Skill.js +5 -5
- package/dist/schemes/Skill.js.map +1 -1
- package/dist/schemes/Unknown.js +5 -5
- package/dist/schemes/Unknown.js.map +1 -1
- package/dist/schemes/{Run.d.ts → Worker.d.ts} +2 -2
- package/dist/schemes/Worker.d.ts.map +1 -0
- package/dist/schemes/{Run.js → Worker.js} +67 -67
- package/dist/schemes/Worker.js.map +1 -0
- package/dist/schemes/_entry-crud.js +11 -11
- package/dist/schemes/_entry-crud.js.map +1 -1
- package/dist/schemes/_entry-crud.sql +29 -29
- package/dist/schemes/_entry-find.d.ts +2 -1
- package/dist/schemes/_entry-find.d.ts.map +1 -1
- package/dist/schemes/_entry-find.js +31 -19
- package/dist/schemes/_entry-find.js.map +1 -1
- package/dist/schemes/_entry-find.sql +9 -9
- package/dist/schemes/_entry-graph.d.ts +2 -2
- package/dist/schemes/_entry-graph.d.ts.map +1 -1
- package/dist/schemes/_entry-graph.js +18 -18
- package/dist/schemes/_entry-graph.js.map +1 -1
- package/dist/schemes/_entry-graph.sql +8 -8
- package/dist/schemes/_entry-manifest.d.ts +1 -1
- package/dist/schemes/_entry-manifest.d.ts.map +1 -1
- package/dist/schemes/_entry-manifest.js +74 -50
- package/dist/schemes/_entry-manifest.js.map +1 -1
- package/dist/schemes/_entry-ops.d.ts +4 -4
- package/dist/schemes/_entry-ops.d.ts.map +1 -1
- package/dist/schemes/_entry-ops.js +25 -25
- package/dist/schemes/_entry-ops.js.map +1 -1
- package/dist/schemes/_entry-ops.sql +9 -9
- package/dist/schemes/_entry-semantic.d.ts +2 -1
- package/dist/schemes/_entry-semantic.d.ts.map +1 -1
- package/dist/schemes/_entry-semantic.js +23 -4
- package/dist/schemes/_entry-semantic.js.map +1 -1
- package/dist/schemes/_entry-semantic.sql +18 -4
- package/dist/schemes/_entry-send.d.ts +1 -1
- package/dist/schemes/_entry-send.d.ts.map +1 -1
- package/dist/schemes/_entry-send.js +6 -6
- package/dist/schemes/_entry-send.js.map +1 -1
- package/dist/schemes/exec-abort.js +1 -1
- package/dist/schemes/exec-abort.js.map +1 -1
- package/dist/server/Daemon.d.ts +50 -48
- package/dist/server/Daemon.d.ts.map +1 -1
- package/dist/server/Daemon.js +307 -297
- package/dist/server/Daemon.js.map +1 -1
- package/dist/server/client-input.js +20 -20
- package/dist/server/client-input.js.map +1 -1
- package/dist/server/dispatch-as-plurnk.d.ts +1 -1
- package/dist/server/dispatch-as-plurnk.d.ts.map +1 -1
- package/dist/server/dispatch-as-plurnk.js +7 -7
- package/dist/server/dispatch-as-plurnk.js.map +1 -1
- package/dist/server/drain.sql +27 -27
- package/dist/server/envelope.d.ts +20 -20
- package/dist/server/envelope.d.ts.map +1 -1
- package/dist/server/envelope.js +86 -86
- package/dist/server/envelope.js.map +1 -1
- package/dist/server/envelope.sql +39 -39
- package/dist/server/logEntry.d.ts +1 -1
- package/dist/server/logEntry.d.ts.map +1 -1
- package/dist/server/logEntry.js +1 -1
- package/dist/server/logEntry.js.map +1 -1
- package/dist/server/loop-model.d.ts +3 -0
- package/dist/server/loop-model.d.ts.map +1 -0
- package/dist/server/loop-model.js +26 -0
- package/dist/server/loop-model.js.map +1 -0
- package/dist/server/loopDocs.d.ts +1 -1
- package/dist/server/loopDocs.d.ts.map +1 -1
- package/dist/server/loopDocs.js +6 -6
- package/dist/server/loopDocs.js.map +1 -1
- package/dist/server/seam-entry-read.sql +2 -2
- package/dist/server/seam-log-read.sql +3 -3
- package/dist/server/seam-loop.sql +3 -3
- package/dist/server/seam-proposal-list.sql +4 -4
- package/dist/server/yolo.js +2 -2
- package/dist/server/yolo.js.map +1 -1
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +21 -6
- package/dist/service.js.map +1 -1
- package/migrations/0000-00-00.01_schema.sql +84 -85
- package/package.json +17 -14
- package/dist/core/TokenGauge.d.ts +0 -16
- package/dist/core/TokenGauge.d.ts.map +0 -1
- package/dist/core/TokenGauge.js +0 -58
- package/dist/core/TokenGauge.js.map +0 -1
- package/dist/core/run-cap.d.ts +0 -8
- package/dist/core/run-cap.d.ts.map +0 -1
- package/dist/core/run-cap.js +0 -20
- package/dist/core/run-cap.js.map +0 -1
- package/dist/core/search-prefetch.d.ts +0 -17
- package/dist/core/search-prefetch.d.ts.map +0 -1
- package/dist/core/search-prefetch.js +0 -164
- package/dist/core/search-prefetch.js.map +0 -1
- package/dist/core/session-settings.d.ts.map +0 -1
- package/dist/core/session-settings.js.map +0 -1
- package/dist/schemes/Run.d.ts.map +0 -1
- package/dist/schemes/Run.js.map +0 -1
package/dist/server/Daemon.js
CHANGED
|
@@ -17,7 +17,10 @@ import LoopDocs from "./loopDocs.js";
|
|
|
17
17
|
import GitMembership from "../core/git-membership.js";
|
|
18
18
|
import Fork from "../core/fork.js";
|
|
19
19
|
import { promptLoopPrefix } from "../core/plurnk-uri.js";
|
|
20
|
+
import { rulerCount } from "../core/token-ruler.js";
|
|
20
21
|
import { parseAliasesFromEnv, resolveActiveAlias } from "@plurnk/plurnk-providers";
|
|
22
|
+
import ProviderInstantiate from "../core/ProviderInstantiate.js";
|
|
23
|
+
import { resolveLoopAlias } from "./loop-model.js";
|
|
21
24
|
import Yolo from "./yolo.js";
|
|
22
25
|
import NoProposals from "./noProposals.js";
|
|
23
26
|
import { DEFAULT_LOOP_FLAGS } from "../core/scheme-types.js";
|
|
@@ -34,7 +37,7 @@ export default class Daemon {
|
|
|
34
37
|
// module (plurnk-agui) subscribes and fans out to its OWN clients; core emits, never fans out
|
|
35
38
|
// for it. The WS fan-out below is legacy scaffolding that retires at the AG-UI+ cutover.
|
|
36
39
|
#eventSubscribers = new Set();
|
|
37
|
-
// Run-level drain registry. At most one drain per
|
|
40
|
+
// Run-level drain registry. At most one drain per worker. The stored object
|
|
38
41
|
// is the drain's identity handle: start/exit compare it by reference so a
|
|
39
42
|
// drain exiting never clobbers a successor that raced in, and a loop
|
|
40
43
|
// enqueued during teardown is never stranded. A drain is a pure queue
|
|
@@ -48,18 +51,18 @@ export default class Daemon {
|
|
|
48
51
|
// cancel self-aborts against the already-aborted signal (no race). Outlives
|
|
49
52
|
// any single (ephemeral) drain; replaced with a fresh controller once
|
|
50
53
|
// aborted so a later loop.run isn't born cancelled.
|
|
51
|
-
#
|
|
52
|
-
// grammar 0.74.20 EXEC `<T,P>` — per-
|
|
54
|
+
#workerAborts = new Map();
|
|
55
|
+
// grammar 0.74.20 EXEC `<T,P>` — per-worker hibernation poll-wake timer. When a loop parks at
|
|
53
56
|
// a park with a polled stream, a timer fires every P seconds to resume it (§exec-poll). One
|
|
54
|
-
// per
|
|
57
|
+
// per worker (the tightest cadence); cleared/replaced on each park and on cancel.
|
|
55
58
|
#parkTimers = new Map();
|
|
56
59
|
#pollTimers = new Map();
|
|
57
|
-
// Per-run drain-transition lock — see #withDrainLock (R4 / §
|
|
60
|
+
// Per-run drain-transition lock — see #withDrainLock (R4 / §worker-lifecycle-single-drain).
|
|
58
61
|
#drainLocks = new Map();
|
|
59
|
-
// §
|
|
60
|
-
// mid-turn (not yet slept), so #
|
|
62
|
+
// §worker-lifecycle-child-wake — runs OWED a wake: a child/stream conclusion fired while the worker was
|
|
63
|
+
// mid-turn (not yet slept), so #wakeParkedWorker could not resume it. A worker-run conclusion is a
|
|
61
64
|
// BOUNDED, lossless wake (a worker always concludes), so a hibernation awaiting one MUST return —
|
|
62
|
-
// never deadlock. The drain honors the owed wake at the
|
|
65
|
+
// never deadlock. The drain honors the owed wake at the worker's next park, closing the conclude-
|
|
63
66
|
// before-park race. (Only a live exec stream, unbounded absent a timeout, may hold a park open.)
|
|
64
67
|
#owedWakes = new Set();
|
|
65
68
|
constructor({ db, schemes, mimetypes, provider, nodeModulesPath, }) {
|
|
@@ -72,43 +75,43 @@ export default class Daemon {
|
|
|
72
75
|
// in-repo tests. discover() takes a cwd and joins node_modules, so derive the parent.
|
|
73
76
|
this.#nodeModulesPath = nodeModulesPath ?? resolve(process.cwd(), "node_modules");
|
|
74
77
|
this.#discoveryCwd = dirname(this.#nodeModulesPath);
|
|
75
|
-
// Mimetypes owns discovery + detection;
|
|
76
|
-
//
|
|
78
|
+
// Mimetypes owns discovery + detection; default mimetype text/markdown. (Token counting
|
|
79
|
+
// is NOT wired here — the engine's ruler below is §tokenomics-agnostic-ruler.)
|
|
77
80
|
this.#mimetypes = mimetypes ?? new Mimetypes({
|
|
78
81
|
defaultMimetype: "text/markdown",
|
|
79
82
|
discoverOptions: { cwd: this.#discoveryCwd },
|
|
80
83
|
});
|
|
81
84
|
this.#engine = new Engine({
|
|
82
85
|
db, schemes: this.#schemes, mimetypes: this.#mimetypes,
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
// write
|
|
86
|
-
//
|
|
87
|
-
tokenize:
|
|
88
|
-
streamEventNotify: (
|
|
89
|
-
|
|
90
|
-
//
|
|
86
|
+
// §tokenomics-agnostic-ruler — the ONE model-facing token ruler (chars/2), NOT the
|
|
87
|
+
// boot provider: token accounting is workspace-wide across many concurrent models, so
|
|
88
|
+
// the write-time + catalog counts must be model-independent. Exact per-model counting
|
|
89
|
+
// lives only at the packet-materialization fit-gate.
|
|
90
|
+
tokenize: rulerCount,
|
|
91
|
+
streamEventNotify: (workspaceId, event) => this.notifyStreamEvent(workspaceId, event),
|
|
92
|
+
wakeWorkerNotify: (payload) => { void this.#handleWakeWorker(payload); },
|
|
93
|
+
// worker:// loop-start primitive — spawn/fork/irc deliver through
|
|
91
94
|
// Daemon.inject (active sister → fold; idle → enqueue + drain). The
|
|
92
|
-
// daemon owns provider + the law-file system prompt; the
|
|
95
|
+
// daemon owns provider + the law-file system prompt; the worker scheme
|
|
93
96
|
// handler carries neither. Fire-and-forget: the returned drain runs
|
|
94
|
-
// independently (the sister is its own
|
|
95
|
-
|
|
97
|
+
// independently (the sister is its own worker). §machine-processes
|
|
98
|
+
injectWorker: async ({ workspaceId, workerId, prompt, flags }) => {
|
|
96
99
|
if (this.#provider === null)
|
|
97
|
-
throw new Error("
|
|
100
|
+
throw new Error("injectWorker: no provider configured");
|
|
98
101
|
const systemPrompt = await readFile(Paths.instructionsSystem, "utf8");
|
|
99
|
-
const { action, loopId } = await this.inject({
|
|
102
|
+
const { action, loopId } = await this.inject({ workspaceId, workerId, prompt, provider: this.#provider, systemPrompt, ...(flags === undefined ? {} : { flags }) });
|
|
100
103
|
return { action, loopId };
|
|
101
104
|
},
|
|
102
|
-
//
|
|
105
|
+
// worker:// KILL (terminate) — abort any worker's in-flight work by id. One
|
|
103
106
|
// abort, one scope (Daemon.cancelDrain): the active loop closes 499,
|
|
104
107
|
// background streams tear down. Whoever holds the address may end it.
|
|
105
|
-
|
|
106
|
-
telemetryEventNotify: (
|
|
108
|
+
cancelWorker: (workerId) => this.cancelDrain(workerId, "killed via worker:// KILL"),
|
|
109
|
+
telemetryEventNotify: (workspaceId, payload) => this.notifyTelemetryEvent(workspaceId, payload),
|
|
107
110
|
});
|
|
108
111
|
// Wire proposal-pending events to the loop/proposal WS notification.
|
|
109
|
-
// Sessionid scopes the broadcast to clients on the same
|
|
112
|
+
// Sessionid scopes the broadcast to clients on the same workspace.
|
|
110
113
|
this.#engine.onProposalPending((event) => {
|
|
111
|
-
this.#broadcast({
|
|
114
|
+
this.#broadcast({ workspaceId: event.workspaceId }, "loop/proposal", {
|
|
112
115
|
logEntryId: event.logEntryId,
|
|
113
116
|
loopId: event.loopId,
|
|
114
117
|
turnId: event.turnId,
|
|
@@ -132,56 +135,69 @@ export default class Daemon {
|
|
|
132
135
|
NoProposals.attachNoProposals(this.#engine, this.#db);
|
|
133
136
|
}
|
|
134
137
|
// The client-interface seam (#355). A transport module subscribes to the daemon's in-process
|
|
135
|
-
// event source: it receives every
|
|
138
|
+
// event source: it receives every workspace-scoped engine event as `(workspaceId, method, params)`
|
|
136
139
|
// and fans out to its OWN clients — core emits, it never fans out for the module. Returns an
|
|
137
|
-
// unsubscribe. `
|
|
140
|
+
// unsubscribe. `workspaceId` is the event's workspace, or null for a global event (e.g. workspace/created).
|
|
138
141
|
// The engine and its events are core; the fan-out belongs to the module.
|
|
139
142
|
subscribeToEvents(handler) {
|
|
140
143
|
this.#eventSubscribers.add(handler);
|
|
141
144
|
return () => { this.#eventSubscribers.delete(handler); };
|
|
142
145
|
}
|
|
143
146
|
// The client-interface seam (#355) — proposal HITL. A transport module reads the stopped-world
|
|
144
|
-
// proposals for a
|
|
147
|
+
// proposals for a workspace (rendering each as a TOOL_CALL) and feeds back the human's decision. The
|
|
145
148
|
// gate, validation, and applyResolution stay core (Engine.resolveProposal); the seam is the read +
|
|
146
149
|
// the resolve, never the mechanism. `resolveProposal` throws for an unknown/already-resolved id.
|
|
147
|
-
async pendingProposals(
|
|
148
|
-
return this.#db.proposal_list_pending.all({
|
|
150
|
+
async pendingProposals(workspaceId) {
|
|
151
|
+
return this.#db.proposal_list_pending.all({ workspace_id: workspaceId });
|
|
149
152
|
}
|
|
150
153
|
resolveProposal(logEntryId, resolution) {
|
|
151
154
|
this.#engine.resolveProposal(logEntryId, resolution);
|
|
152
155
|
}
|
|
153
|
-
// The client-interface seam (#355) — drive/steer a loop. The module supplies only
|
|
156
|
+
// The client-interface seam (#355) — drive/steer a loop. The module supplies only workspace/run/prompt;
|
|
154
157
|
// the provider and the law-file system prompt are core's and stay inside. Returns immediately — the
|
|
155
158
|
// loop runs async and its outcome arrives on the event source (loop/terminated). `cancelDrain` (public)
|
|
156
159
|
// is the cancel hook. Both funnel through the unified `inject`, which owns the drain lifecycle.
|
|
157
160
|
async runLoop(args) {
|
|
158
161
|
ClientInput.validateLoopFlags("loop.run", args.flags); // seam fail-hard (#364) — a truthy string must never flip YOLO
|
|
159
|
-
|
|
162
|
+
// #414 — per-loop model selection: a client sends its alias/model on every loop, so a
|
|
163
|
+
// switch takes effect turn-to-turn. `model` (client-resolved <provider>/<model>, #90) wins
|
|
164
|
+
// over `alias`; neither → the boot default. Instantiation is cached, so ping-ponging
|
|
165
|
+
// between two models is cheap, and an unresolvable alias/model fails loud here.
|
|
166
|
+
const provider = await this.#resolveLoopProvider(args.alias, args.model);
|
|
167
|
+
if (provider === null)
|
|
160
168
|
throw new Error("runLoop: no provider configured");
|
|
161
169
|
const systemPrompt = await readFile(Paths.instructionsSystem, "utf8");
|
|
162
170
|
// §machine-processes — the model NEVER runs in a client-origin run (its packets would carry
|
|
163
|
-
// client op.* rows). The module resolves the model
|
|
164
|
-
// fork); a client
|
|
165
|
-
const target = await this.#db.
|
|
171
|
+
// client op.* rows). The module resolves the model worker via ensureModelWorker and passes it (or a
|
|
172
|
+
// fork); a client worker here is a caller error, refused loudly rather than silently rehomed.
|
|
173
|
+
const target = await this.#db.envelope_get_worker_by_id.get({ id: args.workerId });
|
|
166
174
|
if (target === undefined)
|
|
167
|
-
throw new Error(`runLoop: run ${args.
|
|
175
|
+
throw new Error(`runLoop: run ${args.workerId} not found`);
|
|
168
176
|
if (target.origin === "client")
|
|
169
|
-
throw new Error(`runLoop: run ${args.
|
|
177
|
+
throw new Error(`runLoop: run ${args.workerId} is a client worker — loops run in model workers (§machine-processes); resolve one with ensureModelWorker(workspaceId)`);
|
|
170
178
|
// Pre-loop docs (both sets: operator/client mdDocs + the teaching docs the turn-1
|
|
171
179
|
// FIND(plurnk://docs/**) foist discovers) — ONE truth shared with the legacy loop.run route.
|
|
172
|
-
await LoopDocs.materialize(this.#engine, this.#db, args.
|
|
180
|
+
await LoopDocs.materialize(this.#engine, this.#db, args.workspaceId);
|
|
173
181
|
// §operator-config-max-turns-ceiling — the operator ceiling clamps a per-call maxTurns; a
|
|
174
182
|
// seam caller must not bypass operator policy (inject only DEFAULTS from env, never clamps).
|
|
175
183
|
const ceiling = Number(process.env.PLURNK_SERVICE_MAX_TURNS ?? "-1");
|
|
176
184
|
const requested = args.maxTurns ?? ceiling;
|
|
177
185
|
const maxTurns = ceiling < 0 ? requested : (requested < 0 ? ceiling : Math.min(requested, ceiling));
|
|
178
|
-
const { action, loopId, turnSeq } = await this.inject({ ...args, ...(maxTurns >= 0 ? { maxTurns } : {}), provider
|
|
186
|
+
const { action, loopId, turnSeq } = await this.inject({ ...args, ...(maxTurns >= 0 ? { maxTurns } : {}), provider, systemPrompt });
|
|
179
187
|
return { action, loopId, ...(turnSeq !== undefined ? { turnSeq } : {}) };
|
|
180
188
|
}
|
|
181
|
-
//
|
|
189
|
+
// #414 — resolve a per-loop model override to a Provider (cached instances). `model`
|
|
190
|
+
// (<provider>/<model>, client-resolved #90) wins over a named `alias`; absent both, the
|
|
191
|
+
// boot default. A named alias missing from the env cascade, or a malformed model spec, throws
|
|
192
|
+
// legibly rather than silently running the wrong model.
|
|
193
|
+
async #resolveLoopProvider(alias, model) {
|
|
194
|
+
const spec = resolveLoopAlias(alias, model, parseAliasesFromEnv());
|
|
195
|
+
return spec === null ? this.#provider : ProviderInstantiate.instantiateProvider(spec);
|
|
196
|
+
}
|
|
197
|
+
// §machine-processes — the workspace's model worker (created on first use), distinct from the client
|
|
182
198
|
// run so the model's packets never carry client op.* rows. The module binds its threads to this.
|
|
183
|
-
|
|
184
|
-
return Envelope.
|
|
199
|
+
ensureModelWorker(workspaceId) {
|
|
200
|
+
return Envelope.ensureModelWorker(this.#db, workspaceId);
|
|
185
201
|
}
|
|
186
202
|
// The op-dispatch hook (#355) — execute one parsed op on behalf of a client: journaled as a
|
|
187
203
|
// client-origin turn (the log is core's, a client op is a first-class citizen), dispatched through
|
|
@@ -189,17 +205,17 @@ export default class Daemon {
|
|
|
189
205
|
// family (read/edit/copy/find/fold/look/move/open/send/exec); the module parses at its edge with the
|
|
190
206
|
// grammar package and hands over the statement, then fans the emitted entry out to its own clients.
|
|
191
207
|
async dispatchAsClient(args) {
|
|
192
|
-
const {
|
|
193
|
-
const clientLoopId = await Envelope.ensureClientLoop(this.#db,
|
|
208
|
+
const { workspaceId, workerId, statement } = args;
|
|
209
|
+
const clientLoopId = await Envelope.ensureClientLoop(this.#db, workerId);
|
|
194
210
|
const turnId = await ClientTurn.insertClientTurn(this.#db, clientLoopId);
|
|
195
211
|
const entryIds = [];
|
|
196
212
|
const result = await this.#engine.dispatch({
|
|
197
|
-
statement,
|
|
213
|
+
statement, workspaceId, workerId, loopId: clientLoopId, turnId, sequence: 1,
|
|
198
214
|
origin: "client", onDispatch: (logEntryId) => { entryIds.push(logEntryId); },
|
|
199
215
|
});
|
|
200
216
|
for (const logEntryId of entryIds) {
|
|
201
217
|
const entry = await LogEntry.fetchLogEntry(this.#db, logEntryId);
|
|
202
|
-
this.#broadcast({
|
|
218
|
+
this.#broadcast({ workspaceId }, "log/entry", { entry });
|
|
203
219
|
}
|
|
204
220
|
return result;
|
|
205
221
|
}
|
|
@@ -209,23 +225,23 @@ export default class Daemon {
|
|
|
209
225
|
// dispatchAsClient). Rides the client loop so log:/// coordinates resolve run-relative;
|
|
210
226
|
// invisible to the model. Engine.look enforces READ-only.
|
|
211
227
|
async look(args) {
|
|
212
|
-
const {
|
|
213
|
-
const clientLoopId = await Envelope.ensureClientLoop(this.#db,
|
|
214
|
-
return await this.#engine.look({ statement,
|
|
228
|
+
const { workspaceId, workerId, statement } = args;
|
|
229
|
+
const clientLoopId = await Envelope.ensureClientLoop(this.#db, workerId);
|
|
230
|
+
return await this.#engine.look({ statement, workspaceId, workerId, loopId: clientLoopId });
|
|
215
231
|
}
|
|
216
|
-
// The log-read hook (#355) — a
|
|
217
|
-
// ownership-verified against the
|
|
232
|
+
// The log-read hook (#355) — a workspace's journal, the module's primary render input. The worker is
|
|
233
|
+
// ownership-verified against the workspace (a workspace reads only its own runs — the model worker included,
|
|
218
234
|
// #214); entries filter by loop/turn/since-id or the full L/T/S display coordinate. Core owns the
|
|
219
235
|
// journal + the invariant; the module shapes the entries into AG-UI messages at its edge.
|
|
220
236
|
async readLog(args) {
|
|
221
|
-
const {
|
|
222
|
-
const target = await this.#db.
|
|
237
|
+
const { workspaceId, workerId } = args;
|
|
238
|
+
const target = await this.#db.envelope_get_worker_by_id.get({ id: workerId });
|
|
223
239
|
if (target === undefined)
|
|
224
|
-
throw new Error(`run ${
|
|
225
|
-
if (target.
|
|
226
|
-
throw new Error(`run ${
|
|
240
|
+
throw new Error(`run ${workerId} not found`);
|
|
241
|
+
if (target.workspace_id !== workspaceId)
|
|
242
|
+
throw new Error(`run ${workerId} is not in this workspace (${workspaceId})`);
|
|
227
243
|
const rows = await this.#db.log_read_recent_ids.all({
|
|
228
|
-
|
|
244
|
+
worker_id: workerId,
|
|
229
245
|
loop_id: args.loopId ?? null, turn_id: args.turnId ?? null, since_id: args.sinceId ?? null,
|
|
230
246
|
loop_seq: args.loopSeq ?? null, turn_seq: args.turnSeq ?? null, sequence: args.sequence ?? null,
|
|
231
247
|
limit: Math.min(args.limit ?? 100, 1000),
|
|
@@ -244,78 +260,78 @@ export default class Daemon {
|
|
|
244
260
|
const isActive = active !== null && active.alias === a.alias;
|
|
245
261
|
return {
|
|
246
262
|
alias: a.alias, provider: a.provider, model: a.model, active: isActive,
|
|
247
|
-
//
|
|
263
|
+
// promptBudget = the EFFECTIVE prompt budget (window minus reserves, #345; named honestly #481) — the same
|
|
248
264
|
// denominator loop-usage reports; known for the active alias, null elsewhere.
|
|
249
|
-
|
|
265
|
+
promptBudget: isActive && this.#provider !== null ? this.#engine.promptBudgetFor(this.#provider) : null,
|
|
250
266
|
};
|
|
251
267
|
}),
|
|
252
268
|
};
|
|
253
269
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
listPrompts(
|
|
257
|
-
listMembers(
|
|
258
|
-
listConstraints(
|
|
259
|
-
return this.#db.
|
|
270
|
+
listWorkspaces() { return Envelope.listWorkspaces(this.#db); }
|
|
271
|
+
listWorkers(workspaceId) { return Envelope.listWorkersForWorkspace(this.#db, workspaceId); }
|
|
272
|
+
listPrompts(workspaceId, limit = 100) { return Envelope.listPromptsForWorkspace(this.#db, workspaceId, limit); }
|
|
273
|
+
listMembers(workspaceId) { return GitMembership.resolveMembershipEffects(this.#db, workspaceId, undefined); }
|
|
274
|
+
listConstraints(workspaceId) {
|
|
275
|
+
return this.#db.crud_list_workspace_constraints.all({ workspace_id: workspaceId });
|
|
260
276
|
}
|
|
261
|
-
//
|
|
277
|
+
// Workspace lifecycle (#355): the module's workspace-management surface. Inputs arrive already validated
|
|
262
278
|
// at the module's edge ("I am the wall" — settings as the stored JSON string, constraints as a typed
|
|
263
279
|
// array, roots absolute); core owns the envelope, its reserved-name + name-uniqueness invariants,
|
|
264
|
-
// membership resolution,
|
|
265
|
-
// (which client is on which
|
|
266
|
-
async
|
|
280
|
+
// membership resolution, warmWorkspaceDerivations, and the workspace/created emit. No connection state
|
|
281
|
+
// (which client is on which workspace) lives here — that's the module's.
|
|
282
|
+
async createWorkspace(args) {
|
|
267
283
|
// The SEAM fail-hards on malformed client input (#364 — validation flushed out of the
|
|
268
284
|
// retired WS handlers so every module inherits it): settings bag (#231/#232/#249/#328),
|
|
269
285
|
// constraints (#200), absolute projectRoot.
|
|
270
|
-
const projectRoot = ClientInput.assertProjectRoot("
|
|
286
|
+
const projectRoot = ClientInput.assertProjectRoot("workspace.create", args.projectRoot);
|
|
271
287
|
const settings = ClientInput.parseSettings(args.settings);
|
|
272
288
|
const constraints = ClientInput.parseConstraints(args.constraints);
|
|
273
289
|
const envelope = await Envelope.createClientEnvelope(this.#db, { name: args.name, projectRoot, settings });
|
|
274
290
|
for (const { effect, glob } of constraints) {
|
|
275
|
-
await this.#db.
|
|
291
|
+
await this.#db.crud_insert_workspace_constraint.run({ workspace_id: envelope.workspaceId, effect, glob });
|
|
276
292
|
}
|
|
277
293
|
if (constraints.length > 0)
|
|
278
|
-
await GitMembership.resolveGitMembership(this.#db, envelope.
|
|
279
|
-
void this.#engine.
|
|
280
|
-
this.#broadcast("all", "
|
|
294
|
+
await GitMembership.resolveGitMembership(this.#db, envelope.workspaceId, undefined);
|
|
295
|
+
void this.#engine.warmWorkspaceDerivations(envelope.workspaceId).catch(() => { });
|
|
296
|
+
this.#broadcast("all", "workspace/created", { id: envelope.workspaceId, name: envelope.workspaceName, projectRoot: envelope.projectRoot });
|
|
281
297
|
return envelope;
|
|
282
298
|
}
|
|
283
|
-
async
|
|
284
|
-
//
|
|
285
|
-
const envelope = await Envelope.
|
|
286
|
-
void this.#engine.
|
|
299
|
+
async attachWorkspace(args) {
|
|
300
|
+
// attachToWorkspace owns the reserved-name + run-ownership invariants; the seam just delegates + warms.
|
|
301
|
+
const envelope = await Envelope.attachToWorkspace(this.#db, args.workspaceId, { workerId: args.workerId, workerName: args.workerName });
|
|
302
|
+
void this.#engine.warmWorkspaceDerivations(envelope.workspaceId).catch(() => { });
|
|
287
303
|
return envelope;
|
|
288
304
|
}
|
|
289
|
-
async
|
|
305
|
+
async renameWorkspace(workspaceId, name) {
|
|
290
306
|
if (typeof name !== "string" || name.length === 0)
|
|
291
|
-
throw new Error("
|
|
292
|
-
const taken = await this.#db.
|
|
293
|
-
if (taken !== undefined && taken.id !==
|
|
294
|
-
throw new Error(`a
|
|
295
|
-
return { id:
|
|
307
|
+
throw new Error("workspace.rename: name must be a non-empty string"); // seam fail-hard (#364)
|
|
308
|
+
const taken = await this.#db.envelope_get_workspace_by_name.get({ name });
|
|
309
|
+
if (taken !== undefined && taken.id !== workspaceId)
|
|
310
|
+
throw new Error(`a workspace named "${name}" already exists — pick another`);
|
|
311
|
+
return { id: workspaceId, name: await Envelope.updateWorkspaceName(this.#db, workspaceId, name) };
|
|
296
312
|
}
|
|
297
|
-
async constrain(
|
|
298
|
-
ClientInput.assertConstraint("
|
|
299
|
-
// Headless is FOREVER (owner ruling, 2026-07-11, matching the client SPEC): a
|
|
313
|
+
async constrain(workspaceId, effect, glob) {
|
|
314
|
+
ClientInput.assertConstraint("workspace.constrain", effect, glob);
|
|
315
|
+
// Headless is FOREVER (owner ruling, 2026-07-11, matching the client SPEC): a workspace is
|
|
300
316
|
// born with its workspace pointer or never has one — so a 'repo' constraint on a headless
|
|
301
|
-
//
|
|
317
|
+
// workspace can never resolve. Refuse legibly instead of recording a forever-pending lie.
|
|
302
318
|
if (effect === "repo") {
|
|
303
|
-
const s = await this.#db.
|
|
319
|
+
const s = await this.#db.envelope_get_workspace.get({ id: workspaceId });
|
|
304
320
|
if (s?.project_root == null)
|
|
305
|
-
throw new Error("
|
|
321
|
+
throw new Error("workspace.constrain: this workspace is headless — and headless is forever (a workspace pointer is set at workspace.create or never). A 'repo' overlay needs a workspace created with projectRoot.");
|
|
306
322
|
}
|
|
307
|
-
await this.#db.
|
|
308
|
-
await GitMembership.resolveGitMembership(this.#db,
|
|
323
|
+
await this.#db.crud_insert_workspace_constraint.run({ workspace_id: workspaceId, effect, glob });
|
|
324
|
+
await GitMembership.resolveGitMembership(this.#db, workspaceId, undefined);
|
|
309
325
|
// Members may have just landed — warm their derivations NOW (fire-and-forget, off the hot
|
|
310
|
-
// path), exactly like
|
|
326
|
+
// path), exactly like createWorkspace does (dogfood catch: '/repo' embeddings waited for a
|
|
311
327
|
// later turn's pump).
|
|
312
|
-
void this.#engine.
|
|
328
|
+
void this.#engine.warmWorkspaceDerivations(workspaceId).catch(() => { });
|
|
313
329
|
return { effect, glob };
|
|
314
330
|
}
|
|
315
|
-
async unconstrain(
|
|
316
|
-
ClientInput.assertConstraint("
|
|
317
|
-
await this.#db.
|
|
318
|
-
await GitMembership.resolveGitMembership(this.#db,
|
|
331
|
+
async unconstrain(workspaceId, effect, glob) {
|
|
332
|
+
ClientInput.assertConstraint("workspace.unconstrain", effect, glob);
|
|
333
|
+
await this.#db.crud_delete_workspace_constraint.run({ workspace_id: workspaceId, effect, glob });
|
|
334
|
+
await GitMembership.resolveGitMembership(this.#db, workspaceId, undefined);
|
|
319
335
|
return { effect, glob };
|
|
320
336
|
}
|
|
321
337
|
// The entry-shape hook (#355) — one entry's channels + tags + metadata at a path. With channel+offset,
|
|
@@ -329,7 +345,7 @@ export default class Daemon {
|
|
|
329
345
|
throw new Error("readEntry: offset requires channel (which channel to slice)");
|
|
330
346
|
const scheme = m[1];
|
|
331
347
|
const pathname = m[2].split("#")[0];
|
|
332
|
-
const row = await this.#db.entry_read_lookup.get({
|
|
348
|
+
const row = await this.#db.entry_read_lookup.get({ workspace_id: args.workspaceId, scheme, pathname });
|
|
333
349
|
if (row === undefined)
|
|
334
350
|
return { status: 404, entry: null };
|
|
335
351
|
let channelRows;
|
|
@@ -344,52 +360,52 @@ export default class Daemon {
|
|
|
344
360
|
for (const c of channelRows)
|
|
345
361
|
channels[c.name] = { content: c.content, contentLength: c.contentLength, mimetype: c.mimetype, tokens: c.tokens, state: c.state };
|
|
346
362
|
const tagRows = await this.#db.crud_read_tags.all({ entry_id: row.id });
|
|
347
|
-
return { status: 200, entry: { id: row.id, scope: row.scope,
|
|
363
|
+
return { status: 200, entry: { id: row.id, scope: row.scope, workspaceId: row.workspace_id, scheme: row.scheme, pathname: row.pathname, channels, tags: tagRows.map((t) => t.tag) } };
|
|
348
364
|
}
|
|
349
|
-
// The fork hook (#355) — branch a
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
// #366 — a fresh conversation
|
|
353
|
-
// is the workspace, the
|
|
354
|
-
//
|
|
365
|
+
// The fork hook (#355) — branch a worker's log into a new worker in the same workspace (#228), sharing the
|
|
366
|
+
// workspace's world (entries + overlay), copying nothing of it. The module resolves the default (the
|
|
367
|
+
// workspace's model worker) from its own connection state and passes the concrete workerId; the seam owns the
|
|
368
|
+
// #366 — a fresh conversation worker: AG-UI threads map to RUNS (§machine-processes — the workspace
|
|
369
|
+
// is the workspace, the worker is the conversation). ensureModelWorker is the stable DEFAULT door,
|
|
370
|
+
// forkWorker the branching door (copies history); this is the fresh door — a named, empty-log,
|
|
355
371
|
// model-origin root that runLoop accepts. New chat = new conversation, same workspace.
|
|
356
|
-
async
|
|
357
|
-
const {
|
|
372
|
+
async createConversationWorker(args) {
|
|
373
|
+
const { workspaceId, name } = args;
|
|
358
374
|
if (name !== undefined && (typeof name !== "string" || name.length === 0))
|
|
359
375
|
throw new Error("run.create: name must be a non-empty string");
|
|
360
|
-
const
|
|
361
|
-
if (
|
|
362
|
-
throw new Error(`run.create:
|
|
376
|
+
const workspace = await this.#db.envelope_get_workspace.get({ id: workspaceId });
|
|
377
|
+
if (workspace === undefined)
|
|
378
|
+
throw new Error(`run.create: workspace ${workspaceId} not found`);
|
|
363
379
|
if (name !== undefined) {
|
|
364
380
|
if (Envelope.RESERVED_RUN_NAMES.has(name.toLowerCase()))
|
|
365
381
|
throw new Error(`run.create: name "${name}" is reserved for a non-client actor`);
|
|
366
|
-
const taken = await this.#db.
|
|
382
|
+
const taken = await this.#db.envelope_get_worker_by_name.get({ workspace_id: workspaceId, name });
|
|
367
383
|
if (taken !== undefined)
|
|
368
|
-
throw new Error(`run.create: a
|
|
384
|
+
throw new Error(`run.create: a worker named "${name}" already exists — worker names are immutable, pick another`);
|
|
369
385
|
}
|
|
370
|
-
const run = await Envelope.
|
|
371
|
-
return {
|
|
386
|
+
const run = await Envelope.createModelWorker(this.#db, workspaceId, name);
|
|
387
|
+
return { workerId: run.id, workerName: run.name };
|
|
372
388
|
}
|
|
373
389
|
// ownership check and the run-name namespace + uniqueness invariants (names are immutable — no rename).
|
|
374
|
-
async
|
|
390
|
+
async forkWorker(args) {
|
|
375
391
|
if (args.name !== undefined && (typeof args.name !== "string" || args.name.length === 0))
|
|
376
392
|
throw new Error("run.fork: name must be a non-empty string"); // seam fail-hard (#364)
|
|
377
|
-
const {
|
|
378
|
-
const owner = await this.#db.
|
|
393
|
+
const { workspaceId, workerId, name } = args;
|
|
394
|
+
const owner = await this.#db.envelope_get_worker_by_id.get({ id: workerId });
|
|
379
395
|
if (owner === undefined)
|
|
380
|
-
throw new Error(`
|
|
381
|
-
if (owner.
|
|
382
|
-
throw new Error(`
|
|
396
|
+
throw new Error(`forkWorker: run ${workerId} not found`);
|
|
397
|
+
if (owner.workspace_id !== workspaceId)
|
|
398
|
+
throw new Error(`forkWorker: run ${workerId} is not in workspace ${workspaceId}`);
|
|
383
399
|
if (name !== undefined) {
|
|
384
400
|
if (Envelope.RESERVED_RUN_NAMES.has(name.toLowerCase()))
|
|
385
|
-
throw new Error(`
|
|
386
|
-
const taken = await this.#db.
|
|
401
|
+
throw new Error(`forkWorker: name "${name}" is reserved for a non-client actor`);
|
|
402
|
+
const taken = await this.#db.envelope_get_worker_by_name.get({ workspace_id: workspaceId, name });
|
|
387
403
|
if (taken !== undefined)
|
|
388
|
-
throw new Error(`
|
|
404
|
+
throw new Error(`forkWorker: a worker named "${name}" already exists — worker names are immutable, pick another`);
|
|
389
405
|
}
|
|
390
|
-
const
|
|
391
|
-
const branch = await this.#db.
|
|
392
|
-
return {
|
|
406
|
+
const branchWorkerId = await Fork.fork(this.#db, workerId, name);
|
|
407
|
+
const branch = await this.#db.envelope_get_worker_by_id.get({ id: branchWorkerId });
|
|
408
|
+
return { workerId: branchWorkerId, workerName: branch?.name ?? null, parentWorkerId: workerId };
|
|
393
409
|
}
|
|
394
410
|
// The module-load hook (#355 / #289) — register a runtime into the live registry, driver-agnostic:
|
|
395
411
|
// the kernel knows nothing about MCP or any specific driver. The struct is the booth window agreed
|
|
@@ -452,7 +468,7 @@ export default class Daemon {
|
|
|
452
468
|
// to completion, (3) drain streaming schemes' background work
|
|
453
469
|
// (exec spawn cleanup, channel writes). Only THEN close the DB
|
|
454
470
|
// upstream — drain queries hit the DB right up until they exit.
|
|
455
|
-
// Abort every
|
|
471
|
+
// Abort every worker's cancellation scope — stops in-flight loops AND the
|
|
456
472
|
// streams (background execs) linked to them, so idle() doesn't block on
|
|
457
473
|
// a long-running command. Covers runs whose drain already exited but
|
|
458
474
|
// whose exec is still in flight.
|
|
@@ -460,13 +476,19 @@ export default class Daemon {
|
|
|
460
476
|
// that will never arrive once clients are gone — allSettled(drains) below would deadlock
|
|
461
477
|
// the stop forever (a daemon with a pending HITL proposal could not shut down).
|
|
462
478
|
this.#engine.cancelAllProposals("daemon_stopping");
|
|
463
|
-
for (const scope of this.#
|
|
479
|
+
for (const scope of this.#workerAborts.values()) {
|
|
464
480
|
if (!scope.signal.aborted)
|
|
465
481
|
scope.abort("daemon_stopping");
|
|
466
482
|
}
|
|
467
483
|
for (const t of this.#pollTimers.values())
|
|
468
484
|
clearTimeout(t); // drop pending hibernation poll-wakes
|
|
469
485
|
this.#pollTimers.clear();
|
|
486
|
+
// …and the park-DEADLINE timers (#432): a bounded park's timer fires #wakeParkedWorker after
|
|
487
|
+
// stop/db-close if left pending — an unhandled rejection (SqlRite closed) that abnormally
|
|
488
|
+
// exits the worker under load. Symmetric with the poll-wakes above; both must be reaped.
|
|
489
|
+
for (const t of this.#parkTimers.values())
|
|
490
|
+
clearTimeout(t);
|
|
491
|
+
this.#parkTimers.clear();
|
|
470
492
|
const drainPromises = [...this.#activeDrains.values()].map((d) => d.promise);
|
|
471
493
|
await Promise.allSettled(drainPromises);
|
|
472
494
|
await this.#drainStreamingSchemes();
|
|
@@ -480,25 +502,25 @@ export default class Daemon {
|
|
|
480
502
|
await exec.idle();
|
|
481
503
|
}
|
|
482
504
|
/**
|
|
483
|
-
* Emit a stream/event notification scoped to the
|
|
505
|
+
* Emit a stream/event notification scoped to the workspace containing the
|
|
484
506
|
* entry. ChannelWrite helpers (src/core/ChannelWrite.ts) invoke this when
|
|
485
507
|
* they update channel content or state. SPEC §notifications.
|
|
486
508
|
*/
|
|
487
|
-
notifyStreamEvent(
|
|
488
|
-
this.#broadcast({
|
|
509
|
+
notifyStreamEvent(workspaceId, event) {
|
|
510
|
+
this.#broadcast({ workspaceId }, "stream/event", event);
|
|
489
511
|
}
|
|
490
512
|
/**
|
|
491
|
-
* Emit a telemetry/event notification scoped to the
|
|
513
|
+
* Emit a telemetry/event notification scoped to the workspace containing
|
|
492
514
|
* the loop. TelemetryChannel.push invokes this for every TelemetryEvent
|
|
493
515
|
* (parse_error, strike, cycle, sudden_death, no_ops, max_commands_exceeded,
|
|
494
516
|
* action_failure) the moment it lands in the loop's telemetry buffer.
|
|
495
517
|
* SPEC §telemetry.
|
|
496
518
|
*/
|
|
497
|
-
notifyTelemetryEvent(
|
|
498
|
-
this.#broadcast({
|
|
519
|
+
notifyTelemetryEvent(workspaceId, payload) {
|
|
520
|
+
this.#broadcast({ workspaceId }, "telemetry/event", payload);
|
|
499
521
|
}
|
|
500
522
|
/**
|
|
501
|
-
* Inject a prompt into a
|
|
523
|
+
* Inject a prompt into a worker. Two paths:
|
|
502
524
|
* - Active drain: writes a plurnk://prompt/<run>/<loop>/<next-turn> entry
|
|
503
525
|
* via Engine.inject. Current loop sees the new prompt at its next
|
|
504
526
|
* turn. Returns immediately with {action: "injected_next_turn"}.
|
|
@@ -514,12 +536,12 @@ export default class Daemon {
|
|
|
514
536
|
// to: an inject carrying flags that DIFFER from the target loop's effective flags is refused
|
|
515
537
|
// legibly (cancel the loop or omit the flags), never a silent posture discard. Identical or
|
|
516
538
|
// absent flags fold clean.
|
|
517
|
-
async #assertFoldPosture(
|
|
539
|
+
async #assertFoldPosture(workerId, flags, loopId) {
|
|
518
540
|
if (flags === undefined || Object.keys(flags).length === 0)
|
|
519
541
|
return;
|
|
520
542
|
const row = loopId !== undefined
|
|
521
543
|
? await this.#db.engine_get_loop_flags.get({ loop_id: loopId })
|
|
522
|
-
: await this.#db.drain_active_loop_flags.get({
|
|
544
|
+
: await this.#db.drain_active_loop_flags.get({ worker_id: workerId });
|
|
523
545
|
const effective = { ...DEFAULT_LOOP_FLAGS, ...JSON.parse(row?.flags ?? "{}") };
|
|
524
546
|
const conflicts = Object.entries(flags).filter(([k, v]) => v !== undefined && effective[k] !== v).map(([k, v]) => `${k}: ${JSON.stringify(effective[k])} → ${JSON.stringify(v)}`);
|
|
525
547
|
if (conflicts.length > 0) {
|
|
@@ -527,41 +549,41 @@ export default class Daemon {
|
|
|
527
549
|
}
|
|
528
550
|
}
|
|
529
551
|
async inject(args) {
|
|
530
|
-
const {
|
|
552
|
+
const { workspaceId, workerId, prompt } = args;
|
|
531
553
|
// Active loop (status=102)? Fold the wake/prompt into its next turn.
|
|
532
554
|
// engine.inject returns null when no loop is currently executing, so
|
|
533
555
|
// we enqueue a fresh loop below and ensure a drain claims it.
|
|
534
|
-
if (this.#activeDrains.has(
|
|
535
|
-
await this.#assertFoldPosture(
|
|
536
|
-
const result = await this.#engine.inject(
|
|
556
|
+
if (this.#activeDrains.has(workerId)) {
|
|
557
|
+
await this.#assertFoldPosture(workerId, args.flags); // #368 — a fold never silently discards intent
|
|
558
|
+
const result = await this.#engine.inject(workerId, prompt);
|
|
537
559
|
if (result !== null) {
|
|
538
560
|
return { action: "injected_next_turn", loopId: result.loopId, turnSeq: result.turnSeq };
|
|
539
561
|
}
|
|
540
562
|
}
|
|
541
|
-
// #55 — a
|
|
563
|
+
// #55 — a worker PARKED at 202 RESUMES that slept loop in place: the voice door (irc / loop.inject)
|
|
542
564
|
// is a wake edge like a stream/child conclusion, not a fresh loop that orphans the parked one
|
|
543
|
-
// (which would leave the
|
|
565
|
+
// (which would leave the worker non-quiescent forever). engine.inject writes the message as the
|
|
544
566
|
// slept loop's next-turn prompt (the directed message — distinct from the env door, which
|
|
545
|
-
// resumes promptless); then re-queue + drain it. §
|
|
546
|
-
if (!this.#activeDrains.has(
|
|
547
|
-
const slept = await this.#db.drain_find_slept_loop.get({
|
|
567
|
+
// resumes promptless); then re-queue + drain it. §worker-lifecycle-wake-liveness.
|
|
568
|
+
if (!this.#activeDrains.has(workerId)) {
|
|
569
|
+
const slept = await this.#db.drain_find_slept_loop.get({ worker_id: workerId });
|
|
548
570
|
if (slept !== undefined) {
|
|
549
|
-
await this.#assertFoldPosture(
|
|
550
|
-
const injected = await this.#engine.inject(
|
|
571
|
+
await this.#assertFoldPosture(workerId, args.flags, slept.id); // #368 — the resume path drops nothing silently either
|
|
572
|
+
const injected = await this.#engine.inject(workerId, prompt);
|
|
551
573
|
await this.#db.drain_resume_slept_loop.run({ loop_id: slept.id });
|
|
552
574
|
const started = await this.#ensureDrain({
|
|
553
|
-
|
|
575
|
+
workspaceId, workerId, provider: args.provider, systemPrompt: args.systemPrompt,
|
|
554
576
|
maxTurns: args.maxTurns ?? Number(process.env.PLURNK_SERVICE_MAX_TURNS ?? "50"),
|
|
555
577
|
});
|
|
556
578
|
return { action: "injected_next_turn", loopId: slept.id, ...(injected?.turnSeq !== undefined ? { turnSeq: injected.turnSeq } : {}), ...(started ?? {}) };
|
|
557
579
|
}
|
|
558
580
|
}
|
|
559
581
|
// Enqueue a fresh loop. Persist flags on the row.
|
|
560
|
-
const seqRow = await this.#db.loop_run_next_sequence.get({
|
|
582
|
+
const seqRow = await this.#db.loop_run_next_sequence.get({ worker_id: workerId });
|
|
561
583
|
if (seqRow === undefined)
|
|
562
584
|
throw new Error("inject: next-sequence query returned no row");
|
|
563
585
|
const loopRow = await this.#db.drain_enqueue_loop.get({
|
|
564
|
-
|
|
586
|
+
worker_id: workerId, sequence: seqRow.next, prompt,
|
|
565
587
|
});
|
|
566
588
|
if (loopRow === undefined)
|
|
567
589
|
throw new Error("inject: loop enqueue returned no row");
|
|
@@ -579,14 +601,14 @@ export default class Daemon {
|
|
|
579
601
|
});
|
|
580
602
|
}
|
|
581
603
|
// Guarantee a drain claims the loop we just enqueued. #ensureDrain runs its
|
|
582
|
-
// check-and-start UNDER the per-
|
|
604
|
+
// check-and-start UNDER the per-worker drain lock (§worker-lifecycle-single-drain),
|
|
583
605
|
// serialized against a draining sibling's teardown relinquish so the two can't
|
|
584
606
|
// both register a drain (R4). A live drain re-claims the loop in its own
|
|
585
607
|
// iteration or its lock-held exit re-claim, so it's never stranded.
|
|
586
608
|
// firstLoopPromise is present only when THIS call started the drain — loop.run
|
|
587
609
|
// keys its fast-path response on that.
|
|
588
610
|
const started = await this.#ensureDrain({
|
|
589
|
-
|
|
611
|
+
workspaceId, workerId, provider: args.provider,
|
|
590
612
|
systemPrompt: args.systemPrompt,
|
|
591
613
|
maxTurns: args.maxTurns ?? Number(process.env.PLURNK_SERVICE_MAX_TURNS ?? "50"),
|
|
592
614
|
});
|
|
@@ -596,7 +618,7 @@ export default class Daemon {
|
|
|
596
618
|
* Start a drain for the given run. The drain claims queued loops via
|
|
597
619
|
* drain_claim_next_loop (atomic 100→102 flip), executes each via
|
|
598
620
|
* Engine.runLoop, and re-checks. Stream-aware: when the queue is empty
|
|
599
|
-
* but the
|
|
621
|
+
* but the worker has active subscriptions, the drain parks on a
|
|
600
622
|
* #drainPokes signal — wake-on-completion → inject() wakes it. Drain
|
|
601
623
|
* exits when queue is empty AND no active subscriptions remain.
|
|
602
624
|
*
|
|
@@ -606,10 +628,10 @@ export default class Daemon {
|
|
|
606
628
|
* (resolves only when the whole drain finishes, queue+subs settled).
|
|
607
629
|
*/
|
|
608
630
|
#startDrain(opts) {
|
|
609
|
-
const {
|
|
610
|
-
// The drain runs under the
|
|
631
|
+
const { workspaceId, workerId, provider, systemPrompt, maxTurns } = opts;
|
|
632
|
+
// The drain runs under the worker's cancellation scope (shared with the
|
|
611
633
|
// execs its loops spawn), so loop.cancel/shutdown abort it as a unit.
|
|
612
|
-
const controller = this.#
|
|
634
|
+
const controller = this.#workerSignal(workerId);
|
|
613
635
|
const handle = {
|
|
614
636
|
controller, promise: Promise.resolve(),
|
|
615
637
|
};
|
|
@@ -620,7 +642,7 @@ export default class Daemon {
|
|
|
620
642
|
rejectFirst = rej;
|
|
621
643
|
});
|
|
622
644
|
let firstSettled = false;
|
|
623
|
-
const claim = () => this.#db.drain_claim_next_loop.get({
|
|
645
|
+
const claim = () => this.#db.drain_claim_next_loop.get({ worker_id: workerId });
|
|
624
646
|
const drainPromise = (async () => {
|
|
625
647
|
let loopsDrained = 0;
|
|
626
648
|
let lastResult = null;
|
|
@@ -630,16 +652,16 @@ export default class Daemon {
|
|
|
630
652
|
controller.signal.throwIfAborted();
|
|
631
653
|
let loopRow = await claim();
|
|
632
654
|
if (loopRow === undefined) {
|
|
633
|
-
// Queue empty → teardown UNDER the per-
|
|
655
|
+
// Queue empty → teardown UNDER the per-worker drain lock (R4 / I1),
|
|
634
656
|
// serialized against #ensureDrain so a concurrent inject can't
|
|
635
657
|
// start a 2nd drain in the gap. Re-claim while holding the lock;
|
|
636
658
|
// relinquish the registry slot only if it's empty too. A loop
|
|
637
659
|
// that raced in is returned and run — we stay registered, so
|
|
638
660
|
// there's no transient delete for #ensureDrain to catch.
|
|
639
|
-
loopRow = await this.#withDrainLock(
|
|
661
|
+
loopRow = await this.#withDrainLock(workerId, async () => {
|
|
640
662
|
const claimed = await claim();
|
|
641
|
-
if (claimed === undefined && this.#activeDrains.get(
|
|
642
|
-
this.#activeDrains.delete(
|
|
663
|
+
if (claimed === undefined && this.#activeDrains.get(workerId) === handle) {
|
|
664
|
+
this.#activeDrains.delete(workerId);
|
|
643
665
|
}
|
|
644
666
|
return claimed;
|
|
645
667
|
});
|
|
@@ -650,11 +672,11 @@ export default class Daemon {
|
|
|
650
672
|
const onDispatch = (logEntryId) => {
|
|
651
673
|
void (async () => {
|
|
652
674
|
const entry = await LogEntry.fetchLogEntry(this.#db, logEntryId);
|
|
653
|
-
this.#broadcast({
|
|
675
|
+
this.#broadcast({ workspaceId }, "log/entry", { entry });
|
|
654
676
|
})();
|
|
655
677
|
};
|
|
656
678
|
const result = await this.#engine.runLoop({
|
|
657
|
-
provider,
|
|
679
|
+
provider, workspaceId, workerId, loopId: loopRow.id, maxTurns,
|
|
658
680
|
messages: [
|
|
659
681
|
{ role: "system", content: systemPrompt },
|
|
660
682
|
{ role: "user", content: loopRow.prompt },
|
|
@@ -666,9 +688,9 @@ export default class Daemon {
|
|
|
666
688
|
if (result.finalStatus === 202) {
|
|
667
689
|
// The loop SLEPT (parked via [102]<T>/<-1>) — suspended, not terminated. Leave it at 202
|
|
668
690
|
// (resumable); no loop/terminated, no orphan-reconcile. A stream conclusion
|
|
669
|
-
// (#
|
|
670
|
-
// wakes it every P to inspect (§exec-poll). §
|
|
671
|
-
void this.#schedulePollWake(
|
|
691
|
+
// (#handleWakeWorker) re-queues it; and if it holds a polled stream, a poll timer
|
|
692
|
+
// wakes it every P to inspect (§exec-poll). §worker-lifecycle-wake-liveness.
|
|
693
|
+
void this.#schedulePollWake(workspaceId, workerId, provider, systemPrompt).catch((err) => console.error("poll-wake scheduling failed:", err instanceof Error ? err.message : String(err)));
|
|
672
694
|
// §send-premature-terminate/[102]<T> — the park DEADLINE (grammar 0.75.0): the
|
|
673
695
|
// dispatcher recorded the marker's seconds; a bounded park is woken at T
|
|
674
696
|
// regardless of arrivals, so a park always has a next turn. -1 (indefinite:
|
|
@@ -677,25 +699,25 @@ export default class Daemon {
|
|
|
677
699
|
if (currentLoopId !== null) {
|
|
678
700
|
const deadline = this.#engine.parkDeadlines.get(currentLoopId);
|
|
679
701
|
this.#engine.parkDeadlines.delete(currentLoopId);
|
|
680
|
-
const prior = this.#parkTimers.get(
|
|
702
|
+
const prior = this.#parkTimers.get(workerId);
|
|
681
703
|
if (prior !== undefined) {
|
|
682
704
|
clearTimeout(prior);
|
|
683
|
-
this.#parkTimers.delete(
|
|
705
|
+
this.#parkTimers.delete(workerId);
|
|
684
706
|
}
|
|
685
707
|
if (deadline !== undefined && deadline > 0) {
|
|
686
708
|
const t = setTimeout(() => {
|
|
687
|
-
this.#parkTimers.delete(
|
|
688
|
-
void this.#
|
|
709
|
+
this.#parkTimers.delete(workerId);
|
|
710
|
+
void this.#wakeParkedWorker(workspaceId, workerId, provider, systemPrompt).catch((err) => console.error("park-deadline wake failed:", err instanceof Error ? err.message : String(err)));
|
|
689
711
|
}, deadline * 1000);
|
|
690
712
|
t.unref();
|
|
691
|
-
this.#parkTimers.set(
|
|
713
|
+
this.#parkTimers.set(workerId, t);
|
|
692
714
|
}
|
|
693
715
|
}
|
|
694
|
-
// Honor an OWED wake (§
|
|
695
|
-
// this
|
|
716
|
+
// Honor an OWED wake (§worker-lifecycle-child-wake): a child/stream concluded while
|
|
717
|
+
// this worker was mid-turn, before it slept — resume in place rather than park blind,
|
|
696
718
|
// so a worker-run hibernation always returns. The loop is 202 here; reset to
|
|
697
719
|
// claimable and the drain re-runs it on the next claim below.
|
|
698
|
-
if (this.#owedWakes.delete(
|
|
720
|
+
if (this.#owedWakes.delete(workerId)) {
|
|
699
721
|
await this.#db.drain_resume_slept_loop.run({ loop_id: loopRow.id });
|
|
700
722
|
continue;
|
|
701
723
|
}
|
|
@@ -704,9 +726,9 @@ export default class Daemon {
|
|
|
704
726
|
// conclude-before-block race). An idle wait never reaches here — it concluded at dispatch.
|
|
705
727
|
continue;
|
|
706
728
|
}
|
|
707
|
-
this.#owedWakes.delete(
|
|
729
|
+
this.#owedWakes.delete(workerId); // the loop concluded (non-202) — no park to honor a held wake at
|
|
708
730
|
const usage = await this.#engine.loopUsage(loopRow.id);
|
|
709
|
-
this.#broadcast({
|
|
731
|
+
this.#broadcast({ workspaceId }, "loop/terminated", {
|
|
710
732
|
loopId: loopRow.id,
|
|
711
733
|
finalStatus: result.finalStatus,
|
|
712
734
|
hitMaxTurns: result.hitMaxTurns,
|
|
@@ -729,7 +751,7 @@ export default class Daemon {
|
|
|
729
751
|
// A next-turn prompt this loop ended before consuming (a
|
|
730
752
|
// wake conclusion or a loop.run-while-active) is promoted to
|
|
731
753
|
// a fresh queued loop so it's never silently dropped.
|
|
732
|
-
await this.#reconcileOrphanedWake(
|
|
754
|
+
await this.#reconcileOrphanedWake(workerId, loopRow.id);
|
|
733
755
|
}
|
|
734
756
|
}
|
|
735
757
|
catch (err) {
|
|
@@ -738,7 +760,7 @@ export default class Daemon {
|
|
|
738
760
|
// is the loop's TERMINAL state (499), delivered via loop/terminated (loop.run no
|
|
739
761
|
// longer blocks to return it). A genuine error rejects firstLoopPromise.
|
|
740
762
|
const usage = currentLoopId === null
|
|
741
|
-
? { promptTokens: 0, completionTokens: 0, costPico: 0, contextTokens: 0,
|
|
763
|
+
? { promptTokens: 0, completionTokens: 0, costPico: 0, contextTokens: 0, promptBudget: null, meta: {} }
|
|
742
764
|
: await this.#engine.loopUsage(currentLoopId);
|
|
743
765
|
if (currentLoopId !== null) {
|
|
744
766
|
// #380 (owner ruling) — the cancel is allowed but provenanced: the ROW goes
|
|
@@ -748,7 +770,7 @@ export default class Daemon {
|
|
|
748
770
|
// loop.cancel reason (cancelDrain threads it through scope.abort).
|
|
749
771
|
const message = String(controller.signal.reason ?? "user_cancelled").slice(0, 500);
|
|
750
772
|
await this.#db.engine_loop_cancel_external.run({ loop_id: currentLoopId, message });
|
|
751
|
-
this.#broadcast({
|
|
773
|
+
this.#broadcast({ workspaceId }, "loop/terminated", {
|
|
752
774
|
loopId: currentLoopId, finalStatus: 499, hitMaxTurns: false, turnIds: [], usage, message,
|
|
753
775
|
});
|
|
754
776
|
}
|
|
@@ -771,7 +793,7 @@ export default class Daemon {
|
|
|
771
793
|
const message = (err instanceof Error ? err.message : String(err)).slice(0, 500);
|
|
772
794
|
await this.#db.engine_loop_set_status.run({ loop_id: currentLoopId, status: 500, message });
|
|
773
795
|
const usage = await this.#engine.loopUsage(currentLoopId);
|
|
774
|
-
this.#broadcast({
|
|
796
|
+
this.#broadcast({ workspaceId }, "loop/terminated", {
|
|
775
797
|
loopId: currentLoopId, finalStatus: 500, hitMaxTurns: false, turnIds: [], usage, message,
|
|
776
798
|
});
|
|
777
799
|
}
|
|
@@ -787,47 +809,47 @@ export default class Daemon {
|
|
|
787
809
|
firstSettled = true;
|
|
788
810
|
rejectFirst(new Error("drain exited without producing a result"));
|
|
789
811
|
}
|
|
790
|
-
if (this.#activeDrains.get(
|
|
791
|
-
this.#activeDrains.delete(
|
|
812
|
+
if (this.#activeDrains.get(workerId) === handle)
|
|
813
|
+
this.#activeDrains.delete(workerId);
|
|
792
814
|
}
|
|
793
815
|
return { loopsDrained, lastResult };
|
|
794
816
|
})();
|
|
795
817
|
handle.promise = drainPromise;
|
|
796
|
-
this.#activeDrains.set(
|
|
797
|
-
// Topology join (§run-lifecycle): when this drain exits having CONCLUDED the
|
|
818
|
+
this.#activeDrains.set(workerId, handle);
|
|
819
|
+
// Topology join (§run-lifecycle): when this drain exits having CONCLUDED the worker, wake its parent
|
|
798
820
|
// if parked. Runs after the drain fully tears down (settled promise) so the quiescence check sees
|
|
799
|
-
// final state; speculative (#onDrainExit no-ops unless the
|
|
800
|
-
drainPromise.then(() => this.#onDrainExit(
|
|
821
|
+
// final state; speculative (#onDrainExit no-ops unless the worker concluded AND the parent is parked).
|
|
822
|
+
drainPromise.then(() => this.#onDrainExit(workspaceId, workerId, provider, systemPrompt)).catch(() => { });
|
|
801
823
|
// Swallow unhandled rejections (drain aborts with no awaiter); the
|
|
802
824
|
// error already surfaced via firstLoopPromise or was logged inside.
|
|
803
825
|
drainPromise.catch(() => { });
|
|
804
826
|
firstLoopPromise.catch(() => { });
|
|
805
827
|
return { firstLoopPromise, drainPromise };
|
|
806
828
|
}
|
|
807
|
-
// Per-run drain-transition lock (R4 / §
|
|
829
|
+
// Per-run drain-transition lock (R4 / §worker-lifecycle-single-drain). #ensureDrain's
|
|
808
830
|
// start and a drain's teardown relinquish both run under it, serialized, so the two
|
|
809
|
-
// can't interleave and register two drains for one
|
|
831
|
+
// can't interleave and register two drains for one worker. The critical section is the
|
|
810
832
|
// registry decision only (never a loop's work) — a sub-ms hop at drain boundaries.
|
|
811
833
|
// A promise-chain mutex: each caller awaits the prior holder; the tail self-prunes
|
|
812
834
|
// when idle so the Map stays bounded to runs mid-transition.
|
|
813
|
-
#withDrainLock(
|
|
814
|
-
const prev = this.#drainLocks.get(
|
|
835
|
+
#withDrainLock(workerId, fn) {
|
|
836
|
+
const prev = this.#drainLocks.get(workerId) ?? Promise.resolve();
|
|
815
837
|
const run = prev.then(fn, fn);
|
|
816
838
|
const tail = run.catch(() => { });
|
|
817
|
-
this.#drainLocks.set(
|
|
818
|
-
void tail.then(() => { if (this.#drainLocks.get(
|
|
819
|
-
this.#drainLocks.delete(
|
|
839
|
+
this.#drainLocks.set(workerId, tail);
|
|
840
|
+
void tail.then(() => { if (this.#drainLocks.get(workerId) === tail)
|
|
841
|
+
this.#drainLocks.delete(workerId); });
|
|
820
842
|
return run;
|
|
821
843
|
}
|
|
822
|
-
// The drain guarantee, serialized per
|
|
844
|
+
// The drain guarantee, serialized per worker via #withDrainLock so it can't race a
|
|
823
845
|
// sibling drain's teardown relinquish into a double-drain (R4). A live drain
|
|
824
846
|
// (registered, NOT aborting) will claim the just-enqueued loop in its own iteration
|
|
825
847
|
// or its lock-held exit re-claim → return null. A registered-but-ABORTING drain is
|
|
826
848
|
// in teardown and won't claim, so we don't defer to it — start fresh, or the loop
|
|
827
849
|
// strands on a cancel/resume race (I6 no-lost-loop). Otherwise start one.
|
|
828
850
|
#ensureDrain(opts) {
|
|
829
|
-
return this.#withDrainLock(opts.
|
|
830
|
-
const existing = this.#activeDrains.get(opts.
|
|
851
|
+
return this.#withDrainLock(opts.workerId, async () => {
|
|
852
|
+
const existing = this.#activeDrains.get(opts.workerId);
|
|
831
853
|
if (existing !== undefined && !existing.controller.signal.aborted)
|
|
832
854
|
return null;
|
|
833
855
|
return this.#startDrain(opts);
|
|
@@ -838,17 +860,17 @@ export default class Daemon {
|
|
|
838
860
|
// that landed on a turn the loop didn't reach — into a fresh queued loop.
|
|
839
861
|
// The drain claims it on its next iteration, so a conclusion or client
|
|
840
862
|
// prompt is never silently dropped. Inherits the ended loop's flags.
|
|
841
|
-
async #reconcileOrphanedWake(
|
|
863
|
+
async #reconcileOrphanedWake(workerId, endedLoopId) {
|
|
842
864
|
const endedSeq = (await this.#db.engine_loop_sequence.get({ loop_id: endedLoopId }))?.sequence ?? endedLoopId;
|
|
843
|
-
const prefix = promptLoopPrefix(
|
|
865
|
+
const prefix = promptLoopPrefix(workerId, endedSeq);
|
|
844
866
|
const orphan = await this.#db.drain_orphaned_prompt_for_loop.get({ loop_id: endedLoopId, pattern: `${prefix}%`, prefix_len: prefix.length });
|
|
845
867
|
if (orphan === undefined)
|
|
846
868
|
return;
|
|
847
|
-
const seqRow = await this.#db.loop_run_next_sequence.get({
|
|
869
|
+
const seqRow = await this.#db.loop_run_next_sequence.get({ worker_id: workerId });
|
|
848
870
|
if (seqRow === undefined)
|
|
849
871
|
throw new Error("reconcileOrphanedWake: next-sequence query returned no row");
|
|
850
872
|
const fresh = await this.#db.drain_enqueue_loop.get({
|
|
851
|
-
|
|
873
|
+
worker_id: workerId, sequence: seqRow.next, prompt: orphan.body,
|
|
852
874
|
});
|
|
853
875
|
if (fresh === undefined)
|
|
854
876
|
throw new Error("reconcileOrphanedWake: enqueue returned no row");
|
|
@@ -856,89 +878,89 @@ export default class Daemon {
|
|
|
856
878
|
await this.#db.engine_set_loop_flags.run({ loop_id: fresh.id, flags: orphan.flags });
|
|
857
879
|
}
|
|
858
880
|
}
|
|
859
|
-
// The
|
|
881
|
+
// The worker's cancellation scope — lazily created, and replaced once aborted
|
|
860
882
|
// so a later loop.run gets a live signal. The drain and the execs its loops
|
|
861
883
|
// spawn all run under it.
|
|
862
|
-
#
|
|
863
|
-
const existing = this.#
|
|
884
|
+
#workerSignal(workerId) {
|
|
885
|
+
const existing = this.#workerAborts.get(workerId);
|
|
864
886
|
if (existing !== undefined && !existing.signal.aborted)
|
|
865
887
|
return existing;
|
|
866
888
|
const fresh = new AbortController();
|
|
867
|
-
this.#
|
|
889
|
+
this.#workerAborts.set(workerId, fresh);
|
|
868
890
|
return fresh;
|
|
869
891
|
}
|
|
870
892
|
/**
|
|
871
|
-
* Cancel the
|
|
893
|
+
* Cancel the worker's in-flight work (loop.cancel). One abort, one scope: the
|
|
872
894
|
* run signal stops the running loop's turn generation AND tears down every
|
|
873
895
|
* stream linked to it — a background exec that outlived its loop, or even a
|
|
874
896
|
* spawn that registers after this abort (it self-aborts against the aborted
|
|
875
897
|
* signal). Returns cancelled iff there was work. Queued loops stay enqueued.
|
|
876
898
|
*/
|
|
877
|
-
cancelDrain(
|
|
878
|
-
const hadDrain = this.#activeDrains.has(
|
|
879
|
-
const hadWork = hadDrain || this.#
|
|
880
|
-
// A cancel is deliberate — kill any pending hibernation poll-wake so it can't resurrect the
|
|
881
|
-
const pollTimer = this.#pollTimers.get(
|
|
899
|
+
cancelDrain(workerId, reason = "user_cancelled") {
|
|
900
|
+
const hadDrain = this.#activeDrains.has(workerId);
|
|
901
|
+
const hadWork = hadDrain || this.#workerHasActiveStreams(workerId);
|
|
902
|
+
// A cancel is deliberate — kill any pending hibernation poll-wake so it can't resurrect the worker.
|
|
903
|
+
const pollTimer = this.#pollTimers.get(workerId);
|
|
882
904
|
if (pollTimer !== undefined) {
|
|
883
905
|
clearTimeout(pollTimer);
|
|
884
|
-
this.#pollTimers.delete(
|
|
906
|
+
this.#pollTimers.delete(workerId);
|
|
885
907
|
}
|
|
886
|
-
// Stop the active drain's turn-generation (its loop closes 499). The
|
|
908
|
+
// Stop the active drain's turn-generation (its loop closes 499). The worker
|
|
887
909
|
// signal is the optimization path — the fast, listener-driven reap.
|
|
888
|
-
const scope = this.#
|
|
910
|
+
const scope = this.#workerAborts.get(workerId);
|
|
889
911
|
if (scope !== undefined && !scope.signal.aborted)
|
|
890
912
|
scope.abort(reason);
|
|
891
913
|
// #380, the PARKED case — a 202-blocked loop has no drain to observe the abort (the
|
|
892
914
|
// drain tears down on 202), so before this a cancelled park stayed 202 forever with no
|
|
893
|
-
// terminal and no broadcast. Terminalize the
|
|
915
|
+
// terminal and no broadcast. Terminalize the worker's live loops (102/202; queued 100 stays
|
|
894
916
|
// enqueued) with provenance and broadcast each. With a live drain its abort catch does
|
|
895
917
|
// this instead — skipping here keeps the broadcast single.
|
|
896
918
|
if (!hadDrain) {
|
|
897
919
|
void (async () => {
|
|
898
920
|
const message = reason.slice(0, 500);
|
|
899
|
-
const dead = await this.#db.
|
|
921
|
+
const dead = await this.#db.engine_worker_cancel_live_loops.all({ worker_id: workerId, message });
|
|
900
922
|
if (dead.length === 0)
|
|
901
923
|
return;
|
|
902
|
-
const srow = await this.#db.
|
|
924
|
+
const srow = await this.#db.drain_get_worker_workspace.get({ worker_id: workerId });
|
|
903
925
|
if (srow === undefined)
|
|
904
926
|
return;
|
|
905
927
|
for (const { id } of dead) {
|
|
906
928
|
const usage = await this.#engine.loopUsage(id);
|
|
907
|
-
this.#broadcast({
|
|
929
|
+
this.#broadcast({ workspaceId: srow.workspace_id }, "loop/terminated", {
|
|
908
930
|
loopId: id, finalStatus: 499, hitMaxTurns: false, turnIds: [], usage, message,
|
|
909
931
|
});
|
|
910
932
|
}
|
|
911
933
|
})().catch((err) => {
|
|
912
|
-
console.error(`cancelDrain(${
|
|
934
|
+
console.error(`cancelDrain(${workerId}) live-loop terminalize failed:`, err);
|
|
913
935
|
});
|
|
914
936
|
}
|
|
915
|
-
// Total reap by the REGISTRY (§
|
|
916
|
-
// of truth. Every open subscription the
|
|
937
|
+
// Total reap by the REGISTRY (§worker-lifecycle-total-reap): the durable source
|
|
938
|
+
// of truth. Every open subscription the worker holds, aborted via its owning
|
|
917
939
|
// scheme — independent of the signal-listener timing, so an exec mid-spawn
|
|
918
940
|
// (registry row written before it is killable) is reaped too. A late spawn
|
|
919
941
|
// (registering after this) self-aborts against its captured, now-aborted
|
|
920
942
|
// epoch (§exec-timeout). Idempotent; fire-and-forget (the
|
|
921
943
|
// abort is sync, the registry read async; the 499 conclusion surfaces async).
|
|
922
|
-
void this.#
|
|
923
|
-
console.error(`
|
|
944
|
+
void this.#reapWorkerStreams(workerId).catch((err) => {
|
|
945
|
+
console.error(`reapWorkerStreams(${workerId}) failed:`, err);
|
|
924
946
|
});
|
|
925
947
|
return hadWork;
|
|
926
948
|
}
|
|
927
|
-
// Does the
|
|
949
|
+
// Does the worker have an in-flight stream (a background exec)? Used only for
|
|
928
950
|
// loop.cancel's cancelled=true/false answer; the teardown itself rides the
|
|
929
951
|
// run signal. Duck-typed like #drainStreamingSchemes.
|
|
930
|
-
#
|
|
952
|
+
#workerHasActiveStreams(workerId) {
|
|
931
953
|
const exec = this.#schemes.get("exec");
|
|
932
|
-
return exec?.hasActiveSpawns?.(
|
|
954
|
+
return exec?.hasActiveSpawns?.(workerId) ?? false;
|
|
933
955
|
}
|
|
934
|
-
// The registry-routed reap (§
|
|
935
|
-
// the
|
|
956
|
+
// The registry-routed reap (§worker-lifecycle-total-reap): every open subscription
|
|
957
|
+
// the worker holds, aborted via its owning scheme. The durable answer to "reap
|
|
936
958
|
// everything" — the in-process AbortSignal listener is the optimization, this is
|
|
937
959
|
// the source of truth: an exec mid-spawn (registry row written before it is
|
|
938
960
|
// killable) or a background exec from any past loop is caught regardless of
|
|
939
961
|
// listener timing. Idempotent — a stream the signal already reaped is a no-op.
|
|
940
|
-
async #
|
|
941
|
-
const open = await ChannelWrite.
|
|
962
|
+
async #reapWorkerStreams(workerId) {
|
|
963
|
+
const open = await ChannelWrite.findOpenSubscriptionsForWorker(this.#db, workerId);
|
|
942
964
|
for (const { id, scheme } of open) {
|
|
943
965
|
const handler = this.#schemes.get(scheme);
|
|
944
966
|
handler?.abortSubscription?.(id);
|
|
@@ -946,7 +968,7 @@ export default class Daemon {
|
|
|
946
968
|
}
|
|
947
969
|
/**
|
|
948
970
|
* Wake-on-completion handler. Streaming schemes call this when a
|
|
949
|
-
* subscription closes. If the
|
|
971
|
+
* subscription closes. If the worker has an active loop, the channel
|
|
950
972
|
* transition will surface at that loop's next turn boundary — no new
|
|
951
973
|
* loop needed. Otherwise we open a fresh loop with the synthetic
|
|
952
974
|
* summary as the user prompt so the model gets a chance to react.
|
|
@@ -957,65 +979,53 @@ export default class Daemon {
|
|
|
957
979
|
*
|
|
958
980
|
* Rummy parallel: plugins/stream/stream.js stream/completed wake:true.
|
|
959
981
|
*/
|
|
960
|
-
async #
|
|
982
|
+
async #handleWakeWorker(payload) {
|
|
961
983
|
// §search-gate — settle the dedup registration: promote on a 200 conclusion, drop on
|
|
962
984
|
// failure (a dead search must never serve as a duplicate). No-op for non-search streams.
|
|
963
985
|
this.#engine.searchGate.settle(payload.target.replace(/^[a-z+.-]+:\/\//, "/").replace(/^\/+/, "/"), payload.closeStatus);
|
|
964
|
-
// §search-prefetch — page conclusions from the pass's own fan-out are sync-write
|
|
965
|
-
// internals, never wake edges; swallow them (still broadcast for client telemetry).
|
|
966
|
-
if (this.#engine.searchPrefetch.ownsConclusion(payload)) {
|
|
967
|
-
this.#broadcast({ sessionId: payload.sessionId }, "stream/concluded", { ...payload, wakeAction: "prefetch-page" });
|
|
968
|
-
return;
|
|
969
|
-
}
|
|
970
|
-
// §search-prefetch — a search runtime concluded: materialize the candidates and rewrite
|
|
971
|
-
// #results as the survivor render BEFORE the wake proceeds, so the fold-back the model
|
|
972
|
-
// reads is post-prefetch truth (the Engine hold loop holds while the pass runs).
|
|
973
|
-
if (this.#engine.searchPrefetch.isSearchConclusion(payload)) {
|
|
974
|
-
await this.#engine.searchPrefetch.onSearchConcluded(payload, (s, r) => this.#engine.prefetchCtx(s, r));
|
|
975
|
-
}
|
|
976
986
|
// Aborted streams don't wake — the abort was deliberate.
|
|
977
987
|
if (payload.closeStatus === 499) {
|
|
978
|
-
this.#broadcast({
|
|
988
|
+
this.#broadcast({ workspaceId: payload.workspaceId }, "stream/concluded", {
|
|
979
989
|
...payload, wakeAction: "skipped-aborted",
|
|
980
990
|
});
|
|
981
991
|
return;
|
|
982
992
|
}
|
|
983
|
-
// No resurrection (§
|
|
993
|
+
// No resurrection (§worker-lifecycle-no-resurrection): a non-499 completion whose
|
|
984
994
|
// run was CANCELLED (idle + its scope aborted) must not start a fresh drain —
|
|
985
995
|
// the cancel was deliberate. The deliverable is already in the channel/log and
|
|
986
|
-
// surfaces as a `collect` environment delta (§env-delta) if the
|
|
996
|
+
// surfaces as a `collect` environment delta (§env-delta) if the worker is read or
|
|
987
997
|
// resumed; we just don't inject a turn. (An active run folds the wake into its
|
|
988
998
|
// next turn via inject below; a resumed run is active, never aborted, so it is
|
|
989
999
|
// unaffected.)
|
|
990
|
-
const scope = this.#
|
|
991
|
-
if (scope?.signal.aborted === true && !this.#activeDrains.has(payload.
|
|
992
|
-
this.#broadcast({
|
|
1000
|
+
const scope = this.#workerAborts.get(payload.workerId);
|
|
1001
|
+
if (scope?.signal.aborted === true && !this.#activeDrains.has(payload.workerId)) {
|
|
1002
|
+
this.#broadcast({ workspaceId: payload.workspaceId }, "stream/concluded", {
|
|
993
1003
|
...payload, wakeAction: "skipped-cancelled",
|
|
994
1004
|
});
|
|
995
1005
|
return;
|
|
996
1006
|
}
|
|
997
1007
|
if (this.#provider === null) {
|
|
998
|
-
this.#broadcast({
|
|
1008
|
+
this.#broadcast({ workspaceId: payload.workspaceId }, "stream/concluded", {
|
|
999
1009
|
...payload, wakeAction: "skipped-no-provider",
|
|
1000
1010
|
});
|
|
1001
1011
|
return;
|
|
1002
1012
|
}
|
|
1003
1013
|
try {
|
|
1004
1014
|
const systemPrompt = await readFile(Paths.instructionsSystem, "utf8");
|
|
1005
|
-
// A slept (202) loop means the
|
|
1015
|
+
// A slept (202) loop means the worker PARKED ([102]<T>/<-1>) → RESUME it IN PLACE: re-queue
|
|
1006
1016
|
// it (202→100) so the drain re-claims and CONTINUES it (seq>1 → no re-foist). Checked
|
|
1007
|
-
// FIRST: the slept status is the
|
|
1017
|
+
// FIRST: the slept status is the worker's true disposition regardless of a draining
|
|
1008
1018
|
// sibling mid-teardown (the #ensureDrain lock serializes the re-claim). No fresh loop,
|
|
1009
1019
|
// no summary-as-prompt — the resumed loop reads the concluded stream's own state from
|
|
1010
|
-
// the manifest. §
|
|
1011
|
-
const slept = await this.#db.drain_find_slept_loop.get({
|
|
1020
|
+
// the manifest. §worker-lifecycle-wake-liveness.
|
|
1021
|
+
const slept = await this.#db.drain_find_slept_loop.get({ worker_id: payload.workerId });
|
|
1012
1022
|
if (slept !== undefined) {
|
|
1013
1023
|
await this.#db.drain_resume_slept_loop.run({ loop_id: slept.id });
|
|
1014
1024
|
const started = await this.#ensureDrain({
|
|
1015
|
-
|
|
1025
|
+
workspaceId: payload.workspaceId, workerId: payload.workerId, provider: this.#provider,
|
|
1016
1026
|
systemPrompt, maxTurns: Number(process.env.PLURNK_SERVICE_MAX_TURNS ?? "50"),
|
|
1017
1027
|
});
|
|
1018
|
-
this.#broadcast({
|
|
1028
|
+
this.#broadcast({ workspaceId: payload.workspaceId }, "stream/concluded", {
|
|
1019
1029
|
...payload, wakeAction: "resumed-loop", wakeLoopId: slept.id,
|
|
1020
1030
|
});
|
|
1021
1031
|
started?.drainPromise?.catch((err) => {
|
|
@@ -1027,15 +1037,15 @@ export default class Daemon {
|
|
|
1027
1037
|
// environment-observation injector (§exec-stream) on its next turn — there is no prompt
|
|
1028
1038
|
// to inject and NO task to overwrite. The obsolete "automated environment update"
|
|
1029
1039
|
// synthesis (which clobbered the model's actual goal) is retired; just tell the client.
|
|
1030
|
-
if (this.#activeDrains.has(payload.
|
|
1031
|
-
this.#broadcast({
|
|
1040
|
+
if (this.#activeDrains.has(payload.workerId)) {
|
|
1041
|
+
this.#broadcast({ workspaceId: payload.workspaceId }, "stream/concluded", {
|
|
1032
1042
|
...payload, wakeAction: "no-op-active-loop",
|
|
1033
1043
|
});
|
|
1034
1044
|
return;
|
|
1035
1045
|
}
|
|
1036
1046
|
// No slept loop, no active drain — nothing to resume (e.g. a SEND[200]-done run whose
|
|
1037
1047
|
// streams were swept). Surface the conclusion without opening a loop.
|
|
1038
|
-
this.#broadcast({
|
|
1048
|
+
this.#broadcast({ workspaceId: payload.workspaceId }, "stream/concluded", {
|
|
1039
1049
|
...payload, wakeAction: "no-loop",
|
|
1040
1050
|
});
|
|
1041
1051
|
}
|
|
@@ -1045,18 +1055,18 @@ export default class Daemon {
|
|
|
1045
1055
|
}
|
|
1046
1056
|
/**
|
|
1047
1057
|
* grammar 0.74.20 EXEC `<T,P>` — schedule a hibernation poll-wake. Called when a loop parks at
|
|
1048
|
-
* a park; if the
|
|
1058
|
+
* a park; if the worker holds an open polled stream, arm a timer for its tightest cadence P that
|
|
1049
1059
|
* resumes the slept loop so the model inspects progress. While the loop is ACTIVE there is no
|
|
1050
1060
|
* poll work — ambient folded stream deltas already surface progress (§exec-stream); the wake
|
|
1051
1061
|
* matters only across hibernation. A wake-edge-less 202 (no polled stream) gets no timer. §exec-poll
|
|
1052
1062
|
*/
|
|
1053
|
-
async #schedulePollWake(
|
|
1054
|
-
const existing = this.#pollTimers.get(
|
|
1063
|
+
async #schedulePollWake(workspaceId, workerId, provider, systemPrompt) {
|
|
1064
|
+
const existing = this.#pollTimers.get(workerId);
|
|
1055
1065
|
if (existing !== undefined) {
|
|
1056
1066
|
clearTimeout(existing);
|
|
1057
|
-
this.#pollTimers.delete(
|
|
1067
|
+
this.#pollTimers.delete(workerId);
|
|
1058
1068
|
}
|
|
1059
|
-
const row = await this.#db.
|
|
1069
|
+
const row = await this.#db.drain_worker_min_poll.get({ worker_id: workerId });
|
|
1060
1070
|
const pollSec = row?.poll_seconds ?? null;
|
|
1061
1071
|
if (pollSec === null || pollSec <= 0)
|
|
1062
1072
|
return; // no polled stream → the 202 just sleeps (woken only by conclusion)
|
|
@@ -1064,71 +1074,71 @@ export default class Daemon {
|
|
|
1064
1074
|
// faster than a turn settles — §exec-poll.
|
|
1065
1075
|
const execWaitMs = Number(process.env.PLURNK_SERVICE_EXEC_WAIT_MS ?? "0");
|
|
1066
1076
|
const timer = setTimeout(() => {
|
|
1067
|
-
this.#pollTimers.delete(
|
|
1068
|
-
void this.#
|
|
1077
|
+
this.#pollTimers.delete(workerId);
|
|
1078
|
+
void this.#wakeParkedWorker(workspaceId, workerId, provider, systemPrompt);
|
|
1069
1079
|
}, Math.max(pollSec * 1000, execWaitMs));
|
|
1070
1080
|
timer.unref();
|
|
1071
|
-
this.#pollTimers.set(
|
|
1081
|
+
this.#pollTimers.set(workerId, timer);
|
|
1072
1082
|
}
|
|
1073
|
-
/** Resume `
|
|
1083
|
+
/** Resume `workerId`'s slept (202) loop in place — the same 202→100 resume #handleWakeWorker uses, minus a
|
|
1074
1084
|
* wake payload. The shared wake primitive: a poll cadence (§exec-poll), a watched stream concluding,
|
|
1075
|
-
* or a child
|
|
1085
|
+
* or a child worker finishing (§run-lifecycle topology join) all call this. A no-op if the worker was
|
|
1076
1086
|
* cancelled or isn't actually parked (no slept loop) — so calling it speculatively is safe. */
|
|
1077
|
-
async #
|
|
1078
|
-
const scope = this.#
|
|
1079
|
-
if (scope?.signal.aborted === true && !this.#activeDrains.has(
|
|
1087
|
+
async #wakeParkedWorker(workspaceId, workerId, provider, systemPrompt) {
|
|
1088
|
+
const scope = this.#workerAborts.get(workerId);
|
|
1089
|
+
if (scope?.signal.aborted === true && !this.#activeDrains.has(workerId))
|
|
1080
1090
|
return; // cancelled — no resurrection
|
|
1081
|
-
const slept = await this.#db.drain_find_slept_loop.get({
|
|
1091
|
+
const slept = await this.#db.drain_find_slept_loop.get({ worker_id: workerId });
|
|
1082
1092
|
if (slept === undefined) {
|
|
1083
|
-
// Not parked. If a drain is still ACTIVE, the
|
|
1093
|
+
// Not parked. If a drain is still ACTIVE, the worker is mid-turn and about to park — the
|
|
1084
1094
|
// conclusion that fired this wake arrived before the 202 committed (the conclude-before-park
|
|
1085
1095
|
// race). OWE the wake: the drain honors it at park so a worker-run hibernation never deadlocks.
|
|
1086
1096
|
// (No active drain → already concluded/running; nothing to wake.)
|
|
1087
|
-
if (this.#activeDrains.has(
|
|
1088
|
-
this.#owedWakes.add(
|
|
1097
|
+
if (this.#activeDrains.has(workerId))
|
|
1098
|
+
this.#owedWakes.add(workerId);
|
|
1089
1099
|
return;
|
|
1090
1100
|
}
|
|
1091
1101
|
await this.#db.drain_resume_slept_loop.run({ loop_id: slept.id });
|
|
1092
1102
|
const started = await this.#ensureDrain({
|
|
1093
|
-
|
|
1103
|
+
workspaceId, workerId, provider, systemPrompt,
|
|
1094
1104
|
maxTurns: Number(process.env.PLURNK_SERVICE_MAX_TURNS ?? "50"),
|
|
1095
1105
|
});
|
|
1096
1106
|
started?.drainPromise?.catch((err) => {
|
|
1097
1107
|
console.error("wake-parked resume drain failed:", err instanceof Error ? err.message : String(err));
|
|
1098
1108
|
});
|
|
1099
1109
|
}
|
|
1100
|
-
/** A
|
|
1110
|
+
/** A worker's drain exited. If the worker truly CONCLUDED — no 202-blocked loop, no open stream — then
|
|
1101
1111
|
* wake its PARENT in place if the parent is blocked on the join (the structured-concurrency join — a
|
|
1102
|
-
* child finishing is the wake edge for a parent that waited on it, §
|
|
1112
|
+
* child finishing is the wake edge for a parent that waited on it, §worker-lifecycle-child-wake). A worker
|
|
1103
1113
|
* blocked at 202, or still holding a stream, is NOT concluded — its own wake edges drive it, not this.
|
|
1104
|
-
* The parent reads the child's deliverable from its own log (the §
|
|
1114
|
+
* The parent reads the child's deliverable from its own log (the §worker-scheme-collect delta) on
|
|
1105
1115
|
* resume — control edge here, never an injected prompt. Recurses up via the parent's own drain-exit. */
|
|
1106
|
-
async #onDrainExit(
|
|
1107
|
-
const slept = await this.#db.drain_find_slept_loop.get({
|
|
1116
|
+
async #onDrainExit(workspaceId, workerId, provider, systemPrompt) {
|
|
1117
|
+
const slept = await this.#db.drain_find_slept_loop.get({ worker_id: workerId });
|
|
1108
1118
|
if (slept !== undefined)
|
|
1109
|
-
return; // parked at 202 — not concluded, the
|
|
1110
|
-
const openSubs = await this.#db.
|
|
1119
|
+
return; // parked at 202 — not concluded, the worker is still alive
|
|
1120
|
+
const openSubs = await this.#db.find_open_subscriptions_for_worker.all({ worker_id: workerId });
|
|
1111
1121
|
if (openSubs.length > 0)
|
|
1112
1122
|
return; // a stream still runs — its conclusion re-evaluates, not this exit
|
|
1113
|
-
const parent = await this.#db.
|
|
1114
|
-
if (parent?.
|
|
1123
|
+
const parent = await this.#db.worker_parent_id.get({ worker_id: workerId });
|
|
1124
|
+
if (parent?.parent_worker_id == null)
|
|
1115
1125
|
return; // a root run — nobody to wake
|
|
1116
|
-
await this.#
|
|
1126
|
+
await this.#wakeParkedWorker(workspaceId, parent.parent_worker_id, provider, systemPrompt);
|
|
1117
1127
|
}
|
|
1118
1128
|
#broadcast(target, method, params) {
|
|
1119
1129
|
if (target === "all") {
|
|
1120
|
-
// A global engine event (e.g.
|
|
1130
|
+
// A global engine event (e.g. workspace/created) — emitted to the seam with workspaceId null (#355).
|
|
1121
1131
|
for (const sub of this.#eventSubscribers)
|
|
1122
1132
|
sub(null, method, params);
|
|
1123
1133
|
return;
|
|
1124
1134
|
}
|
|
1125
|
-
const
|
|
1135
|
+
const workspaceId = target.workspaceId;
|
|
1126
1136
|
// Publish the raw event to the in-process source first (#355) — transport modules subscribe
|
|
1127
1137
|
// here (plurnk-agui renders to AG-UI+). Each subscriber owns its own fan-out; core just emits.
|
|
1128
1138
|
for (const sub of this.#eventSubscribers)
|
|
1129
|
-
sub(
|
|
1130
|
-
// Scope-stamping onto the notification envelope (§notifications-envelope-carries-
|
|
1131
|
-
// is each subscriber's edge concern now — the seam hands (
|
|
1139
|
+
sub(workspaceId, method, params);
|
|
1140
|
+
// Scope-stamping onto the notification envelope (§notifications-envelope-carries-workspaceid)
|
|
1141
|
+
// is each subscriber's edge concern now — the seam hands (workspaceId, method, params) raw.
|
|
1132
1142
|
}
|
|
1133
1143
|
}
|
|
1134
1144
|
//# sourceMappingURL=Daemon.js.map
|