@plurnk/plurnk-service 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.defaults +208 -0
- package/INSTALL.md +69 -0
- package/LICENSE +21 -0
- package/README.md +46 -0
- package/SPEC.md +1511 -0
- package/dist/Paths.d.ts +13 -0
- package/dist/Paths.d.ts.map +1 -0
- package/dist/Paths.js +60 -0
- package/dist/Paths.js.map +1 -0
- package/dist/content/edited-span.d.ts +2 -0
- package/dist/content/edited-span.d.ts.map +1 -0
- package/dist/content/edited-span.js +33 -0
- package/dist/content/edited-span.js.map +1 -0
- package/dist/content/index.d.ts +10 -0
- package/dist/content/index.d.ts.map +1 -0
- package/dist/content/index.js +11 -0
- package/dist/content/index.js.map +1 -0
- package/dist/content/line-marker.d.ts +15 -0
- package/dist/content/line-marker.d.ts.map +1 -0
- package/dist/content/line-marker.js +21 -0
- package/dist/content/line-marker.js.map +1 -0
- package/dist/content/matcher.d.ts +44 -0
- package/dist/content/matcher.d.ts.map +1 -0
- package/dist/content/matcher.js +126 -0
- package/dist/content/matcher.js.map +1 -0
- package/dist/content/mimetype-binary.d.ts +8 -0
- package/dist/content/mimetype-binary.d.ts.map +1 -0
- package/dist/content/mimetype-binary.js +17 -0
- package/dist/content/mimetype-binary.js.map +1 -0
- package/dist/content/path-mimetype.d.ts +5 -0
- package/dist/content/path-mimetype.d.ts.map +1 -0
- package/dist/content/path-mimetype.js +15 -0
- package/dist/content/path-mimetype.js.map +1 -0
- package/dist/content/read-resolve.d.ts +20 -0
- package/dist/content/read-resolve.d.ts.map +1 -0
- package/dist/content/read-resolve.js +68 -0
- package/dist/content/read-resolve.js.map +1 -0
- package/dist/core/ChannelWrite.d.ts +100 -0
- package/dist/core/ChannelWrite.d.ts.map +1 -0
- package/dist/core/ChannelWrite.js +112 -0
- package/dist/core/ChannelWrite.js.map +1 -0
- package/dist/core/ChannelWrite.sql +84 -0
- package/dist/core/Db.d.ts +14 -0
- package/dist/core/Db.d.ts.map +1 -0
- package/dist/core/Db.js +7 -0
- package/dist/core/Db.js.map +1 -0
- package/dist/core/Dispatcher.d.ts +67 -0
- package/dist/core/Dispatcher.d.ts.map +1 -0
- package/dist/core/Dispatcher.js +1021 -0
- package/dist/core/Dispatcher.js.map +1 -0
- package/dist/core/Engine.d.ts +113 -0
- package/dist/core/Engine.d.ts.map +1 -0
- package/dist/core/Engine.js +1410 -0
- package/dist/core/Engine.js.map +1 -0
- package/dist/core/Engine.sql +538 -0
- package/dist/core/EnvFlags.d.ts +13 -0
- package/dist/core/EnvFlags.d.ts.map +1 -0
- package/dist/core/EnvFlags.js +84 -0
- package/dist/core/EnvFlags.js.map +1 -0
- package/dist/core/ExecutorRegistry.d.ts +45 -0
- package/dist/core/ExecutorRegistry.d.ts.map +1 -0
- package/dist/core/ExecutorRegistry.js +128 -0
- package/dist/core/ExecutorRegistry.js.map +1 -0
- package/dist/core/PacketBuilder.d.ts +67 -0
- package/dist/core/PacketBuilder.d.ts.map +1 -0
- package/dist/core/PacketBuilder.js +524 -0
- package/dist/core/PacketBuilder.js.map +1 -0
- package/dist/core/PluginLoader.d.ts +17 -0
- package/dist/core/PluginLoader.d.ts.map +1 -0
- package/dist/core/PluginLoader.js +97 -0
- package/dist/core/PluginLoader.js.map +1 -0
- package/dist/core/ProposalLifecycle.d.ts +59 -0
- package/dist/core/ProposalLifecycle.d.ts.map +1 -0
- package/dist/core/ProposalLifecycle.js +215 -0
- package/dist/core/ProposalLifecycle.js.map +1 -0
- package/dist/core/ProviderInstantiate.d.ts +9 -0
- package/dist/core/ProviderInstantiate.d.ts.map +1 -0
- package/dist/core/ProviderInstantiate.js +156 -0
- package/dist/core/ProviderInstantiate.js.map +1 -0
- package/dist/core/SchemeRegistry.d.ts +26 -0
- package/dist/core/SchemeRegistry.d.ts.map +1 -0
- package/dist/core/SchemeRegistry.js +205 -0
- package/dist/core/SchemeRegistry.js.map +1 -0
- package/dist/core/StrikeRail.d.ts +28 -0
- package/dist/core/StrikeRail.d.ts.map +1 -0
- package/dist/core/StrikeRail.js +165 -0
- package/dist/core/StrikeRail.js.map +1 -0
- package/dist/core/TelemetryChannel.d.ts +37 -0
- package/dist/core/TelemetryChannel.d.ts.map +1 -0
- package/dist/core/TelemetryChannel.js +77 -0
- package/dist/core/TelemetryChannel.js.map +1 -0
- package/dist/core/TokenGauge.d.ts +16 -0
- package/dist/core/TokenGauge.d.ts.map +1 -0
- package/dist/core/TokenGauge.js +58 -0
- package/dist/core/TokenGauge.js.map +1 -0
- package/dist/core/caps/CapsResolve.d.ts +5 -0
- package/dist/core/caps/CapsResolve.d.ts.map +1 -0
- package/dist/core/caps/CapsResolve.js +13 -0
- package/dist/core/caps/CapsResolve.js.map +1 -0
- package/dist/core/caps/DbChannelCaps.d.ts +16 -0
- package/dist/core/caps/DbChannelCaps.d.ts.map +1 -0
- package/dist/core/caps/DbChannelCaps.js +41 -0
- package/dist/core/caps/DbChannelCaps.js.map +1 -0
- package/dist/core/caps/DbEntryCaps.d.ts +19 -0
- package/dist/core/caps/DbEntryCaps.d.ts.map +1 -0
- package/dist/core/caps/DbEntryCaps.js +28 -0
- package/dist/core/caps/DbEntryCaps.js.map +1 -0
- package/dist/core/caps/DbNotifyCaps.d.ts +8 -0
- package/dist/core/caps/DbNotifyCaps.d.ts.map +1 -0
- package/dist/core/caps/DbNotifyCaps.js +35 -0
- package/dist/core/caps/DbNotifyCaps.js.map +1 -0
- package/dist/core/caps/DbSubscriptionCaps.d.ts +10 -0
- package/dist/core/caps/DbSubscriptionCaps.d.ts.map +1 -0
- package/dist/core/caps/DbSubscriptionCaps.js +86 -0
- package/dist/core/caps/DbSubscriptionCaps.js.map +1 -0
- package/dist/core/caps/DbTagCaps.d.ts +17 -0
- package/dist/core/caps/DbTagCaps.d.ts.map +1 -0
- package/dist/core/caps/DbTagCaps.js +37 -0
- package/dist/core/caps/DbTagCaps.js.map +1 -0
- package/dist/core/caps/SchemeCtxImpl.d.ts +18 -0
- package/dist/core/caps/SchemeCtxImpl.d.ts.map +1 -0
- package/dist/core/caps/SchemeCtxImpl.js +42 -0
- package/dist/core/caps/SchemeCtxImpl.js.map +1 -0
- package/dist/core/env-defaults.d.ts +19 -0
- package/dist/core/env-defaults.d.ts.map +1 -0
- package/dist/core/env-defaults.js +89 -0
- package/dist/core/env-defaults.js.map +1 -0
- package/dist/core/fork.d.ts +6 -0
- package/dist/core/fork.d.ts.map +1 -0
- package/dist/core/fork.js +84 -0
- package/dist/core/fork.js.map +1 -0
- package/dist/core/fork.sql +85 -0
- package/dist/core/git-membership.d.ts +23 -0
- package/dist/core/git-membership.d.ts.map +1 -0
- package/dist/core/git-membership.js +366 -0
- package/dist/core/git-membership.js.map +1 -0
- package/dist/core/git-state.d.ts +15 -0
- package/dist/core/git-state.d.ts.map +1 -0
- package/dist/core/git-state.js +67 -0
- package/dist/core/git-state.js.map +1 -0
- package/dist/core/packet-inject.d.ts +5 -0
- package/dist/core/packet-inject.d.ts.map +1 -0
- package/dist/core/packet-inject.js +48 -0
- package/dist/core/packet-inject.js.map +1 -0
- package/dist/core/packet-wire.d.ts +46 -0
- package/dist/core/packet-wire.d.ts.map +1 -0
- package/dist/core/packet-wire.js +453 -0
- package/dist/core/packet-wire.js.map +1 -0
- package/dist/core/path-decode.d.ts +2 -0
- package/dist/core/path-decode.d.ts.map +1 -0
- package/dist/core/path-decode.js +8 -0
- package/dist/core/path-decode.js.map +1 -0
- package/dist/core/plugin-attribution.d.ts +5 -0
- package/dist/core/plugin-attribution.d.ts.map +1 -0
- package/dist/core/plugin-attribution.js +39 -0
- package/dist/core/plugin-attribution.js.map +1 -0
- package/dist/core/plurnk-uri.d.ts +7 -0
- package/dist/core/plurnk-uri.d.ts.map +1 -0
- package/dist/core/plurnk-uri.js +52 -0
- package/dist/core/plurnk-uri.js.map +1 -0
- package/dist/core/resolveForLoop.d.ts +5 -0
- package/dist/core/resolveForLoop.d.ts.map +1 -0
- package/dist/core/resolveForLoop.js +12 -0
- package/dist/core/resolveForLoop.js.map +1 -0
- package/dist/core/results.d.ts +12 -0
- package/dist/core/results.d.ts.map +1 -0
- package/dist/core/results.js +27 -0
- package/dist/core/results.js.map +1 -0
- package/dist/core/run-cap.d.ts +8 -0
- package/dist/core/run-cap.d.ts.map +1 -0
- package/dist/core/run-cap.js +20 -0
- package/dist/core/run-cap.js.map +1 -0
- package/dist/core/run-ops.sql +43 -0
- package/dist/core/scheme-types.d.ts +45 -0
- package/dist/core/scheme-types.d.ts.map +1 -0
- package/dist/core/scheme-types.js +6 -0
- package/dist/core/scheme-types.js.map +1 -0
- package/dist/core/session-settings.d.ts +23 -0
- package/dist/core/session-settings.d.ts.map +1 -0
- package/dist/core/session-settings.js +55 -0
- package/dist/core/session-settings.js.map +1 -0
- package/dist/core/teaching.d.ts +3 -0
- package/dist/core/teaching.d.ts.map +1 -0
- package/dist/core/teaching.js +13 -0
- package/dist/core/teaching.js.map +1 -0
- package/dist/core/types.d.ts +3 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +2 -0
- package/dist/core/types.js.map +1 -0
- package/dist/digest/Digest.d.ts +20 -0
- package/dist/digest/Digest.d.ts.map +1 -0
- package/dist/digest/Digest.js +451 -0
- package/dist/digest/Digest.js.map +1 -0
- package/dist/digest/Digest.sql +1 -0
- package/dist/digest/digest.sql +55 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/schemes/EffectPolicy.d.ts +6 -0
- package/dist/schemes/EffectPolicy.d.ts.map +1 -0
- package/dist/schemes/EffectPolicy.js +18 -0
- package/dist/schemes/EffectPolicy.js.map +1 -0
- package/dist/schemes/Exec.d.ts +39 -0
- package/dist/schemes/Exec.d.ts.map +1 -0
- package/dist/schemes/Exec.js +510 -0
- package/dist/schemes/Exec.js.map +1 -0
- package/dist/schemes/ExecOutputScheme.d.ts +20 -0
- package/dist/schemes/ExecOutputScheme.d.ts.map +1 -0
- package/dist/schemes/ExecOutputScheme.js +39 -0
- package/dist/schemes/ExecOutputScheme.js.map +1 -0
- package/dist/schemes/File.d.ts +42 -0
- package/dist/schemes/File.d.ts.map +1 -0
- package/dist/schemes/File.js +356 -0
- package/dist/schemes/File.js.map +1 -0
- package/dist/schemes/Known.d.ts +17 -0
- package/dist/schemes/Known.d.ts.map +1 -0
- package/dist/schemes/Known.js +40 -0
- package/dist/schemes/Known.js.map +1 -0
- package/dist/schemes/Log.d.ts +22 -0
- package/dist/schemes/Log.d.ts.map +1 -0
- package/dist/schemes/Log.js +269 -0
- package/dist/schemes/Log.js.map +1 -0
- package/dist/schemes/Log.sql +59 -0
- package/dist/schemes/Plurnk.d.ts +17 -0
- package/dist/schemes/Plurnk.d.ts.map +1 -0
- package/dist/schemes/Plurnk.js +46 -0
- package/dist/schemes/Plurnk.js.map +1 -0
- package/dist/schemes/Run.d.ts +25 -0
- package/dist/schemes/Run.d.ts.map +1 -0
- package/dist/schemes/Run.js +172 -0
- package/dist/schemes/Run.js.map +1 -0
- package/dist/schemes/Skill.d.ts +17 -0
- package/dist/schemes/Skill.d.ts.map +1 -0
- package/dist/schemes/Skill.js +40 -0
- package/dist/schemes/Skill.js.map +1 -0
- package/dist/schemes/Unknown.d.ts +17 -0
- package/dist/schemes/Unknown.d.ts.map +1 -0
- package/dist/schemes/Unknown.js +39 -0
- package/dist/schemes/Unknown.js.map +1 -0
- package/dist/schemes/_entry-chunk.d.ts +11 -0
- package/dist/schemes/_entry-chunk.d.ts.map +1 -0
- package/dist/schemes/_entry-chunk.js +100 -0
- package/dist/schemes/_entry-chunk.js.map +1 -0
- package/dist/schemes/_entry-crud.d.ts +31 -0
- package/dist/schemes/_entry-crud.d.ts.map +1 -0
- package/dist/schemes/_entry-crud.js +77 -0
- package/dist/schemes/_entry-crud.js.map +1 -0
- package/dist/schemes/_entry-crud.sql +101 -0
- package/dist/schemes/_entry-find.d.ts +32 -0
- package/dist/schemes/_entry-find.d.ts.map +1 -0
- package/dist/schemes/_entry-find.js +226 -0
- package/dist/schemes/_entry-find.js.map +1 -0
- package/dist/schemes/_entry-find.sql +54 -0
- package/dist/schemes/_entry-graph.d.ts +16 -0
- package/dist/schemes/_entry-graph.d.ts.map +1 -0
- package/dist/schemes/_entry-graph.js +94 -0
- package/dist/schemes/_entry-graph.js.map +1 -0
- package/dist/schemes/_entry-graph.sql +52 -0
- package/dist/schemes/_entry-manifest.d.ts +25 -0
- package/dist/schemes/_entry-manifest.d.ts.map +1 -0
- package/dist/schemes/_entry-manifest.js +266 -0
- package/dist/schemes/_entry-manifest.js.map +1 -0
- package/dist/schemes/_entry-ops.d.ts +32 -0
- package/dist/schemes/_entry-ops.d.ts.map +1 -0
- package/dist/schemes/_entry-ops.js +216 -0
- package/dist/schemes/_entry-ops.js.map +1 -0
- package/dist/schemes/_entry-ops.sql +31 -0
- package/dist/schemes/_entry-semantic.d.ts +38 -0
- package/dist/schemes/_entry-semantic.d.ts.map +1 -0
- package/dist/schemes/_entry-semantic.js +202 -0
- package/dist/schemes/_entry-semantic.js.map +1 -0
- package/dist/schemes/_entry-semantic.sql +95 -0
- package/dist/schemes/_entry-send.d.ts +11 -0
- package/dist/schemes/_entry-send.d.ts.map +1 -0
- package/dist/schemes/_entry-send.js +66 -0
- package/dist/schemes/_entry-send.js.map +1 -0
- package/dist/schemes/cosine.d.ts +4 -0
- package/dist/schemes/cosine.d.ts.map +1 -0
- package/dist/schemes/cosine.js +9 -0
- package/dist/schemes/cosine.js.map +1 -0
- package/dist/schemes/exec-abort.d.ts +15 -0
- package/dist/schemes/exec-abort.d.ts.map +1 -0
- package/dist/schemes/exec-abort.js +29 -0
- package/dist/schemes/exec-abort.js.map +1 -0
- package/dist/schemes/exec-env.d.ts +5 -0
- package/dist/schemes/exec-env.d.ts.map +1 -0
- package/dist/schemes/exec-env.js +34 -0
- package/dist/schemes/exec-env.js.map +1 -0
- package/dist/server/Daemon.d.ts +244 -0
- package/dist/server/Daemon.d.ts.map +1 -0
- package/dist/server/Daemon.js +1119 -0
- package/dist/server/Daemon.js.map +1 -0
- package/dist/server/client-input.d.ts +11 -0
- package/dist/server/client-input.d.ts.map +1 -0
- package/dist/server/client-input.js +145 -0
- package/dist/server/client-input.js.map +1 -0
- package/dist/server/clientTurn.d.ts +6 -0
- package/dist/server/clientTurn.d.ts.map +1 -0
- package/dist/server/clientTurn.js +23 -0
- package/dist/server/clientTurn.js.map +1 -0
- package/dist/server/clientTurn.sql +10 -0
- package/dist/server/dispatch-as-plurnk.d.ts +7 -0
- package/dist/server/dispatch-as-plurnk.d.ts.map +1 -0
- package/dist/server/dispatch-as-plurnk.js +23 -0
- package/dist/server/dispatch-as-plurnk.js.map +1 -0
- package/dist/server/drain.sql +124 -0
- package/dist/server/envelope.d.ts +53 -0
- package/dist/server/envelope.d.ts.map +1 -0
- package/dist/server/envelope.js +172 -0
- package/dist/server/envelope.js.map +1 -0
- package/dist/server/envelope.sql +76 -0
- package/dist/server/logEntry.d.ts +35 -0
- package/dist/server/logEntry.d.ts.map +1 -0
- package/dist/server/logEntry.js +49 -0
- package/dist/server/logEntry.js.map +1 -0
- package/dist/server/logEntry.sql +10 -0
- package/dist/server/loopDocs.d.ts +6 -0
- package/dist/server/loopDocs.d.ts.map +1 -0
- package/dist/server/loopDocs.js +22 -0
- package/dist/server/loopDocs.js.map +1 -0
- package/dist/server/noProposals.d.ts +6 -0
- package/dist/server/noProposals.d.ts.map +1 -0
- package/dist/server/noProposals.js +37 -0
- package/dist/server/noProposals.js.map +1 -0
- package/dist/server/seam-entry-read.sql +21 -0
- package/dist/server/seam-log-read.sql +21 -0
- package/dist/server/seam-loop.sql +9 -0
- package/dist/server/seam-proposal-list.sql +13 -0
- package/dist/server/version-info.d.ts +14 -0
- package/dist/server/version-info.d.ts.map +1 -0
- package/dist/server/version-info.js +69 -0
- package/dist/server/version-info.js.map +1 -0
- package/dist/server/yolo.d.ts +6 -0
- package/dist/server/yolo.d.ts.map +1 -0
- package/dist/server/yolo.js +64 -0
- package/dist/server/yolo.js.map +1 -0
- package/dist/service.d.ts +6 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +309 -0
- package/dist/service.js.map +1 -0
- package/migrations/0000-00-00.01_schema.sql +500 -0
- package/package.json +86 -0
|
@@ -0,0 +1,1119 @@
|
|
|
1
|
+
// Top-level daemon orchestrator. Owns the DB connection, engine, registries,
|
|
2
|
+
// the daughter-module seam (#364: the daemon owns no transport).
|
|
3
|
+
// SPEC §rpc.
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { resolve, dirname } from "node:path";
|
|
6
|
+
import ChannelWrite from "../core/ChannelWrite.js";
|
|
7
|
+
import { Paths } from "../index.js";
|
|
8
|
+
import Engine from "../core/Engine.js";
|
|
9
|
+
import ExecutorRegistry from "../core/ExecutorRegistry.js";
|
|
10
|
+
import SchemeRegistry from "../core/SchemeRegistry.js";
|
|
11
|
+
import { Mimetypes } from "@plurnk/plurnk-mimetypes";
|
|
12
|
+
import LogEntry from "./logEntry.js";
|
|
13
|
+
import Envelope from "./envelope.js";
|
|
14
|
+
import ClientInput from "./client-input.js";
|
|
15
|
+
import ClientTurn from "./clientTurn.js";
|
|
16
|
+
import LoopDocs from "./loopDocs.js";
|
|
17
|
+
import GitMembership from "../core/git-membership.js";
|
|
18
|
+
import Fork from "../core/fork.js";
|
|
19
|
+
import { promptLoopPrefix } from "../core/plurnk-uri.js";
|
|
20
|
+
import { parseAliasesFromEnv, resolveActiveAlias } from "@plurnk/plurnk-providers";
|
|
21
|
+
import Yolo from "./yolo.js";
|
|
22
|
+
import NoProposals from "./noProposals.js";
|
|
23
|
+
import { DEFAULT_LOOP_FLAGS } from "../core/scheme-types.js";
|
|
24
|
+
export default class Daemon {
|
|
25
|
+
#db;
|
|
26
|
+
#engine;
|
|
27
|
+
#schemes;
|
|
28
|
+
#mimetypes;
|
|
29
|
+
#provider;
|
|
30
|
+
#nodeModulesPath;
|
|
31
|
+
#discoveryCwd;
|
|
32
|
+
#started = false; // start() runs once — boots discovery + daughter modules (#364: no listener, ever)
|
|
33
|
+
// The emit half of the broadcast, exposed as an in-process event source (#355). A transport
|
|
34
|
+
// module (plurnk-agui) subscribes and fans out to its OWN clients; core emits, never fans out
|
|
35
|
+
// for it. The WS fan-out below is legacy scaffolding that retires at the AG-UI+ cutover.
|
|
36
|
+
#eventSubscribers = new Set();
|
|
37
|
+
// Run-level drain registry. At most one drain per run. The stored object
|
|
38
|
+
// is the drain's identity handle: start/exit compare it by reference so a
|
|
39
|
+
// drain exiting never clobbers a successor that raced in, and a loop
|
|
40
|
+
// enqueued during teardown is never stranded. A drain is a pure queue
|
|
41
|
+
// consumer (claim → run → exit on empty queue); streams live independently
|
|
42
|
+
// (subscriptions + Exec.idle), and a concluding stream routes through
|
|
43
|
+
// inject() like any other loop source.
|
|
44
|
+
#activeDrains = new Map();
|
|
45
|
+
// Per-run cancellation scope. Loops AND the streams they spawn (execs)
|
|
46
|
+
// share this signal, so loop.cancel / shutdown abort it once and every
|
|
47
|
+
// in-flight subscription tears down — even a spawn that registers AFTER the
|
|
48
|
+
// cancel self-aborts against the already-aborted signal (no race). Outlives
|
|
49
|
+
// any single (ephemeral) drain; replaced with a fresh controller once
|
|
50
|
+
// aborted so a later loop.run isn't born cancelled.
|
|
51
|
+
#runAborts = new Map();
|
|
52
|
+
// grammar 0.74.20 EXEC `<T,P>` — per-run hibernation poll-wake timer. When a loop parks at
|
|
53
|
+
// a park with a polled stream, a timer fires every P seconds to resume it (§exec-poll). One
|
|
54
|
+
// per run (the tightest cadence); cleared/replaced on each park and on cancel.
|
|
55
|
+
#parkTimers = new Map();
|
|
56
|
+
#pollTimers = new Map();
|
|
57
|
+
// Per-run drain-transition lock — see #withDrainLock (R4 / §run-lifecycle-single-drain).
|
|
58
|
+
#drainLocks = new Map();
|
|
59
|
+
// §run-lifecycle-child-wake — runs OWED a wake: a child/stream conclusion fired while the run was
|
|
60
|
+
// mid-turn (not yet slept), so #wakeParkedRun could not resume it. A worker-run conclusion is a
|
|
61
|
+
// 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 run's next park, closing the conclude-
|
|
63
|
+
// before-park race. (Only a live exec stream, unbounded absent a timeout, may hold a park open.)
|
|
64
|
+
#owedWakes = new Set();
|
|
65
|
+
constructor({ db, schemes, mimetypes, provider, nodeModulesPath, }) {
|
|
66
|
+
this.#db = db;
|
|
67
|
+
this.#schemes = schemes ?? new SchemeRegistry();
|
|
68
|
+
this.#provider = provider ?? null;
|
|
69
|
+
// Plugin discovery resolves from the SERVICE's node_modules (its exec/scheme/mimetype
|
|
70
|
+
// deps), NOT process.cwd() — else a globally-installed daemon started from a project dir
|
|
71
|
+
// finds no plugins. The bin passes the package-relative path; cwd default holds for
|
|
72
|
+
// in-repo tests. discover() takes a cwd and joins node_modules, so derive the parent.
|
|
73
|
+
this.#nodeModulesPath = nodeModulesPath ?? resolve(process.cwd(), "node_modules");
|
|
74
|
+
this.#discoveryCwd = dirname(this.#nodeModulesPath);
|
|
75
|
+
// Mimetypes owns discovery + detection; we inject the tokenize fn (from the provider's
|
|
76
|
+
// countTokens) and default to text/markdown.
|
|
77
|
+
this.#mimetypes = mimetypes ?? new Mimetypes({
|
|
78
|
+
defaultMimetype: "text/markdown",
|
|
79
|
+
discoverOptions: { cwd: this.#discoveryCwd },
|
|
80
|
+
});
|
|
81
|
+
this.#engine = new Engine({
|
|
82
|
+
db, schemes: this.#schemes, mimetypes: this.#mimetypes,
|
|
83
|
+
// Same provider-backed source as the Mimetypes tokenize lambda
|
|
84
|
+
// above; sync here because countTokens is sync (§provider-surface) and the
|
|
85
|
+
// write helpers store the count inline. Divisor tripwire only
|
|
86
|
+
// until a provider is resolved.
|
|
87
|
+
tokenize: (text) => this.#provider?.countTokens(text) ?? Math.ceil(text.length / 4),
|
|
88
|
+
streamEventNotify: (sessionId, event) => this.notifyStreamEvent(sessionId, event),
|
|
89
|
+
wakeRunNotify: (payload) => { void this.#handleWakeRun(payload); },
|
|
90
|
+
// run:// loop-start primitive — spawn/fork/irc deliver through
|
|
91
|
+
// Daemon.inject (active sister → fold; idle → enqueue + drain). The
|
|
92
|
+
// daemon owns provider + the law-file system prompt; the run scheme
|
|
93
|
+
// handler carries neither. Fire-and-forget: the returned drain runs
|
|
94
|
+
// independently (the sister is its own run). §machine-processes
|
|
95
|
+
injectRun: async ({ sessionId, runId, prompt, flags }) => {
|
|
96
|
+
if (this.#provider === null)
|
|
97
|
+
throw new Error("injectRun: no provider configured");
|
|
98
|
+
const systemPrompt = await readFile(Paths.instructionsSystem, "utf8");
|
|
99
|
+
const { action, loopId } = await this.inject({ sessionId, runId, prompt, provider: this.#provider, systemPrompt, ...(flags === undefined ? {} : { flags }) });
|
|
100
|
+
return { action, loopId };
|
|
101
|
+
},
|
|
102
|
+
// run:// KILL (terminate) — abort any run's in-flight work by id. One
|
|
103
|
+
// abort, one scope (Daemon.cancelDrain): the active loop closes 499,
|
|
104
|
+
// background streams tear down. Whoever holds the address may end it.
|
|
105
|
+
cancelRun: (runId) => this.cancelDrain(runId, "killed via run:// KILL"),
|
|
106
|
+
telemetryEventNotify: (sessionId, payload) => this.notifyTelemetryEvent(sessionId, payload),
|
|
107
|
+
});
|
|
108
|
+
// Wire proposal-pending events to the loop/proposal WS notification.
|
|
109
|
+
// Sessionid scopes the broadcast to clients on the same session.
|
|
110
|
+
this.#engine.onProposalPending((event) => {
|
|
111
|
+
this.#broadcast({ sessionId: event.sessionId }, "loop/proposal", {
|
|
112
|
+
logEntryId: event.logEntryId,
|
|
113
|
+
loopId: event.loopId,
|
|
114
|
+
turnId: event.turnId,
|
|
115
|
+
op: event.op,
|
|
116
|
+
target: event.target,
|
|
117
|
+
body: event.body,
|
|
118
|
+
attrs: event.attrs,
|
|
119
|
+
// event.flags is carried for discoverability — a client in
|
|
120
|
+
// server-YOLO mode (event.flags.yolo=true) knows to skip
|
|
121
|
+
// rendering review UI because the entry will resolve in-
|
|
122
|
+
// process before any human can react.
|
|
123
|
+
flags: event.flags,
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
// In-tree YOLO listener — auto-accepts proposals when the loop's
|
|
127
|
+
// persisted flags.yolo === true. Skips client roundtrip entirely.
|
|
128
|
+
Yolo.attachYolo(this.#engine, this.#db);
|
|
129
|
+
// Inverse of YOLO: auto-REJECT proposals in-process when the loop's
|
|
130
|
+
// persisted flags.noProposals === true (client has no review channel).
|
|
131
|
+
// The model sees an ordinary 400, never the orchestration reason.
|
|
132
|
+
NoProposals.attachNoProposals(this.#engine, this.#db);
|
|
133
|
+
}
|
|
134
|
+
// The client-interface seam (#355). A transport module subscribes to the daemon's in-process
|
|
135
|
+
// event source: it receives every session-scoped engine event as `(sessionId, method, params)`
|
|
136
|
+
// and fans out to its OWN clients — core emits, it never fans out for the module. Returns an
|
|
137
|
+
// unsubscribe. `sessionId` is the event's session, or null for a global event (e.g. session/created).
|
|
138
|
+
// The engine and its events are core; the fan-out belongs to the module.
|
|
139
|
+
subscribeToEvents(handler) {
|
|
140
|
+
this.#eventSubscribers.add(handler);
|
|
141
|
+
return () => { this.#eventSubscribers.delete(handler); };
|
|
142
|
+
}
|
|
143
|
+
// The client-interface seam (#355) — proposal HITL. A transport module reads the stopped-world
|
|
144
|
+
// proposals for a session (rendering each as a TOOL_CALL) and feeds back the human's decision. The
|
|
145
|
+
// gate, validation, and applyResolution stay core (Engine.resolveProposal); the seam is the read +
|
|
146
|
+
// the resolve, never the mechanism. `resolveProposal` throws for an unknown/already-resolved id.
|
|
147
|
+
async pendingProposals(sessionId) {
|
|
148
|
+
return this.#db.proposal_list_pending.all({ session_id: sessionId });
|
|
149
|
+
}
|
|
150
|
+
resolveProposal(logEntryId, resolution) {
|
|
151
|
+
this.#engine.resolveProposal(logEntryId, resolution);
|
|
152
|
+
}
|
|
153
|
+
// The client-interface seam (#355) — drive/steer a loop. The module supplies only session/run/prompt;
|
|
154
|
+
// the provider and the law-file system prompt are core's and stay inside. Returns immediately — the
|
|
155
|
+
// loop runs async and its outcome arrives on the event source (loop/terminated). `cancelDrain` (public)
|
|
156
|
+
// is the cancel hook. Both funnel through the unified `inject`, which owns the drain lifecycle.
|
|
157
|
+
async runLoop(args) {
|
|
158
|
+
ClientInput.validateLoopFlags("loop.run", args.flags); // seam fail-hard (#364) — a truthy string must never flip YOLO
|
|
159
|
+
if (this.#provider === null)
|
|
160
|
+
throw new Error("runLoop: no provider configured");
|
|
161
|
+
const systemPrompt = await readFile(Paths.instructionsSystem, "utf8");
|
|
162
|
+
// §machine-processes — the model NEVER runs in a client-origin run (its packets would carry
|
|
163
|
+
// client op.* rows). The module resolves the model run via ensureModelRun and passes it (or a
|
|
164
|
+
// fork); a client run here is a caller error, refused loudly rather than silently rehomed.
|
|
165
|
+
const target = await this.#db.envelope_get_run_by_id.get({ id: args.runId });
|
|
166
|
+
if (target === undefined)
|
|
167
|
+
throw new Error(`runLoop: run ${args.runId} not found`);
|
|
168
|
+
if (target.origin === "client")
|
|
169
|
+
throw new Error(`runLoop: run ${args.runId} is a client run — loops run in model runs (§machine-processes); resolve one with ensureModelRun(sessionId)`);
|
|
170
|
+
// Pre-loop docs (both sets: operator/client mdDocs + the teaching docs the turn-1
|
|
171
|
+
// FIND(plurnk://docs/**) foist discovers) — ONE truth shared with the legacy loop.run route.
|
|
172
|
+
await LoopDocs.materialize(this.#engine, this.#db, args.sessionId);
|
|
173
|
+
// §operator-config-max-turns-ceiling — the operator ceiling clamps a per-call maxTurns; a
|
|
174
|
+
// seam caller must not bypass operator policy (inject only DEFAULTS from env, never clamps).
|
|
175
|
+
const ceiling = Number(process.env.PLURNK_SERVICE_MAX_TURNS ?? "-1");
|
|
176
|
+
const requested = args.maxTurns ?? ceiling;
|
|
177
|
+
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: this.#provider, systemPrompt });
|
|
179
|
+
return { action, loopId, ...(turnSeq !== undefined ? { turnSeq } : {}) };
|
|
180
|
+
}
|
|
181
|
+
// §machine-processes — the session's model run (created on first use), distinct from the client
|
|
182
|
+
// run so the model's packets never carry client op.* rows. The module binds its threads to this.
|
|
183
|
+
ensureModelRun(sessionId) {
|
|
184
|
+
return Envelope.ensureModelRun(this.#db, sessionId);
|
|
185
|
+
}
|
|
186
|
+
// The op-dispatch hook (#355) — execute one parsed op on behalf of a client: journaled as a
|
|
187
|
+
// client-origin turn (the log is core's, a client op is a first-class citizen), dispatched through
|
|
188
|
+
// the engine, then emitted as log/entry on the event source. One seam op backs the whole op_*
|
|
189
|
+
// family (read/edit/copy/find/fold/look/move/open/send/exec); the module parses at its edge with the
|
|
190
|
+
// grammar package and hands over the statement, then fans the emitted entry out to its own clients.
|
|
191
|
+
async dispatchAsClient(args) {
|
|
192
|
+
const { sessionId, runId, statement } = args;
|
|
193
|
+
const clientLoopId = await Envelope.ensureClientLoop(this.#db, runId);
|
|
194
|
+
const turnId = await ClientTurn.insertClientTurn(this.#db, clientLoopId);
|
|
195
|
+
const entryIds = [];
|
|
196
|
+
const result = await this.#engine.dispatch({
|
|
197
|
+
statement, sessionId, runId, loopId: clientLoopId, turnId, sequence: 1,
|
|
198
|
+
origin: "client", onDispatch: (logEntryId) => { entryIds.push(logEntryId); },
|
|
199
|
+
});
|
|
200
|
+
for (const logEntryId of entryIds) {
|
|
201
|
+
const entry = await LogEntry.fetchLogEntry(this.#db, logEntryId);
|
|
202
|
+
this.#broadcast({ sessionId }, "log/entry", { entry });
|
|
203
|
+
}
|
|
204
|
+
return result;
|
|
205
|
+
}
|
|
206
|
+
// op.look (#283/#358) — the pure READ-projection query on the seam: resolve a READ through the
|
|
207
|
+
// full scheme resolver and return its content, writing NO log row — the client's off-run
|
|
208
|
+
// inspection primitive (the module rewrites LOOK→READ and parses at its edge, exactly like
|
|
209
|
+
// dispatchAsClient). Rides the client loop so log:/// coordinates resolve run-relative;
|
|
210
|
+
// invisible to the model. Engine.look enforces READ-only.
|
|
211
|
+
async look(args) {
|
|
212
|
+
const { sessionId, runId, statement } = args;
|
|
213
|
+
const clientLoopId = await Envelope.ensureClientLoop(this.#db, runId);
|
|
214
|
+
return await this.#engine.look({ statement, sessionId, runId, loopId: clientLoopId });
|
|
215
|
+
}
|
|
216
|
+
// The log-read hook (#355) — a session's journal, the module's primary render input. The run is
|
|
217
|
+
// ownership-verified against the session (a session reads only its own runs — the model run included,
|
|
218
|
+
// #214); entries filter by loop/turn/since-id or the full L/T/S display coordinate. Core owns the
|
|
219
|
+
// journal + the invariant; the module shapes the entries into AG-UI messages at its edge.
|
|
220
|
+
async readLog(args) {
|
|
221
|
+
const { sessionId, runId } = args;
|
|
222
|
+
const target = await this.#db.envelope_get_run_by_id.get({ id: runId });
|
|
223
|
+
if (target === undefined)
|
|
224
|
+
throw new Error(`run ${runId} not found`);
|
|
225
|
+
if (target.session_id !== sessionId)
|
|
226
|
+
throw new Error(`run ${runId} is not in this session (${sessionId})`);
|
|
227
|
+
const rows = await this.#db.log_read_recent_ids.all({
|
|
228
|
+
run_id: runId,
|
|
229
|
+
loop_id: args.loopId ?? null, turn_id: args.turnId ?? null, since_id: args.sinceId ?? null,
|
|
230
|
+
loop_seq: args.loopSeq ?? null, turn_seq: args.turnSeq ?? null, sequence: args.sequence ?? null,
|
|
231
|
+
limit: Math.min(args.limit ?? 100, 1000),
|
|
232
|
+
});
|
|
233
|
+
const entries = [];
|
|
234
|
+
for (const r of rows)
|
|
235
|
+
entries.push(await LogEntry.fetchLogEntry(this.#db, r.id));
|
|
236
|
+
return entries;
|
|
237
|
+
}
|
|
238
|
+
// The metadata-read hooks (#355) — the module's render surface beyond the journal. Thin delegations
|
|
239
|
+
// into core's envelope / membership / provider machinery; the module fans the results into its own views.
|
|
240
|
+
listProviders() {
|
|
241
|
+
const active = resolveActiveAlias();
|
|
242
|
+
return {
|
|
243
|
+
aliases: parseAliasesFromEnv().map((a) => {
|
|
244
|
+
const isActive = active !== null && active.alias === a.alias;
|
|
245
|
+
return {
|
|
246
|
+
alias: a.alias, provider: a.provider, model: a.model, active: isActive,
|
|
247
|
+
// contextSize = the EFFECTIVE prompt budget (window minus reserves, #345) — the same
|
|
248
|
+
// denominator loop-usage reports; known for the active alias, null elsewhere.
|
|
249
|
+
contextSize: isActive && this.#provider !== null ? this.#engine.promptBudgetFor(this.#provider) : null,
|
|
250
|
+
};
|
|
251
|
+
}),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
listSessions() { return Envelope.listSessions(this.#db); }
|
|
255
|
+
listRuns(sessionId) { return Envelope.listRunsForSession(this.#db, sessionId); }
|
|
256
|
+
listPrompts(sessionId, limit = 100) { return Envelope.listPromptsForSession(this.#db, sessionId, limit); }
|
|
257
|
+
listMembers(sessionId) { return GitMembership.resolveMembershipEffects(this.#db, sessionId, undefined); }
|
|
258
|
+
listConstraints(sessionId) {
|
|
259
|
+
return this.#db.crud_list_session_constraints.all({ session_id: sessionId });
|
|
260
|
+
}
|
|
261
|
+
// Session lifecycle (#355): the module's session-management surface. Inputs arrive already validated
|
|
262
|
+
// at the module's edge ("I am the wall" — settings as the stored JSON string, constraints as a typed
|
|
263
|
+
// array, roots absolute); core owns the envelope, its reserved-name + name-uniqueness invariants,
|
|
264
|
+
// membership resolution, warmSessionDerivations, and the session/created emit. No connection state
|
|
265
|
+
// (which client is on which session) lives here — that's the module's.
|
|
266
|
+
async createSession(args) {
|
|
267
|
+
// The SEAM fail-hards on malformed client input (#364 — validation flushed out of the
|
|
268
|
+
// retired WS handlers so every module inherits it): settings bag (#231/#232/#249/#328),
|
|
269
|
+
// constraints (#200), absolute projectRoot.
|
|
270
|
+
const projectRoot = ClientInput.assertProjectRoot("session.create", args.projectRoot);
|
|
271
|
+
const settings = ClientInput.parseSettings(args.settings);
|
|
272
|
+
const constraints = ClientInput.parseConstraints(args.constraints);
|
|
273
|
+
const envelope = await Envelope.createClientEnvelope(this.#db, { name: args.name, projectRoot, settings });
|
|
274
|
+
for (const { effect, glob } of constraints) {
|
|
275
|
+
await this.#db.crud_insert_session_constraint.run({ session_id: envelope.sessionId, effect, glob });
|
|
276
|
+
}
|
|
277
|
+
if (constraints.length > 0)
|
|
278
|
+
await GitMembership.resolveGitMembership(this.#db, envelope.sessionId, undefined);
|
|
279
|
+
void this.#engine.warmSessionDerivations(envelope.sessionId).catch(() => { });
|
|
280
|
+
this.#broadcast("all", "session/created", { id: envelope.sessionId, name: envelope.sessionName, projectRoot: envelope.projectRoot });
|
|
281
|
+
return envelope;
|
|
282
|
+
}
|
|
283
|
+
async attachSession(args) {
|
|
284
|
+
// attachToSession owns the reserved-name + run-ownership invariants; the seam just delegates + warms.
|
|
285
|
+
const envelope = await Envelope.attachToSession(this.#db, args.sessionId, { runId: args.runId, runName: args.runName });
|
|
286
|
+
void this.#engine.warmSessionDerivations(envelope.sessionId).catch(() => { });
|
|
287
|
+
return envelope;
|
|
288
|
+
}
|
|
289
|
+
async renameSession(sessionId, name) {
|
|
290
|
+
if (typeof name !== "string" || name.length === 0)
|
|
291
|
+
throw new Error("session.rename: name must be a non-empty string"); // seam fail-hard (#364)
|
|
292
|
+
const taken = await this.#db.envelope_get_session_by_name.get({ name });
|
|
293
|
+
if (taken !== undefined && taken.id !== sessionId)
|
|
294
|
+
throw new Error(`a session named "${name}" already exists — pick another`);
|
|
295
|
+
return { id: sessionId, name: await Envelope.updateSessionName(this.#db, sessionId, name) };
|
|
296
|
+
}
|
|
297
|
+
async constrain(sessionId, effect, glob) {
|
|
298
|
+
ClientInput.assertConstraint("session.constrain", effect, glob);
|
|
299
|
+
// Headless is FOREVER (owner ruling, 2026-07-11, matching the client SPEC): a session is
|
|
300
|
+
// born with its workspace pointer or never has one — so a 'repo' constraint on a headless
|
|
301
|
+
// session can never resolve. Refuse legibly instead of recording a forever-pending lie.
|
|
302
|
+
if (effect === "repo") {
|
|
303
|
+
const s = await this.#db.envelope_get_session.get({ id: sessionId });
|
|
304
|
+
if (s?.project_root == null)
|
|
305
|
+
throw new Error("session.constrain: this session is headless — and headless is forever (a workspace pointer is set at session.create or never). A 'repo' overlay needs a session created with projectRoot.");
|
|
306
|
+
}
|
|
307
|
+
await this.#db.crud_insert_session_constraint.run({ session_id: sessionId, effect, glob });
|
|
308
|
+
await GitMembership.resolveGitMembership(this.#db, sessionId, undefined);
|
|
309
|
+
// Members may have just landed — warm their derivations NOW (fire-and-forget, off the hot
|
|
310
|
+
// path), exactly like createSession does (dogfood catch: '/repo' embeddings waited for a
|
|
311
|
+
// later turn's pump).
|
|
312
|
+
void this.#engine.warmSessionDerivations(sessionId).catch(() => { });
|
|
313
|
+
return { effect, glob };
|
|
314
|
+
}
|
|
315
|
+
async unconstrain(sessionId, effect, glob) {
|
|
316
|
+
ClientInput.assertConstraint("session.unconstrain", effect, glob);
|
|
317
|
+
await this.#db.crud_delete_session_constraint.run({ session_id: sessionId, effect, glob });
|
|
318
|
+
await GitMembership.resolveGitMembership(this.#db, sessionId, undefined);
|
|
319
|
+
return { effect, glob };
|
|
320
|
+
}
|
|
321
|
+
// The entry-shape hook (#355) — one entry's channels + tags + metadata at a path. With channel+offset,
|
|
322
|
+
// returns just that channel's content sliced from the offset: the incremental streaming read (#192,
|
|
323
|
+
// the delta leaves storage, not the whole channel). The module renders growing output by re-polling.
|
|
324
|
+
async readEntry(args) {
|
|
325
|
+
const m = args.target.match(/^([a-z][a-z0-9+.-]*):\/\/(.*)$/);
|
|
326
|
+
if (m === null)
|
|
327
|
+
throw new Error(`readEntry: target must be URL-shaped (scheme://pathname); got: ${args.target}`);
|
|
328
|
+
if (args.offset !== undefined && args.channel === undefined)
|
|
329
|
+
throw new Error("readEntry: offset requires channel (which channel to slice)");
|
|
330
|
+
const scheme = m[1];
|
|
331
|
+
const pathname = m[2].split("#")[0];
|
|
332
|
+
const row = await this.#db.entry_read_lookup.get({ session_id: args.sessionId, scheme, pathname });
|
|
333
|
+
if (row === undefined)
|
|
334
|
+
return { status: 404, entry: null };
|
|
335
|
+
let channelRows;
|
|
336
|
+
if (args.channel === undefined) {
|
|
337
|
+
channelRows = await this.#db.entry_read_channels.all({ entry_id: row.id });
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
const r = await this.#db.entry_read_channel_slice.get({ entry_id: row.id, channel: args.channel, offset: args.offset ?? 0 });
|
|
341
|
+
channelRows = r === undefined ? [] : [r];
|
|
342
|
+
}
|
|
343
|
+
const channels = {};
|
|
344
|
+
for (const c of channelRows)
|
|
345
|
+
channels[c.name] = { content: c.content, contentLength: c.contentLength, mimetype: c.mimetype, tokens: c.tokens, state: c.state };
|
|
346
|
+
const tagRows = await this.#db.crud_read_tags.all({ entry_id: row.id });
|
|
347
|
+
return { status: 200, entry: { id: row.id, scope: row.scope, sessionId: row.session_id, scheme: row.scheme, pathname: row.pathname, channels, tags: tagRows.map((t) => t.tag) } };
|
|
348
|
+
}
|
|
349
|
+
// The fork hook (#355) — branch a run's log into a new run in the same session (#228), sharing the
|
|
350
|
+
// session's world (entries + overlay), copying nothing of it. The module resolves the default (the
|
|
351
|
+
// session's model run) from its own connection state and passes the concrete runId; the seam owns the
|
|
352
|
+
// #366 — a fresh conversation run: AG-UI threads map to RUNS (§machine-processes — the session
|
|
353
|
+
// is the workspace, the run is the conversation). ensureModelRun is the stable DEFAULT door,
|
|
354
|
+
// forkRun the branching door (copies history); this is the fresh door — a named, empty-log,
|
|
355
|
+
// model-origin root that runLoop accepts. New chat = new conversation, same workspace.
|
|
356
|
+
async createConversationRun(args) {
|
|
357
|
+
const { sessionId, name } = args;
|
|
358
|
+
if (name !== undefined && (typeof name !== "string" || name.length === 0))
|
|
359
|
+
throw new Error("run.create: name must be a non-empty string");
|
|
360
|
+
const session = await this.#db.envelope_get_session.get({ id: sessionId });
|
|
361
|
+
if (session === undefined)
|
|
362
|
+
throw new Error(`run.create: session ${sessionId} not found`);
|
|
363
|
+
if (name !== undefined) {
|
|
364
|
+
if (Envelope.RESERVED_RUN_NAMES.has(name.toLowerCase()))
|
|
365
|
+
throw new Error(`run.create: name "${name}" is reserved for a non-client actor`);
|
|
366
|
+
const taken = await this.#db.envelope_get_run_by_name.get({ session_id: sessionId, name });
|
|
367
|
+
if (taken !== undefined)
|
|
368
|
+
throw new Error(`run.create: a run named "${name}" already exists — run names are immutable, pick another`);
|
|
369
|
+
}
|
|
370
|
+
const run = await Envelope.createModelRun(this.#db, sessionId, name);
|
|
371
|
+
return { runId: run.id, runName: run.name };
|
|
372
|
+
}
|
|
373
|
+
// ownership check and the run-name namespace + uniqueness invariants (names are immutable — no rename).
|
|
374
|
+
async forkRun(args) {
|
|
375
|
+
if (args.name !== undefined && (typeof args.name !== "string" || args.name.length === 0))
|
|
376
|
+
throw new Error("run.fork: name must be a non-empty string"); // seam fail-hard (#364)
|
|
377
|
+
const { sessionId, runId, name } = args;
|
|
378
|
+
const owner = await this.#db.envelope_get_run_by_id.get({ id: runId });
|
|
379
|
+
if (owner === undefined)
|
|
380
|
+
throw new Error(`forkRun: run ${runId} not found`);
|
|
381
|
+
if (owner.session_id !== sessionId)
|
|
382
|
+
throw new Error(`forkRun: run ${runId} is not in session ${sessionId}`);
|
|
383
|
+
if (name !== undefined) {
|
|
384
|
+
if (Envelope.RESERVED_RUN_NAMES.has(name.toLowerCase()))
|
|
385
|
+
throw new Error(`forkRun: name "${name}" is reserved for a non-client actor`);
|
|
386
|
+
const taken = await this.#db.envelope_get_run_by_name.get({ session_id: sessionId, name });
|
|
387
|
+
if (taken !== undefined)
|
|
388
|
+
throw new Error(`forkRun: a run named "${name}" already exists — run names are immutable, pick another`);
|
|
389
|
+
}
|
|
390
|
+
const branchRunId = await Fork.fork(this.#db, runId, name);
|
|
391
|
+
const branch = await this.#db.envelope_get_run_by_id.get({ id: branchRunId });
|
|
392
|
+
return { runId: branchRunId, runName: branch?.name ?? null, parentRunId: runId };
|
|
393
|
+
}
|
|
394
|
+
// The module-load hook (#355 / #289) — register a runtime into the live registry, driver-agnostic:
|
|
395
|
+
// the kernel knows nothing about MCP or any specific driver. The struct is the booth window agreed
|
|
396
|
+
// with the execs agent (execs-mcp installServer's hotload callback): framework types only — the decl
|
|
397
|
+
// (tag + glyph/example/documentation), the executor, the driver's probe result. RegistryEntry never
|
|
398
|
+
// leaves the kernel; it's wrapped here, mirroring boot. The engine's scheme-face arbitration
|
|
399
|
+
// (reserved / cross-family collision, #240) gates the tag before registering.
|
|
400
|
+
hotloadRuntime(reg) {
|
|
401
|
+
const { decl, executor, availability } = reg;
|
|
402
|
+
this.#engine.hotloadRuntime(decl.name, {
|
|
403
|
+
executor,
|
|
404
|
+
glyph: decl.glyph ?? "",
|
|
405
|
+
example: decl.example ?? "",
|
|
406
|
+
documentation: decl.documentation ?? "",
|
|
407
|
+
available: availability.available,
|
|
408
|
+
detail: availability.detail,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
get engine() { return this.#engine; }
|
|
412
|
+
get provider() { return this.#provider; }
|
|
413
|
+
get schemes() { return this.#schemes; }
|
|
414
|
+
get mimetypes() { return this.#mimetypes; }
|
|
415
|
+
// The boot plug-point (#355 hook D) — register a daughter module before start(); its init runs at
|
|
416
|
+
// boot with the curated CoreSeam handle, where it opens its own transport/listener. Direct wiring, no
|
|
417
|
+
// plugin-kind abstraction: a second transport earns one if it ever appears. "Here's your handle."
|
|
418
|
+
// The init's return value is ignored — a module may hand back its instance (or nothing).
|
|
419
|
+
#moduleInits = [];
|
|
420
|
+
registerModule(init) {
|
|
421
|
+
this.#moduleInits.push(init);
|
|
422
|
+
}
|
|
423
|
+
async start() {
|
|
424
|
+
if (this.#started)
|
|
425
|
+
throw new Error("daemon already started");
|
|
426
|
+
this.#started = true;
|
|
427
|
+
// Mimetypes owns its own discovery scan over @plurnk/plurnk-mimetypes-*
|
|
428
|
+
// packages; pre-warm it so first index render doesn't pay the cost.
|
|
429
|
+
await this.#mimetypes.ready();
|
|
430
|
+
// Discover + probe the installed executor siblings, then hand the
|
|
431
|
+
// registry to the engine for exec dispatch (plurnk-service#181). The
|
|
432
|
+
// shell is the default runtime, so its executor must boot usable.
|
|
433
|
+
const executors = await ExecutorRegistry.build({ defaultRuntime: "sh", cwd: this.#discoveryCwd });
|
|
434
|
+
this.#engine.setExecutors(executors);
|
|
435
|
+
// §exec — mint a scheme per runtime tag so exec output entries address by tag
|
|
436
|
+
// authority (sh:///l/t/s). The "exec" scheme stays for the EXEC op dispatch.
|
|
437
|
+
this.#schemes.registerRuntimeSchemes(executors);
|
|
438
|
+
// Discover external @plurnk/plurnk-schemes-* siblings + register them
|
|
439
|
+
// (agnostic, by plurnk.kind:"scheme"). They light up http://, etc. with
|
|
440
|
+
// no further engine change — #run wraps their ctx in SchemeCtxImpl (#195).
|
|
441
|
+
await this.#schemes.discoverExternal(this.#discoveryCwd);
|
|
442
|
+
// #364 — the daemon opens NO transport, ever: daughter modules open theirs via the seam.
|
|
443
|
+
for (const init of this.#moduleInits)
|
|
444
|
+
await init(this);
|
|
445
|
+
}
|
|
446
|
+
async stop() {
|
|
447
|
+
if (!this.#started)
|
|
448
|
+
return;
|
|
449
|
+
this.#started = false;
|
|
450
|
+
// Drain order: (1) abort in-flight loops via #activeDrains so
|
|
451
|
+
// strike paths don't keep going, (2) await each drain's promise
|
|
452
|
+
// to completion, (3) drain streaming schemes' background work
|
|
453
|
+
// (exec spawn cleanup, channel writes). Only THEN close the DB
|
|
454
|
+
// upstream — drain queries hit the DB right up until they exit.
|
|
455
|
+
// Abort every run's cancellation scope — stops in-flight loops AND the
|
|
456
|
+
// streams (background execs) linked to them, so idle() doesn't block on
|
|
457
|
+
// a long-running command. Covers runs whose drain already exited but
|
|
458
|
+
// whose exec is still in flight.
|
|
459
|
+
// Settle the stopped world FIRST: a drain paused at a pending proposal awaits a resolution
|
|
460
|
+
// that will never arrive once clients are gone — allSettled(drains) below would deadlock
|
|
461
|
+
// the stop forever (a daemon with a pending HITL proposal could not shut down).
|
|
462
|
+
this.#engine.cancelAllProposals("daemon_stopping");
|
|
463
|
+
for (const scope of this.#runAborts.values()) {
|
|
464
|
+
if (!scope.signal.aborted)
|
|
465
|
+
scope.abort("daemon_stopping");
|
|
466
|
+
}
|
|
467
|
+
for (const t of this.#pollTimers.values())
|
|
468
|
+
clearTimeout(t); // drop pending hibernation poll-wakes
|
|
469
|
+
this.#pollTimers.clear();
|
|
470
|
+
const drainPromises = [...this.#activeDrains.values()].map((d) => d.promise);
|
|
471
|
+
await Promise.allSettled(drainPromises);
|
|
472
|
+
await this.#drainStreamingSchemes();
|
|
473
|
+
await this.#engine.drainDerivations(); // §derivation-off-hot-path — background pumps finish before the db closes upstream
|
|
474
|
+
}
|
|
475
|
+
// Per-scheme idle awaits for clean shutdown. New streaming schemes
|
|
476
|
+
// (SSE, WS) add themselves here as they land.
|
|
477
|
+
async #drainStreamingSchemes() {
|
|
478
|
+
const exec = this.#schemes.get("exec");
|
|
479
|
+
if (exec?.idle !== undefined)
|
|
480
|
+
await exec.idle();
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Emit a stream/event notification scoped to the session containing the
|
|
484
|
+
* entry. ChannelWrite helpers (src/core/ChannelWrite.ts) invoke this when
|
|
485
|
+
* they update channel content or state. SPEC §notifications.
|
|
486
|
+
*/
|
|
487
|
+
notifyStreamEvent(sessionId, event) {
|
|
488
|
+
this.#broadcast({ sessionId }, "stream/event", event);
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Emit a telemetry/event notification scoped to the session containing
|
|
492
|
+
* the loop. TelemetryChannel.push invokes this for every TelemetryEvent
|
|
493
|
+
* (parse_error, strike, cycle, sudden_death, no_ops, max_commands_exceeded,
|
|
494
|
+
* action_failure) the moment it lands in the loop's telemetry buffer.
|
|
495
|
+
* SPEC §telemetry.
|
|
496
|
+
*/
|
|
497
|
+
notifyTelemetryEvent(sessionId, payload) {
|
|
498
|
+
this.#broadcast({ sessionId }, "telemetry/event", payload);
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Inject a prompt into a run. Two paths:
|
|
502
|
+
* - Active drain: writes a plurnk://prompt/<run>/<loop>/<next-turn> entry
|
|
503
|
+
* via Engine.inject. Current loop sees the new prompt at its next
|
|
504
|
+
* turn. Returns immediately with {action: "injected_next_turn"}.
|
|
505
|
+
* - No active drain: enqueues a fresh loop with the prompt at
|
|
506
|
+
* status=100, starts a drain. Returns the drain promise so the
|
|
507
|
+
* caller can await full completion.
|
|
508
|
+
*
|
|
509
|
+
* Rummy parallel: AgentLoop.inject(). Unified surface — both `loop.run`
|
|
510
|
+
* and wake-on-completion go through this method. §actor-boundary-passive-wake
|
|
511
|
+
*/
|
|
512
|
+
// #368 — flags are LOOP-scoped (persisted per loop row; the packet's teaching follows them), so a
|
|
513
|
+
// prompt folding into a live/parked loop cannot re-flag it mid-flight — and it must never PRETEND
|
|
514
|
+
// to: an inject carrying flags that DIFFER from the target loop's effective flags is refused
|
|
515
|
+
// legibly (cancel the loop or omit the flags), never a silent posture discard. Identical or
|
|
516
|
+
// absent flags fold clean.
|
|
517
|
+
async #assertFoldPosture(runId, flags, loopId) {
|
|
518
|
+
if (flags === undefined || Object.keys(flags).length === 0)
|
|
519
|
+
return;
|
|
520
|
+
const row = loopId !== undefined
|
|
521
|
+
? await this.#db.engine_get_loop_flags.get({ loop_id: loopId })
|
|
522
|
+
: await this.#db.drain_active_loop_flags.get({ run_id: runId });
|
|
523
|
+
const effective = { ...DEFAULT_LOOP_FLAGS, ...JSON.parse(row?.flags ?? "{}") };
|
|
524
|
+
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
|
+
if (conflicts.length > 0) {
|
|
526
|
+
throw new Error(`inject: the prompt would fold into a live loop whose flags differ (${conflicts.join(", ")}) — flags are loop-scoped and never change mid-flight. Cancel the loop (loop.cancel) and re-run with the new flags, or send the prompt without flags to adopt the loop's posture.`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
async inject(args) {
|
|
530
|
+
const { sessionId, runId, prompt } = args;
|
|
531
|
+
// Active loop (status=102)? Fold the wake/prompt into its next turn.
|
|
532
|
+
// engine.inject returns null when no loop is currently executing, so
|
|
533
|
+
// we enqueue a fresh loop below and ensure a drain claims it.
|
|
534
|
+
if (this.#activeDrains.has(runId)) {
|
|
535
|
+
await this.#assertFoldPosture(runId, args.flags); // #368 — a fold never silently discards intent
|
|
536
|
+
const result = await this.#engine.inject(runId, prompt);
|
|
537
|
+
if (result !== null) {
|
|
538
|
+
return { action: "injected_next_turn", loopId: result.loopId, turnSeq: result.turnSeq };
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
// #55 — a run PARKED at 202 RESUMES that slept loop in place: the voice door (irc / loop.inject)
|
|
542
|
+
// is a wake edge like a stream/child conclusion, not a fresh loop that orphans the parked one
|
|
543
|
+
// (which would leave the run non-quiescent forever). engine.inject writes the message as the
|
|
544
|
+
// slept loop's next-turn prompt (the directed message — distinct from the env door, which
|
|
545
|
+
// resumes promptless); then re-queue + drain it. §run-lifecycle-wake-liveness.
|
|
546
|
+
if (!this.#activeDrains.has(runId)) {
|
|
547
|
+
const slept = await this.#db.drain_find_slept_loop.get({ run_id: runId });
|
|
548
|
+
if (slept !== undefined) {
|
|
549
|
+
await this.#assertFoldPosture(runId, args.flags, slept.id); // #368 — the resume path drops nothing silently either
|
|
550
|
+
const injected = await this.#engine.inject(runId, prompt);
|
|
551
|
+
await this.#db.drain_resume_slept_loop.run({ loop_id: slept.id });
|
|
552
|
+
const started = await this.#ensureDrain({
|
|
553
|
+
sessionId, runId, provider: args.provider, systemPrompt: args.systemPrompt,
|
|
554
|
+
maxTurns: args.maxTurns ?? Number(process.env.PLURNK_SERVICE_MAX_TURNS ?? "50"),
|
|
555
|
+
});
|
|
556
|
+
return { action: "injected_next_turn", loopId: slept.id, ...(injected?.turnSeq !== undefined ? { turnSeq: injected.turnSeq } : {}), ...(started ?? {}) };
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
// Enqueue a fresh loop. Persist flags on the row.
|
|
560
|
+
const seqRow = await this.#db.loop_run_next_sequence.get({ run_id: runId });
|
|
561
|
+
if (seqRow === undefined)
|
|
562
|
+
throw new Error("inject: next-sequence query returned no row");
|
|
563
|
+
const loopRow = await this.#db.drain_enqueue_loop.get({
|
|
564
|
+
run_id: runId, sequence: seqRow.next, prompt,
|
|
565
|
+
});
|
|
566
|
+
if (loopRow === undefined)
|
|
567
|
+
throw new Error("inject: loop enqueue returned no row");
|
|
568
|
+
const loopId = loopRow.id;
|
|
569
|
+
if (args.flags !== undefined) {
|
|
570
|
+
const merged = { ...DEFAULT_LOOP_FLAGS, ...args.flags };
|
|
571
|
+
await this.#db.engine_set_loop_flags.run({
|
|
572
|
+
loop_id: loopId, flags: JSON.stringify(merged),
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
// #260 — persist client-passed @file paths before the drain claims the loop, so turn 0 foists them.
|
|
576
|
+
if (args.openPaths !== undefined && args.openPaths.length > 0) {
|
|
577
|
+
await this.#db.engine_set_loop_open_paths.run({
|
|
578
|
+
loop_id: loopId, open_paths: JSON.stringify(args.openPaths),
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
// Guarantee a drain claims the loop we just enqueued. #ensureDrain runs its
|
|
582
|
+
// check-and-start UNDER the per-run drain lock (§run-lifecycle-single-drain),
|
|
583
|
+
// serialized against a draining sibling's teardown relinquish so the two can't
|
|
584
|
+
// both register a drain (R4). A live drain re-claims the loop in its own
|
|
585
|
+
// iteration or its lock-held exit re-claim, so it's never stranded.
|
|
586
|
+
// firstLoopPromise is present only when THIS call started the drain — loop.run
|
|
587
|
+
// keys its fast-path response on that.
|
|
588
|
+
const started = await this.#ensureDrain({
|
|
589
|
+
sessionId, runId, provider: args.provider,
|
|
590
|
+
systemPrompt: args.systemPrompt,
|
|
591
|
+
maxTurns: args.maxTurns ?? Number(process.env.PLURNK_SERVICE_MAX_TURNS ?? "50"),
|
|
592
|
+
});
|
|
593
|
+
return { action: "enqueued_new_loop", loopId, ...(started ?? {}) };
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Start a drain for the given run. The drain claims queued loops via
|
|
597
|
+
* drain_claim_next_loop (atomic 100→102 flip), executes each via
|
|
598
|
+
* Engine.runLoop, and re-checks. Stream-aware: when the queue is empty
|
|
599
|
+
* but the run has active subscriptions, the drain parks on a
|
|
600
|
+
* #drainPokes signal — wake-on-completion → inject() wakes it. Drain
|
|
601
|
+
* exits when queue is empty AND no active subscriptions remain.
|
|
602
|
+
*
|
|
603
|
+
* Returns both `firstLoopPromise` (resolves once the first loop the
|
|
604
|
+
* drain processes completes — used by loop.run to give the caller a
|
|
605
|
+
* fast response containing their loop's result) and `drainPromise`
|
|
606
|
+
* (resolves only when the whole drain finishes, queue+subs settled).
|
|
607
|
+
*/
|
|
608
|
+
#startDrain(opts) {
|
|
609
|
+
const { sessionId, runId, provider, systemPrompt, maxTurns } = opts;
|
|
610
|
+
// The drain runs under the run's cancellation scope (shared with the
|
|
611
|
+
// execs its loops spawn), so loop.cancel/shutdown abort it as a unit.
|
|
612
|
+
const controller = this.#runSignal(runId);
|
|
613
|
+
const handle = {
|
|
614
|
+
controller, promise: Promise.resolve(),
|
|
615
|
+
};
|
|
616
|
+
let resolveFirst = () => { };
|
|
617
|
+
let rejectFirst = () => { };
|
|
618
|
+
const firstLoopPromise = new Promise((res, rej) => {
|
|
619
|
+
resolveFirst = res;
|
|
620
|
+
rejectFirst = rej;
|
|
621
|
+
});
|
|
622
|
+
let firstSettled = false;
|
|
623
|
+
const claim = () => this.#db.drain_claim_next_loop.get({ run_id: runId });
|
|
624
|
+
const drainPromise = (async () => {
|
|
625
|
+
let loopsDrained = 0;
|
|
626
|
+
let lastResult = null;
|
|
627
|
+
let currentLoopId = null; // the loop being drained — for the #204 abort→499 resolution below
|
|
628
|
+
try {
|
|
629
|
+
while (true) {
|
|
630
|
+
controller.signal.throwIfAborted();
|
|
631
|
+
let loopRow = await claim();
|
|
632
|
+
if (loopRow === undefined) {
|
|
633
|
+
// Queue empty → teardown UNDER the per-run drain lock (R4 / I1),
|
|
634
|
+
// serialized against #ensureDrain so a concurrent inject can't
|
|
635
|
+
// start a 2nd drain in the gap. Re-claim while holding the lock;
|
|
636
|
+
// relinquish the registry slot only if it's empty too. A loop
|
|
637
|
+
// that raced in is returned and run — we stay registered, so
|
|
638
|
+
// there's no transient delete for #ensureDrain to catch.
|
|
639
|
+
loopRow = await this.#withDrainLock(runId, async () => {
|
|
640
|
+
const claimed = await claim();
|
|
641
|
+
if (claimed === undefined && this.#activeDrains.get(runId) === handle) {
|
|
642
|
+
this.#activeDrains.delete(runId);
|
|
643
|
+
}
|
|
644
|
+
return claimed;
|
|
645
|
+
});
|
|
646
|
+
if (loopRow === undefined)
|
|
647
|
+
break;
|
|
648
|
+
}
|
|
649
|
+
currentLoopId = loopRow.id;
|
|
650
|
+
const onDispatch = (logEntryId) => {
|
|
651
|
+
void (async () => {
|
|
652
|
+
const entry = await LogEntry.fetchLogEntry(this.#db, logEntryId);
|
|
653
|
+
this.#broadcast({ sessionId }, "log/entry", { entry });
|
|
654
|
+
})();
|
|
655
|
+
};
|
|
656
|
+
const result = await this.#engine.runLoop({
|
|
657
|
+
provider, sessionId, runId, loopId: loopRow.id, maxTurns,
|
|
658
|
+
messages: [
|
|
659
|
+
{ role: "system", content: systemPrompt },
|
|
660
|
+
{ role: "user", content: loopRow.prompt },
|
|
661
|
+
],
|
|
662
|
+
origin: "model",
|
|
663
|
+
onDispatch,
|
|
664
|
+
signal: controller.signal,
|
|
665
|
+
});
|
|
666
|
+
if (result.finalStatus === 202) {
|
|
667
|
+
// The loop SLEPT (parked via [102]<T>/<-1>) — suspended, not terminated. Leave it at 202
|
|
668
|
+
// (resumable); no loop/terminated, no orphan-reconcile. A stream conclusion
|
|
669
|
+
// (#handleWakeRun) re-queues it; and if it holds a polled stream, a poll timer
|
|
670
|
+
// wakes it every P to inspect (§exec-poll). §run-lifecycle-wake-liveness.
|
|
671
|
+
void this.#schedulePollWake(sessionId, runId, provider, systemPrompt);
|
|
672
|
+
// §send-premature-terminate/[102]<T> — the park DEADLINE (grammar 0.75.0): the
|
|
673
|
+
// dispatcher recorded the marker's seconds; a bounded park is woken at T
|
|
674
|
+
// regardless of arrivals, so a park always has a next turn. -1 (indefinite:
|
|
675
|
+
// the butler, a [300] ask) schedules nothing — irc/inject/conclusions wake it.
|
|
676
|
+
// In-memory: a daemon restart drops pending deadlines.
|
|
677
|
+
if (currentLoopId !== null) {
|
|
678
|
+
const deadline = this.#engine.parkDeadlines.get(currentLoopId);
|
|
679
|
+
this.#engine.parkDeadlines.delete(currentLoopId);
|
|
680
|
+
const prior = this.#parkTimers.get(runId);
|
|
681
|
+
if (prior !== undefined) {
|
|
682
|
+
clearTimeout(prior);
|
|
683
|
+
this.#parkTimers.delete(runId);
|
|
684
|
+
}
|
|
685
|
+
if (deadline !== undefined && deadline > 0) {
|
|
686
|
+
const t = setTimeout(() => {
|
|
687
|
+
this.#parkTimers.delete(runId);
|
|
688
|
+
void this.#wakeParkedRun(sessionId, runId, provider, systemPrompt);
|
|
689
|
+
}, deadline * 1000);
|
|
690
|
+
t.unref();
|
|
691
|
+
this.#parkTimers.set(runId, t);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
// Honor an OWED wake (§run-lifecycle-child-wake): a child/stream concluded while
|
|
695
|
+
// this run was mid-turn, before it slept — resume in place rather than park blind,
|
|
696
|
+
// so a worker-run hibernation always returns. The loop is 202 here; reset to
|
|
697
|
+
// claimable and the drain re-runs it on the next claim below.
|
|
698
|
+
if (this.#owedWakes.delete(runId)) {
|
|
699
|
+
await this.#db.drain_resume_slept_loop.run({ loop_id: loopRow.id });
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
// The loop is blocked at 202 on a live obligation (§wait-obligation-matrix);
|
|
703
|
+
// that obligation's conclusion is its wake edge (the owed-wake above covers the
|
|
704
|
+
// conclude-before-block race). An idle wait never reaches here — it concluded at dispatch.
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
this.#owedWakes.delete(runId); // the loop concluded (non-202) — no park to honor a held wake at
|
|
708
|
+
const usage = await this.#engine.loopUsage(loopRow.id);
|
|
709
|
+
this.#broadcast({ sessionId }, "loop/terminated", {
|
|
710
|
+
loopId: loopRow.id,
|
|
711
|
+
finalStatus: result.finalStatus,
|
|
712
|
+
hitMaxTurns: result.hitMaxTurns,
|
|
713
|
+
turnIds: result.turnIds,
|
|
714
|
+
usage,
|
|
715
|
+
});
|
|
716
|
+
loopsDrained++;
|
|
717
|
+
const loopResult = {
|
|
718
|
+
loopId: loopRow.id,
|
|
719
|
+
turnIds: result.turnIds,
|
|
720
|
+
finalStatus: result.finalStatus,
|
|
721
|
+
hitMaxTurns: result.hitMaxTurns,
|
|
722
|
+
usage,
|
|
723
|
+
};
|
|
724
|
+
lastResult = loopResult;
|
|
725
|
+
if (!firstSettled) {
|
|
726
|
+
firstSettled = true;
|
|
727
|
+
resolveFirst(loopResult);
|
|
728
|
+
}
|
|
729
|
+
// A next-turn prompt this loop ended before consuming (a
|
|
730
|
+
// wake conclusion or a loop.run-while-active) is promoted to
|
|
731
|
+
// a fresh queued loop so it's never silently dropped.
|
|
732
|
+
await this.#reconcileOrphanedWake(runId, loopRow.id);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
catch (err) {
|
|
736
|
+
if (controller.signal.aborted) {
|
|
737
|
+
// #204 / Model 3 — loop.cancel / shutdown aborted the live drain. A cancellation
|
|
738
|
+
// is the loop's TERMINAL state (499), delivered via loop/terminated (loop.run no
|
|
739
|
+
// longer blocks to return it). A genuine error rejects firstLoopPromise.
|
|
740
|
+
const usage = currentLoopId === null
|
|
741
|
+
? { promptTokens: 0, completionTokens: 0, costPico: 0, contextTokens: 0, contextSize: null, meta: {} }
|
|
742
|
+
: await this.#engine.loopUsage(currentLoopId);
|
|
743
|
+
if (currentLoopId !== null) {
|
|
744
|
+
// #380 (owner ruling) — the cancel is allowed but provenanced: the ROW goes
|
|
745
|
+
// terminal 499 (a dead loop must never read as live 102, #311) carrying
|
|
746
|
+
// terminated_by='cancel' + the abort reason as the abandonment message, and
|
|
747
|
+
// the broadcast carries the same message. The abort reason is the client's
|
|
748
|
+
// loop.cancel reason (cancelDrain threads it through scope.abort).
|
|
749
|
+
const message = String(controller.signal.reason ?? "user_cancelled").slice(0, 500);
|
|
750
|
+
await this.#db.engine_loop_cancel_external.run({ loop_id: currentLoopId, message });
|
|
751
|
+
this.#broadcast({ sessionId }, "loop/terminated", {
|
|
752
|
+
loopId: currentLoopId, finalStatus: 499, hitMaxTurns: false, turnIds: [], usage, message,
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
if (!firstSettled) {
|
|
756
|
+
firstSettled = true;
|
|
757
|
+
resolveFirst({ loopId: currentLoopId ?? 0, turnIds: [], finalStatus: 499, hitMaxTurns: false, usage });
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
else {
|
|
761
|
+
// #265 — a genuine (non-abort) loop error must still reach the client. loop.run only
|
|
762
|
+
// acked finalStatus:100, so loop/terminated is the sole outcome channel; the rejection
|
|
763
|
+
// alone reaches no one (firstLoopPromise/drainPromise are .catch()'d). Broadcast 500
|
|
764
|
+
// (failed) — distinct from an abort's 499 — for every error, not just the pre-first one.
|
|
765
|
+
if (currentLoopId !== null) {
|
|
766
|
+
// #311 — the failure must be first-class on BOTH surfaces: the loop row goes
|
|
767
|
+
// terminal 500 carrying the cause (a dead loop must never read as live 102 —
|
|
768
|
+
// the premature-terminate gate counts live loops), and the broadcast carries
|
|
769
|
+
// the same message so a backend 400 (context overflow, auth, …) reaches the
|
|
770
|
+
// client as text, never a contentless 500.
|
|
771
|
+
const message = (err instanceof Error ? err.message : String(err)).slice(0, 500);
|
|
772
|
+
await this.#db.engine_loop_set_status.run({ loop_id: currentLoopId, status: 500, message });
|
|
773
|
+
const usage = await this.#engine.loopUsage(currentLoopId);
|
|
774
|
+
this.#broadcast({ sessionId }, "loop/terminated", {
|
|
775
|
+
loopId: currentLoopId, finalStatus: 500, hitMaxTurns: false, turnIds: [], usage, message,
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
if (!firstSettled) {
|
|
779
|
+
firstSettled = true;
|
|
780
|
+
rejectFirst(err);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
throw err;
|
|
784
|
+
}
|
|
785
|
+
finally {
|
|
786
|
+
if (!firstSettled) {
|
|
787
|
+
firstSettled = true;
|
|
788
|
+
rejectFirst(new Error("drain exited without producing a result"));
|
|
789
|
+
}
|
|
790
|
+
if (this.#activeDrains.get(runId) === handle)
|
|
791
|
+
this.#activeDrains.delete(runId);
|
|
792
|
+
}
|
|
793
|
+
return { loopsDrained, lastResult };
|
|
794
|
+
})();
|
|
795
|
+
handle.promise = drainPromise;
|
|
796
|
+
this.#activeDrains.set(runId, handle);
|
|
797
|
+
// Topology join (§run-lifecycle): when this drain exits having CONCLUDED the run, wake its parent
|
|
798
|
+
// 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 run concluded AND the parent is parked).
|
|
800
|
+
drainPromise.then(() => this.#onDrainExit(sessionId, runId, provider, systemPrompt)).catch(() => { });
|
|
801
|
+
// Swallow unhandled rejections (drain aborts with no awaiter); the
|
|
802
|
+
// error already surfaced via firstLoopPromise or was logged inside.
|
|
803
|
+
drainPromise.catch(() => { });
|
|
804
|
+
firstLoopPromise.catch(() => { });
|
|
805
|
+
return { firstLoopPromise, drainPromise };
|
|
806
|
+
}
|
|
807
|
+
// Per-run drain-transition lock (R4 / §run-lifecycle-single-drain). #ensureDrain's
|
|
808
|
+
// 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 run. The critical section is the
|
|
810
|
+
// registry decision only (never a loop's work) — a sub-ms hop at drain boundaries.
|
|
811
|
+
// A promise-chain mutex: each caller awaits the prior holder; the tail self-prunes
|
|
812
|
+
// when idle so the Map stays bounded to runs mid-transition.
|
|
813
|
+
#withDrainLock(runId, fn) {
|
|
814
|
+
const prev = this.#drainLocks.get(runId) ?? Promise.resolve();
|
|
815
|
+
const run = prev.then(fn, fn);
|
|
816
|
+
const tail = run.catch(() => { });
|
|
817
|
+
this.#drainLocks.set(runId, tail);
|
|
818
|
+
void tail.then(() => { if (this.#drainLocks.get(runId) === tail)
|
|
819
|
+
this.#drainLocks.delete(runId); });
|
|
820
|
+
return run;
|
|
821
|
+
}
|
|
822
|
+
// The drain guarantee, serialized per run via #withDrainLock so it can't race a
|
|
823
|
+
// sibling drain's teardown relinquish into a double-drain (R4). A live drain
|
|
824
|
+
// (registered, NOT aborting) will claim the just-enqueued loop in its own iteration
|
|
825
|
+
// or its lock-held exit re-claim → return null. A registered-but-ABORTING drain is
|
|
826
|
+
// in teardown and won't claim, so we don't defer to it — start fresh, or the loop
|
|
827
|
+
// strands on a cancel/resume race (I6 no-lost-loop). Otherwise start one.
|
|
828
|
+
#ensureDrain(opts) {
|
|
829
|
+
return this.#withDrainLock(opts.runId, async () => {
|
|
830
|
+
const existing = this.#activeDrains.get(opts.runId);
|
|
831
|
+
if (existing !== undefined && !existing.controller.signal.aborted)
|
|
832
|
+
return null;
|
|
833
|
+
return this.#startDrain(opts);
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
// After a loop terminates, promote any next-turn prompt it never consumed —
|
|
837
|
+
// an injected wake (stream conclusion) or a loop.run-while-active prompt
|
|
838
|
+
// that landed on a turn the loop didn't reach — into a fresh queued loop.
|
|
839
|
+
// The drain claims it on its next iteration, so a conclusion or client
|
|
840
|
+
// prompt is never silently dropped. Inherits the ended loop's flags.
|
|
841
|
+
async #reconcileOrphanedWake(runId, endedLoopId) {
|
|
842
|
+
const endedSeq = (await this.#db.engine_loop_sequence.get({ loop_id: endedLoopId }))?.sequence ?? endedLoopId;
|
|
843
|
+
const prefix = promptLoopPrefix(runId, endedSeq);
|
|
844
|
+
const orphan = await this.#db.drain_orphaned_prompt_for_loop.get({ loop_id: endedLoopId, pattern: `${prefix}%`, prefix_len: prefix.length });
|
|
845
|
+
if (orphan === undefined)
|
|
846
|
+
return;
|
|
847
|
+
const seqRow = await this.#db.loop_run_next_sequence.get({ run_id: runId });
|
|
848
|
+
if (seqRow === undefined)
|
|
849
|
+
throw new Error("reconcileOrphanedWake: next-sequence query returned no row");
|
|
850
|
+
const fresh = await this.#db.drain_enqueue_loop.get({
|
|
851
|
+
run_id: runId, sequence: seqRow.next, prompt: orphan.body,
|
|
852
|
+
});
|
|
853
|
+
if (fresh === undefined)
|
|
854
|
+
throw new Error("reconcileOrphanedWake: enqueue returned no row");
|
|
855
|
+
if (orphan.flags !== null) {
|
|
856
|
+
await this.#db.engine_set_loop_flags.run({ loop_id: fresh.id, flags: orphan.flags });
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
// The run's cancellation scope — lazily created, and replaced once aborted
|
|
860
|
+
// so a later loop.run gets a live signal. The drain and the execs its loops
|
|
861
|
+
// spawn all run under it.
|
|
862
|
+
#runSignal(runId) {
|
|
863
|
+
const existing = this.#runAborts.get(runId);
|
|
864
|
+
if (existing !== undefined && !existing.signal.aborted)
|
|
865
|
+
return existing;
|
|
866
|
+
const fresh = new AbortController();
|
|
867
|
+
this.#runAborts.set(runId, fresh);
|
|
868
|
+
return fresh;
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Cancel the run's in-flight work (loop.cancel). One abort, one scope: the
|
|
872
|
+
* run signal stops the running loop's turn generation AND tears down every
|
|
873
|
+
* stream linked to it — a background exec that outlived its loop, or even a
|
|
874
|
+
* spawn that registers after this abort (it self-aborts against the aborted
|
|
875
|
+
* signal). Returns cancelled iff there was work. Queued loops stay enqueued.
|
|
876
|
+
*/
|
|
877
|
+
cancelDrain(runId, reason = "user_cancelled") {
|
|
878
|
+
const hadDrain = this.#activeDrains.has(runId);
|
|
879
|
+
const hadWork = hadDrain || this.#runHasActiveStreams(runId);
|
|
880
|
+
// A cancel is deliberate — kill any pending hibernation poll-wake so it can't resurrect the run.
|
|
881
|
+
const pollTimer = this.#pollTimers.get(runId);
|
|
882
|
+
if (pollTimer !== undefined) {
|
|
883
|
+
clearTimeout(pollTimer);
|
|
884
|
+
this.#pollTimers.delete(runId);
|
|
885
|
+
}
|
|
886
|
+
// Stop the active drain's turn-generation (its loop closes 499). The run
|
|
887
|
+
// signal is the optimization path — the fast, listener-driven reap.
|
|
888
|
+
const scope = this.#runAborts.get(runId);
|
|
889
|
+
if (scope !== undefined && !scope.signal.aborted)
|
|
890
|
+
scope.abort(reason);
|
|
891
|
+
// #380, the PARKED case — a 202-blocked loop has no drain to observe the abort (the
|
|
892
|
+
// drain tears down on 202), so before this a cancelled park stayed 202 forever with no
|
|
893
|
+
// terminal and no broadcast. Terminalize the run's live loops (102/202; queued 100 stays
|
|
894
|
+
// enqueued) with provenance and broadcast each. With a live drain its abort catch does
|
|
895
|
+
// this instead — skipping here keeps the broadcast single.
|
|
896
|
+
if (!hadDrain) {
|
|
897
|
+
void (async () => {
|
|
898
|
+
const message = reason.slice(0, 500);
|
|
899
|
+
const dead = await this.#db.engine_run_cancel_live_loops.all({ run_id: runId, message });
|
|
900
|
+
if (dead.length === 0)
|
|
901
|
+
return;
|
|
902
|
+
const srow = await this.#db.drain_get_run_session.get({ run_id: runId });
|
|
903
|
+
if (srow === undefined)
|
|
904
|
+
return;
|
|
905
|
+
for (const { id } of dead) {
|
|
906
|
+
const usage = await this.#engine.loopUsage(id);
|
|
907
|
+
this.#broadcast({ sessionId: srow.session_id }, "loop/terminated", {
|
|
908
|
+
loopId: id, finalStatus: 499, hitMaxTurns: false, turnIds: [], usage, message,
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
})().catch((err) => {
|
|
912
|
+
console.error(`cancelDrain(${runId}) live-loop terminalize failed:`, err);
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
// Total reap by the REGISTRY (§run-lifecycle-total-reap): the durable source
|
|
916
|
+
// of truth. Every open subscription the run holds, aborted via its owning
|
|
917
|
+
// scheme — independent of the signal-listener timing, so an exec mid-spawn
|
|
918
|
+
// (registry row written before it is killable) is reaped too. A late spawn
|
|
919
|
+
// (registering after this) self-aborts against its captured, now-aborted
|
|
920
|
+
// epoch (§exec-timeout). Idempotent; fire-and-forget (the
|
|
921
|
+
// abort is sync, the registry read async; the 499 conclusion surfaces async).
|
|
922
|
+
void this.#reapRunStreams(runId).catch((err) => {
|
|
923
|
+
console.error(`reapRunStreams(${runId}) failed:`, err);
|
|
924
|
+
});
|
|
925
|
+
return hadWork;
|
|
926
|
+
}
|
|
927
|
+
// Does the run have an in-flight stream (a background exec)? Used only for
|
|
928
|
+
// loop.cancel's cancelled=true/false answer; the teardown itself rides the
|
|
929
|
+
// run signal. Duck-typed like #drainStreamingSchemes.
|
|
930
|
+
#runHasActiveStreams(runId) {
|
|
931
|
+
const exec = this.#schemes.get("exec");
|
|
932
|
+
return exec?.hasActiveSpawns?.(runId) ?? false;
|
|
933
|
+
}
|
|
934
|
+
// The registry-routed reap (§run-lifecycle-total-reap): every open subscription
|
|
935
|
+
// the run holds, aborted via its owning scheme. The durable answer to "reap
|
|
936
|
+
// everything" — the in-process AbortSignal listener is the optimization, this is
|
|
937
|
+
// the source of truth: an exec mid-spawn (registry row written before it is
|
|
938
|
+
// killable) or a background exec from any past loop is caught regardless of
|
|
939
|
+
// listener timing. Idempotent — a stream the signal already reaped is a no-op.
|
|
940
|
+
async #reapRunStreams(runId) {
|
|
941
|
+
const open = await ChannelWrite.findOpenSubscriptionsForRun(this.#db, runId);
|
|
942
|
+
for (const { id, scheme } of open) {
|
|
943
|
+
const handler = this.#schemes.get(scheme);
|
|
944
|
+
handler?.abortSubscription?.(id);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
/**
|
|
948
|
+
* Wake-on-completion handler. Streaming schemes call this when a
|
|
949
|
+
* subscription closes. If the run has an active loop, the channel
|
|
950
|
+
* transition will surface at that loop's next turn boundary — no new
|
|
951
|
+
* loop needed. Otherwise we open a fresh loop with the synthetic
|
|
952
|
+
* summary as the user prompt so the model gets a chance to react.
|
|
953
|
+
*
|
|
954
|
+
* Skipped on closeStatus=499 (aborted): the model already knows about
|
|
955
|
+
* its own SEND[499], and a forcefully-cancelled loop's spawn-abort
|
|
956
|
+
* shouldn't resurrect into a wake loop (defeats the cancel).
|
|
957
|
+
*
|
|
958
|
+
* Rummy parallel: plugins/stream/stream.js stream/completed wake:true.
|
|
959
|
+
*/
|
|
960
|
+
async #handleWakeRun(payload) {
|
|
961
|
+
// Aborted streams don't wake — the abort was deliberate.
|
|
962
|
+
if (payload.closeStatus === 499) {
|
|
963
|
+
this.#broadcast({ sessionId: payload.sessionId }, "stream/concluded", {
|
|
964
|
+
...payload, wakeAction: "skipped-aborted",
|
|
965
|
+
});
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
// No resurrection (§run-lifecycle-no-resurrection): a non-499 completion whose
|
|
969
|
+
// run was CANCELLED (idle + its scope aborted) must not start a fresh drain —
|
|
970
|
+
// the cancel was deliberate. The deliverable is already in the channel/log and
|
|
971
|
+
// surfaces as a `collect` environment delta (§env-delta) if the run is read or
|
|
972
|
+
// resumed; we just don't inject a turn. (An active run folds the wake into its
|
|
973
|
+
// next turn via inject below; a resumed run is active, never aborted, so it is
|
|
974
|
+
// unaffected.)
|
|
975
|
+
const scope = this.#runAborts.get(payload.runId);
|
|
976
|
+
if (scope?.signal.aborted === true && !this.#activeDrains.has(payload.runId)) {
|
|
977
|
+
this.#broadcast({ sessionId: payload.sessionId }, "stream/concluded", {
|
|
978
|
+
...payload, wakeAction: "skipped-cancelled",
|
|
979
|
+
});
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
if (this.#provider === null) {
|
|
983
|
+
this.#broadcast({ sessionId: payload.sessionId }, "stream/concluded", {
|
|
984
|
+
...payload, wakeAction: "skipped-no-provider",
|
|
985
|
+
});
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
try {
|
|
989
|
+
const systemPrompt = await readFile(Paths.instructionsSystem, "utf8");
|
|
990
|
+
// A slept (202) loop means the run PARKED ([102]<T>/<-1>) → RESUME it IN PLACE: re-queue
|
|
991
|
+
// it (202→100) so the drain re-claims and CONTINUES it (seq>1 → no re-foist). Checked
|
|
992
|
+
// FIRST: the slept status is the run's true disposition regardless of a draining
|
|
993
|
+
// sibling mid-teardown (the #ensureDrain lock serializes the re-claim). No fresh loop,
|
|
994
|
+
// no summary-as-prompt — the resumed loop reads the concluded stream's own state from
|
|
995
|
+
// the manifest. §run-lifecycle-wake-liveness.
|
|
996
|
+
const slept = await this.#db.drain_find_slept_loop.get({ run_id: payload.runId });
|
|
997
|
+
if (slept !== undefined) {
|
|
998
|
+
await this.#db.drain_resume_slept_loop.run({ loop_id: slept.id });
|
|
999
|
+
const started = await this.#ensureDrain({
|
|
1000
|
+
sessionId: payload.sessionId, runId: payload.runId, provider: this.#provider,
|
|
1001
|
+
systemPrompt, maxTurns: Number(process.env.PLURNK_SERVICE_MAX_TURNS ?? "50"),
|
|
1002
|
+
});
|
|
1003
|
+
this.#broadcast({ sessionId: payload.sessionId }, "stream/concluded", {
|
|
1004
|
+
...payload, wakeAction: "resumed-loop", wakeLoopId: slept.id,
|
|
1005
|
+
});
|
|
1006
|
+
started?.drainPromise?.catch((err) => {
|
|
1007
|
+
console.error("wake resume drain failed:", err instanceof Error ? err.message : String(err));
|
|
1008
|
+
});
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
// No slept loop. A live loop surfaces the concluded stream ambiently via the
|
|
1012
|
+
// environment-observation injector (§exec-stream) on its next turn — there is no prompt
|
|
1013
|
+
// to inject and NO task to overwrite. The obsolete "automated environment update"
|
|
1014
|
+
// synthesis (which clobbered the model's actual goal) is retired; just tell the client.
|
|
1015
|
+
if (this.#activeDrains.has(payload.runId)) {
|
|
1016
|
+
this.#broadcast({ sessionId: payload.sessionId }, "stream/concluded", {
|
|
1017
|
+
...payload, wakeAction: "no-op-active-loop",
|
|
1018
|
+
});
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
// No slept loop, no active drain — nothing to resume (e.g. a SEND[200]-done run whose
|
|
1022
|
+
// streams were swept). Surface the conclusion without opening a loop.
|
|
1023
|
+
this.#broadcast({ sessionId: payload.sessionId }, "stream/concluded", {
|
|
1024
|
+
...payload, wakeAction: "no-loop",
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
catch (err) {
|
|
1028
|
+
console.error("wake-on-completion setup failed:", err instanceof Error ? err.message : String(err));
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
/**
|
|
1032
|
+
* grammar 0.74.20 EXEC `<T,P>` — schedule a hibernation poll-wake. Called when a loop parks at
|
|
1033
|
+
* a park; if the run holds an open polled stream, arm a timer for its tightest cadence P that
|
|
1034
|
+
* resumes the slept loop so the model inspects progress. While the loop is ACTIVE there is no
|
|
1035
|
+
* poll work — ambient folded stream deltas already surface progress (§exec-stream); the wake
|
|
1036
|
+
* matters only across hibernation. A wake-edge-less 202 (no polled stream) gets no timer. §exec-poll
|
|
1037
|
+
*/
|
|
1038
|
+
async #schedulePollWake(sessionId, runId, provider, systemPrompt) {
|
|
1039
|
+
const existing = this.#pollTimers.get(runId);
|
|
1040
|
+
if (existing !== undefined) {
|
|
1041
|
+
clearTimeout(existing);
|
|
1042
|
+
this.#pollTimers.delete(runId);
|
|
1043
|
+
}
|
|
1044
|
+
const row = await this.#db.drain_run_min_poll.get({ run_id: runId });
|
|
1045
|
+
const pollSec = row?.poll_seconds ?? null;
|
|
1046
|
+
if (pollSec === null || pollSec <= 0)
|
|
1047
|
+
return; // no polled stream → the 202 just sleeps (woken only by conclusion)
|
|
1048
|
+
// Floored by the post-EXEC breath (PLURNK_SERVICE_EXEC_WAIT_MS) so a `<…,1>` can't wake the loop
|
|
1049
|
+
// faster than a turn settles — §exec-poll.
|
|
1050
|
+
const execWaitMs = Number(process.env.PLURNK_SERVICE_EXEC_WAIT_MS ?? "0");
|
|
1051
|
+
const timer = setTimeout(() => {
|
|
1052
|
+
this.#pollTimers.delete(runId);
|
|
1053
|
+
void this.#wakeParkedRun(sessionId, runId, provider, systemPrompt);
|
|
1054
|
+
}, Math.max(pollSec * 1000, execWaitMs));
|
|
1055
|
+
timer.unref();
|
|
1056
|
+
this.#pollTimers.set(runId, timer);
|
|
1057
|
+
}
|
|
1058
|
+
/** Resume `runId`'s slept (202) loop in place — the same 202→100 resume #handleWakeRun uses, minus a
|
|
1059
|
+
* wake payload. The shared wake primitive: a poll cadence (§exec-poll), a watched stream concluding,
|
|
1060
|
+
* or a child run finishing (§run-lifecycle topology join) all call this. A no-op if the run was
|
|
1061
|
+
* cancelled or isn't actually parked (no slept loop) — so calling it speculatively is safe. */
|
|
1062
|
+
async #wakeParkedRun(sessionId, runId, provider, systemPrompt) {
|
|
1063
|
+
const scope = this.#runAborts.get(runId);
|
|
1064
|
+
if (scope?.signal.aborted === true && !this.#activeDrains.has(runId))
|
|
1065
|
+
return; // cancelled — no resurrection
|
|
1066
|
+
const slept = await this.#db.drain_find_slept_loop.get({ run_id: runId });
|
|
1067
|
+
if (slept === undefined) {
|
|
1068
|
+
// Not parked. If a drain is still ACTIVE, the run is mid-turn and about to park — the
|
|
1069
|
+
// conclusion that fired this wake arrived before the 202 committed (the conclude-before-park
|
|
1070
|
+
// race). OWE the wake: the drain honors it at park so a worker-run hibernation never deadlocks.
|
|
1071
|
+
// (No active drain → already concluded/running; nothing to wake.)
|
|
1072
|
+
if (this.#activeDrains.has(runId))
|
|
1073
|
+
this.#owedWakes.add(runId);
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
await this.#db.drain_resume_slept_loop.run({ loop_id: slept.id });
|
|
1077
|
+
const started = await this.#ensureDrain({
|
|
1078
|
+
sessionId, runId, provider, systemPrompt,
|
|
1079
|
+
maxTurns: Number(process.env.PLURNK_SERVICE_MAX_TURNS ?? "50"),
|
|
1080
|
+
});
|
|
1081
|
+
started?.drainPromise?.catch((err) => {
|
|
1082
|
+
console.error("wake-parked resume drain failed:", err instanceof Error ? err.message : String(err));
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
/** A run's drain exited. If the run truly CONCLUDED — no 202-blocked loop, no open stream — then
|
|
1086
|
+
* wake its PARENT in place if the parent is blocked on the join (the structured-concurrency join — a
|
|
1087
|
+
* child finishing is the wake edge for a parent that waited on it, §run-lifecycle-child-wake). A run
|
|
1088
|
+
* blocked at 202, or still holding a stream, is NOT concluded — its own wake edges drive it, not this.
|
|
1089
|
+
* The parent reads the child's deliverable from its own log (the §run-scheme-collect delta) on
|
|
1090
|
+
* resume — control edge here, never an injected prompt. Recurses up via the parent's own drain-exit. */
|
|
1091
|
+
async #onDrainExit(sessionId, runId, provider, systemPrompt) {
|
|
1092
|
+
const slept = await this.#db.drain_find_slept_loop.get({ run_id: runId });
|
|
1093
|
+
if (slept !== undefined)
|
|
1094
|
+
return; // parked at 202 — not concluded, the run is still alive
|
|
1095
|
+
const openSubs = await this.#db.find_open_subscriptions_for_run.all({ run_id: runId });
|
|
1096
|
+
if (openSubs.length > 0)
|
|
1097
|
+
return; // a stream still runs — its conclusion re-evaluates, not this exit
|
|
1098
|
+
const parent = await this.#db.run_parent_id.get({ run_id: runId });
|
|
1099
|
+
if (parent?.parent_run_id == null)
|
|
1100
|
+
return; // a root run — nobody to wake
|
|
1101
|
+
await this.#wakeParkedRun(sessionId, parent.parent_run_id, provider, systemPrompt);
|
|
1102
|
+
}
|
|
1103
|
+
#broadcast(target, method, params) {
|
|
1104
|
+
if (target === "all") {
|
|
1105
|
+
// A global engine event (e.g. session/created) — emitted to the seam with sessionId null (#355).
|
|
1106
|
+
for (const sub of this.#eventSubscribers)
|
|
1107
|
+
sub(null, method, params);
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
const sessionId = target.sessionId;
|
|
1111
|
+
// Publish the raw event to the in-process source first (#355) — transport modules subscribe
|
|
1112
|
+
// here (plurnk-agui renders to AG-UI+). Each subscriber owns its own fan-out; core just emits.
|
|
1113
|
+
for (const sub of this.#eventSubscribers)
|
|
1114
|
+
sub(sessionId, method, params);
|
|
1115
|
+
// Scope-stamping onto the notification envelope (§notifications-envelope-carries-sessionid)
|
|
1116
|
+
// is each subscriber's edge concern now — the seam hands (sessionId, method, params) raw.
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
//# sourceMappingURL=Daemon.js.map
|