@timqi/pier 0.0.29 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -125
- package/dist/agent/config.js +24 -11
- package/dist/agent/credentials.js +11 -23
- package/dist/agent/events.js +43 -62
- package/dist/agent/listing.js +113 -68
- package/dist/agent/pi.js +204 -211
- package/dist/boards/boards.js +19 -29
- package/dist/channels/attach.js +14 -42
- package/dist/channels/chains.js +33 -37
- package/dist/channels/chunk.js +8 -28
- package/dist/channels/commands.js +3 -14
- package/dist/channels/config.js +33 -52
- package/dist/channels/control.js +4 -13
- package/dist/channels/conversations.js +8 -25
- package/dist/channels/dedup.js +8 -17
- package/dist/channels/gatekeeper.js +13 -23
- package/dist/channels/lark-api.js +23 -63
- package/dist/channels/lark-outbound.js +12 -44
- package/dist/channels/lark-panel.js +8 -24
- package/dist/channels/lark-render.js +18 -62
- package/dist/channels/lark.js +52 -141
- package/dist/channels/lines.js +13 -15
- package/dist/channels/panel.js +16 -36
- package/dist/channels/receipts.js +29 -52
- package/dist/channels/routes.js +3 -9
- package/dist/channels/runtime.js +12 -23
- package/dist/channels/slack-api.js +34 -86
- package/dist/channels/slack-directory.js +7 -23
- package/dist/channels/slack-outbound.js +12 -56
- package/dist/channels/slack-panel.js +4 -13
- package/dist/channels/slack-render.js +23 -91
- package/dist/channels/slack-tool.js +48 -171
- package/dist/channels/slack.js +73 -239
- package/dist/channels/telegram-api.js +8 -20
- package/dist/channels/telegram-panel.js +5 -21
- package/dist/channels/telegram-render.js +13 -40
- package/dist/channels/telegram.js +54 -146
- package/dist/channels/types.js +5 -16
- package/dist/cli.js +17 -41
- package/dist/config-sync.js +87 -4
- package/dist/core/hub.js +7 -20
- package/dist/core/identity.js +20 -59
- package/dist/core/inbound-file.js +15 -49
- package/dist/core/inbox.js +13 -35
- package/dist/core/queue.js +3 -5
- package/dist/core/reply.js +41 -142
- package/dist/core/router.js +209 -260
- package/dist/core/types.js +17 -1
- package/dist/db.js +98 -252
- package/dist/drain.js +58 -51
- package/dist/extensions/index.js +3 -11
- package/dist/extensions/web/anthropic.js +3 -9
- package/dist/extensions/web/artifacts.js +2 -5
- package/dist/extensions/web/content.js +6 -14
- package/dist/extensions/web/http.js +2 -6
- package/dist/extensions/web/language.js +8 -18
- package/dist/extensions/web/openai.js +1 -1
- package/dist/extensions/web/provider.js +5 -18
- package/dist/extensions/web/tools.js +19 -63
- package/dist/lock.js +98 -0
- package/dist/log.js +9 -26
- package/dist/main.js +87 -179
- package/dist/paths.js +10 -26
- package/dist/secrets.js +19 -46
- package/dist/service.js +33 -75
- package/dist/settings.js +42 -65
- package/dist/tasks/agent.js +129 -114
- package/dist/tasks/callbacks.js +9 -19
- package/dist/tasks/command.js +29 -14
- package/dist/tasks/definitions.js +39 -62
- package/dist/tasks/execution.js +46 -42
- package/dist/tasks/groups.js +41 -35
- package/dist/tasks/messages.js +121 -182
- package/dist/tasks/outbox.js +61 -55
- package/dist/tasks/routes.js +5 -11
- package/dist/tasks/runs.js +14 -13
- package/dist/tasks/service.js +53 -54
- package/dist/tasks/store.js +53 -30
- package/dist/tasks/tool.js +132 -61
- package/dist/tools-task.js +20 -60
- package/dist/tools.js +100 -327
- package/dist/update.js +21 -44
- package/dist/web/auth.js +118 -179
- package/dist/web/config-sync.js +2 -2
- package/dist/web/config.js +3 -7
- package/dist/web/explorer.js +10 -21
- package/dist/web/fs.js +20 -42
- package/dist/web/instance.js +45 -85
- package/dist/web/providers.js +14 -13
- package/dist/web/public/assets/{activity-D3m4L2IL.js → activity-B89_hH7q.js} +2 -2
- package/dist/web/public/assets/activity-B89_hH7q.js.br +0 -0
- package/dist/web/public/assets/activity-B89_hH7q.js.gz +0 -0
- package/dist/web/public/assets/boards-BeKW0ZXK.js +1 -0
- package/dist/web/public/assets/boards-BeKW0ZXK.js.br +0 -0
- package/dist/web/public/assets/boards-BeKW0ZXK.js.gz +0 -0
- package/dist/web/public/assets/explorer-DIuMlaV3.js +4 -0
- package/dist/web/public/assets/explorer-DIuMlaV3.js.br +0 -0
- package/dist/web/public/assets/explorer-DIuMlaV3.js.gz +0 -0
- package/dist/web/public/assets/index-DzXDXra_.js +85 -0
- package/dist/web/public/assets/index-DzXDXra_.js.br +0 -0
- package/dist/web/public/assets/index-DzXDXra_.js.gz +0 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css +2 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
- package/dist/web/public/assets/runs-Cwy0mN8i.js +1 -0
- package/dist/web/public/assets/runs-Cwy0mN8i.js.br +0 -0
- package/dist/web/public/assets/runs-Cwy0mN8i.js.gz +0 -0
- package/dist/web/public/assets/settings-DzZLmujq.js +5 -0
- package/dist/web/public/assets/settings-DzZLmujq.js.br +0 -0
- package/dist/web/public/assets/settings-DzZLmujq.js.gz +0 -0
- package/dist/web/public/assets/task-runs-BCakxFk8.js +3 -0
- package/dist/web/public/assets/task-runs-BCakxFk8.js.br +0 -0
- package/dist/web/public/assets/task-runs-BCakxFk8.js.gz +0 -0
- package/dist/web/public/assets/tasks-BlzEbk11.js +4 -0
- package/dist/web/public/assets/tasks-BlzEbk11.js.br +0 -0
- package/dist/web/public/assets/tasks-BlzEbk11.js.gz +0 -0
- package/dist/web/public/index.html +100 -130
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/manifest.webmanifest +2 -2
- package/dist/web/public/manifest.webmanifest.br +0 -0
- package/dist/web/public/manifest.webmanifest.gz +0 -0
- package/dist/web/public/sw.js +14 -2
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/push.js +55 -77
- package/dist/web/route.js +3 -7
- package/dist/web/server.js +131 -180
- package/dist/web/session-state.js +14 -54
- package/dist/web/types.js +2 -4
- package/dist/web/webpush.js +10 -25
- package/docs/deploy.md +115 -330
- package/package.json +2 -1
- package/skills/pier-boards/SKILL.md +81 -160
- package/skills/pier-help/SKILL.md +23 -20
- package/skills/pier-slack/SKILL.md +2 -2
- package/skills/pier-tasks/SKILL.md +153 -160
- package/dist/config-sync-fetch.js +0 -84
- package/dist/limits.js +0 -14
- package/dist/web/public/assets/activity-D3m4L2IL.js.br +0 -0
- package/dist/web/public/assets/activity-D3m4L2IL.js.gz +0 -0
- package/dist/web/public/assets/boards-BIObcQeX.js +0 -1
- package/dist/web/public/assets/boards-BIObcQeX.js.br +0 -0
- package/dist/web/public/assets/boards-BIObcQeX.js.gz +0 -0
- package/dist/web/public/assets/explorer-C_rSWPNB.js +0 -4
- package/dist/web/public/assets/explorer-C_rSWPNB.js.br +0 -0
- package/dist/web/public/assets/explorer-C_rSWPNB.js.gz +0 -0
- package/dist/web/public/assets/index-CX3fYZY5.css +0 -2
- package/dist/web/public/assets/index-CX3fYZY5.css.br +0 -0
- package/dist/web/public/assets/index-CX3fYZY5.css.gz +0 -0
- package/dist/web/public/assets/index-uFsZkKOQ.js +0 -85
- package/dist/web/public/assets/index-uFsZkKOQ.js.br +0 -0
- package/dist/web/public/assets/index-uFsZkKOQ.js.gz +0 -0
- package/dist/web/public/assets/runs-Ch6DZq6O.js +0 -1
- package/dist/web/public/assets/runs-Ch6DZq6O.js.br +0 -0
- package/dist/web/public/assets/runs-Ch6DZq6O.js.gz +0 -0
- package/dist/web/public/assets/settings-BWcEIEcv.js +0 -5
- package/dist/web/public/assets/settings-BWcEIEcv.js.br +0 -0
- package/dist/web/public/assets/settings-BWcEIEcv.js.gz +0 -0
- package/dist/web/public/assets/task-runs-DPkwv2UE.js +0 -3
- package/dist/web/public/assets/task-runs-DPkwv2UE.js.br +0 -0
- package/dist/web/public/assets/task-runs-DPkwv2UE.js.gz +0 -0
- package/dist/web/public/assets/tasks-DTiCi2mH.js +0 -4
- package/dist/web/public/assets/tasks-DTiCi2mH.js.br +0 -0
- package/dist/web/public/assets/tasks-DTiCi2mH.js.gz +0 -0
package/dist/core/router.js
CHANGED
|
@@ -7,37 +7,25 @@ import { SenderPrefix, withPrefix } from "./identity.js";
|
|
|
7
7
|
import { decide } from "./queue.js";
|
|
8
8
|
import { splitReply } from "./reply.js";
|
|
9
9
|
const log = logger("core");
|
|
10
|
-
/**
|
|
11
|
-
*
|
|
12
|
-
* resume plus the transcript being read back. */
|
|
10
|
+
/** Generous: eviction is a memory measure, and re-opening costs a Pi resume
|
|
11
|
+
* plus a transcript read. */
|
|
13
12
|
const IDLE_TTL_MS = 30 * 60_000;
|
|
14
|
-
/** Sweep interval. Nothing here is urgent, so it is coarse. */
|
|
15
13
|
const SWEEP_MS = 5 * 60_000;
|
|
16
14
|
/** An error goes into a chat window, so it is trimmed to something readable. */
|
|
17
15
|
const truncate = (message) => message.length > 600 ? `${message.slice(0, 600)}…` : message;
|
|
18
|
-
/** What a chat window gets of a system input
|
|
19
|
-
*
|
|
20
|
-
* (tasks/callbacks.ts). */
|
|
16
|
+
/** What a chat window gets of a system input; a task callback carries up to
|
|
17
|
+
* 8000 characters of result text (tasks/callbacks.ts). */
|
|
21
18
|
const NOTE_CHARS = 200;
|
|
22
19
|
const NOTE_LINES = 4;
|
|
23
|
-
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* A note is *context* for the turn it precedes, not the message: pasted whole,
|
|
27
|
-
* a run result buries the conversation it was meant to explain — and on IM
|
|
28
|
-
* that is the one surface with no way to collapse it. The hub, the web
|
|
29
|
-
* timeline and the Pi transcript keep every character.
|
|
30
|
-
*
|
|
31
|
-
* Cut on a line boundary and then on a word, so the head reads as text rather
|
|
32
|
-
* than as a string that ran out.
|
|
33
|
-
*/
|
|
20
|
+
/** A note is context for the turn it precedes, not the message: pasted whole,
|
|
21
|
+
* a run result buries the chat on IM, which cannot collapse it. The hub and
|
|
22
|
+
* the transcript keep every character. */
|
|
34
23
|
function digest(text) {
|
|
35
24
|
const body = text.trimEnd();
|
|
36
25
|
let head = body.split("\n").slice(0, NOTE_LINES).join("\n");
|
|
37
26
|
if (head.length > NOTE_CHARS) {
|
|
38
27
|
const capped = head.slice(0, NOTE_CHARS);
|
|
39
|
-
//
|
|
40
|
-
// than that loses more than the ragged edge was costing.
|
|
28
|
+
// A boundary before the midpoint loses more than the ragged edge costs.
|
|
41
29
|
const boundary = Math.max(capped.lastIndexOf("\n"), capped.lastIndexOf(" "));
|
|
42
30
|
head = capped.slice(0, boundary > NOTE_CHARS / 2 ? boundary : NOTE_CHARS);
|
|
43
31
|
}
|
|
@@ -50,10 +38,8 @@ function digest(text) {
|
|
|
50
38
|
function keyOf(key) {
|
|
51
39
|
return `${key.channelId}:${key.conversationId}`;
|
|
52
40
|
}
|
|
53
|
-
/** `web:<id>` and `task:<id>`
|
|
54
|
-
*
|
|
55
|
-
* `ensure`, and which of the two a session records costs nothing but the
|
|
56
|
-
* answer to "what is it answering". */
|
|
41
|
+
/** `web:<id>` and `task:<id>` name one session id and neither is a chat — no
|
|
42
|
+
* Channel is registered under them — so they share a lock in `ensure`. */
|
|
57
43
|
const isAlias = (key) => key.channelId === "web" || key.channelId === "task";
|
|
58
44
|
export class QueueOperationError extends Error {
|
|
59
45
|
reason;
|
|
@@ -65,55 +51,47 @@ export class QueueOperationError extends Error {
|
|
|
65
51
|
export class Router {
|
|
66
52
|
hub;
|
|
67
53
|
resolve;
|
|
54
|
+
sessionIdOf;
|
|
68
55
|
byKey = new Map();
|
|
69
56
|
bySession = new Map();
|
|
70
|
-
/** Resolves in flight
|
|
71
|
-
* object
|
|
72
|
-
* Web and task keys collapse to the session id they both name. */
|
|
57
|
+
/** Resolves in flight: two surfaces asking at once must share one session
|
|
58
|
+
* object, not open a second Pi runtime on the same transcript. */
|
|
73
59
|
opening = new Map();
|
|
74
60
|
channels = new Map();
|
|
75
61
|
/** Who each session last heard from, so a header costs tokens only on news. */
|
|
76
62
|
senders = new SenderPrefix();
|
|
77
|
-
/** Set by a graceful restart (src/drain.ts)
|
|
78
|
-
*
|
|
79
|
-
* caller that drains *speculatively* and may not get to exit. */
|
|
63
|
+
/** Set by a graceful restart (src/drain.ts); `endDrain` is for the caller
|
|
64
|
+
* that drains speculatively and may not get to exit. */
|
|
80
65
|
draining = false;
|
|
81
66
|
spokenTo;
|
|
82
67
|
constructor(hub,
|
|
83
68
|
/** Create or resume the session owning a conversation (wired in main.ts). */
|
|
84
|
-
resolve
|
|
69
|
+
resolve,
|
|
70
|
+
/** The durable chat → session mapping, read before opening: a chat and the
|
|
71
|
+
* workbench asking for one transcript must share one lock and one object.
|
|
72
|
+
* Undefined for a chat that has none yet. */
|
|
73
|
+
sessionIdOf = () => undefined) {
|
|
85
74
|
this.hub = hub;
|
|
86
75
|
this.resolve = resolve;
|
|
76
|
+
this.sessionIdOf = sessionIdOf;
|
|
87
77
|
}
|
|
88
78
|
registerChannel(channel) {
|
|
89
79
|
this.channels.set(channel.id, channel);
|
|
90
80
|
}
|
|
91
|
-
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* listener is the rail's working set (web/session-state.ts), built with the
|
|
95
|
-
* web surface long after the router. */
|
|
81
|
+
/** Fires for humans only: chats and the workbench pass through `dispatch`,
|
|
82
|
+
* tasks and subagents do not. Registered late because the listener
|
|
83
|
+
* (web/session-state.ts) is built with the web surface. */
|
|
96
84
|
onSpokenTo(listener) {
|
|
97
85
|
this.spokenTo = listener;
|
|
98
86
|
}
|
|
99
|
-
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* Applies to every channel, because the failure mode is the same everywhere:
|
|
103
|
-
* an IM user watching the eyes come off with no reply cannot tell a crash
|
|
104
|
-
* from a deliberate silence, and the operator cannot debug what they never
|
|
105
|
-
* saw. The web reads errors off the hub already, so only a registered channel
|
|
106
|
-
* gets a note; `notify` is used rather than `send` so it is never mistaken
|
|
107
|
-
* for an assistant turn.
|
|
108
|
-
*/
|
|
87
|
+
/** A failure reaches the chat as well as the hub (§5): on IM, silence is
|
|
88
|
+
* indistinguishable from a crash. `notify`, not `send`, so it is never
|
|
89
|
+
* mistaken for an assistant turn. */
|
|
109
90
|
report(sessionId, key, message) {
|
|
110
|
-
// Three surfaces, one failure: the chat that is waiting, the web timeline,
|
|
111
|
-
// and the log the operator greps once it is reported to them.
|
|
112
91
|
log.error(`${keyOf(key)} session ${sessionId}: ${message}`);
|
|
113
92
|
this.hub.emit(sessionId, { type: "error", message });
|
|
114
93
|
const channel = this.channels.get(key.channelId);
|
|
115
|
-
//
|
|
116
|
-
// already has the original.
|
|
94
|
+
// Never recursive: if telling the chat also fails, the hub has the original.
|
|
117
95
|
channel?.notify(key.conversationId, { text: truncate(message), origin: { kind: "error" } })
|
|
118
96
|
.catch((err) => {
|
|
119
97
|
log.error(`could not report the failure to ${key.channelId}`, err);
|
|
@@ -123,13 +101,8 @@ export class Router {
|
|
|
123
101
|
});
|
|
124
102
|
});
|
|
125
103
|
}
|
|
126
|
-
/**
|
|
127
|
-
*
|
|
128
|
-
* result that could not be delivered, say. Its conversation is told when one
|
|
129
|
-
* is attached — an agent that was promised an answer and a human watching the
|
|
130
|
-
* same thread learn it is not coming from the same place they were waiting.
|
|
131
|
-
* Otherwise the hub carries it for the web timeline.
|
|
132
|
-
*/
|
|
104
|
+
/** Something that happened *to* a session (an undeliverable task result): the
|
|
105
|
+
* attached conversation is told where it was waiting, else the hub carries it. */
|
|
133
106
|
reportTo(sessionId, message) {
|
|
134
107
|
const key = this.conversationOf(sessionId);
|
|
135
108
|
if (key)
|
|
@@ -137,36 +110,32 @@ export class Router {
|
|
|
137
110
|
else
|
|
138
111
|
this.hub.emit(sessionId, { type: "error", message });
|
|
139
112
|
}
|
|
140
|
-
/**
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
* Skipped for anything that would notice: a streaming turn, and a session
|
|
148
|
-
* someone is still watching over SSE.
|
|
149
|
-
*
|
|
150
|
-
* `includeWatched` is the one caller that may take a watched session too:
|
|
151
|
-
* configuration a session reads only when it opens has just changed, and the
|
|
152
|
-
* session most likely to need it is the one open in the tab that changed it.
|
|
153
|
-
* A turn in flight is still never touched — the exemption that stands is the
|
|
154
|
-
* one about interrupting work, not the one about being looked at.
|
|
155
|
-
*/
|
|
113
|
+
/** Only the in-memory attachment goes; the durable mapping
|
|
114
|
+
* (channels/conversations.ts) resumes the same transcript on the next message.
|
|
115
|
+
* `includeWatched` is for config a session reads only at open: the session
|
|
116
|
+
* most likely to need it is the one open in the tab that changed it. A
|
|
117
|
+
* streaming turn is never evicted, nor a session holding queued messages:
|
|
118
|
+
* Pi's queue lives only in the runtime, so disposing it would drop them. */
|
|
156
119
|
async evictIdle(ttlMs = IDLE_TTL_MS, now = Date.now(), { includeWatched = false } = {}) {
|
|
157
120
|
let evicted = 0;
|
|
158
|
-
// Snapshot
|
|
159
|
-
// attach or drop a session while it is suspended.
|
|
121
|
+
// Snapshot: the loop awaits dispose(), and the map may change meanwhile.
|
|
160
122
|
// oxlint-disable-next-line unicorn/no-useless-spread
|
|
161
123
|
for (const [id, attached] of [...this.bySession]) {
|
|
162
|
-
if (attached.session.state === "streaming")
|
|
163
|
-
continue;
|
|
164
124
|
if (this.queueOperations.has(id) || this.recoveries.get(id)?.some((b) => b.status === "submitting"))
|
|
165
125
|
continue;
|
|
166
126
|
if (!includeWatched && this.hub.hasSubscribers(id))
|
|
167
127
|
continue;
|
|
168
128
|
if (now - attached.activeAt < ttlMs)
|
|
169
129
|
continue;
|
|
130
|
+
const touched = attached.touched;
|
|
131
|
+
const queued = await attached.session.pendingQueue();
|
|
132
|
+
// Everything re-read after the await: a dispatch, a queue operation or a
|
|
133
|
+
// replacement may have landed meanwhile, and a prompt already accepted
|
|
134
|
+
// must not run against a disposed session.
|
|
135
|
+
if (this.bySession.get(id) !== attached || attached.touched !== touched || this.queueOperations.has(id))
|
|
136
|
+
continue;
|
|
137
|
+
if (attached.session.state === "streaming" || queued.steering.length || queued.followUp.length)
|
|
138
|
+
continue;
|
|
170
139
|
this.bySession.delete(id);
|
|
171
140
|
this.forgetKeys(attached.session);
|
|
172
141
|
attached.unsubscribe();
|
|
@@ -174,8 +143,7 @@ export class Router {
|
|
|
174
143
|
this.hub.dropReplay(id);
|
|
175
144
|
evicted += 1;
|
|
176
145
|
log.info(`evicted idle session ${id} (${keyOf(attached.key)})`);
|
|
177
|
-
//
|
|
178
|
-
// sweeper from releasing the rest.
|
|
146
|
+
// A runtime that will not shut down must not block releasing the rest.
|
|
179
147
|
await attached.session.dispose().catch((err) => log.error(`disposing session ${id} failed`, err));
|
|
180
148
|
}
|
|
181
149
|
return evicted;
|
|
@@ -199,18 +167,12 @@ export class Router {
|
|
|
199
167
|
modelOf(sessionId) {
|
|
200
168
|
return this.bySession.get(sessionId)?.session.model;
|
|
201
169
|
}
|
|
202
|
-
/**
|
|
203
|
-
*
|
|
204
|
-
* `sessionOf`, and what lets a tool act on "here" — an agent reached through
|
|
205
|
-
* a Slack thread otherwise has no way to name the thread it is replying in.
|
|
206
|
-
* A task or subagent session is attached to nothing and answers undefined.
|
|
207
|
-
*/
|
|
170
|
+
/** Inverse of `sessionOf`; lets a tool act on "here". A task or subagent
|
|
171
|
+
* session is attached to nothing and answers undefined. */
|
|
208
172
|
conversationOf(sessionId) {
|
|
209
173
|
return this.bySession.get(sessionId)?.key;
|
|
210
174
|
}
|
|
211
|
-
/** Every key
|
|
212
|
-
* under more than one — `web:<id>` and `task:<id>` name the same session —
|
|
213
|
-
* and a key left behind hands out a session that is no longer live. */
|
|
175
|
+
/** Every alias: a key left behind hands out a session that is no longer live. */
|
|
214
176
|
forgetKeys(session) {
|
|
215
177
|
for (const [key, held] of this.byKey)
|
|
216
178
|
if (held === session)
|
|
@@ -225,92 +187,109 @@ export class Router {
|
|
|
225
187
|
return;
|
|
226
188
|
}
|
|
227
189
|
if (existing) {
|
|
228
|
-
// Two live objects on one transcript
|
|
229
|
-
//
|
|
230
|
-
// this, so reaching here is a bug worth seeing — the replaced one is
|
|
231
|
-
// silenced and unreachable, rather than left answering under aliases
|
|
232
|
-
// nobody knows are stale.
|
|
190
|
+
// Two live objects on one transcript would both write it and both answer
|
|
191
|
+
// the chat; single-flight `ensure` should make this unreachable.
|
|
233
192
|
log.warn(`session ${session.id} replaced while attached to ${keyOf(existing.key)}`);
|
|
234
193
|
existing.unsubscribe();
|
|
235
194
|
this.forgetKeys(existing.session);
|
|
195
|
+
void existing.session.dispose().catch((err) => log.error(`disposing replaced session ${session.id} failed`, err));
|
|
236
196
|
}
|
|
237
197
|
this.byKey.set(keyOf(key), session);
|
|
238
198
|
log.info(`attached ${keyOf(key)} → session ${session.id}`);
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
if (payload.type === "state") {
|
|
243
|
-
const attached = this.bySession.get(session.id);
|
|
244
|
-
// Every turn passes through here, so this is also where a session
|
|
245
|
-
// proves to the sweeper that it is still in use.
|
|
246
|
-
if (attached)
|
|
247
|
-
attached.stateSince = attached.activeAt = Date.now();
|
|
248
|
-
this.hub.emitWorkspace({
|
|
249
|
-
type: "session-state",
|
|
250
|
-
sessionId: session.id,
|
|
251
|
-
state: payload.state,
|
|
252
|
-
});
|
|
253
|
-
}
|
|
254
|
-
// An error the session itself reported (a tool that threw, a model
|
|
255
|
-
// refusal, a lost connection). Without this it lands only in the web
|
|
256
|
-
// timeline and the IM side goes quiet for no visible reason.
|
|
257
|
-
if (payload.type === "error") {
|
|
258
|
-
log.error(`${keyOf(key)} session ${session.id} reported: ${payload.message}`);
|
|
259
|
-
const channel = this.channels.get(key.channelId);
|
|
260
|
-
channel?.notify(key.conversationId, {
|
|
261
|
-
text: truncate(payload.message),
|
|
262
|
-
origin: { kind: "error" },
|
|
263
|
-
}).catch((err) => log.error(`notify ${key.channelId} failed`, err));
|
|
264
|
-
}
|
|
265
|
-
// A system input is context the chat did not see being typed. It goes
|
|
266
|
-
// out before the turn it triggers, so the answer has a visible cause.
|
|
267
|
-
if (payload.type === "system-input") {
|
|
268
|
-
const channel = this.channels.get(key.channelId);
|
|
269
|
-
// A digest, not the input: the hub emit above is what carries it whole.
|
|
270
|
-
channel?.notify(key.conversationId, { text: digest(payload.text), origin: payload.origin })
|
|
271
|
-
.catch((err) => {
|
|
272
|
-
log.error(`notify ${key.channelId} failed`, err);
|
|
273
|
-
this.hub.emit(session.id, {
|
|
274
|
-
type: "error",
|
|
275
|
-
message: `notify ${key.channelId} failed: ${String(err)}`,
|
|
276
|
-
});
|
|
277
|
-
});
|
|
278
|
-
}
|
|
279
|
-
// A queued message with no turn left to deliver it. `decide` reads the
|
|
280
|
-
// state once, so a steer chosen against a turn that ends before the call
|
|
281
|
-
// lands sits in Pi's queue until some *later* turn reads it — on IM that
|
|
282
|
-
// is indistinguishable from the message never arriving (§5b). Pi drains
|
|
283
|
-
// its own queues up to the agent_end handler, so a non-empty queue on an
|
|
284
|
-
// idle session is exactly the message that missed that window.
|
|
285
|
-
if (payload.type === "queue-state" && (payload.steering.length || payload.followUp.length)) {
|
|
286
|
-
this.promoteQueued(session);
|
|
287
|
-
}
|
|
288
|
-
// Every turn-end reaches the channel, empty text included: an adapter's
|
|
289
|
-
// per-turn UI (Telegram's 👀 receipts) is retired here, and a turn that
|
|
290
|
-
// settled with nothing to say still has to settle.
|
|
291
|
-
if (payload.type === "turn-end") {
|
|
292
|
-
log.info(`turn end ${keyOf(key)} session ${session.id}: ${String(payload.text.length)} chars`);
|
|
293
|
-
const channel = this.channels.get(key.channelId);
|
|
294
|
-
if (channel) {
|
|
295
|
-
channel.send(key.conversationId, splitReply(payload.text, payload.meta)).catch((err) => {
|
|
296
|
-
this.report(session.id, key, `outbound to ${key.channelId} failed: ${String(err)}`);
|
|
297
|
-
});
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
});
|
|
301
|
-
this.bySession.set(session.id, {
|
|
199
|
+
// Delivery reads `attached.key` live: a chat attaching after the workbench
|
|
200
|
+
// opened the session takes over (`reached`), and the closure must follow.
|
|
201
|
+
const attached = {
|
|
302
202
|
session,
|
|
303
203
|
key,
|
|
304
204
|
stateSince: Date.now(),
|
|
305
205
|
activeAt: Date.now(),
|
|
306
|
-
|
|
206
|
+
touched: 0,
|
|
207
|
+
unsubscribe: session.subscribe((payload) => {
|
|
208
|
+
const key = attached.key;
|
|
209
|
+
this.hub.emit(session.id, payload);
|
|
210
|
+
if (payload.type === "state") {
|
|
211
|
+
// Every turn passes here, so it also proves liveness to the sweeper.
|
|
212
|
+
attached.stateSince = attached.activeAt = Date.now();
|
|
213
|
+
attached.touched += 1;
|
|
214
|
+
this.hub.emitWorkspace({
|
|
215
|
+
type: "session-state",
|
|
216
|
+
sessionId: session.id,
|
|
217
|
+
state: payload.state,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
if (payload.type === "renamed")
|
|
221
|
+
this.hub.emitWorkspace({ type: "sessions-changed" });
|
|
222
|
+
// Without this a session-reported error lands only in the web timeline
|
|
223
|
+
// and the IM side goes quiet for no visible reason.
|
|
224
|
+
if (payload.type === "error") {
|
|
225
|
+
log.error(`${keyOf(key)} session ${session.id} reported: ${payload.message}`);
|
|
226
|
+
const channel = this.channels.get(key.channelId);
|
|
227
|
+
channel?.notify(key.conversationId, {
|
|
228
|
+
text: truncate(payload.message),
|
|
229
|
+
origin: { kind: "error" },
|
|
230
|
+
}).catch((err) => log.error(`notify ${key.channelId} failed`, err));
|
|
231
|
+
}
|
|
232
|
+
// Context the chat did not see typed goes out before the turn it
|
|
233
|
+
// triggers, so the answer has a visible cause. The hub carries it whole.
|
|
234
|
+
if (payload.type === "system-input") {
|
|
235
|
+
const channel = this.channels.get(key.channelId);
|
|
236
|
+
channel?.notify(key.conversationId, { text: digest(payload.text), origin: payload.origin })
|
|
237
|
+
.catch((err) => {
|
|
238
|
+
log.error(`notify ${key.channelId} failed`, err);
|
|
239
|
+
this.hub.emit(session.id, {
|
|
240
|
+
type: "error",
|
|
241
|
+
message: `notify ${key.channelId} failed: ${String(err)}`,
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
// A steer chosen against a turn that ended before the call landed sits in
|
|
246
|
+
// Pi's queue until some later turn — on IM, a message that never arrived
|
|
247
|
+
// (§5). A non-empty queue on an idle session is exactly that case.
|
|
248
|
+
if (payload.type === "queue-state" && (payload.steering.length || payload.followUp.length)) {
|
|
249
|
+
this.promoteQueued(session);
|
|
250
|
+
}
|
|
251
|
+
// Empty text included: adapters retire per-turn UI (👀 receipts) on it.
|
|
252
|
+
if (payload.type === "turn-end") {
|
|
253
|
+
log.info(`turn end ${keyOf(key)} session ${session.id}: ${String(payload.text.length)} chars`);
|
|
254
|
+
const channel = this.channels.get(key.channelId);
|
|
255
|
+
if (channel) {
|
|
256
|
+
const reply = splitReply(payload.text, payload.meta);
|
|
257
|
+
this.deliver(session, key, () => channel.send(key.conversationId, reply))
|
|
258
|
+
.catch((err) => {
|
|
259
|
+
this.report(session.id, key, `outbound to ${key.channelId} failed: ${String(err)}`);
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}),
|
|
264
|
+
};
|
|
265
|
+
this.bySession.set(session.id, attached);
|
|
266
|
+
}
|
|
267
|
+
/** An adapter's send is several platform calls (chunks, then attachments),
|
|
268
|
+
* so two answers left to overlap interleave in the chat. Per conversation:
|
|
269
|
+
* a slow chat may not hold up another. Also what `busy` counts as still
|
|
270
|
+
* sending: a finished turn is not delivered until the adapter says so. */
|
|
271
|
+
delivering = new Map();
|
|
272
|
+
deliver(session, key, send) {
|
|
273
|
+
const id = keyOf(key);
|
|
274
|
+
const pending = this.delivering.get(id)?.settled;
|
|
275
|
+
// The async wrapper turns a synchronous throw into this reply's rejection.
|
|
276
|
+
const done = pending ? pending.then(send) : (async () => send())();
|
|
277
|
+
// A rejection is the caller's to report; inherited, it would fail every
|
|
278
|
+
// later reply to this conversation.
|
|
279
|
+
const settled = done.catch(() => { });
|
|
280
|
+
this.delivering.set(id, { session, key, settled });
|
|
281
|
+
// Only the tail clears the slot — a newer reply owns it by then.
|
|
282
|
+
void settled.then(() => {
|
|
283
|
+
if (this.delivering.get(id)?.settled === settled)
|
|
284
|
+
this.delivering.delete(id);
|
|
307
285
|
});
|
|
286
|
+
return done;
|
|
308
287
|
}
|
|
309
288
|
queueOperations = new Set();
|
|
310
289
|
promotionRequested = new Set();
|
|
311
290
|
recoveries = new Map();
|
|
312
|
-
//
|
|
313
|
-
//
|
|
291
|
+
// Latest failed batch id, so a clear cannot erase a newer rejection that
|
|
292
|
+
// arrived while it awaited the backend.
|
|
314
293
|
uncertaintyHeld = new Map();
|
|
315
294
|
queueUncertain(sessionId) {
|
|
316
295
|
return this.uncertaintyHeld.has(sessionId);
|
|
@@ -336,8 +315,8 @@ export class Router {
|
|
|
336
315
|
this.promotionRequested.delete(sessionId);
|
|
337
316
|
this.recoveryChanged(sessionId);
|
|
338
317
|
}
|
|
339
|
-
/** Only queue mutation
|
|
340
|
-
*
|
|
318
|
+
/** Only queue mutation holds this lock; a running turn does not, so manual
|
|
319
|
+
* controls stay available while it runs. */
|
|
341
320
|
async useQueue(sessionId, action, key = { channelId: "web", conversationId: sessionId }) {
|
|
342
321
|
if (this.queueOperations.has(sessionId))
|
|
343
322
|
throw new QueueOperationError("busy", "Queue operation in progress");
|
|
@@ -400,8 +379,8 @@ export class Router {
|
|
|
400
379
|
if (mode === "restart")
|
|
401
380
|
await this.abort(sessionId);
|
|
402
381
|
this.checkQueueDrain();
|
|
403
|
-
//
|
|
404
|
-
//
|
|
382
|
+
// Not via dispatch: the text was headed at original dispatch, and a
|
|
383
|
+
// second pass could attribute these words to the operator.
|
|
405
384
|
invoked = true;
|
|
406
385
|
const submitted = mode === "steer" && session.state === "streaming"
|
|
407
386
|
? session.steer(text) : session.prompt(text);
|
|
@@ -424,16 +403,9 @@ export class Router {
|
|
|
424
403
|
throw err;
|
|
425
404
|
}
|
|
426
405
|
}
|
|
427
|
-
/**
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
* (identity.ts), and sending it back through would head it a second time.
|
|
431
|
-
*
|
|
432
|
-
* Only ever reached from a queue-state event, never from a turn ending: Pi
|
|
433
|
-
* leaves the queue alone on `abort()`, so promoting on idle would make /stop
|
|
434
|
-
* start the very turn it was asked to stop. Recovering *those* messages stays
|
|
435
|
-
* the web's recall route, which hands them back to the composer.
|
|
436
|
-
*/
|
|
406
|
+
/** Only from a queue-state event, never from a turn ending: Pi leaves the
|
|
407
|
+
* queue alone on `abort()`, so promoting on idle would make /stop start the
|
|
408
|
+
* very turn it was asked to stop. */
|
|
437
409
|
promoteQueued(session) {
|
|
438
410
|
if (session.state !== "idle")
|
|
439
411
|
return;
|
|
@@ -445,7 +417,6 @@ export class Router {
|
|
|
445
417
|
queueMicrotask(() => {
|
|
446
418
|
if (!this.promotionRequested.has(sessionId) || this.queueOperations.has(sessionId))
|
|
447
419
|
return;
|
|
448
|
-
// A queue event followed by rejection might describe the same input.
|
|
449
420
|
// Retained failures require a human decision, not an automatic resend.
|
|
450
421
|
if (this.queueUncertain(sessionId) || this.recoveries.get(sessionId)?.length)
|
|
451
422
|
return;
|
|
@@ -468,17 +439,9 @@ export class Router {
|
|
|
468
439
|
this.promotionRequested.delete(sessionId);
|
|
469
440
|
}
|
|
470
441
|
}
|
|
471
|
-
/**
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
*
|
|
475
|
-
* For the surfaces that take a prefixed message back *out* of the context it
|
|
476
|
-
* was counted into — a recalled queue, a rewound turn. The tracker's whole
|
|
477
|
-
* job is "the model has already been told" (identity.ts), and a header that
|
|
478
|
-
* never reached the model, or reached it and was then rewound away, makes
|
|
479
|
-
* every later message from that speaker unattributed in a group chat. One
|
|
480
|
-
* redundant header is the same price eviction already pays.
|
|
481
|
-
*/
|
|
442
|
+
/** For surfaces that take a prefixed message back out of the context it was
|
|
443
|
+
* counted into (recalled queue, rewound turn): the tracker means "the model
|
|
444
|
+
* has been told" (identity.ts), and here it has not. */
|
|
482
445
|
forgetSender(sessionId) {
|
|
483
446
|
this.senders.forget(sessionId);
|
|
484
447
|
}
|
|
@@ -486,21 +449,17 @@ export class Router {
|
|
|
486
449
|
beginDrain() {
|
|
487
450
|
this.draining = true;
|
|
488
451
|
}
|
|
489
|
-
/**
|
|
490
|
-
*
|
|
491
|
-
* never happens, a Pier left refusing every message forever would be a far
|
|
492
|
-
* worse outcome than the race it was avoiding. */
|
|
452
|
+
/** The auto-updater closes the gate before handing over; when the handover
|
|
453
|
+
* never happens, refusing every message forever is the worse outcome. */
|
|
493
454
|
endDrain() {
|
|
494
455
|
this.draining = false;
|
|
495
456
|
}
|
|
496
|
-
/** For surfaces that mutate state before dispatching (
|
|
497
|
-
*
|
|
498
|
-
* rewound transcript or a cleared queue. */
|
|
457
|
+
/** For surfaces that mutate state before dispatching (edit, queue-deliver):
|
|
458
|
+
* a refused dispatch must not cost a rewound transcript or a cleared queue. */
|
|
499
459
|
isDraining() {
|
|
500
460
|
return this.draining;
|
|
501
461
|
}
|
|
502
|
-
/**
|
|
503
|
-
* dispatch catch only logs, and the web caller gets the throw. */
|
|
462
|
+
/** Told to the chat directly (§5): an adapter's dispatch catch only logs. */
|
|
504
463
|
refuseDraining(key) {
|
|
505
464
|
const message = "Pier is restarting — this message was not taken; send it again in a moment.";
|
|
506
465
|
this.channels.get(key.channelId)
|
|
@@ -508,17 +467,25 @@ export class Router {
|
|
|
508
467
|
.catch((err) => log.error(`could not report the drain to ${key.channelId}`, err));
|
|
509
468
|
throw new Error(message);
|
|
510
469
|
}
|
|
511
|
-
/** Attached sessions still mid-turn
|
|
512
|
-
*
|
|
470
|
+
/** Attached sessions still mid-turn, and conversations whose answer is still
|
|
471
|
+
* going out (`sending`) — what the drain waits on, and what its deadline
|
|
472
|
+
* writes into the ledger. */
|
|
513
473
|
busy() {
|
|
514
|
-
return [
|
|
515
|
-
.
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
474
|
+
return [
|
|
475
|
+
...[...this.bySession.values()]
|
|
476
|
+
.filter((attached) => attached.session.state === "streaming")
|
|
477
|
+
.map((attached) => ({ session: attached.session, key: attached.key })),
|
|
478
|
+
...[...this.delivering.values()]
|
|
479
|
+
.map(({ session, key }) => ({ session, key, sending: true })),
|
|
480
|
+
];
|
|
481
|
+
}
|
|
482
|
+
/** Every attached session, mid-turn or not — what the drain snapshots: Pi's
|
|
483
|
+
* queue lives only in the runtime, so an idle session's queued messages die
|
|
484
|
+
* with the process just the same. */
|
|
485
|
+
attachedSessions() {
|
|
486
|
+
return [...this.bySession.values()].map(({ session, key }) => ({ session, key }));
|
|
487
|
+
}
|
|
488
|
+
/** Never creates one: a stop or settings command must not open a session. */
|
|
522
489
|
sessionOf(key) {
|
|
523
490
|
return this.byKey.get(keyOf(key));
|
|
524
491
|
}
|
|
@@ -527,55 +494,46 @@ export class Router {
|
|
|
527
494
|
if (session)
|
|
528
495
|
await this.abort(session.id);
|
|
529
496
|
}
|
|
530
|
-
/** Session owning a conversation, resolving and attaching it on first use.
|
|
497
|
+
/** Session owning a conversation, resolving and attaching it on first use.
|
|
498
|
+
* One object per session id whichever key asks first: an IM key is looked up
|
|
499
|
+
* to its session id so it shares the lock with `web:`/`task:` aliases. */
|
|
531
500
|
async ensure(key) {
|
|
532
501
|
let session = this.byKey.get(keyOf(key));
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
if (session)
|
|
538
|
-
this.byKey.set(keyOf(key), session);
|
|
539
|
-
}
|
|
502
|
+
if (session)
|
|
503
|
+
return this.reached(session, key);
|
|
504
|
+
const id = isAlias(key) ? key.conversationId : this.sessionIdOf(key);
|
|
505
|
+
session = id === undefined ? undefined : this.bySession.get(id)?.session;
|
|
540
506
|
if (!session) {
|
|
541
|
-
|
|
542
|
-
const lock = isAlias(key) ? `session:${key.conversationId}` : keyOf(key);
|
|
507
|
+
const lock = id === undefined ? keyOf(key) : `session:${id}`;
|
|
543
508
|
const inflight = this.opening.get(lock);
|
|
544
|
-
// A second caller rides the first one's resolve — which attaches before
|
|
545
|
-
// this continuation runs, having awaited it first — and registers its own
|
|
546
|
-
// key against the session that came back.
|
|
547
509
|
if (inflight) {
|
|
548
510
|
session = await inflight;
|
|
549
|
-
this.byKey.set(keyOf(key), session);
|
|
550
|
-
return this.reached(session, key);
|
|
551
|
-
}
|
|
552
|
-
try {
|
|
553
|
-
// Inside the try: a resolver that throws synchronously is the same
|
|
554
|
-
// failure as one that rejects, and reports the same way.
|
|
555
|
-
const opening = this.resolve(key);
|
|
556
|
-
this.opening.set(lock, opening);
|
|
557
|
-
session = await opening;
|
|
558
511
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
512
|
+
else {
|
|
513
|
+
try {
|
|
514
|
+
// Inside the try: a synchronous throw must report like a rejection.
|
|
515
|
+
const opening = this.resolve(key);
|
|
516
|
+
this.opening.set(lock, opening);
|
|
517
|
+
session = await opening;
|
|
518
|
+
}
|
|
519
|
+
catch (err) {
|
|
520
|
+
this.unopened(key, err);
|
|
521
|
+
throw err;
|
|
522
|
+
}
|
|
523
|
+
finally {
|
|
524
|
+
this.opening.delete(lock);
|
|
525
|
+
}
|
|
565
526
|
}
|
|
566
|
-
this.attach(key, session);
|
|
567
527
|
}
|
|
568
|
-
|
|
528
|
+
// Attaches fresh, or adds this key to the object already attached.
|
|
529
|
+
this.attach(key, session);
|
|
530
|
+
return session;
|
|
569
531
|
}
|
|
570
|
-
/** A
|
|
571
|
-
*
|
|
572
|
-
* is. Otherwise the chat that is waiting is told directly. Callers still get
|
|
573
|
-
* the rejection; this is only so the waiting side is not left with nothing. */
|
|
532
|
+
/** A web or task key names the session's own stream; an IM key names a chat
|
|
533
|
+
* that is waiting. Either way the waiting side is not left with nothing. */
|
|
574
534
|
unopened(key, err) {
|
|
575
535
|
log.error(`could not open a session for ${keyOf(key)}`, err);
|
|
576
536
|
const message = truncate(`could not open a session: ${String(err)}`);
|
|
577
|
-
// A web or task key names the session that would not open, so its own
|
|
578
|
-
// stream is where the waiting surface is looking; an IM key names a chat.
|
|
579
537
|
if (key.channelId === "web" || key.channelId === "task") {
|
|
580
538
|
this.reportTo(key.conversationId, message);
|
|
581
539
|
return;
|
|
@@ -584,45 +542,36 @@ export class Router {
|
|
|
584
542
|
?.notify(key.conversationId, { text: message, origin: { kind: "error" } })
|
|
585
543
|
.catch((e) => log.error(`could not report it to ${key.channelId}`, e));
|
|
586
544
|
}
|
|
587
|
-
/**
|
|
588
|
-
*
|
|
589
|
-
*
|
|
590
|
-
*
|
|
591
|
-
*
|
|
592
|
-
* whenever nothing had it attached, and the key from that first attach used
|
|
593
|
-
* to stand forever — so the workbench's own next turn was still "a task",
|
|
594
|
-
* and the notification for it (web/push.ts) was never sent. A chat key is
|
|
595
|
-
* never overwritten: that one is also where turn-ends are delivered. */
|
|
545
|
+
/** Also where a session learns which key is current: a task callback
|
|
546
|
+
* attaches under `task:<id>`, and the workbench's next turn must not still
|
|
547
|
+
* read as "a task" (web/push.ts). A chat outranks an alias — the workbench
|
|
548
|
+
* may have opened the session, but its turns are answered in the chat — and
|
|
549
|
+
* an alias never overwrites a chat. */
|
|
596
550
|
reached(session, key) {
|
|
597
551
|
const attached = this.bySession.get(session.id);
|
|
598
552
|
if (!attached)
|
|
599
553
|
return session;
|
|
600
554
|
attached.activeAt = Date.now();
|
|
601
|
-
|
|
555
|
+
attached.touched += 1;
|
|
556
|
+
if (!isAlias(key) || isAlias(attached.key))
|
|
602
557
|
attached.key = key;
|
|
603
558
|
return session;
|
|
604
559
|
}
|
|
605
560
|
async dispatch(msg) {
|
|
606
|
-
// Before ensure — a drain must not
|
|
561
|
+
// Before ensure — a drain must not open a session — and after, for a
|
|
562
|
+
// dispatch that was inside a slow ensure when the gate closed.
|
|
607
563
|
if (this.draining)
|
|
608
564
|
this.refuseDraining(msg.key);
|
|
609
565
|
const session = await this.ensure(msg.key);
|
|
610
|
-
// … and after — a dispatch that was inside a slow ensure when the gate
|
|
611
|
-
// closed must not start the turn the drain just declared finished with.
|
|
612
566
|
if (this.draining)
|
|
613
567
|
this.refuseDraining(msg.key);
|
|
614
568
|
this.spokenTo?.(session.id);
|
|
615
569
|
const { action, text } = decide(msg, session.state);
|
|
616
|
-
// A group chat is many people talking into one session; without a speaker
|
|
617
|
-
// line the agent cannot tell them apart or mention anyone back. Emitted
|
|
618
|
-
// only when the speaker or the clock says something new.
|
|
619
570
|
const prompt = withPrefix(this.senders.next(session.id, msg.sender), text);
|
|
620
571
|
log.debug(`${action} ${keyOf(msg.key)} → session ${session.id} (${String(prompt.length)} chars)`);
|
|
621
|
-
//
|
|
622
|
-
// there too, never as a thrown exception across the seam.
|
|
572
|
+
// A rejected call surfaces on the event stream, never as a throw across the seam.
|
|
623
573
|
session[action](prompt).catch((err) => {
|
|
624
|
-
// The header was counted as delivered
|
|
625
|
-
// arrived, so the next one from this speaker must carry it again.
|
|
574
|
+
// The header was counted as delivered above; it never arrived.
|
|
626
575
|
this.senders.forget(session.id);
|
|
627
576
|
this.report(session.id, msg.key, String(err));
|
|
628
577
|
});
|