talon-agent 5.18.2 → 5.19.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 +2 -1
- package/package.json +2 -2
- package/prompts/system/agent-brief.md +20 -3
- package/src/app.ts +13 -0
- package/src/backend/claude-sdk/handler.ts +4 -4
- package/src/backend/claude-sdk/mcp-ready.ts +16 -2
- package/src/backend/claude-sdk/one-shot.ts +32 -2
- package/src/backend/claude-sdk/stream.ts +2 -2
- package/src/backend/codex/auth.ts +1 -1
- package/src/backend/codex/handler/message.ts +11 -11
- package/src/backend/codex/init.ts +4 -9
- package/src/backend/codex/mcp-config.ts +1 -2
- package/src/backend/codex/oauth-incompat.ts +8 -4
- package/src/backend/codex/one-shot.ts +1 -1
- package/src/backend/openai-agents/builtins.ts +55 -27
- package/src/backend/openai-agents/factory.ts +3 -3
- package/src/backend/openai-agents/handler/message.ts +3 -5
- package/src/backend/openai-agents/mcp-pool.ts +10 -27
- package/src/backend/remote-server/chat-turn.ts +6 -6
- package/src/backend/remote-server/events.ts +1 -5
- package/src/backend/remote-server/index.ts +0 -1
- package/src/backend/remote-server/messages.ts +3 -7
- package/src/backend/remote-server/one-shot.ts +1 -3
- package/src/backend/remote-server/session-helpers.ts +1 -4
- package/src/backend/remote-server/sse-stream.ts +8 -9
- package/src/backend/runtime/metrics.ts +7 -13
- package/src/backend/runtime/sleep.ts +1 -2
- package/src/backend/runtime/turn/handle-retry.ts +48 -2
- package/src/backend/runtime/turn/handler-to-events.ts +3 -4
- package/src/bootstrap.ts +9 -1
- package/src/cli/doctor.ts +3 -0
- package/src/cli/index.ts +8 -10
- package/src/cli/logs.ts +148 -9
- package/src/cli/setup.ts +9 -11
- package/src/cli/status.ts +29 -0
- package/src/core/agent-runtime/README.md +5 -19
- package/src/core/agent-runtime/events.ts +3 -39
- package/src/core/agent-runtime/model-ref.ts +0 -8
- package/src/core/agents/registry.ts +65 -2
- package/src/core/auth/expiry-monitor.ts +9 -1
- package/src/core/auth/login-flow.ts +9 -1
- package/src/core/auth/status.ts +31 -3
- package/src/core/background/cron/scheduler.ts +25 -5
- package/src/core/background/dream/index.ts +29 -10
- package/src/core/background/failure-backoff.ts +30 -0
- package/src/core/background/heartbeat/agent.ts +2 -27
- package/src/core/background/heartbeat/index.ts +0 -2
- package/src/core/background/heartbeat/scheduler.ts +21 -13
- package/src/core/background/heartbeat/state.ts +10 -2
- package/src/core/background/isolated-agent.ts +6 -2
- package/src/core/background/pulse/pulse.ts +9 -0
- package/src/core/background/triggers/exit.ts +54 -0
- package/src/core/background/triggers/index.ts +1 -3
- package/src/core/background/triggers/resume.ts +2 -4
- package/src/core/backup/archive/tar.ts +14 -4
- package/src/core/backup/plan.ts +1 -0
- package/src/core/backup/restore.ts +21 -13
- package/src/core/backup/scheduler.ts +28 -9
- package/src/core/backup/snapshot.ts +43 -10
- package/src/core/backup/store.ts +7 -19
- package/src/core/backup/targets.ts +69 -17
- package/src/core/config/index.ts +14 -0
- package/src/core/daemon/crash-marker.ts +141 -0
- package/src/core/daemon/crash.ts +9 -2
- package/src/core/daemon/handoff.ts +15 -0
- package/src/core/daemon/health-alerts.ts +297 -0
- package/src/core/daemon/log-reader.ts +289 -0
- package/src/core/doctor/index.ts +18 -2
- package/src/core/doctor/logs.ts +124 -0
- package/src/core/doctor/types.ts +1 -1
- package/src/core/engine/backend-controller/index.ts +1 -13
- package/src/core/engine/backend-router/router.ts +1 -1
- package/src/core/engine/dispatcher.ts +55 -2
- package/src/core/engine/fault-text.ts +40 -0
- package/src/core/engine/gateway-actions/agents/index.ts +3 -2
- package/src/core/engine/gateway-actions/agents/report.ts +62 -0
- package/src/core/engine/gateway-actions/history.ts +2 -4
- package/src/core/engine/gateway-actions/mesh.ts +26 -14
- package/src/core/engine/gateway-actions/native/exec.ts +13 -16
- package/src/core/engine/gateway-actions/native/teleport.ts +1 -1
- package/src/core/engine/gateway.ts +60 -1
- package/src/core/engine/turn-health.ts +222 -0
- package/src/core/errors.ts +2 -2
- package/src/core/frontend-runtime/admin-notify.ts +1 -1
- package/src/core/frontend-runtime/alerts.ts +130 -0
- package/src/core/mcp-hub/children.ts +78 -29
- package/src/core/mcp-hub/index.ts +21 -18
- package/src/core/mcp-hub/proxy-server.ts +8 -4
- package/src/core/mcp-hub/talon-server.ts +5 -12
- package/src/core/mesh/credentials/store.ts +16 -1
- package/src/core/mesh/devices/registry.ts +1 -17
- package/src/core/mesh/devices/service.ts +49 -17
- package/src/core/mesh/devices/teleport.ts +14 -2
- package/src/core/mesh/links/node-binaries.ts +13 -6
- package/src/core/mesh/persist.ts +22 -10
- package/src/core/mesh/transfers/device-files.ts +5 -23
- package/src/core/mesh/transfers/transfers.ts +16 -3
- package/src/core/models/active-model.ts +2 -55
- package/src/core/plugin/actions.ts +19 -20
- package/src/core/plugin/builtins.ts +80 -90
- package/src/core/plugin/index.ts +1 -4
- package/src/core/plugin/loader.ts +25 -33
- package/src/core/plugin/mcp.ts +3 -5
- package/src/core/plugin/registry.ts +19 -35
- package/src/core/plugin/types.ts +2 -5
- package/src/core/prompt/assemble.ts +15 -3
- package/src/core/scripts/lua.ts +6 -2
- package/src/core/tasks/table.ts +8 -2
- package/src/core/tools/bridge.ts +2 -4
- package/src/core/tools/chat/cross-send.ts +1 -1
- package/src/core/tools/chat/messaging.ts +1 -1
- package/src/core/tools/index.ts +2 -2
- package/src/core/tools/mcp-env.ts +2 -59
- package/src/core/tools/ops/agents.ts +22 -1
- package/src/core/tools/schemas.ts +4 -9
- package/src/core/vfs/fusefs.ts +0 -5
- package/src/core/vfs/index.ts +9 -2
- package/src/core/vfs/mounts/diagnostics.ts +109 -0
- package/src/core/vfs/mounts/proc.ts +17 -1
- package/src/core/vfs/workspace.ts +7 -3
- package/src/core/weaver/shuttle.ts +8 -1
- package/src/core/weaver/turn-log.ts +320 -0
- package/src/core/weaver/weaver.ts +48 -5
- package/src/frontend/discord/actions/index.ts +8 -1
- package/src/frontend/discord/diagnostics.ts +82 -6
- package/src/frontend/discord/handlers/index.ts +0 -2
- package/src/frontend/discord/middleware.ts +7 -13
- package/src/frontend/discord/runtime.ts +1 -3
- package/src/frontend/health/delivery.ts +115 -0
- package/src/frontend/health/outage.ts +116 -0
- package/src/frontend/native/bridge/routes/chats.ts +3 -5
- package/src/frontend/native/bridge/server.ts +106 -21
- package/src/frontend/native/index.ts +1 -1
- package/src/frontend/native/media/media.ts +5 -1
- package/src/frontend/native/runtime.ts +12 -7
- package/src/frontend/native/surface/handlers.ts +1 -1
- package/src/frontend/native/surface/memory.ts +1 -1
- package/src/frontend/native/surface/models.ts +3 -3
- package/src/frontend/native/surface/settings.ts +20 -8
- package/src/frontend/native/turn/context.ts +6 -8
- package/src/frontend/native/turn/turn-meta.ts +2 -5
- package/src/frontend/native/turn/turn.ts +8 -10
- package/src/frontend/presentation/format.ts +2 -4
- package/src/frontend/presentation/session-status.ts +2 -6
- package/src/frontend/teams/actions.ts +8 -1
- package/src/frontend/teams/graph.ts +0 -1
- package/src/frontend/teams/index.ts +1 -4
- package/src/frontend/teams/poll.ts +40 -2
- package/src/frontend/teams/runtime.ts +14 -5
- package/src/frontend/telegram/actions/index.ts +4 -1
- package/src/frontend/telegram/actions/send.ts +8 -0
- package/src/frontend/telegram/handlers/context.ts +13 -2
- package/src/frontend/telegram/handlers/delivery.ts +12 -9
- package/src/frontend/telegram/handlers/index.ts +0 -2
- package/src/frontend/telegram/index.ts +35 -9
- package/src/frontend/telegram/polling/poll-health.ts +110 -0
- package/src/frontend/telegram/userbot.ts +100 -36
- package/src/frontend/terminal/builtins/session.ts +2 -2
- package/src/frontend/terminal/index.ts +1 -3
- package/src/frontend/terminal/renderer.ts +2 -18
- package/src/frontend/whatsapp/actions/index.ts +12 -1
- package/src/frontend/whatsapp/actions/messaging.ts +7 -2
- package/src/frontend/whatsapp/connection/connection.ts +29 -9
- package/src/frontend/whatsapp/connection/health.ts +89 -0
- package/src/frontend/whatsapp/connection/identity.ts +4 -4
- package/src/frontend/whatsapp/runtime.ts +11 -5
- package/src/native/blake3.ts +28 -2
- package/src/native/fusefs.ts +23 -5
- package/src/native/registry.ts +1 -1
- package/src/native/warden.ts +33 -5
- package/src/plugins/github/index.ts +0 -1
- package/src/plugins/mempalace/index.ts +9 -3
- package/src/plugins/playwright/index.ts +2 -4
- package/src/plugins/playwright/provision.ts +12 -4
- package/src/storage/chat-settings.ts +6 -1
- package/src/storage/cron.ts +29 -4
- package/src/storage/daily-log.ts +43 -47
- package/src/storage/db.ts +61 -33
- package/src/storage/history.ts +6 -1
- package/src/storage/journal.ts +9 -2
- package/src/storage/kv.ts +19 -6
- package/src/storage/media-index.ts +28 -6
- package/src/storage/repositories/chat-settings-repo.ts +8 -2
- package/src/storage/repositories/sessions-repo.ts +10 -3
- package/src/storage/scripts.ts +24 -13
- package/src/storage/sessions.ts +11 -2
- package/src/storage/skills.ts +21 -2
- package/src/storage/stickers.ts +17 -3
- package/src/storage/triggers.ts +8 -3
- package/src/storage/turn-meta.ts +25 -7
- package/src/util/log.ts +189 -6
- package/src/util/logging/turn-scope.ts +85 -0
- package/src/util/time.ts +3 -3
- package/src/util/watchdog.ts +30 -0
- package/src/core/engine/backend-controller/legacy.ts +0 -111
|
@@ -41,6 +41,19 @@ import type { Thread, ThreadSnapshot } from "./thread.js";
|
|
|
41
41
|
import { startTurnCpu } from "./turn-cpu.js";
|
|
42
42
|
import { startTypingLoop } from "./typing-loop.js";
|
|
43
43
|
import { resolveWarp } from "./warp-resolver.js";
|
|
44
|
+
import {
|
|
45
|
+
createTurnTrace,
|
|
46
|
+
logTurnEnd,
|
|
47
|
+
logTurnFailure,
|
|
48
|
+
logTurnSettled,
|
|
49
|
+
logTurnStart,
|
|
50
|
+
type TurnTrace,
|
|
51
|
+
} from "./turn-log.js";
|
|
52
|
+
import {
|
|
53
|
+
closeTurnScope,
|
|
54
|
+
createTurnScope,
|
|
55
|
+
runInTurnScope,
|
|
56
|
+
} from "../../util/logging/turn-scope.js";
|
|
44
57
|
|
|
45
58
|
export type WeaverDeps = {
|
|
46
59
|
/**
|
|
@@ -84,6 +97,10 @@ export class Weaver {
|
|
|
84
97
|
const chat = this.deps.getBackend(params.chatId).chat;
|
|
85
98
|
const interrupt = chat?.interruptChatTurn?.bind(chat);
|
|
86
99
|
const lifecycle = { started: false, killed: false, enqueuedAt: Date.now() };
|
|
100
|
+
// The turn id is minted at enqueue so a queued turn's wait is already
|
|
101
|
+
// attributable; the log scope goes live when the turn starts running.
|
|
102
|
+
const scope = createTurnScope(params.chatId);
|
|
103
|
+
const trace = createTurnTrace(scope.turnId, params, thread.inFlightCount);
|
|
87
104
|
// Registered before enqueueing so a turn waiting in its chat's FIFO is
|
|
88
105
|
// visible as `queued` in the task table, not invisible until it runs.
|
|
89
106
|
const task = taskTable.enqueue({
|
|
@@ -99,7 +116,11 @@ export class Weaver {
|
|
|
99
116
|
}
|
|
100
117
|
: {}),
|
|
101
118
|
});
|
|
102
|
-
return thread.enqueue(() =>
|
|
119
|
+
return thread.enqueue(() =>
|
|
120
|
+
runInTurnScope(scope, () =>
|
|
121
|
+
this.run(thread, params, task, lifecycle, trace),
|
|
122
|
+
).finally(() => closeTurnScope(scope)),
|
|
123
|
+
);
|
|
103
124
|
}
|
|
104
125
|
|
|
105
126
|
/** Number of turns currently running (not queued) across all chats. */
|
|
@@ -121,21 +142,28 @@ export class Weaver {
|
|
|
121
142
|
params: ExecuteParams,
|
|
122
143
|
task: TaskHandle,
|
|
123
144
|
lifecycle: { started: boolean; killed: boolean; enqueuedAt: number },
|
|
145
|
+
trace: TurnTrace,
|
|
124
146
|
): Promise<ExecuteResult> {
|
|
125
147
|
if (lifecycle.killed) {
|
|
126
148
|
// Killed while queued — the turn never reaches the backend. The
|
|
127
149
|
// caller still gets a resolved (empty) result; nothing is delivered
|
|
128
150
|
// to the chat, which is the point of the kill.
|
|
129
151
|
task.fail(new Error("killed while queued"));
|
|
152
|
+
logTurnEnd(trace, "aborted", { reason: "killed-while-queued" });
|
|
130
153
|
return this.emptyResult("Turn killed before it started.", params);
|
|
131
154
|
}
|
|
132
155
|
lifecycle.started = true;
|
|
156
|
+
trace.startedAt = Date.now();
|
|
133
157
|
this.activeCount++;
|
|
134
158
|
task.start();
|
|
135
159
|
try {
|
|
136
|
-
const result = await this.executeInner(
|
|
137
|
-
|
|
138
|
-
|
|
160
|
+
const result = await this.executeInner(
|
|
161
|
+
thread,
|
|
162
|
+
params,
|
|
163
|
+
task,
|
|
164
|
+
{ queueWait: Date.now() - lifecycle.enqueuedAt },
|
|
165
|
+
trace,
|
|
166
|
+
);
|
|
139
167
|
const usage = {
|
|
140
168
|
inputTokens: result.inputTokens,
|
|
141
169
|
outputTokens: result.outputTokens,
|
|
@@ -152,9 +180,11 @@ export class Weaver {
|
|
|
152
180
|
} else {
|
|
153
181
|
task.succeed(usage);
|
|
154
182
|
}
|
|
183
|
+
logTurnSettled(trace, lifecycle.killed, usage);
|
|
155
184
|
return result;
|
|
156
185
|
} catch (err) {
|
|
157
186
|
task.fail(err);
|
|
187
|
+
logTurnFailure(trace, err, lifecycle.killed);
|
|
158
188
|
if (lifecycle.killed) {
|
|
159
189
|
// The backend didn't manage a clean interrupt-completion (some
|
|
160
190
|
// SDK versions surface an interrupted turn as an error result).
|
|
@@ -168,6 +198,7 @@ export class Weaver {
|
|
|
168
198
|
throw err;
|
|
169
199
|
} finally {
|
|
170
200
|
this.activeCount--;
|
|
201
|
+
trace.tools.close();
|
|
171
202
|
}
|
|
172
203
|
}
|
|
173
204
|
|
|
@@ -176,6 +207,7 @@ export class Weaver {
|
|
|
176
207
|
params: ExecuteParams,
|
|
177
208
|
task: TaskHandle,
|
|
178
209
|
phases: Partial<Record<TurnPhase, number>>,
|
|
210
|
+
trace: TurnTrace,
|
|
179
211
|
): Promise<ExecuteResult> {
|
|
180
212
|
const { context } = this.deps;
|
|
181
213
|
const backend = this.deps.getBackend(params.chatId);
|
|
@@ -189,7 +221,12 @@ export class Weaver {
|
|
|
189
221
|
reqId,
|
|
190
222
|
});
|
|
191
223
|
phases.warpResolve = Date.now() - warpStartedAt;
|
|
224
|
+
logTurnStart(trace, {
|
|
225
|
+
backendId: warp.backendId,
|
|
226
|
+
model: warp.ok ? warp.ref.id : undefined,
|
|
227
|
+
});
|
|
192
228
|
if (!warp.ok) {
|
|
229
|
+
trace.refused = "no-model";
|
|
193
230
|
await deliverRefusal(params, warp.message, "no-model");
|
|
194
231
|
return this.emptyResult(warp.message, params);
|
|
195
232
|
}
|
|
@@ -202,6 +239,7 @@ export class Weaver {
|
|
|
202
239
|
"dispatcher",
|
|
203
240
|
`[${reqId}] guest-scoped turn refused chat=${params.chatId}: backend "${backend.id}" cannot enforce the guest tool scope`,
|
|
204
241
|
);
|
|
242
|
+
trace.refused = "guest-scope";
|
|
205
243
|
await deliverRefusal(params, GUEST_BACKEND_REFUSAL, "guest-scope");
|
|
206
244
|
return this.emptyResult(GUEST_BACKEND_REFUSAL, params);
|
|
207
245
|
}
|
|
@@ -270,7 +308,12 @@ export class Weaver {
|
|
|
270
308
|
// same path `phases.stream` is, so both cover one population.
|
|
271
309
|
const stopCpu = startTurnCpu();
|
|
272
310
|
const streamStartedAt = Date.now();
|
|
273
|
-
const agentResult = await carryTurnEvents(
|
|
311
|
+
const agentResult = await carryTurnEvents(
|
|
312
|
+
stream,
|
|
313
|
+
params.onEvent,
|
|
314
|
+
timing,
|
|
315
|
+
trace.tools,
|
|
316
|
+
);
|
|
274
317
|
phases.stream = Date.now() - streamStartedAt;
|
|
275
318
|
stopCpu();
|
|
276
319
|
phases.delivery = timing.deliveryMs;
|
|
@@ -28,6 +28,12 @@ import { messagingHandlers, restoreScheduledMessages } from "./messaging.js";
|
|
|
28
28
|
import { mediaHandlers } from "./media.js";
|
|
29
29
|
import { chatInfoHandlers } from "./chat-info.js";
|
|
30
30
|
import type { DiscordActionContext, DiscordActionHandlers } from "./types.js";
|
|
31
|
+
import {
|
|
32
|
+
createDeliveryTracker,
|
|
33
|
+
trackDeliveries,
|
|
34
|
+
} from "../../health/delivery.js";
|
|
35
|
+
|
|
36
|
+
const delivery = createDeliveryTracker("discord", "Discord", "discord");
|
|
31
37
|
|
|
32
38
|
// Null-prototype so a request `action` of "toString" / "constructor" / etc.
|
|
33
39
|
// can't resolve an inherited Object.prototype method via `handlers[action]`.
|
|
@@ -44,7 +50,7 @@ export function createDiscordActionHandler(client: Client, gateway: Gateway) {
|
|
|
44
50
|
// shutdown — the timers died with the process, the store didn't.
|
|
45
51
|
restoreScheduledMessages(client, scheduledMessages);
|
|
46
52
|
|
|
47
|
-
|
|
53
|
+
const dispatch = async (
|
|
48
54
|
body: Record<string, unknown>,
|
|
49
55
|
chatId: number,
|
|
50
56
|
): Promise<ActionResult | null> => {
|
|
@@ -74,4 +80,5 @@ export function createDiscordActionHandler(client: Client, gateway: Gateway) {
|
|
|
74
80
|
};
|
|
75
81
|
return handler(body, chatId, ctx);
|
|
76
82
|
};
|
|
83
|
+
return trackDeliveries(delivery, dispatch);
|
|
77
84
|
}
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Client diagnostics — the error/warn/disconnect/rate-limit listeners
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* visible in the daemon log
|
|
2
|
+
* Client diagnostics — the error/warn/disconnect/rate-limit listeners.
|
|
3
|
+
* discord.js handles reconnects and retries itself; these exist so a
|
|
4
|
+
* silent failure mode (a fatal close code, an approaching IP ban) is
|
|
5
|
+
* visible in the daemon log, and so the operator hears when the gateway
|
|
6
|
+
* stays down: `discord.gateway` is raised once a shard has been
|
|
7
|
+
* disconnected for `GATEWAY_OUTAGE_MS` (at once, critically, for a close
|
|
8
|
+
* code discord.js will never recover from) and resolved on ready/resume.
|
|
6
9
|
*/
|
|
7
10
|
|
|
8
11
|
import { type Client, Events } from "discord.js";
|
|
9
|
-
import { logError, logWarn } from "../../util/log.js";
|
|
12
|
+
import { log, logError, logWarn } from "../../util/log.js";
|
|
13
|
+
import { createOutage, errorText, type Outage } from "../health/outage.js";
|
|
14
|
+
|
|
15
|
+
/** A gateway down this long reaches the operator. */
|
|
16
|
+
const GATEWAY_OUTAGE_MS = 5 * 60_000;
|
|
10
17
|
|
|
11
18
|
function shardDisconnectLabel(code: number | undefined): string {
|
|
12
19
|
return code === 4004
|
|
@@ -18,7 +25,76 @@ function shardDisconnectLabel(code: number | undefined): string {
|
|
|
18
25
|
: `code=${code}`;
|
|
19
26
|
}
|
|
20
27
|
|
|
21
|
-
|
|
28
|
+
function createGatewayOutage(thresholdMs: number): Outage {
|
|
29
|
+
return createOutage({
|
|
30
|
+
key: "discord.gateway",
|
|
31
|
+
thresholdMs,
|
|
32
|
+
describe: (err, mins) =>
|
|
33
|
+
`The Discord gateway has been disconnected for ${mins} min: ${err}. Messages are not being received.`,
|
|
34
|
+
recovered: "The Discord gateway is connected again.",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Gateway connection health: which shards are down, and the outage that
|
|
40
|
+
* spans them. Reconnect attempts, errors and recoveries are logged with
|
|
41
|
+
* the shard, attempt number and time down.
|
|
42
|
+
*/
|
|
43
|
+
function bindGatewayHealth(client: Client, thresholdMs: number): void {
|
|
44
|
+
const outage = createGatewayOutage(thresholdMs);
|
|
45
|
+
const down = new Set<number>();
|
|
46
|
+
|
|
47
|
+
const lost = (shardId: number, event: string, err: unknown): void => {
|
|
48
|
+
down.add(shardId);
|
|
49
|
+
const { attempt, downMs } = outage.fail(err);
|
|
50
|
+
logWarn(
|
|
51
|
+
"discord",
|
|
52
|
+
`gateway.${event} shard=${shardId} attempt=${attempt} down_ms=${downMs} err=${errorText(err)}`,
|
|
53
|
+
);
|
|
54
|
+
};
|
|
55
|
+
const back = (shardId: number, event: string): void => {
|
|
56
|
+
down.delete(shardId);
|
|
57
|
+
if (down.size > 0) return;
|
|
58
|
+
const ended = outage.ok();
|
|
59
|
+
if (ended) {
|
|
60
|
+
log(
|
|
61
|
+
"discord",
|
|
62
|
+
`gateway.${event} shard=${shardId} failed_attempts=${ended.attempts} down_ms=${ended.downMs}`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
client.on(Events.ShardReconnecting, (shardId) =>
|
|
68
|
+
lost(shardId, "reconnecting", "connection closed, reconnecting"),
|
|
69
|
+
);
|
|
70
|
+
client.on(Events.ShardError, (err, shardId) => {
|
|
71
|
+
// An error on a live shard is followed by a close if it matters; only
|
|
72
|
+
// an error during an outage is part of it (its text is the useful one).
|
|
73
|
+
if (down.has(shardId)) lost(shardId, "error", err);
|
|
74
|
+
});
|
|
75
|
+
client.on(Events.ShardDisconnect, (event, shardId) => {
|
|
76
|
+
const code = event?.code;
|
|
77
|
+
lost(shardId, "disconnect", shardDisconnectLabel(code));
|
|
78
|
+
if (code === 4004 || code === 4013 || code === 4014) {
|
|
79
|
+
outage.raiseNow(
|
|
80
|
+
`The Discord gateway closed and will not reconnect: ${shardDisconnectLabel(code)}. ` +
|
|
81
|
+
"Messages are not being received until this is fixed and Talon restarts.",
|
|
82
|
+
"critical",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
client.on(Events.Invalidated, () =>
|
|
87
|
+
lost(0, "invalidated", "session invalidated"),
|
|
88
|
+
);
|
|
89
|
+
client.on(Events.ShardReady, (shardId) => back(shardId, "ready"));
|
|
90
|
+
client.on(Events.ShardResume, (shardId) => back(shardId, "resumed"));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function bindClientDiagnostics(
|
|
94
|
+
client: Client,
|
|
95
|
+
gatewayOutageMs = GATEWAY_OUTAGE_MS,
|
|
96
|
+
): void {
|
|
97
|
+
bindGatewayHealth(client, gatewayOutageMs);
|
|
22
98
|
client.on("error", (err) => {
|
|
23
99
|
logError("discord", "Client error", err);
|
|
24
100
|
});
|
|
@@ -11,8 +11,6 @@
|
|
|
11
11
|
* - `delivery` — the agent run + reply pipeline (processAndReply)
|
|
12
12
|
* - `queue` — per-chat debounce queue
|
|
13
13
|
* - `messages` — the messageCreate handler (handleMessage)
|
|
14
|
-
*
|
|
15
|
-
* Re-exports the same public surface the old single-file module exposed.
|
|
16
14
|
*/
|
|
17
15
|
|
|
18
16
|
export { registerDiscordChat, lookupDiscordChat } from "./registry.js";
|
|
@@ -1,17 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Discord middleware — wires up message events to handlers
|
|
2
|
+
* Discord middleware — wires up message events to handlers/.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* Discord-specific behavior:
|
|
10
|
-
* - We don't have a separate "my_chat_member" event; instead, when the bot
|
|
11
|
-
* is removed from a guild we get `guildDelete`. The handler in index.ts
|
|
12
|
-
* handles that to revoke access.
|
|
13
|
-
* - Every guild message gets the chat registered for pulse so periodic
|
|
14
|
-
* check-ins work (DMs are excluded because we always respond).
|
|
4
|
+
* A single messageCreate listener filters out bots/system messages and
|
|
5
|
+
* delegates to handleMessage. Every message is also pushed into the
|
|
6
|
+
* in-memory history buffer so /admin commands and /status reflect real
|
|
7
|
+
* activity, and every guild message registers its chat for pulse (DMs are
|
|
8
|
+
* excluded because we always respond).
|
|
15
9
|
*/
|
|
16
10
|
|
|
17
11
|
import type { Client, Message } from "discord.js";
|
|
@@ -24,7 +18,7 @@ import { handleMessage, getSenderName } from "./handlers/index.js";
|
|
|
24
18
|
|
|
25
19
|
export function registerMiddleware(client: Client, config: TalonConfig): void {
|
|
26
20
|
client.on("messageCreate", (msg: Message) => {
|
|
27
|
-
// Ignore self/bots/system here too —
|
|
21
|
+
// Ignore self/bots/system here too — handleMessage checks again, but
|
|
28
22
|
// we don't even want to record those in history.
|
|
29
23
|
if (msg.author.bot || msg.system) return;
|
|
30
24
|
if (msg.author.id === client.user?.id) return;
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Discord frontend runtime — the state every module of this frontend shares.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* variables with every gateway-event handler nested inside `init`. It is
|
|
6
|
-
* now one explicit object, constructed once, that each module (ready,
|
|
4
|
+
* One explicit object, constructed once, that each module (ready,
|
|
7
5
|
* guild-policy, diagnostics, outbound, connection) takes as its first
|
|
8
6
|
* parameter. The runtime carries state only; the modules own the behaviour.
|
|
9
7
|
*/
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reply delivery health — the worst silent failure is a turn that ran but
|
|
3
|
+
* whose answer never reached the chat. The model sees a failed tool call;
|
|
4
|
+
* the person who asked sees nothing, and so does the operator.
|
|
5
|
+
*
|
|
6
|
+
* Each frontend wraps its action handler with `trackDeliveries`. The
|
|
7
|
+
* reply actions (`send_message`, `send_message_with_buttons`, `reply_to`)
|
|
8
|
+
* are counted per chat: `DELIVERY_ALERT_AFTER` failures in a row for one
|
|
9
|
+
* chat raise `delivery.<frontend>` with that chat and the error, and the
|
|
10
|
+
* next success to it resolves the alert once no chat is still failing.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ActionResult } from "../../core/types.js";
|
|
14
|
+
import {
|
|
15
|
+
raiseAlert,
|
|
16
|
+
resolveAlert,
|
|
17
|
+
} from "../../core/frontend-runtime/alerts.js";
|
|
18
|
+
import { log, logWarn, type LogComponent } from "../../util/log.js";
|
|
19
|
+
import { errorText } from "./outage.js";
|
|
20
|
+
|
|
21
|
+
/** Consecutive failed replies to one chat before the operator hears of it. */
|
|
22
|
+
const DELIVERY_ALERT_AFTER = 3;
|
|
23
|
+
|
|
24
|
+
const REPLY_ACTIONS = new Set([
|
|
25
|
+
"send_message",
|
|
26
|
+
"send_message_with_buttons",
|
|
27
|
+
"reply_to",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
export type DeliveryTracker = {
|
|
31
|
+
failed(chat: string | number, err: unknown): void;
|
|
32
|
+
delivered(chat: string | number): void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `label` is the operator-facing platform name ("Telegram"); the alert key
|
|
37
|
+
* is `delivery.<frontend>`.
|
|
38
|
+
*/
|
|
39
|
+
export function createDeliveryTracker(
|
|
40
|
+
frontend: string,
|
|
41
|
+
label: string,
|
|
42
|
+
component: LogComponent,
|
|
43
|
+
): DeliveryTracker {
|
|
44
|
+
const key = `delivery.${frontend}`;
|
|
45
|
+
const streaks = new Map<string, number>();
|
|
46
|
+
/** Chats that crossed the threshold and have not delivered since. */
|
|
47
|
+
const alerted = new Set<string>();
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
failed(chat, err) {
|
|
51
|
+
const id = String(chat);
|
|
52
|
+
const streak = (streaks.get(id) ?? 0) + 1;
|
|
53
|
+
streaks.set(id, streak);
|
|
54
|
+
const error = errorText(err);
|
|
55
|
+
logWarn(
|
|
56
|
+
component,
|
|
57
|
+
`delivery.fail frontend=${frontend} chat=${id} streak=${streak} err=${error}`,
|
|
58
|
+
);
|
|
59
|
+
if (streak < DELIVERY_ALERT_AFTER) return;
|
|
60
|
+
alerted.add(id);
|
|
61
|
+
raiseAlert(
|
|
62
|
+
key,
|
|
63
|
+
`${label} replies to chat ${id} have failed ${streak} times in a row: ${error}. ` +
|
|
64
|
+
"Answers are not reaching that chat.",
|
|
65
|
+
);
|
|
66
|
+
},
|
|
67
|
+
delivered(chat) {
|
|
68
|
+
const id = String(chat);
|
|
69
|
+
const streak = streaks.get(id);
|
|
70
|
+
if (streak === undefined) return;
|
|
71
|
+
streaks.delete(id);
|
|
72
|
+
log(
|
|
73
|
+
component,
|
|
74
|
+
`delivery.recovered frontend=${frontend} chat=${id} after_failures=${streak}`,
|
|
75
|
+
);
|
|
76
|
+
if (alerted.delete(id) && alerted.size === 0) {
|
|
77
|
+
resolveAlert(key, `${label} replies are being delivered again.`);
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type ActionHandler = (
|
|
84
|
+
body: Record<string, unknown>,
|
|
85
|
+
chatId: number,
|
|
86
|
+
) => Promise<ActionResult | null>;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Wrap a frontend action handler so its reply actions feed `tracker`.
|
|
90
|
+
* Results and thrown errors pass through untouched. `chatOf` names the
|
|
91
|
+
* destination when an action can target a chat other than `chatId`.
|
|
92
|
+
*/
|
|
93
|
+
export function trackDeliveries(
|
|
94
|
+
tracker: DeliveryTracker,
|
|
95
|
+
handler: ActionHandler,
|
|
96
|
+
chatOf: (body: Record<string, unknown>, chatId: number) => string | number = (
|
|
97
|
+
_body,
|
|
98
|
+
chatId,
|
|
99
|
+
) => chatId,
|
|
100
|
+
): ActionHandler {
|
|
101
|
+
return async (body, chatId) => {
|
|
102
|
+
if (!REPLY_ACTIONS.has(body.action as string)) return handler(body, chatId);
|
|
103
|
+
const chat = chatOf(body, chatId);
|
|
104
|
+
let result: ActionResult | null;
|
|
105
|
+
try {
|
|
106
|
+
result = await handler(body, chatId);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
tracker.failed(chat, err);
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
if (result?.ok) tracker.delivered(chat);
|
|
112
|
+
else if (result) tracker.failed(chat, result.error ?? "unknown error");
|
|
113
|
+
return result;
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection outages — the line between "a reconnect failed" (log it) and
|
|
3
|
+
* "this chat channel is down" (tell the operator).
|
|
4
|
+
*
|
|
5
|
+
* A frontend reports every failed poll / reconnect with `fail` and every
|
|
6
|
+
* healthy one with `ok`. The first failure arms a timer; if nothing healthy
|
|
7
|
+
* arrives before `thresholdMs`, the alert is raised with the latest error.
|
|
8
|
+
* The timer — not the next failure — decides, because a link that dies
|
|
9
|
+
* silently (a gateway that never reconnects) produces no further events.
|
|
10
|
+
* `ok` ends the outage and, when an alert went out, sends the recovery.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
raiseAlert,
|
|
15
|
+
resolveAlert,
|
|
16
|
+
type AlertSeverity,
|
|
17
|
+
} from "../../core/frontend-runtime/alerts.js";
|
|
18
|
+
|
|
19
|
+
export type OutageOptions = {
|
|
20
|
+
/** Stable alert key, e.g. "telegram.polling". */
|
|
21
|
+
key: string;
|
|
22
|
+
/** How long failures must persist before the operator hears of it. */
|
|
23
|
+
thresholdMs: number;
|
|
24
|
+
severity?: AlertSeverity;
|
|
25
|
+
/** Operator text for the raise, given the latest error and minutes down. */
|
|
26
|
+
describe: (lastError: string, downMin: number) => string;
|
|
27
|
+
/** Operator text for the recovery notice. */
|
|
28
|
+
recovered: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type Outage = {
|
|
32
|
+
/** Record a failure. Returns the streak so the caller can log it. */
|
|
33
|
+
fail(err: unknown): { attempt: number; downMs: number };
|
|
34
|
+
/** Record a healthy round-trip. Returns the outage it ended, if any. */
|
|
35
|
+
ok(): { attempts: number; downMs: number } | null;
|
|
36
|
+
/** Raise immediately — for failures that will not heal on their own. */
|
|
37
|
+
raiseNow(message: string, severity?: AlertSeverity): void;
|
|
38
|
+
/** True between the first failure and the next `ok`. */
|
|
39
|
+
readonly down: boolean;
|
|
40
|
+
/** Forget the outage without resolving it — shutdown. */
|
|
41
|
+
dispose(): void;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const MAX_ERROR_CHARS = 200;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* One-line error text fit for an alert or a log line: the message only,
|
|
48
|
+
* bounded, with bot tokens and webhook signatures masked.
|
|
49
|
+
*/
|
|
50
|
+
export function errorText(err: unknown): string {
|
|
51
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
52
|
+
const text = raw
|
|
53
|
+
.replace(/bot\d+:[\w-]+/g, "bot<redacted>")
|
|
54
|
+
.replace(/([?&]sig=)[^&\s]+/g, "$1<redacted>")
|
|
55
|
+
.replace(/\s+/g, " ")
|
|
56
|
+
.trim();
|
|
57
|
+
return text.length > MAX_ERROR_CHARS
|
|
58
|
+
? `${text.slice(0, MAX_ERROR_CHARS)}…`
|
|
59
|
+
: text;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createOutage(opts: OutageOptions): Outage {
|
|
63
|
+
let since = 0;
|
|
64
|
+
let attempts = 0;
|
|
65
|
+
let lastError = "";
|
|
66
|
+
let raised = false;
|
|
67
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
68
|
+
|
|
69
|
+
const clearTimer = (): void => {
|
|
70
|
+
if (timer) clearTimeout(timer);
|
|
71
|
+
timer = null;
|
|
72
|
+
};
|
|
73
|
+
const raise = (message: string, severity?: AlertSeverity): void => {
|
|
74
|
+
raised = true;
|
|
75
|
+
raiseAlert(opts.key, message, { severity: severity ?? opts.severity });
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
get down() {
|
|
80
|
+
return attempts > 0;
|
|
81
|
+
},
|
|
82
|
+
fail(err) {
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
if (attempts === 0) {
|
|
85
|
+
since = now;
|
|
86
|
+
timer = setTimeout(() => {
|
|
87
|
+
timer = null;
|
|
88
|
+
const mins = Math.max(1, Math.round((Date.now() - since) / 60_000));
|
|
89
|
+
raise(opts.describe(lastError, mins));
|
|
90
|
+
}, opts.thresholdMs);
|
|
91
|
+
timer.unref?.();
|
|
92
|
+
}
|
|
93
|
+
attempts++;
|
|
94
|
+
lastError = errorText(err);
|
|
95
|
+
return { attempt: attempts, downMs: now - since };
|
|
96
|
+
},
|
|
97
|
+
ok() {
|
|
98
|
+
if (attempts === 0 && !raised) return null;
|
|
99
|
+
const ended = { attempts, downMs: attempts ? Date.now() - since : 0 };
|
|
100
|
+
clearTimer();
|
|
101
|
+
attempts = 0;
|
|
102
|
+
if (raised) resolveAlert(opts.key, opts.recovered);
|
|
103
|
+
raised = false;
|
|
104
|
+
return ended;
|
|
105
|
+
},
|
|
106
|
+
raiseNow(message, severity) {
|
|
107
|
+
clearTimer();
|
|
108
|
+
raise(message, severity);
|
|
109
|
+
},
|
|
110
|
+
dispose() {
|
|
111
|
+
clearTimer();
|
|
112
|
+
attempts = 0;
|
|
113
|
+
raised = false;
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -132,11 +132,9 @@ export function chatRoutes(
|
|
|
132
132
|
const startedAt = Date.now();
|
|
133
133
|
try {
|
|
134
134
|
const attachment = await h.upload(filename, contentType, req);
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
// read. Both outcomes are logged now, with the size and duration
|
|
139
|
-
// that distinguish a rejected upload from a dropped connection.
|
|
135
|
+
// Log both outcomes, with the size and duration that tell a rejected
|
|
136
|
+
// upload from a dropped connection — the JSON answer is otherwise
|
|
137
|
+
// only visible in the app.
|
|
140
138
|
log(
|
|
141
139
|
"native",
|
|
142
140
|
`upload ok: ${attachment.name} (${attachment.size} bytes, ${attachment.mimeType}) in ${Date.now() - startedAt}ms`,
|