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