@botcord/daemon 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/dist/activity-tracker.d.ts +43 -0
- package/dist/activity-tracker.js +110 -0
- package/dist/adapters/runtimes.d.ts +14 -0
- package/dist/adapters/runtimes.js +18 -0
- package/dist/agent-discovery.d.ts +81 -0
- package/dist/agent-discovery.js +181 -0
- package/dist/agent-workspace.d.ts +31 -0
- package/dist/agent-workspace.js +221 -0
- package/dist/config.d.ts +116 -0
- package/dist/config.js +180 -0
- package/dist/control-channel.d.ts +99 -0
- package/dist/control-channel.js +388 -0
- package/dist/cross-room.d.ts +23 -0
- package/dist/cross-room.js +55 -0
- package/dist/daemon-config-map.d.ts +61 -0
- package/dist/daemon-config-map.js +153 -0
- package/dist/daemon.d.ts +123 -0
- package/dist/daemon.js +349 -0
- package/dist/doctor.d.ts +89 -0
- package/dist/doctor.js +191 -0
- package/dist/gateway/channel-manager.d.ts +54 -0
- package/dist/gateway/channel-manager.js +292 -0
- package/dist/gateway/channels/botcord.d.ts +93 -0
- package/dist/gateway/channels/botcord.js +510 -0
- package/dist/gateway/channels/index.d.ts +2 -0
- package/dist/gateway/channels/index.js +1 -0
- package/dist/gateway/channels/sanitize.d.ts +20 -0
- package/dist/gateway/channels/sanitize.js +56 -0
- package/dist/gateway/dispatcher.d.ts +73 -0
- package/dist/gateway/dispatcher.js +431 -0
- package/dist/gateway/gateway.d.ts +87 -0
- package/dist/gateway/gateway.js +158 -0
- package/dist/gateway/index.d.ts +15 -0
- package/dist/gateway/index.js +15 -0
- package/dist/gateway/log.d.ts +9 -0
- package/dist/gateway/log.js +20 -0
- package/dist/gateway/router.d.ts +10 -0
- package/dist/gateway/router.js +48 -0
- package/dist/gateway/runtimes/claude-code.d.ts +30 -0
- package/dist/gateway/runtimes/claude-code.js +162 -0
- package/dist/gateway/runtimes/codex.d.ts +83 -0
- package/dist/gateway/runtimes/codex.js +272 -0
- package/dist/gateway/runtimes/gemini.d.ts +15 -0
- package/dist/gateway/runtimes/gemini.js +29 -0
- package/dist/gateway/runtimes/ndjson-stream.d.ts +43 -0
- package/dist/gateway/runtimes/ndjson-stream.js +169 -0
- package/dist/gateway/runtimes/probe.d.ts +17 -0
- package/dist/gateway/runtimes/probe.js +54 -0
- package/dist/gateway/runtimes/registry.d.ts +59 -0
- package/dist/gateway/runtimes/registry.js +94 -0
- package/dist/gateway/session-store.d.ts +39 -0
- package/dist/gateway/session-store.js +133 -0
- package/dist/gateway/types.d.ts +265 -0
- package/dist/gateway/types.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +854 -0
- package/dist/log.d.ts +7 -0
- package/dist/log.js +44 -0
- package/dist/provision.d.ts +88 -0
- package/dist/provision.js +749 -0
- package/dist/room-context-fetcher.d.ts +18 -0
- package/dist/room-context-fetcher.js +101 -0
- package/dist/room-context.d.ts +53 -0
- package/dist/room-context.js +112 -0
- package/dist/sender-classify.d.ts +30 -0
- package/dist/sender-classify.js +32 -0
- package/dist/snapshot-writer.d.ts +37 -0
- package/dist/snapshot-writer.js +84 -0
- package/dist/status-render.d.ts +28 -0
- package/dist/status-render.js +97 -0
- package/dist/system-context.d.ts +57 -0
- package/dist/system-context.js +91 -0
- package/dist/turn-text.d.ts +36 -0
- package/dist/turn-text.js +57 -0
- package/dist/user-auth.d.ts +75 -0
- package/dist/user-auth.js +245 -0
- package/dist/working-memory.d.ts +46 -0
- package/dist/working-memory.js +274 -0
- package/package.json +39 -0
- package/src/__tests__/activity-tracker.test.ts +130 -0
- package/src/__tests__/agent-discovery.test.ts +191 -0
- package/src/__tests__/agent-workspace.test.ts +147 -0
- package/src/__tests__/control-channel.test.ts +327 -0
- package/src/__tests__/cross-room.test.ts +116 -0
- package/src/__tests__/daemon-config-map.test.ts +416 -0
- package/src/__tests__/daemon.test.ts +300 -0
- package/src/__tests__/device-code.test.ts +152 -0
- package/src/__tests__/doctor.test.ts +218 -0
- package/src/__tests__/protocol-core-reexport.test.ts +24 -0
- package/src/__tests__/provision.test.ts +922 -0
- package/src/__tests__/room-context.test.ts +233 -0
- package/src/__tests__/runtime-discovery.test.ts +173 -0
- package/src/__tests__/snapshot-writer.test.ts +141 -0
- package/src/__tests__/status-render.test.ts +137 -0
- package/src/__tests__/system-context.test.ts +315 -0
- package/src/__tests__/turn-text.test.ts +116 -0
- package/src/__tests__/user-auth.test.ts +125 -0
- package/src/__tests__/working-memory.test.ts +240 -0
- package/src/activity-tracker.ts +140 -0
- package/src/adapters/runtimes.ts +30 -0
- package/src/agent-discovery.ts +262 -0
- package/src/agent-workspace.ts +247 -0
- package/src/config.ts +290 -0
- package/src/control-channel.ts +455 -0
- package/src/cross-room.ts +89 -0
- package/src/daemon-config-map.ts +200 -0
- package/src/daemon.ts +478 -0
- package/src/doctor.ts +282 -0
- package/src/gateway/__tests__/.gitkeep +0 -0
- package/src/gateway/__tests__/botcord-channel.test.ts +480 -0
- package/src/gateway/__tests__/channel-manager.test.ts +475 -0
- package/src/gateway/__tests__/claude-code-adapter.test.ts +318 -0
- package/src/gateway/__tests__/codex-adapter.test.ts +350 -0
- package/src/gateway/__tests__/dispatcher.test.ts +1159 -0
- package/src/gateway/__tests__/gateway-add-channel.test.ts +180 -0
- package/src/gateway/__tests__/gateway-managed-routes.test.ts +181 -0
- package/src/gateway/__tests__/gateway.test.ts +222 -0
- package/src/gateway/__tests__/router.test.ts +247 -0
- package/src/gateway/__tests__/sanitize.test.ts +193 -0
- package/src/gateway/__tests__/session-store.test.ts +235 -0
- package/src/gateway/channel-manager.ts +349 -0
- package/src/gateway/channels/botcord.ts +605 -0
- package/src/gateway/channels/index.ts +6 -0
- package/src/gateway/channels/sanitize.ts +68 -0
- package/src/gateway/dispatcher.ts +554 -0
- package/src/gateway/gateway.ts +211 -0
- package/src/gateway/index.ts +29 -0
- package/src/gateway/log.ts +30 -0
- package/src/gateway/router.ts +60 -0
- package/src/gateway/runtimes/claude-code.ts +180 -0
- package/src/gateway/runtimes/codex.ts +312 -0
- package/src/gateway/runtimes/gemini.ts +43 -0
- package/src/gateway/runtimes/ndjson-stream.ts +225 -0
- package/src/gateway/runtimes/probe.ts +73 -0
- package/src/gateway/runtimes/registry.ts +143 -0
- package/src/gateway/session-store.ts +157 -0
- package/src/gateway/types.ts +325 -0
- package/src/index.ts +961 -0
- package/src/log.ts +47 -0
- package/src/provision.ts +879 -0
- package/src/room-context-fetcher.ts +124 -0
- package/src/room-context.ts +167 -0
- package/src/sender-classify.ts +46 -0
- package/src/snapshot-writer.ts +103 -0
- package/src/status-render.ts +132 -0
- package/src/system-context.ts +162 -0
- package/src/turn-text.ts +93 -0
- package/src/user-auth.ts +295 -0
- package/src/working-memory.ts +352 -0
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
import type { GatewayLogger } from "./log.js";
|
|
2
|
+
import { resolveRoute } from "./router.js";
|
|
3
|
+
import { sessionKey, type SessionStore } from "./session-store.js";
|
|
4
|
+
import type {
|
|
5
|
+
ChannelAdapter,
|
|
6
|
+
GatewayConfig,
|
|
7
|
+
GatewayInboundEnvelope,
|
|
8
|
+
GatewayOutboundMessage,
|
|
9
|
+
GatewayRoute,
|
|
10
|
+
GatewaySessionEntry,
|
|
11
|
+
InboundObserver,
|
|
12
|
+
QueueMode,
|
|
13
|
+
RuntimeAdapter,
|
|
14
|
+
StreamBlock,
|
|
15
|
+
SystemContextBuilder,
|
|
16
|
+
TurnStatusSnapshot,
|
|
17
|
+
UserTurnBuilder,
|
|
18
|
+
} from "./types.js";
|
|
19
|
+
|
|
20
|
+
const DEFAULT_TURN_TIMEOUT_MS = 10 * 60 * 1000;
|
|
21
|
+
|
|
22
|
+
/** Factory signature for building a runtime adapter at turn dispatch time. */
|
|
23
|
+
export type RuntimeFactory = (
|
|
24
|
+
runtimeId: string,
|
|
25
|
+
extraArgs?: string[],
|
|
26
|
+
) => RuntimeAdapter;
|
|
27
|
+
|
|
28
|
+
/** Constructor options for `Dispatcher`. */
|
|
29
|
+
export interface DispatcherOptions {
|
|
30
|
+
config: GatewayConfig;
|
|
31
|
+
channels: Map<string, ChannelAdapter>;
|
|
32
|
+
runtime: RuntimeFactory;
|
|
33
|
+
sessionStore: SessionStore;
|
|
34
|
+
log: GatewayLogger;
|
|
35
|
+
turnTimeoutMs?: number;
|
|
36
|
+
/**
|
|
37
|
+
* Live reference to the Gateway's managed-route map. Dispatcher reads
|
|
38
|
+
* `values()` on every `resolveRoute` call so hot-add/remove take effect
|
|
39
|
+
* without restart.
|
|
40
|
+
*/
|
|
41
|
+
managedRoutes?: Map<string, GatewayRoute>;
|
|
42
|
+
/**
|
|
43
|
+
* Optional hook producing a `systemContext` string for each turn. Result is
|
|
44
|
+
* forwarded to the runtime as `RuntimeRunOptions.systemContext`. Errors are
|
|
45
|
+
* swallowed and logged — they never abort the turn.
|
|
46
|
+
*/
|
|
47
|
+
buildSystemContext?: SystemContextBuilder;
|
|
48
|
+
/**
|
|
49
|
+
* Optional side-effect hook invoked after ack, before the turn runs.
|
|
50
|
+
* Intended for bookkeeping (e.g. activity tracking). Errors are logged
|
|
51
|
+
* and suppressed so the turn is never cancelled by observer failure.
|
|
52
|
+
*/
|
|
53
|
+
onInbound?: InboundObserver;
|
|
54
|
+
/**
|
|
55
|
+
* Optional composer that wraps `message.text` with channel-specific
|
|
56
|
+
* metadata (sender label, room header, reply hints…) before it is handed
|
|
57
|
+
* to the runtime. Skipped if it throws — the raw trimmed text is used as
|
|
58
|
+
* a fallback so a buggy composer cannot drop turns.
|
|
59
|
+
*/
|
|
60
|
+
composeUserTurn?: UserTurnBuilder;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface TurnSlot {
|
|
64
|
+
controller: AbortController;
|
|
65
|
+
timedOut: boolean;
|
|
66
|
+
snapshot: TurnStatusSnapshot;
|
|
67
|
+
done: Promise<void>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface QueueState {
|
|
71
|
+
/** The currently executing turn on this queue key, if any. */
|
|
72
|
+
current: TurnSlot | null;
|
|
73
|
+
/** Tail of the serial-mode queue — chained via promises; replaced each append. */
|
|
74
|
+
tail: Promise<void>;
|
|
75
|
+
/**
|
|
76
|
+
* Generation counter bumped every time a cancel-previous turn arrives.
|
|
77
|
+
* Any in-flight cancel-previous arrival captures the value at entry; if a
|
|
78
|
+
* newer arrival bumps the counter while it's still awaiting the prior
|
|
79
|
+
* turn's teardown, the older one observes the mismatch and drops out. This
|
|
80
|
+
* closes the race where two cancel-previous calls could both observe
|
|
81
|
+
* `current === null` after an abort and run concurrently.
|
|
82
|
+
*/
|
|
83
|
+
cancelGen: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Gateway dispatcher: consumes `GatewayInboundEnvelope` and drives a runtime
|
|
88
|
+
* turn per message, respecting queue mode, trust level, streaming, and
|
|
89
|
+
* session persistence rules from the plan (§7/§9/§10/§11/§12/§13).
|
|
90
|
+
*
|
|
91
|
+
* Deliberate deviation from daemon: this core does NOT wrap inbound text in
|
|
92
|
+
* BotCord-style XML envelopes for untrusted content. The channel adapter is
|
|
93
|
+
* responsible for any channel-specific sanitization; the dispatcher passes
|
|
94
|
+
* `message.text` through to the runtime as-is (plan §15).
|
|
95
|
+
*/
|
|
96
|
+
export class Dispatcher {
|
|
97
|
+
private readonly config: GatewayConfig;
|
|
98
|
+
private readonly channels: Map<string, ChannelAdapter>;
|
|
99
|
+
private readonly runtimeFactory: RuntimeFactory;
|
|
100
|
+
private readonly sessionStore: SessionStore;
|
|
101
|
+
private readonly log: GatewayLogger;
|
|
102
|
+
private readonly turnTimeoutMs: number;
|
|
103
|
+
private readonly buildSystemContext?: SystemContextBuilder;
|
|
104
|
+
private readonly onInbound?: InboundObserver;
|
|
105
|
+
private readonly composeUserTurn?: UserTurnBuilder;
|
|
106
|
+
private readonly managedRoutes?: Map<string, GatewayRoute>;
|
|
107
|
+
private readonly queues: Map<string, QueueState> = new Map();
|
|
108
|
+
|
|
109
|
+
constructor(opts: DispatcherOptions) {
|
|
110
|
+
this.config = opts.config;
|
|
111
|
+
this.channels = opts.channels;
|
|
112
|
+
this.runtimeFactory = opts.runtime;
|
|
113
|
+
this.sessionStore = opts.sessionStore;
|
|
114
|
+
this.log = opts.log;
|
|
115
|
+
this.turnTimeoutMs = opts.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS;
|
|
116
|
+
this.buildSystemContext = opts.buildSystemContext;
|
|
117
|
+
this.onInbound = opts.onInbound;
|
|
118
|
+
this.composeUserTurn = opts.composeUserTurn;
|
|
119
|
+
this.managedRoutes = opts.managedRoutes;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Consume one inbound envelope, ack it once ownership is decided, then run its turn. */
|
|
123
|
+
async handle(envelope: GatewayInboundEnvelope): Promise<void> {
|
|
124
|
+
const msg = envelope.message;
|
|
125
|
+
|
|
126
|
+
// Skip rule: empty/whitespace text.
|
|
127
|
+
const rawText = typeof msg.text === "string" ? msg.text.trim() : "";
|
|
128
|
+
if (!rawText) {
|
|
129
|
+
this.log.debug("dispatcher skip: empty text", { messageId: msg.id });
|
|
130
|
+
await this.safeAck(envelope);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Skip rule: echo from the agent itself (own agent output looped back).
|
|
135
|
+
// Owner/human messages in dashboard rooms share the agent's id as sender.id
|
|
136
|
+
// but carry sender.kind === "user", so we only skip when kind === "agent".
|
|
137
|
+
if (msg.sender.id === msg.accountId && msg.sender.kind === "agent") {
|
|
138
|
+
this.log.debug("dispatcher skip: own message", { messageId: msg.id });
|
|
139
|
+
await this.safeAck(envelope);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Compose the final user-turn text. The composer can enrich the raw
|
|
144
|
+
// message with sender label, room header, NO_REPLY hint, etc. — anything
|
|
145
|
+
// that should land in the runtime transcript. Failures fall back to the
|
|
146
|
+
// raw trimmed text so a buggy composer cannot drop turns.
|
|
147
|
+
let text = rawText;
|
|
148
|
+
if (this.composeUserTurn) {
|
|
149
|
+
try {
|
|
150
|
+
const composed = this.composeUserTurn(msg);
|
|
151
|
+
if (typeof composed === "string" && composed.length > 0) {
|
|
152
|
+
text = composed;
|
|
153
|
+
}
|
|
154
|
+
} catch (err) {
|
|
155
|
+
this.log.warn("dispatcher: composeUserTurn threw — using raw text", {
|
|
156
|
+
messageId: msg.id,
|
|
157
|
+
error: err instanceof Error ? err.message : String(err),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const managed = this.managedRoutes ? Array.from(this.managedRoutes.values()) : undefined;
|
|
163
|
+
const route = resolveRoute(msg, this.config, managed);
|
|
164
|
+
const mode = resolveQueueMode(route, msg.conversation.kind);
|
|
165
|
+
const queueKey = buildQueueKey(msg);
|
|
166
|
+
|
|
167
|
+
// Ack immediately: once the dispatcher has a route + queue key, ownership is decided.
|
|
168
|
+
await this.safeAck(envelope);
|
|
169
|
+
|
|
170
|
+
// Notify the optional observer (activity tracking, metrics, etc.) as soon
|
|
171
|
+
// as the dispatcher owns the message. Errors must not abort the turn.
|
|
172
|
+
if (this.onInbound) {
|
|
173
|
+
try {
|
|
174
|
+
await this.onInbound(msg);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
this.log.warn("dispatcher: onInbound threw — continuing", {
|
|
177
|
+
messageId: msg.id,
|
|
178
|
+
error: err instanceof Error ? err.message : String(err),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const channel = this.channels.get(msg.channel);
|
|
184
|
+
if (!channel) {
|
|
185
|
+
this.log.warn("dispatcher: unknown channel for outbound reply", {
|
|
186
|
+
channel: msg.channel,
|
|
187
|
+
messageId: msg.id,
|
|
188
|
+
});
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (mode === "cancel-previous") {
|
|
193
|
+
await this.runCancelPrevious(queueKey, route, text, msg, channel);
|
|
194
|
+
} else {
|
|
195
|
+
await this.runSerial(queueKey, route, text, msg, channel);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Snapshot of currently running turns keyed by queue key. */
|
|
200
|
+
turns(): Record<string, TurnStatusSnapshot> {
|
|
201
|
+
const out: Record<string, TurnStatusSnapshot> = {};
|
|
202
|
+
for (const [key, q] of this.queues) {
|
|
203
|
+
if (q.current) out[key] = { ...q.current.snapshot };
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// Internals
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
private async safeAck(env: GatewayInboundEnvelope): Promise<void> {
|
|
213
|
+
const accept = env.ack?.accept;
|
|
214
|
+
if (!accept) return;
|
|
215
|
+
try {
|
|
216
|
+
await accept.call(env.ack);
|
|
217
|
+
} catch (err) {
|
|
218
|
+
this.log.warn("dispatcher: ack.accept failed", {
|
|
219
|
+
messageId: env.message.id,
|
|
220
|
+
error: err instanceof Error ? err.message : String(err),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private getQueue(key: string): QueueState {
|
|
226
|
+
let q = this.queues.get(key);
|
|
227
|
+
if (!q) {
|
|
228
|
+
q = {
|
|
229
|
+
current: null,
|
|
230
|
+
tail: Promise.resolve(),
|
|
231
|
+
cancelGen: 0,
|
|
232
|
+
};
|
|
233
|
+
this.queues.set(key, q);
|
|
234
|
+
}
|
|
235
|
+
return q;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private async runCancelPrevious(
|
|
239
|
+
queueKey: string,
|
|
240
|
+
route: GatewayRoute,
|
|
241
|
+
text: string,
|
|
242
|
+
msg: GatewayInboundEnvelope["message"],
|
|
243
|
+
channel: ChannelAdapter,
|
|
244
|
+
): Promise<void> {
|
|
245
|
+
const q = this.getQueue(queueKey);
|
|
246
|
+
// Bump the generation on every arrival. Older arrivals still awaiting
|
|
247
|
+
// the prior turn's teardown will observe `myGen !== q.cancelGen` when
|
|
248
|
+
// they resume and drop out, so only the newest message reaches runTurn.
|
|
249
|
+
q.cancelGen += 1;
|
|
250
|
+
const myGen = q.cancelGen;
|
|
251
|
+
const prev = q.current;
|
|
252
|
+
if (prev) {
|
|
253
|
+
this.log.info("dispatcher: cancelling previous turn", { queueKey });
|
|
254
|
+
prev.controller.abort();
|
|
255
|
+
// Wait for it to finish cleanup (it won't reply, won't persist).
|
|
256
|
+
await prev.done.catch(() => undefined);
|
|
257
|
+
}
|
|
258
|
+
// After the await, a newer cancel-previous may have arrived and either
|
|
259
|
+
// already fired its own abort + runTurn, or be mid-await itself. If so,
|
|
260
|
+
// drop out silently — the newest turn is the only one that should run.
|
|
261
|
+
if (myGen !== q.cancelGen) {
|
|
262
|
+
this.log.info("dispatcher: cancel-previous superseded", { queueKey });
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
await this.runTurn(queueKey, route, text, msg, channel);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private async runSerial(
|
|
269
|
+
queueKey: string,
|
|
270
|
+
route: GatewayRoute,
|
|
271
|
+
text: string,
|
|
272
|
+
msg: GatewayInboundEnvelope["message"],
|
|
273
|
+
channel: ChannelAdapter,
|
|
274
|
+
): Promise<void> {
|
|
275
|
+
const q = this.getQueue(queueKey);
|
|
276
|
+
const prev = q.tail;
|
|
277
|
+
const next = prev.then(() => this.runTurn(queueKey, route, text, msg, channel));
|
|
278
|
+
q.tail = next.catch(() => undefined);
|
|
279
|
+
return next;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private async runTurn(
|
|
283
|
+
queueKey: string,
|
|
284
|
+
route: GatewayRoute,
|
|
285
|
+
text: string,
|
|
286
|
+
msg: GatewayInboundEnvelope["message"],
|
|
287
|
+
channel: ChannelAdapter,
|
|
288
|
+
): Promise<void> {
|
|
289
|
+
const q = this.getQueue(queueKey);
|
|
290
|
+
const controller = new AbortController();
|
|
291
|
+
const startedAt = Date.now();
|
|
292
|
+
const snapshot: TurnStatusSnapshot = {
|
|
293
|
+
key: queueKey,
|
|
294
|
+
channel: msg.channel,
|
|
295
|
+
accountId: msg.accountId,
|
|
296
|
+
conversationId: msg.conversation.id,
|
|
297
|
+
runtime: route.runtime,
|
|
298
|
+
cwd: route.cwd,
|
|
299
|
+
startedAt,
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
let resolveDone!: () => void;
|
|
303
|
+
const done = new Promise<void>((res) => {
|
|
304
|
+
resolveDone = res;
|
|
305
|
+
});
|
|
306
|
+
const slot: TurnSlot = { controller, timedOut: false, snapshot, done };
|
|
307
|
+
q.current = slot;
|
|
308
|
+
|
|
309
|
+
// Hard-cap turn with a timeout.
|
|
310
|
+
const timer = setTimeout(() => {
|
|
311
|
+
slot.timedOut = true;
|
|
312
|
+
this.log.warn("dispatcher: turn timed out", {
|
|
313
|
+
queueKey,
|
|
314
|
+
timeoutMs: this.turnTimeoutMs,
|
|
315
|
+
});
|
|
316
|
+
controller.abort();
|
|
317
|
+
}, this.turnTimeoutMs);
|
|
318
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
319
|
+
|
|
320
|
+
const key = sessionKey({
|
|
321
|
+
runtime: route.runtime,
|
|
322
|
+
channel: msg.channel,
|
|
323
|
+
accountId: msg.accountId,
|
|
324
|
+
conversationKind: msg.conversation.kind,
|
|
325
|
+
conversationId: msg.conversation.id,
|
|
326
|
+
threadId: msg.conversation.threadId ?? null,
|
|
327
|
+
});
|
|
328
|
+
const entry = this.sessionStore.get(key);
|
|
329
|
+
const sessionId = entry?.runtimeSessionId ?? null;
|
|
330
|
+
const trustLevel = route.trustLevel ?? "trusted";
|
|
331
|
+
|
|
332
|
+
const streamable = msg.trace?.streamable === true;
|
|
333
|
+
const traceId = msg.trace?.id;
|
|
334
|
+
const canStream =
|
|
335
|
+
streamable && typeof traceId === "string" && typeof channel.streamBlock === "function";
|
|
336
|
+
const onBlock = canStream
|
|
337
|
+
? (block: StreamBlock) => {
|
|
338
|
+
// Fire-and-forget: stream errors must not break the turn.
|
|
339
|
+
channel
|
|
340
|
+
.streamBlock!({
|
|
341
|
+
traceId: traceId!,
|
|
342
|
+
accountId: msg.accountId,
|
|
343
|
+
conversationId: msg.conversation.id,
|
|
344
|
+
block,
|
|
345
|
+
log: this.log,
|
|
346
|
+
})
|
|
347
|
+
.catch((err) => {
|
|
348
|
+
this.log.warn("dispatcher: streamBlock failed", {
|
|
349
|
+
traceId,
|
|
350
|
+
error: err instanceof Error ? err.message : String(err),
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
: undefined;
|
|
355
|
+
|
|
356
|
+
// Compute systemContext right before dispatch. The builder must NOT block
|
|
357
|
+
// the turn on failure — log and continue so a flaky memory read can't
|
|
358
|
+
// silence the agent.
|
|
359
|
+
let systemContext: string | undefined;
|
|
360
|
+
if (this.buildSystemContext) {
|
|
361
|
+
try {
|
|
362
|
+
const result = await this.buildSystemContext(msg);
|
|
363
|
+
if (typeof result === "string" && result.length > 0) {
|
|
364
|
+
systemContext = result;
|
|
365
|
+
}
|
|
366
|
+
} catch (err) {
|
|
367
|
+
this.log.warn("buildSystemContext threw — continuing without systemContext", {
|
|
368
|
+
error: err instanceof Error ? err.message : String(err),
|
|
369
|
+
messageId: msg.id,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const runtime = this.runtimeFactory(route.runtime, route.extraArgs);
|
|
375
|
+
let result: { text: string; newSessionId: string; costUsd?: number; error?: string } | undefined;
|
|
376
|
+
let threw: unknown;
|
|
377
|
+
try {
|
|
378
|
+
try {
|
|
379
|
+
result = await runtime.run({
|
|
380
|
+
text,
|
|
381
|
+
sessionId,
|
|
382
|
+
cwd: route.cwd,
|
|
383
|
+
accountId: msg.accountId,
|
|
384
|
+
extraArgs: route.extraArgs,
|
|
385
|
+
signal: controller.signal,
|
|
386
|
+
trustLevel,
|
|
387
|
+
systemContext,
|
|
388
|
+
onBlock,
|
|
389
|
+
});
|
|
390
|
+
} catch (err) {
|
|
391
|
+
threw = err;
|
|
392
|
+
} finally {
|
|
393
|
+
clearTimeout(timer);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Re-check the abort signal AFTER runtime.run resolves but BEFORE any
|
|
397
|
+
// side effects (session write, reply send). This closes the race where
|
|
398
|
+
// a cancel-previous arrives between runtime.run resolving and the
|
|
399
|
+
// post-runtime block running: keeping `q.current` pointing at this slot
|
|
400
|
+
// until after the reply lets the new arrival trip our abort signal, and
|
|
401
|
+
// this check then drops us silently. Timed-out turns still fall through
|
|
402
|
+
// to send their error reply.
|
|
403
|
+
if (controller.signal.aborted && !slot.timedOut) {
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (slot.timedOut) {
|
|
408
|
+
await this.sendReply(channel, {
|
|
409
|
+
channel: msg.channel,
|
|
410
|
+
accountId: msg.accountId,
|
|
411
|
+
conversationId: msg.conversation.id,
|
|
412
|
+
threadId: msg.conversation.threadId ?? null,
|
|
413
|
+
text: `⚠️ Runtime timeout after ${Math.round(this.turnTimeoutMs / 60000)} minute(s); aborted`,
|
|
414
|
+
replyTo: msg.id,
|
|
415
|
+
traceId: msg.trace?.id ?? null,
|
|
416
|
+
});
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (threw) {
|
|
421
|
+
this.log.error("dispatcher: runtime threw", {
|
|
422
|
+
queueKey,
|
|
423
|
+
runtime: route.runtime,
|
|
424
|
+
error: threw instanceof Error ? threw.message : String(threw),
|
|
425
|
+
});
|
|
426
|
+
const shortMsg = threw instanceof Error ? threw.message : String(threw);
|
|
427
|
+
await this.sendReply(channel, {
|
|
428
|
+
channel: msg.channel,
|
|
429
|
+
accountId: msg.accountId,
|
|
430
|
+
conversationId: msg.conversation.id,
|
|
431
|
+
threadId: msg.conversation.threadId ?? null,
|
|
432
|
+
text: `⚠️ Runtime error: ${truncate(shortMsg, 500)}`,
|
|
433
|
+
replyTo: msg.id,
|
|
434
|
+
traceId: msg.trace?.id ?? null,
|
|
435
|
+
});
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (!result) return;
|
|
440
|
+
|
|
441
|
+
// Persist session before reply so next turn sees the new id even if send fails.
|
|
442
|
+
//
|
|
443
|
+
// Adapter contract:
|
|
444
|
+
// result.newSessionId truthy → upsert the entry
|
|
445
|
+
// result.newSessionId empty + had-inbound-sessionId + result.error
|
|
446
|
+
// → the prior session is dead (e.g. Claude Code
|
|
447
|
+
// "--resume <missing-uuid>"); delete the entry so
|
|
448
|
+
// we don't keep resuming a stale id every turn
|
|
449
|
+
// otherwise → no-op (e.g. codex intentionally never persists)
|
|
450
|
+
if (result.newSessionId) {
|
|
451
|
+
const session: GatewaySessionEntry = {
|
|
452
|
+
key,
|
|
453
|
+
runtime: route.runtime,
|
|
454
|
+
runtimeSessionId: result.newSessionId,
|
|
455
|
+
channel: msg.channel,
|
|
456
|
+
accountId: msg.accountId,
|
|
457
|
+
conversationKind: msg.conversation.kind,
|
|
458
|
+
conversationId: msg.conversation.id,
|
|
459
|
+
threadId: msg.conversation.threadId ?? null,
|
|
460
|
+
cwd: route.cwd,
|
|
461
|
+
updatedAt: Date.now(),
|
|
462
|
+
};
|
|
463
|
+
try {
|
|
464
|
+
const prevRuntimeSessionId = sessionId;
|
|
465
|
+
await this.sessionStore.set(session);
|
|
466
|
+
this.log.debug("dispatcher: persisted runtime session", {
|
|
467
|
+
key,
|
|
468
|
+
prevRuntimeSessionId,
|
|
469
|
+
nextRuntimeSessionId: result.newSessionId,
|
|
470
|
+
});
|
|
471
|
+
} catch (err) {
|
|
472
|
+
this.log.warn("dispatcher: session-store.set failed", {
|
|
473
|
+
key,
|
|
474
|
+
error: err instanceof Error ? err.message : String(err),
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
} else if (sessionId && result.error) {
|
|
478
|
+
try {
|
|
479
|
+
await this.sessionStore.delete(key);
|
|
480
|
+
this.log.info("dispatcher: dropped stale runtime session", {
|
|
481
|
+
key,
|
|
482
|
+
prevRuntimeSessionId: sessionId,
|
|
483
|
+
error: result.error,
|
|
484
|
+
});
|
|
485
|
+
} catch (err) {
|
|
486
|
+
this.log.warn("dispatcher: session-store.delete failed", {
|
|
487
|
+
key,
|
|
488
|
+
error: err instanceof Error ? err.message : String(err),
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const replyText = (result.text || "").trim();
|
|
494
|
+
if (!replyText) return;
|
|
495
|
+
|
|
496
|
+
// One last abort check immediately before the send. Narrows the window
|
|
497
|
+
// in which a cancel-previous arriving during session-store.set could
|
|
498
|
+
// still slip a stale reply past us.
|
|
499
|
+
if (controller.signal.aborted && !slot.timedOut) {
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
await this.sendReply(channel, {
|
|
504
|
+
channel: msg.channel,
|
|
505
|
+
accountId: msg.accountId,
|
|
506
|
+
conversationId: msg.conversation.id,
|
|
507
|
+
threadId: msg.conversation.threadId ?? null,
|
|
508
|
+
text: replyText,
|
|
509
|
+
replyTo: msg.id,
|
|
510
|
+
traceId: msg.trace?.id ?? null,
|
|
511
|
+
});
|
|
512
|
+
} finally {
|
|
513
|
+
// Clear slot ownership AFTER the reply has been sent (or skipped).
|
|
514
|
+
// Only then do cancel-previous arrivals stop finding this slot — which
|
|
515
|
+
// is exactly what we want: while we're in the post-runtime window, a
|
|
516
|
+
// newer arrival should find `q.current === slot`, call `abort()`, and
|
|
517
|
+
// let our abort-checks above drop this turn silently.
|
|
518
|
+
if (q.current === slot) q.current = null;
|
|
519
|
+
resolveDone();
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
private async sendReply(
|
|
524
|
+
channel: ChannelAdapter,
|
|
525
|
+
outbound: GatewayOutboundMessage,
|
|
526
|
+
): Promise<void> {
|
|
527
|
+
try {
|
|
528
|
+
await channel.send({ message: outbound, log: this.log });
|
|
529
|
+
} catch (err) {
|
|
530
|
+
this.log.warn("dispatcher: channel.send failed", {
|
|
531
|
+
channel: outbound.channel,
|
|
532
|
+
conversationId: outbound.conversationId,
|
|
533
|
+
error: err instanceof Error ? err.message : String(err),
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function buildQueueKey(msg: GatewayInboundEnvelope["message"]): string {
|
|
540
|
+
const thread = msg.conversation.threadId ?? "";
|
|
541
|
+
return `${msg.channel}:${msg.accountId}:${msg.conversation.id}:${thread}`;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function resolveQueueMode(
|
|
545
|
+
route: GatewayRoute,
|
|
546
|
+
kind: "direct" | "group",
|
|
547
|
+
): QueueMode {
|
|
548
|
+
if (route.queueMode) return route.queueMode;
|
|
549
|
+
return kind === "direct" ? "cancel-previous" : "serial";
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function truncate(s: string, max: number): string {
|
|
553
|
+
return s.length <= max ? s : s.slice(0, max) + "…";
|
|
554
|
+
}
|