@timqi/pier 0.1.0 → 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 +6 -15
- package/dist/agent/credentials.js +11 -23
- package/dist/agent/events.js +42 -64
- package/dist/agent/listing.js +39 -92
- package/dist/agent/pi.js +103 -252
- 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 +7 -23
- 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 +12 -34
- package/dist/core/queue.js +3 -5
- package/dist/core/reply.js +41 -142
- package/dist/core/router.js +209 -264
- package/dist/core/types.js +4 -0
- package/dist/db.js +88 -272
- package/dist/drain.js +57 -50
- 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 +4 -12
- 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 +84 -183
- package/dist/paths.js +10 -26
- package/dist/secrets.js +18 -45
- package/dist/service.js +31 -73
- package/dist/settings.js +19 -63
- package/dist/tasks/agent.js +24 -45
- package/dist/tasks/callbacks.js +8 -16
- package/dist/tasks/command.js +2 -6
- package/dist/tasks/definitions.js +39 -62
- package/dist/tasks/execution.js +41 -39
- package/dist/tasks/groups.js +8 -11
- package/dist/tasks/messages.js +88 -155
- package/dist/tasks/outbox.js +33 -54
- package/dist/tasks/routes.js +4 -7
- package/dist/tasks/runs.js +4 -9
- package/dist/tasks/service.js +29 -42
- package/dist/tasks/store.js +36 -27
- package/dist/tasks/tool.js +59 -60
- package/dist/tools-task.js +20 -60
- package/dist/tools.js +98 -325
- package/dist/update.js +20 -43
- 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 +35 -82
- package/dist/web/providers.js +5 -11
- package/dist/web/public/assets/{activity-Bl3vZukb.js → activity-B89_hH7q.js} +1 -1
- 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-DYuf4Mlj.js → boards-BeKW0ZXK.js} +1 -1
- 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-BLJu7EXN.js → runs-Cwy0mN8i.js} +1 -1
- 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-BrdVh-Zi.js → settings-DzZLmujq.js} +1 -1
- 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-CeQS1rxa.js → task-runs-BCakxFk8.js} +1 -1
- 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 +30 -16
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.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 +109 -190
- package/dist/web/session-state.js +13 -53
- package/dist/web/types.js +2 -4
- package/dist/web/webpush.js +10 -25
- package/docs/deploy.md +115 -330
- package/package.json +1 -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 +23 -15
- package/dist/config-sync-fetch.js +0 -84
- package/dist/limits.js +0 -14
- package/dist/web/public/assets/activity-Bl3vZukb.js.br +0 -0
- package/dist/web/public/assets/activity-Bl3vZukb.js.gz +0 -0
- package/dist/web/public/assets/boards-DYuf4Mlj.js.br +0 -0
- package/dist/web/public/assets/boards-DYuf4Mlj.js.gz +0 -0
- package/dist/web/public/assets/explorer-qJH_9nTE.js +0 -4
- package/dist/web/public/assets/explorer-qJH_9nTE.js.br +0 -0
- package/dist/web/public/assets/explorer-qJH_9nTE.js.gz +0 -0
- package/dist/web/public/assets/index-Dqdb-Eqt.js +0 -85
- package/dist/web/public/assets/index-Dqdb-Eqt.js.br +0 -0
- package/dist/web/public/assets/index-Dqdb-Eqt.js.gz +0 -0
- package/dist/web/public/assets/index-DzmMzvi_.css +0 -2
- package/dist/web/public/assets/index-DzmMzvi_.css.br +0 -0
- package/dist/web/public/assets/index-DzmMzvi_.css.gz +0 -0
- package/dist/web/public/assets/runs-BLJu7EXN.js.br +0 -0
- package/dist/web/public/assets/runs-BLJu7EXN.js.gz +0 -0
- package/dist/web/public/assets/settings-BrdVh-Zi.js.br +0 -0
- package/dist/web/public/assets/settings-BrdVh-Zi.js.gz +0 -0
- package/dist/web/public/assets/task-runs-CeQS1rxa.js.br +0 -0
- package/dist/web/public/assets/task-runs-CeQS1rxa.js.gz +0 -0
- package/dist/web/public/assets/tasks-bcb3fYdK.js +0 -4
- package/dist/web/public/assets/tasks-bcb3fYdK.js.br +0 -0
- package/dist/web/public/assets/tasks-bcb3fYdK.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,96 +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
|
-
// A title the session gave itself: every list reads the transcript, so
|
|
255
|
-
// the same re-list a rename route broadcasts.
|
|
256
|
-
if (payload.type === "renamed")
|
|
257
|
-
this.hub.emitWorkspace({ type: "sessions-changed" });
|
|
258
|
-
// An error the session itself reported (a tool that threw, a model
|
|
259
|
-
// refusal, a lost connection). Without this it lands only in the web
|
|
260
|
-
// timeline and the IM side goes quiet for no visible reason.
|
|
261
|
-
if (payload.type === "error") {
|
|
262
|
-
log.error(`${keyOf(key)} session ${session.id} reported: ${payload.message}`);
|
|
263
|
-
const channel = this.channels.get(key.channelId);
|
|
264
|
-
channel?.notify(key.conversationId, {
|
|
265
|
-
text: truncate(payload.message),
|
|
266
|
-
origin: { kind: "error" },
|
|
267
|
-
}).catch((err) => log.error(`notify ${key.channelId} failed`, err));
|
|
268
|
-
}
|
|
269
|
-
// A system input is context the chat did not see being typed. It goes
|
|
270
|
-
// out before the turn it triggers, so the answer has a visible cause.
|
|
271
|
-
if (payload.type === "system-input") {
|
|
272
|
-
const channel = this.channels.get(key.channelId);
|
|
273
|
-
// A digest, not the input: the hub emit above is what carries it whole.
|
|
274
|
-
channel?.notify(key.conversationId, { text: digest(payload.text), origin: payload.origin })
|
|
275
|
-
.catch((err) => {
|
|
276
|
-
log.error(`notify ${key.channelId} failed`, err);
|
|
277
|
-
this.hub.emit(session.id, {
|
|
278
|
-
type: "error",
|
|
279
|
-
message: `notify ${key.channelId} failed: ${String(err)}`,
|
|
280
|
-
});
|
|
281
|
-
});
|
|
282
|
-
}
|
|
283
|
-
// A queued message with no turn left to deliver it. `decide` reads the
|
|
284
|
-
// state once, so a steer chosen against a turn that ends before the call
|
|
285
|
-
// lands sits in Pi's queue until some *later* turn reads it — on IM that
|
|
286
|
-
// is indistinguishable from the message never arriving (§5b). Pi drains
|
|
287
|
-
// its own queues up to the agent_end handler, so a non-empty queue on an
|
|
288
|
-
// idle session is exactly the message that missed that window.
|
|
289
|
-
if (payload.type === "queue-state" && (payload.steering.length || payload.followUp.length)) {
|
|
290
|
-
this.promoteQueued(session);
|
|
291
|
-
}
|
|
292
|
-
// Every turn-end reaches the channel, empty text included: an adapter's
|
|
293
|
-
// per-turn UI (Telegram's 👀 receipts) is retired here, and a turn that
|
|
294
|
-
// settled with nothing to say still has to settle.
|
|
295
|
-
if (payload.type === "turn-end") {
|
|
296
|
-
log.info(`turn end ${keyOf(key)} session ${session.id}: ${String(payload.text.length)} chars`);
|
|
297
|
-
const channel = this.channels.get(key.channelId);
|
|
298
|
-
if (channel) {
|
|
299
|
-
channel.send(key.conversationId, splitReply(payload.text, payload.meta)).catch((err) => {
|
|
300
|
-
this.report(session.id, key, `outbound to ${key.channelId} failed: ${String(err)}`);
|
|
301
|
-
});
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
});
|
|
305
|
-
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 = {
|
|
306
202
|
session,
|
|
307
203
|
key,
|
|
308
204
|
stateSince: Date.now(),
|
|
309
205
|
activeAt: Date.now(),
|
|
310
|
-
|
|
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);
|
|
311
285
|
});
|
|
286
|
+
return done;
|
|
312
287
|
}
|
|
313
288
|
queueOperations = new Set();
|
|
314
289
|
promotionRequested = new Set();
|
|
315
290
|
recoveries = new Map();
|
|
316
|
-
//
|
|
317
|
-
//
|
|
291
|
+
// Latest failed batch id, so a clear cannot erase a newer rejection that
|
|
292
|
+
// arrived while it awaited the backend.
|
|
318
293
|
uncertaintyHeld = new Map();
|
|
319
294
|
queueUncertain(sessionId) {
|
|
320
295
|
return this.uncertaintyHeld.has(sessionId);
|
|
@@ -340,8 +315,8 @@ export class Router {
|
|
|
340
315
|
this.promotionRequested.delete(sessionId);
|
|
341
316
|
this.recoveryChanged(sessionId);
|
|
342
317
|
}
|
|
343
|
-
/** Only queue mutation
|
|
344
|
-
*
|
|
318
|
+
/** Only queue mutation holds this lock; a running turn does not, so manual
|
|
319
|
+
* controls stay available while it runs. */
|
|
345
320
|
async useQueue(sessionId, action, key = { channelId: "web", conversationId: sessionId }) {
|
|
346
321
|
if (this.queueOperations.has(sessionId))
|
|
347
322
|
throw new QueueOperationError("busy", "Queue operation in progress");
|
|
@@ -404,8 +379,8 @@ export class Router {
|
|
|
404
379
|
if (mode === "restart")
|
|
405
380
|
await this.abort(sessionId);
|
|
406
381
|
this.checkQueueDrain();
|
|
407
|
-
//
|
|
408
|
-
//
|
|
382
|
+
// Not via dispatch: the text was headed at original dispatch, and a
|
|
383
|
+
// second pass could attribute these words to the operator.
|
|
409
384
|
invoked = true;
|
|
410
385
|
const submitted = mode === "steer" && session.state === "streaming"
|
|
411
386
|
? session.steer(text) : session.prompt(text);
|
|
@@ -428,16 +403,9 @@ export class Router {
|
|
|
428
403
|
throw err;
|
|
429
404
|
}
|
|
430
405
|
}
|
|
431
|
-
/**
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
* (identity.ts), and sending it back through would head it a second time.
|
|
435
|
-
*
|
|
436
|
-
* Only ever reached from a queue-state event, never from a turn ending: Pi
|
|
437
|
-
* leaves the queue alone on `abort()`, so promoting on idle would make /stop
|
|
438
|
-
* start the very turn it was asked to stop. Recovering *those* messages stays
|
|
439
|
-
* the web's recall route, which hands them back to the composer.
|
|
440
|
-
*/
|
|
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. */
|
|
441
409
|
promoteQueued(session) {
|
|
442
410
|
if (session.state !== "idle")
|
|
443
411
|
return;
|
|
@@ -449,7 +417,6 @@ export class Router {
|
|
|
449
417
|
queueMicrotask(() => {
|
|
450
418
|
if (!this.promotionRequested.has(sessionId) || this.queueOperations.has(sessionId))
|
|
451
419
|
return;
|
|
452
|
-
// A queue event followed by rejection might describe the same input.
|
|
453
420
|
// Retained failures require a human decision, not an automatic resend.
|
|
454
421
|
if (this.queueUncertain(sessionId) || this.recoveries.get(sessionId)?.length)
|
|
455
422
|
return;
|
|
@@ -472,17 +439,9 @@ export class Router {
|
|
|
472
439
|
this.promotionRequested.delete(sessionId);
|
|
473
440
|
}
|
|
474
441
|
}
|
|
475
|
-
/**
|
|
476
|
-
*
|
|
477
|
-
*
|
|
478
|
-
*
|
|
479
|
-
* For the surfaces that take a prefixed message back *out* of the context it
|
|
480
|
-
* was counted into — a recalled queue, a rewound turn. The tracker's whole
|
|
481
|
-
* job is "the model has already been told" (identity.ts), and a header that
|
|
482
|
-
* never reached the model, or reached it and was then rewound away, makes
|
|
483
|
-
* every later message from that speaker unattributed in a group chat. One
|
|
484
|
-
* redundant header is the same price eviction already pays.
|
|
485
|
-
*/
|
|
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. */
|
|
486
445
|
forgetSender(sessionId) {
|
|
487
446
|
this.senders.forget(sessionId);
|
|
488
447
|
}
|
|
@@ -490,21 +449,17 @@ export class Router {
|
|
|
490
449
|
beginDrain() {
|
|
491
450
|
this.draining = true;
|
|
492
451
|
}
|
|
493
|
-
/**
|
|
494
|
-
*
|
|
495
|
-
* never happens, a Pier left refusing every message forever would be a far
|
|
496
|
-
* 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. */
|
|
497
454
|
endDrain() {
|
|
498
455
|
this.draining = false;
|
|
499
456
|
}
|
|
500
|
-
/** For surfaces that mutate state before dispatching (
|
|
501
|
-
*
|
|
502
|
-
* 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. */
|
|
503
459
|
isDraining() {
|
|
504
460
|
return this.draining;
|
|
505
461
|
}
|
|
506
|
-
/**
|
|
507
|
-
* 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. */
|
|
508
463
|
refuseDraining(key) {
|
|
509
464
|
const message = "Pier is restarting — this message was not taken; send it again in a moment.";
|
|
510
465
|
this.channels.get(key.channelId)
|
|
@@ -512,17 +467,25 @@ export class Router {
|
|
|
512
467
|
.catch((err) => log.error(`could not report the drain to ${key.channelId}`, err));
|
|
513
468
|
throw new Error(message);
|
|
514
469
|
}
|
|
515
|
-
/** Attached sessions still mid-turn
|
|
516
|
-
*
|
|
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. */
|
|
517
473
|
busy() {
|
|
518
|
-
return [
|
|
519
|
-
.
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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. */
|
|
526
489
|
sessionOf(key) {
|
|
527
490
|
return this.byKey.get(keyOf(key));
|
|
528
491
|
}
|
|
@@ -531,55 +494,46 @@ export class Router {
|
|
|
531
494
|
if (session)
|
|
532
495
|
await this.abort(session.id);
|
|
533
496
|
}
|
|
534
|
-
/** 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. */
|
|
535
500
|
async ensure(key) {
|
|
536
501
|
let session = this.byKey.get(keyOf(key));
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
if (session)
|
|
542
|
-
this.byKey.set(keyOf(key), session);
|
|
543
|
-
}
|
|
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;
|
|
544
506
|
if (!session) {
|
|
545
|
-
|
|
546
|
-
const lock = isAlias(key) ? `session:${key.conversationId}` : keyOf(key);
|
|
507
|
+
const lock = id === undefined ? keyOf(key) : `session:${id}`;
|
|
547
508
|
const inflight = this.opening.get(lock);
|
|
548
|
-
// A second caller rides the first one's resolve — which attaches before
|
|
549
|
-
// this continuation runs, having awaited it first — and registers its own
|
|
550
|
-
// key against the session that came back.
|
|
551
509
|
if (inflight) {
|
|
552
510
|
session = await inflight;
|
|
553
|
-
this.byKey.set(keyOf(key), session);
|
|
554
|
-
return this.reached(session, key);
|
|
555
|
-
}
|
|
556
|
-
try {
|
|
557
|
-
// Inside the try: a resolver that throws synchronously is the same
|
|
558
|
-
// failure as one that rejects, and reports the same way.
|
|
559
|
-
const opening = this.resolve(key);
|
|
560
|
-
this.opening.set(lock, opening);
|
|
561
|
-
session = await opening;
|
|
562
511
|
}
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
+
}
|
|
569
526
|
}
|
|
570
|
-
this.attach(key, session);
|
|
571
527
|
}
|
|
572
|
-
|
|
528
|
+
// Attaches fresh, or adds this key to the object already attached.
|
|
529
|
+
this.attach(key, session);
|
|
530
|
+
return session;
|
|
573
531
|
}
|
|
574
|
-
/** A
|
|
575
|
-
*
|
|
576
|
-
* is. Otherwise the chat that is waiting is told directly. Callers still get
|
|
577
|
-
* 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. */
|
|
578
534
|
unopened(key, err) {
|
|
579
535
|
log.error(`could not open a session for ${keyOf(key)}`, err);
|
|
580
536
|
const message = truncate(`could not open a session: ${String(err)}`);
|
|
581
|
-
// A web or task key names the session that would not open, so its own
|
|
582
|
-
// stream is where the waiting surface is looking; an IM key names a chat.
|
|
583
537
|
if (key.channelId === "web" || key.channelId === "task") {
|
|
584
538
|
this.reportTo(key.conversationId, message);
|
|
585
539
|
return;
|
|
@@ -588,45 +542,36 @@ export class Router {
|
|
|
588
542
|
?.notify(key.conversationId, { text: message, origin: { kind: "error" } })
|
|
589
543
|
.catch((e) => log.error(`could not report it to ${key.channelId}`, e));
|
|
590
544
|
}
|
|
591
|
-
/**
|
|
592
|
-
*
|
|
593
|
-
*
|
|
594
|
-
*
|
|
595
|
-
*
|
|
596
|
-
* whenever nothing had it attached, and the key from that first attach used
|
|
597
|
-
* to stand forever — so the workbench's own next turn was still "a task",
|
|
598
|
-
* and the notification for it (web/push.ts) was never sent. A chat key is
|
|
599
|
-
* 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. */
|
|
600
550
|
reached(session, key) {
|
|
601
551
|
const attached = this.bySession.get(session.id);
|
|
602
552
|
if (!attached)
|
|
603
553
|
return session;
|
|
604
554
|
attached.activeAt = Date.now();
|
|
605
|
-
|
|
555
|
+
attached.touched += 1;
|
|
556
|
+
if (!isAlias(key) || isAlias(attached.key))
|
|
606
557
|
attached.key = key;
|
|
607
558
|
return session;
|
|
608
559
|
}
|
|
609
560
|
async dispatch(msg) {
|
|
610
|
-
// 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.
|
|
611
563
|
if (this.draining)
|
|
612
564
|
this.refuseDraining(msg.key);
|
|
613
565
|
const session = await this.ensure(msg.key);
|
|
614
|
-
// … and after — a dispatch that was inside a slow ensure when the gate
|
|
615
|
-
// closed must not start the turn the drain just declared finished with.
|
|
616
566
|
if (this.draining)
|
|
617
567
|
this.refuseDraining(msg.key);
|
|
618
568
|
this.spokenTo?.(session.id);
|
|
619
569
|
const { action, text } = decide(msg, session.state);
|
|
620
|
-
// A group chat is many people talking into one session; without a speaker
|
|
621
|
-
// line the agent cannot tell them apart or mention anyone back. Emitted
|
|
622
|
-
// only when the speaker or the clock says something new.
|
|
623
570
|
const prompt = withPrefix(this.senders.next(session.id, msg.sender), text);
|
|
624
571
|
log.debug(`${action} ${keyOf(msg.key)} → session ${session.id} (${String(prompt.length)} chars)`);
|
|
625
|
-
//
|
|
626
|
-
// 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.
|
|
627
573
|
session[action](prompt).catch((err) => {
|
|
628
|
-
// The header was counted as delivered
|
|
629
|
-
// arrived, so the next one from this speaker must carry it again.
|
|
574
|
+
// The header was counted as delivered above; it never arrived.
|
|
630
575
|
this.senders.forget(session.id);
|
|
631
576
|
this.report(session.id, msg.key, String(err));
|
|
632
577
|
});
|