talon-agent 5.18.2 → 5.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/package.json +1 -1
- 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/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 +49 -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/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/native/exec.ts +13 -16
- 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/service.ts +26 -15
- 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
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
agentIdFromContextLabel,
|
|
17
17
|
agentRegistry,
|
|
18
18
|
deliverMessage,
|
|
19
|
+
deliverToAgent,
|
|
19
20
|
describeParent,
|
|
20
21
|
} from "../../../agents/index.js";
|
|
21
22
|
import { logError } from "../../../../util/log.js";
|
|
@@ -86,6 +87,67 @@ export const agentReportHandlers: SharedActionHandlers = {
|
|
|
86
87
|
};
|
|
87
88
|
},
|
|
88
89
|
|
|
90
|
+
list_peers: (_body, _chatId, _backend, chatKey) => {
|
|
91
|
+
const record = callingAgent(chatKey);
|
|
92
|
+
if (!record) return notAnAgent("list_peers");
|
|
93
|
+
const peers = agentRegistry.peersOf(record.id);
|
|
94
|
+
if (peers.length === 0) {
|
|
95
|
+
return {
|
|
96
|
+
ok: true,
|
|
97
|
+
text:
|
|
98
|
+
"No peers — you are the only agent your parent has running. " +
|
|
99
|
+
"Report to your parent as usual.",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const rendered = peers
|
|
103
|
+
.map(
|
|
104
|
+
(peer) =>
|
|
105
|
+
`- ${peer.label} [${peer.state}]\n ID: ${peer.id}\n Working on: ${peer.brief.slice(0, 160).replace(/\s+/g, " ")}…`,
|
|
106
|
+
)
|
|
107
|
+
.join("\n");
|
|
108
|
+
return {
|
|
109
|
+
ok: true,
|
|
110
|
+
text: `${peers.length} peer(s) running alongside you:\n\n${rendered}`,
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
message_peer: (body, _chatId, _backend, chatKey) => {
|
|
115
|
+
const record = callingAgent(chatKey);
|
|
116
|
+
if (!record) return notAnAgent("message_peer");
|
|
117
|
+
const id = String(body.agent_id ?? "").trim();
|
|
118
|
+
if (!id) return { ok: false, error: "Missing agent_id" };
|
|
119
|
+
const text = String(body.text ?? "").trim();
|
|
120
|
+
if (!text) return { ok: false, error: "Missing text" };
|
|
121
|
+
// Peers only. An agent may address a sibling, never an arbitrary id:
|
|
122
|
+
// resolving through peersOf is what stops one swarm reaching into
|
|
123
|
+
// another, and it means a wrong id fails closed rather than delivering.
|
|
124
|
+
const peer = agentRegistry
|
|
125
|
+
.peersOf(record.id)
|
|
126
|
+
.find((candidate) => candidate.id === id);
|
|
127
|
+
if (!peer) {
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
error:
|
|
131
|
+
`No peer "${id}". You can only message agents spawned by the same ` +
|
|
132
|
+
`parent as you — call list_peers to see them.`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
if (!deliverToAgent(record.id, id, text)) {
|
|
136
|
+
return {
|
|
137
|
+
ok: false,
|
|
138
|
+
error:
|
|
139
|
+
`Could not deliver to "${peer.label}" (${id}): it has already ` +
|
|
140
|
+
`settled, or its inbox is full.`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
ok: true,
|
|
145
|
+
text:
|
|
146
|
+
`Queued for peer "${peer.label}" (${id}). It reads its inbox at its ` +
|
|
147
|
+
`own milestones, so this is not an interrupt.`,
|
|
148
|
+
};
|
|
149
|
+
},
|
|
150
|
+
|
|
89
151
|
check_inbox: (_body, _chatId, _backend, chatKey) => {
|
|
90
152
|
const record = callingAgent(chatKey);
|
|
91
153
|
if (!record) return notAnAgent("check_inbox");
|
|
@@ -19,10 +19,8 @@ export const historyHandlers: SharedActionHandlers = {
|
|
|
19
19
|
const limit = Math.min(100, Number(body.limit ?? 30));
|
|
20
20
|
const cid = chatKey;
|
|
21
21
|
// The tool schema advertises `offset_id` and `before` for paging back;
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
// silent no-op on frontends without a platform history API (WhatsApp,
|
|
25
|
-
// native).
|
|
22
|
+
// ignoring them here would make "go further back" a silent no-op on
|
|
23
|
+
// frontends without a platform history API (WhatsApp, native).
|
|
26
24
|
const offsetId = Number(body.offset_id);
|
|
27
25
|
if (Number.isFinite(offsetId) && offsetId > 0) {
|
|
28
26
|
return { ok: true, text: getFormattedBefore(cid, offsetId, limit) };
|
|
@@ -61,23 +61,20 @@ async function bash(
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
return bashBackground(cmd, dir);
|
|
64
|
+
if (background === true) {
|
|
65
|
+
if (active) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
text:
|
|
69
|
+
"background:true runs on the daemon host only. On a teleported device, " +
|
|
70
|
+
"background it in-shell instead: `cmd > /tmp/out.log 2>&1 &`, then poll " +
|
|
71
|
+
"the log with read.",
|
|
72
|
+
};
|
|
76
73
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return
|
|
74
|
+
return bashBackground(cmd, dir);
|
|
75
|
+
}
|
|
76
|
+
if (active) return bashTeleported(chatId, active.deviceId, cmd, timeoutMs);
|
|
77
|
+
return bashLocal(cmd, dir, timeoutMs);
|
|
81
78
|
}
|
|
82
79
|
|
|
83
80
|
function bashLocal(
|
|
@@ -14,8 +14,10 @@ import { classify } from "../errors.js";
|
|
|
14
14
|
import { getActiveCount } from "./dispatcher.js";
|
|
15
15
|
import { Loom, getActiveLoom, type ContextRegistry } from "../weaver/index.js";
|
|
16
16
|
import { getHealthStatus } from "../../util/watchdog.js";
|
|
17
|
+
import { activeAlerts } from "../frontend-runtime/alerts.js";
|
|
17
18
|
import { getActiveSessionCount } from "../../storage/sessions.js";
|
|
18
19
|
import { log, logError, logDebug } from "../../util/log.js";
|
|
20
|
+
import { runInChatTurnScope } from "../../util/logging/turn-scope.js";
|
|
19
21
|
import {
|
|
20
22
|
handleSharedAction,
|
|
21
23
|
handleChatFreeAction,
|
|
@@ -36,6 +38,38 @@ import type { FrontendActionHandler } from "../types.js";
|
|
|
36
38
|
import type { Backend } from "../agent-runtime/capabilities.js";
|
|
37
39
|
import { resolveOwnerFrontendId } from "../frontend-runtime/routing.js";
|
|
38
40
|
|
|
41
|
+
/** Serialized size of an action result; -1 when it can't be serialized. */
|
|
42
|
+
function actionResultBytes(result: unknown): number {
|
|
43
|
+
try {
|
|
44
|
+
return Buffer.byteLength(JSON.stringify(result) ?? "");
|
|
45
|
+
} catch {
|
|
46
|
+
return -1;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `tool.action name=… chat=… ms=… ok=… bytes=… [err=…]` for one bridge action. */
|
|
51
|
+
function logActionOutcome(
|
|
52
|
+
body: Record<string, unknown>,
|
|
53
|
+
chat: string,
|
|
54
|
+
ms: number,
|
|
55
|
+
result: unknown,
|
|
56
|
+
): void {
|
|
57
|
+
const action = typeof body.action === "string" ? body.action : "?";
|
|
58
|
+
const r = result as { ok?: unknown; error?: unknown } | null | undefined;
|
|
59
|
+
const failed = !!r && typeof r === "object" && r.ok === false;
|
|
60
|
+
const line =
|
|
61
|
+
`tool.action name=${action} chat=${chat || "-"} ms=${ms} ` +
|
|
62
|
+
`ok=${!failed} bytes=${actionResultBytes(result)}`;
|
|
63
|
+
if (!failed) {
|
|
64
|
+
logDebug("gateway", line);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const err = String(r.error ?? "")
|
|
68
|
+
.replace(/\s+/g, " ")
|
|
69
|
+
.slice(0, 200);
|
|
70
|
+
log("gateway", `${line} err=${err}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
39
73
|
// ── Retry helper (stateless — standalone export) ─────────────────────────────
|
|
40
74
|
|
|
41
75
|
/**
|
|
@@ -290,7 +324,31 @@ export class Gateway {
|
|
|
290
324
|
return null;
|
|
291
325
|
}
|
|
292
326
|
|
|
293
|
-
|
|
327
|
+
/**
|
|
328
|
+
* Run one bridge action inside the log scope of the turn that issued it
|
|
329
|
+
* (the MCP subprocess reaches us over HTTP, a fresh async root), so the
|
|
330
|
+
* action's own lines carry `turn=<id>`, and close it with one
|
|
331
|
+
* `tool.action` summary line — debug on success, info with the error
|
|
332
|
+
* text when the action answered `ok:false`.
|
|
333
|
+
*/
|
|
334
|
+
private handleAction(body: Record<string, unknown>): Promise<unknown> {
|
|
335
|
+
const rawChatId = body._chatId ? String(body._chatId) : "";
|
|
336
|
+
const numericId = Number(rawChatId);
|
|
337
|
+
const chatKeys = [
|
|
338
|
+
rawChatId,
|
|
339
|
+
rawChatId !== "" && !isNaN(numericId)
|
|
340
|
+
? this.loom.stringIdForNumeric(numericId)
|
|
341
|
+
: null,
|
|
342
|
+
];
|
|
343
|
+
return runInChatTurnScope(chatKeys, async () => {
|
|
344
|
+
const t0 = Date.now();
|
|
345
|
+
const result = await this.routeAction(body);
|
|
346
|
+
logActionOutcome(body, rawChatId, Date.now() - t0, result);
|
|
347
|
+
return result;
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private async routeAction(body: Record<string, unknown>): Promise<unknown> {
|
|
294
352
|
// Route by _chatId from the MCP subprocess request.
|
|
295
353
|
// _chatId may be a string (Teams: "teams_chat_19:...") or numeric string
|
|
296
354
|
// (Telegram: "123456"). The context map is keyed by numeric chatId, so
|
|
@@ -492,6 +550,7 @@ export class Gateway {
|
|
|
492
550
|
w.msSinceLastMessage < 60000
|
|
493
551
|
? "just now"
|
|
494
552
|
: `${Math.round(w.msSinceLastMessage / 60000)}m ago`,
|
|
553
|
+
alerts: activeAlerts(),
|
|
495
554
|
};
|
|
496
555
|
}
|
|
497
556
|
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn health — turns the per-turn outcomes the dispatcher sees into
|
|
3
|
+
* operator alerts.
|
|
4
|
+
*
|
|
5
|
+
* Every chat turn on every backend settles through `execute`, so this is
|
|
6
|
+
* the one place that can tell "one bad turn" from "this chat is broken"
|
|
7
|
+
* or "this backend is down":
|
|
8
|
+
*
|
|
9
|
+
* - `backend.auth.<backend>` / `backend.quota.<backend>` — the backend
|
|
10
|
+
* lost its login or ran out of quota. Raised on the first such
|
|
11
|
+
* failure: nothing but a human (a re-login, a plan change, waiting
|
|
12
|
+
* out a window the operator should know about) clears it.
|
|
13
|
+
* - `turn.failing.<chatId>` — the chat's last 3 turns failed.
|
|
14
|
+
* - `backend.failing.<backend>` — the backend failed turns in 3
|
|
15
|
+
* different chats within 10 minutes.
|
|
16
|
+
* - `dispatcher.stuck` — the watchdog saw work arrive and nothing
|
|
17
|
+
* finish or make progress for 10 minutes (see util/watchdog.ts).
|
|
18
|
+
*
|
|
19
|
+
* Auth and quota failures carry their own alert, so they do not also
|
|
20
|
+
* count toward the two failure streaks. Every alert resolves on the next
|
|
21
|
+
* successful turn on the same chat / backend. A stopped turn is neither.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { AgentRunError } from "../agent-runtime/events.js";
|
|
25
|
+
import { TalonError } from "../errors.js";
|
|
26
|
+
import { raiseAlert, resolveAlert } from "../frontend-runtime/alerts.js";
|
|
27
|
+
import { taskTable } from "../tasks/index.js";
|
|
28
|
+
import { log, logWarn } from "../../util/log.js";
|
|
29
|
+
import type { StuckLoopListener } from "../../util/watchdog.js";
|
|
30
|
+
import { faultText } from "./fault-text.js";
|
|
31
|
+
|
|
32
|
+
const CHAT_STREAK_THRESHOLD = 3;
|
|
33
|
+
const BACKEND_CHAT_THRESHOLD = 3;
|
|
34
|
+
const BACKEND_WINDOW_MS = 10 * 60_000;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Login / credential failures across backends: Claude Code's "Not logged
|
|
38
|
+
* in · Please run /login", an expired OAuth token, Codex's refresh-token
|
|
39
|
+
* 401, Antigravity's "not authenticated", a bare 401/403.
|
|
40
|
+
*/
|
|
41
|
+
const AUTH_RE =
|
|
42
|
+
/not logged in|please run \/login|not (?:yet )?authenticated|authentication (?:required|failed|error)|unauthori[sz]ed|invalid (?:api[ _-]?key|x-api-key|bearer|token|credentials)|(?:oauth |access )?token (?:has )?expired|login expired|refresh_token_invalidated|failed to refresh token|permission_error|\b40[13]\b/i;
|
|
43
|
+
|
|
44
|
+
/** Subscription / credit exhaustion — a limit a short retry won't clear. */
|
|
45
|
+
const QUOTA_RE =
|
|
46
|
+
/usage limit|usage exhausted|you['’]ve hit your .{0,40}limit|out of (?:extra usage|credits)|insufficient_quota|quota (?:exceeded|exhausted)|exceeded your current quota|credit balance is too low|no remaining credits/i;
|
|
47
|
+
|
|
48
|
+
type TurnFault = "auth" | "quota" | "error";
|
|
49
|
+
|
|
50
|
+
export type TurnBinding = {
|
|
51
|
+
chatId: string;
|
|
52
|
+
backendId: string;
|
|
53
|
+
model?: string;
|
|
54
|
+
source?: string;
|
|
55
|
+
durationMs: number;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** chatId → consecutive failed turns. */
|
|
59
|
+
const chatStreaks = new Map<string, number>();
|
|
60
|
+
/** backend → chatId → time of that chat's latest failure. */
|
|
61
|
+
const backendFailures = new Map<string, Map<string, number>>();
|
|
62
|
+
|
|
63
|
+
/** What kind of failure `err` is — null when it is not a fault at all. */
|
|
64
|
+
function classifyTurnFault(err: unknown): TurnFault | null {
|
|
65
|
+
if (err instanceof TalonError) {
|
|
66
|
+
if (err.reason === "stopped") return null;
|
|
67
|
+
if (err.reason === "auth") return "auth";
|
|
68
|
+
if (err.reason === "usage_limit") return "quota";
|
|
69
|
+
}
|
|
70
|
+
if (err instanceof AgentRunError) {
|
|
71
|
+
if (err.kind === "aborted") return null;
|
|
72
|
+
if (err.kind === "auth") return "auth";
|
|
73
|
+
}
|
|
74
|
+
if (err instanceof Error && err.name === "AbortError") return null;
|
|
75
|
+
const text =
|
|
76
|
+
err instanceof AgentRunError
|
|
77
|
+
? `${err.message} ${err.raw ?? ""}`
|
|
78
|
+
: err instanceof Error
|
|
79
|
+
? err.message
|
|
80
|
+
: String(err);
|
|
81
|
+
if (QUOTA_RE.test(text)) return "quota";
|
|
82
|
+
if (AUTH_RE.test(text)) return "auth";
|
|
83
|
+
if (err instanceof AgentRunError && err.kind === "rate_limit") {
|
|
84
|
+
// A non-retryable rate-limit event is a usage limit, not a 429.
|
|
85
|
+
return err.retryable ? "error" : "quota";
|
|
86
|
+
}
|
|
87
|
+
return "error";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function describe(b: TurnBinding): string {
|
|
91
|
+
return b.model ? `${b.backendId}/${b.model}` : b.backendId;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Record a turn that completed. Clears every alert it can vouch for. */
|
|
95
|
+
export function noteTurnSucceeded(b: TurnBinding): void {
|
|
96
|
+
const streak = chatStreaks.get(b.chatId);
|
|
97
|
+
if (streak) {
|
|
98
|
+
chatStreaks.delete(b.chatId);
|
|
99
|
+
log(
|
|
100
|
+
"dispatcher",
|
|
101
|
+
`turn.recovered chat=${b.chatId} backend=${b.backendId} after_failures=${streak} ms=${b.durationMs}`,
|
|
102
|
+
);
|
|
103
|
+
resolveAlert(
|
|
104
|
+
`turn.failing.${b.chatId}`,
|
|
105
|
+
`Chat ${b.chatId} is answering again (${describe(b)}).`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
if (backendFailures.delete(b.backendId)) {
|
|
109
|
+
resolveAlert(
|
|
110
|
+
`backend.failing.${b.backendId}`,
|
|
111
|
+
`The ${b.backendId} backend is completing turns again.`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
resolveAlert(
|
|
115
|
+
`backend.auth.${b.backendId}`,
|
|
116
|
+
`The ${b.backendId} backend is signed in again.`,
|
|
117
|
+
);
|
|
118
|
+
resolveAlert(
|
|
119
|
+
`backend.quota.${b.backendId}`,
|
|
120
|
+
`The ${b.backendId} backend has quota again.`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Record a turn that threw. Raises once a threshold is crossed. */
|
|
125
|
+
export function noteTurnFailed(b: TurnBinding, err: unknown): void {
|
|
126
|
+
const fault = classifyTurnFault(err);
|
|
127
|
+
if (fault === null) return;
|
|
128
|
+
const detail = faultText(err);
|
|
129
|
+
const prior = chatStreaks.get(b.chatId) ?? 0;
|
|
130
|
+
const count = fault === "error" ? prior + 1 : prior;
|
|
131
|
+
logWarn(
|
|
132
|
+
"dispatcher",
|
|
133
|
+
`turn.fail chat=${b.chatId} backend=${b.backendId} model=${b.model ?? "?"} ` +
|
|
134
|
+
`source=${b.source ?? "?"} cause=${fault} streak=${count} ` +
|
|
135
|
+
`ms=${b.durationMs} error="${detail}"`,
|
|
136
|
+
);
|
|
137
|
+
if (fault === "auth") {
|
|
138
|
+
raiseAlert(
|
|
139
|
+
`backend.auth.${b.backendId}`,
|
|
140
|
+
`The ${b.backendId} backend is not signed in: ${detail}. Every chat on it fails until it is re-authenticated.`,
|
|
141
|
+
{ severity: "critical" },
|
|
142
|
+
);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (fault === "quota") {
|
|
146
|
+
raiseAlert(
|
|
147
|
+
`backend.quota.${b.backendId}`,
|
|
148
|
+
`The ${b.backendId} backend is out of quota: ${detail}. Chats on it fail until the limit resets.`,
|
|
149
|
+
);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
chatStreaks.set(b.chatId, count);
|
|
153
|
+
if (count >= CHAT_STREAK_THRESHOLD) {
|
|
154
|
+
raiseAlert(
|
|
155
|
+
`turn.failing.${b.chatId}`,
|
|
156
|
+
`Chat ${b.chatId} has failed its last ${count} turns on ${describe(b)}: ${detail}`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
noteBackendFailure(b, detail);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function noteBackendFailure(b: TurnBinding, detail: string): void {
|
|
163
|
+
const now = Date.now();
|
|
164
|
+
let chats = backendFailures.get(b.backendId);
|
|
165
|
+
if (!chats) {
|
|
166
|
+
chats = new Map();
|
|
167
|
+
backendFailures.set(b.backendId, chats);
|
|
168
|
+
}
|
|
169
|
+
chats.set(b.chatId, now);
|
|
170
|
+
for (const [chatId, at] of chats) {
|
|
171
|
+
if (now - at > BACKEND_WINDOW_MS) chats.delete(chatId);
|
|
172
|
+
}
|
|
173
|
+
if (chats.size >= BACKEND_CHAT_THRESHOLD) {
|
|
174
|
+
raiseAlert(
|
|
175
|
+
`backend.failing.${b.backendId}`,
|
|
176
|
+
`The ${b.backendId} backend failed turns in ${chats.size} chats in the last 10 min. Last error: ${detail}`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The turns in flight, for the stuck-loop alert: what is wedged where. */
|
|
182
|
+
function describeTurnQueue(now = Date.now()): string {
|
|
183
|
+
const turns = taskTable
|
|
184
|
+
.list()
|
|
185
|
+
.filter(
|
|
186
|
+
(t) =>
|
|
187
|
+
t.kind === "turn" && (t.state === "running" || t.state === "queued"),
|
|
188
|
+
);
|
|
189
|
+
if (turns.length === 0) return "No turn is running or queued.";
|
|
190
|
+
const running = turns
|
|
191
|
+
.filter((t) => t.state === "running")
|
|
192
|
+
.map((t) => {
|
|
193
|
+
const mins = Math.round((now - (t.startedAt ?? t.queuedAt)) / 60_000);
|
|
194
|
+
const on = t.backendId
|
|
195
|
+
? ` on ${t.backendId}${t.model ? `/${t.model}` : ""}`
|
|
196
|
+
: "";
|
|
197
|
+
return `chat ${t.chatId ?? "?"}${on} (${mins} min)`;
|
|
198
|
+
});
|
|
199
|
+
const queued = turns.length - running.length;
|
|
200
|
+
const parts = [
|
|
201
|
+
running.length ? `Running: ${running.join(", ")}.` : "Nothing is running.",
|
|
202
|
+
];
|
|
203
|
+
if (queued) parts.push(`${queued} turn(s) queued.`);
|
|
204
|
+
return parts.join(" ");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Watchdog hook: the stuck message loop as an operator alert. */
|
|
208
|
+
export const stuckLoopAlert: StuckLoopListener = {
|
|
209
|
+
onStuck: ({ pendingMins, silentMins }) =>
|
|
210
|
+
raiseAlert(
|
|
211
|
+
"dispatcher.stuck",
|
|
212
|
+
`The message loop looks stuck: a message received ${pendingMins} min ago is unprocessed and no turn has made progress for ${silentMins} min. ${describeTurnQueue()}`,
|
|
213
|
+
),
|
|
214
|
+
onRecovered: () =>
|
|
215
|
+
resolveAlert("dispatcher.stuck", "The message loop is processing again."),
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/** Test seam: forget every streak. */
|
|
219
|
+
export function resetTurnHealthForTest(): void {
|
|
220
|
+
chatStreaks.clear();
|
|
221
|
+
backendFailures.clear();
|
|
222
|
+
}
|
package/src/core/errors.ts
CHANGED
|
@@ -247,8 +247,8 @@ export function classify(err: unknown): TalonError {
|
|
|
247
247
|
}
|
|
248
248
|
|
|
249
249
|
// 408 Request Timeout — a transient deadline like any other, but it
|
|
250
|
-
// is neither 4xx-terminal nor 5xx, so
|
|
251
|
-
// `unknown`/non-retryable and
|
|
250
|
+
// is neither 4xx-terminal nor 5xx, so without this branch it falls
|
|
251
|
+
// through to `unknown`/non-retryable and strands the request.
|
|
252
252
|
if (status === 408) {
|
|
253
253
|
return new TalonError(msg, {
|
|
254
254
|
reason: "network",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* First consumer: WhatsApp pairing. When WhatsApp unlinks the device,
|
|
12
12
|
* recovery needs a human to type a pairing code into the phone — a code
|
|
13
|
-
* that
|
|
13
|
+
* that would otherwise only appear in the daemon log, which nobody
|
|
14
14
|
* watches. Alerts about a dead frontend must travel over a LIVE one.
|
|
15
15
|
*/
|
|
16
16
|
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator alerts — "something is wrong" (and "it's fine again") on the
|
|
3
|
+
* admin's chat, deduplicated so a flapping fault can't flood it.
|
|
4
|
+
*
|
|
5
|
+
* Every alert has a stable key naming the fault ("telegram.polling",
|
|
6
|
+
* "backend.auth", "disk.low"). The first raise delivers; repeats inside
|
|
7
|
+
* the cooldown only count, and the next delivery after it says how many
|
|
8
|
+
* were folded in. `resolveAlert` sends one recovery notice for a key that
|
|
9
|
+
* was delivered, and nothing for one that never was. Delivery rides
|
|
10
|
+
* `notifyAdmin`, so alerts degrade to a log line when no frontend is up.
|
|
11
|
+
*
|
|
12
|
+
* Two exceptions to the cooldown: an escalation (warn → error → critical)
|
|
13
|
+
* always delivers, and a delivery that failed (no notifier wired, the
|
|
14
|
+
* admin's frontend down) doesn't count, so the next raise tries again.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { log, logWarn } from "../../util/log.js";
|
|
18
|
+
import { notifyAdmin } from "./admin-notify.js";
|
|
19
|
+
|
|
20
|
+
export type AlertSeverity = "warn" | "error" | "critical";
|
|
21
|
+
|
|
22
|
+
type ActiveAlert = {
|
|
23
|
+
severity: AlertSeverity;
|
|
24
|
+
message: string;
|
|
25
|
+
firstAt: number;
|
|
26
|
+
lastSentAt: number;
|
|
27
|
+
/** Raises folded into the cooldown since the last delivery. */
|
|
28
|
+
suppressed: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const DEFAULT_COOLDOWN_MS = 30 * 60_000;
|
|
32
|
+
const ICON: Record<AlertSeverity, string> = {
|
|
33
|
+
warn: "⚠️",
|
|
34
|
+
error: "🔴",
|
|
35
|
+
critical: "🚨",
|
|
36
|
+
};
|
|
37
|
+
const RANK: Record<AlertSeverity, number> = { warn: 0, error: 1, critical: 2 };
|
|
38
|
+
|
|
39
|
+
const active = new Map<string, ActiveAlert>();
|
|
40
|
+
let cooldownMs = DEFAULT_COOLDOWN_MS;
|
|
41
|
+
let enabled = true;
|
|
42
|
+
let send: (text: string) => Promise<unknown> = notifyAdmin;
|
|
43
|
+
|
|
44
|
+
/** Apply operator settings (config `alerts`). */
|
|
45
|
+
export function configureAlerts(opts: {
|
|
46
|
+
enabled?: boolean;
|
|
47
|
+
cooldownMs?: number;
|
|
48
|
+
}): void {
|
|
49
|
+
if (opts.enabled !== undefined) enabled = opts.enabled;
|
|
50
|
+
if (opts.cooldownMs !== undefined && opts.cooldownMs >= 0)
|
|
51
|
+
cooldownMs = opts.cooldownMs;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Report a fault. Always logged; delivered to the admin unless the same
|
|
56
|
+
* key was delivered within the cooldown. Never throws.
|
|
57
|
+
*/
|
|
58
|
+
export function raiseAlert(
|
|
59
|
+
key: string,
|
|
60
|
+
message: string,
|
|
61
|
+
opts: { severity?: AlertSeverity } = {},
|
|
62
|
+
): void {
|
|
63
|
+
const severity = opts.severity ?? "error";
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
logWarn("alert", `[${severity}] ${key}: ${message}`);
|
|
66
|
+
const prior = active.get(key);
|
|
67
|
+
const escalated =
|
|
68
|
+
prior !== undefined && RANK[severity] > RANK[prior.severity];
|
|
69
|
+
if (prior && !escalated && now - prior.lastSentAt < cooldownMs) {
|
|
70
|
+
prior.suppressed++;
|
|
71
|
+
prior.message = message;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const folded = prior?.suppressed ?? 0;
|
|
75
|
+
const entry: ActiveAlert = {
|
|
76
|
+
severity,
|
|
77
|
+
message,
|
|
78
|
+
firstAt: prior?.firstAt ?? now,
|
|
79
|
+
lastSentAt: now,
|
|
80
|
+
suppressed: 0,
|
|
81
|
+
};
|
|
82
|
+
active.set(key, entry);
|
|
83
|
+
if (!enabled) return;
|
|
84
|
+
const repeat = folded > 0 ? `\n(+${folded} more since the last alert)` : "";
|
|
85
|
+
const undelivered = (): void => {
|
|
86
|
+
// Nobody heard it: don't let the cooldown swallow the next raise.
|
|
87
|
+
if (active.get(key) === entry) entry.lastSentAt = 0;
|
|
88
|
+
};
|
|
89
|
+
void send(`${ICON[severity]} ${message}${repeat}`).then((ok) => {
|
|
90
|
+
if (ok === false) undelivered();
|
|
91
|
+
}, undelivered);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Clear a fault; announces recovery only if its alert was delivered. */
|
|
95
|
+
export function resolveAlert(key: string, message?: string): void {
|
|
96
|
+
const prior = active.get(key);
|
|
97
|
+
if (!prior) return;
|
|
98
|
+
active.delete(key);
|
|
99
|
+
const mins = Math.max(1, Math.round((Date.now() - prior.firstAt) / 60_000));
|
|
100
|
+
log("alert", `resolved ${key} after ${mins} min`);
|
|
101
|
+
if (!enabled) return;
|
|
102
|
+
void send(`✅ ${message ?? `Recovered: ${key}`} (after ${mins} min)`).catch(
|
|
103
|
+
() => {},
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Keys currently raised — for status surfaces and doctor. */
|
|
108
|
+
export function activeAlerts(): ReadonlyArray<{
|
|
109
|
+
key: string;
|
|
110
|
+
severity: AlertSeverity;
|
|
111
|
+
message: string;
|
|
112
|
+
since: number;
|
|
113
|
+
}> {
|
|
114
|
+
return [...active].map(([key, a]) => ({
|
|
115
|
+
key,
|
|
116
|
+
severity: a.severity,
|
|
117
|
+
message: a.message,
|
|
118
|
+
since: a.firstAt,
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Test seam: reset state and swap the delivery function. */
|
|
123
|
+
export function resetAlertsForTest(
|
|
124
|
+
deliver: (text: string) => Promise<unknown> = notifyAdmin,
|
|
125
|
+
): void {
|
|
126
|
+
active.clear();
|
|
127
|
+
cooldownMs = DEFAULT_COOLDOWN_MS;
|
|
128
|
+
enabled = true;
|
|
129
|
+
send = deliver;
|
|
130
|
+
}
|