talon-agent 5.18.1 → 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/LICENSE +202 -21
- package/LICENSE-MIT +21 -0
- package/NOTICE +16 -0
- package/README.md +8 -3
- package/package.json +4 -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/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/outgoing-log.ts +70 -0
- package/src/frontend/telegram/actions/send.ts +12 -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 +147 -41
- 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
|
@@ -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
|
+
}
|
|
@@ -2,11 +2,8 @@
|
|
|
2
2
|
* Hub child manager — the single owner of external MCP server
|
|
3
3
|
* subprocesses (plugins, brave-search).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* plugin MCP server — memory grew linearly with chats. The hub instead
|
|
8
|
-
* keeps one child per key and reaps it after an idle TTL, so resident
|
|
9
|
-
* cost tracks *recently active* keys, not every chat ever seen.
|
|
5
|
+
* One child per key, reaped after an idle TTL, so resident cost tracks
|
|
6
|
+
* *recently active* keys, not every chat ever seen.
|
|
10
7
|
*
|
|
11
8
|
* Keys: chat-scoped plugins get `name + chatId` (they read
|
|
12
9
|
* `TALON_CHAT_ID` at boot, so instances cannot be shared across chats
|
|
@@ -14,8 +11,8 @@
|
|
|
14
11
|
* use a shared key. Either way the spec factory decides — this module
|
|
15
12
|
* only manages lifecycles.
|
|
16
13
|
*
|
|
17
|
-
* Each child is spawned through the
|
|
18
|
-
*
|
|
14
|
+
* Each child is spawned through the supervisor wrap (stdout JSON
|
|
15
|
+
* filtering + orphan cleanup if the daemon is SIGKILLed),
|
|
19
16
|
* connected once over stdio, and shared by every hub session that
|
|
20
17
|
* proxies to it. The tools list is cached per child lifetime — plugin
|
|
21
18
|
* reload restarts children, which naturally invalidates the cache.
|
|
@@ -30,6 +27,8 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
|
30
27
|
import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
31
28
|
import { log, logError, logWarn } from "../../util/log.js";
|
|
32
29
|
import { HubChildTransport, type ChildExit } from "./child-transport.js";
|
|
30
|
+
import { raiseAlert, resolveAlert } from "../frontend-runtime/alerts.js";
|
|
31
|
+
import { faultText } from "../engine/fault-text.js";
|
|
33
32
|
|
|
34
33
|
export type ChildSpec = {
|
|
35
34
|
command: string;
|
|
@@ -40,13 +39,15 @@ export type ChildSpec = {
|
|
|
40
39
|
export type ChildHandle = {
|
|
41
40
|
/** Cached tools/list result — fetched once per child lifetime. */
|
|
42
41
|
listTools(): Promise<Tool[]>;
|
|
43
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Forward one tool call; tracked so retirement can drain in-flight work.
|
|
44
|
+
* Aborting `signal` cancels the call on the child too.
|
|
45
|
+
*/
|
|
44
46
|
callTool(
|
|
45
47
|
name: string,
|
|
46
48
|
args: Record<string, unknown>,
|
|
49
|
+
signal?: AbortSignal,
|
|
47
50
|
): Promise<CallToolResult>;
|
|
48
|
-
/** Mark activity so the idle reaper skips this child. */
|
|
49
|
-
touch(): void;
|
|
50
51
|
};
|
|
51
52
|
|
|
52
53
|
type ChildEntry = {
|
|
@@ -151,6 +152,8 @@ type SpawnFailure = { at: number; count: number; error: unknown };
|
|
|
151
152
|
const spawnFailures = new Map<string, SpawnFailure>();
|
|
152
153
|
const FAILURE_BACKOFF_BASE_MS = 30_000;
|
|
153
154
|
const FAILURE_BACKOFF_MAX_MS = 10 * 60_000;
|
|
155
|
+
/** Consecutive spawn failures of one child before the operator hears. */
|
|
156
|
+
const SPAWN_FAILURE_ALERT_THRESHOLD = 3;
|
|
154
157
|
|
|
155
158
|
function failureBackoffMs(count: number): number {
|
|
156
159
|
return Math.min(
|
|
@@ -159,6 +162,42 @@ function failureBackoffMs(count: number): number {
|
|
|
159
162
|
);
|
|
160
163
|
}
|
|
161
164
|
|
|
165
|
+
/** The MCP server name of a child key (keys are `server\0chat`). */
|
|
166
|
+
function serverOf(key: string): string {
|
|
167
|
+
const nul = key.indexOf("\u0000");
|
|
168
|
+
return nul === -1 ? key : key.slice(0, nul);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Count a failed spawn into the negative cache, log it with the child's
|
|
173
|
+
* last words, and alert once the same child has failed
|
|
174
|
+
* SPAWN_FAILURE_ALERT_THRESHOLD times running — its tools are gone for
|
|
175
|
+
* every turn until whatever backs it comes back.
|
|
176
|
+
*/
|
|
177
|
+
function noteSpawnFailure(key: string, err: unknown): void {
|
|
178
|
+
const count = (spawnFailures.get(key)?.count ?? 0) + 1;
|
|
179
|
+
spawnFailures.set(key, { at: Date.now(), count, error: err });
|
|
180
|
+
const backoffMs = failureBackoffMs(count);
|
|
181
|
+
// The exit record is this attempt's only when it is fresh — an older
|
|
182
|
+
// one belongs to a child that ran and was reaped long ago.
|
|
183
|
+
const exit = lastExits.get(key);
|
|
184
|
+
const stderr =
|
|
185
|
+
exit && Date.now() - exit.at < 60_000 ? exit.stderr.at(-1) : undefined;
|
|
186
|
+
const detail = faultText(err);
|
|
187
|
+
logWarn(
|
|
188
|
+
"gateway",
|
|
189
|
+
`hub child spawn failed ${describeKey(key)} attempt=${count} backoff_ms=${backoffMs} ` +
|
|
190
|
+
`error="${detail}"${stderr ? ` stderr="${faultText(stderr)}"` : ""}`,
|
|
191
|
+
);
|
|
192
|
+
if (count < SPAWN_FAILURE_ALERT_THRESHOLD) return;
|
|
193
|
+
const server = serverOf(key);
|
|
194
|
+
raiseAlert(
|
|
195
|
+
`mcp.child.${server}`,
|
|
196
|
+
`MCP server "${server}" has failed to start ${count} times in a row: ${detail}` +
|
|
197
|
+
`${stderr ? ` (stderr: ${faultText(stderr, 120)})` : ""}. Its tools are unavailable until it starts.`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
162
201
|
/** Idle TTL for hub children; tunable for tests / tight deployments. */
|
|
163
202
|
function idleTtlMs(): number {
|
|
164
203
|
const raw = Number(process.env.TALON_MCP_HUB_IDLE_MS);
|
|
@@ -166,6 +205,14 @@ function idleTtlMs(): number {
|
|
|
166
205
|
}
|
|
167
206
|
|
|
168
207
|
const REAP_INTERVAL_MS = 60_000;
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Backstop for one forwarded tool call. The SDK's default (60s) would cut
|
|
211
|
+
* any longer plugin call, though the upstream client allows far more —
|
|
212
|
+
* its own timeout or cancel reaches the child through the forwarded
|
|
213
|
+
* signal. This only bounds a call nobody is waiting on any more.
|
|
214
|
+
*/
|
|
215
|
+
const CHILD_CALL_TIMEOUT_MS = 65 * 60_000;
|
|
169
216
|
let reaper: ReturnType<typeof setInterval> | null = null;
|
|
170
217
|
|
|
171
218
|
async function spawnChild(key: string, spec: ChildSpec): Promise<ChildHandle> {
|
|
@@ -217,9 +264,6 @@ async function spawnChild(key: string, spec: ChildSpec): Promise<ChildHandle> {
|
|
|
217
264
|
})());
|
|
218
265
|
})(),
|
|
219
266
|
handle: {
|
|
220
|
-
touch: () => {
|
|
221
|
-
entry.lastActivity = Date.now();
|
|
222
|
-
},
|
|
223
267
|
listTools: () =>
|
|
224
268
|
track(async () => {
|
|
225
269
|
if (toolsCache) return toolsCache;
|
|
@@ -227,12 +271,12 @@ async function spawnChild(key: string, spec: ChildSpec): Promise<ChildHandle> {
|
|
|
227
271
|
toolsCache = result.tools;
|
|
228
272
|
return toolsCache;
|
|
229
273
|
}),
|
|
230
|
-
callTool: (name, args) =>
|
|
274
|
+
callTool: (name, args, signal) =>
|
|
231
275
|
track(
|
|
232
276
|
() =>
|
|
233
|
-
client.callTool({
|
|
234
|
-
|
|
235
|
-
|
|
277
|
+
client.callTool({ name, arguments: args }, undefined, {
|
|
278
|
+
signal,
|
|
279
|
+
timeout: CHILD_CALL_TIMEOUT_MS,
|
|
236
280
|
}) as Promise<CallToolResult>,
|
|
237
281
|
),
|
|
238
282
|
},
|
|
@@ -272,23 +316,26 @@ export function acquireChild(
|
|
|
272
316
|
);
|
|
273
317
|
}
|
|
274
318
|
|
|
275
|
-
const
|
|
319
|
+
const spawn = async (): Promise<ChildHandle> => {
|
|
276
320
|
try {
|
|
277
321
|
const handle = await spawnChild(key, spec());
|
|
278
|
-
spawnFailures.delete(key)
|
|
322
|
+
if (spawnFailures.delete(key)) {
|
|
323
|
+
const server = serverOf(key);
|
|
324
|
+
resolveAlert(
|
|
325
|
+
`mcp.child.${server}`,
|
|
326
|
+
`MCP server "${server}" is starting normally again.`,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
279
329
|
return handle;
|
|
280
330
|
} catch (err) {
|
|
281
|
-
|
|
282
|
-
spawnFailures.set(key, {
|
|
283
|
-
at: Date.now(),
|
|
284
|
-
count: (prior?.count ?? 0) + 1,
|
|
285
|
-
error: err,
|
|
286
|
-
});
|
|
331
|
+
noteSpawnFailure(key, err);
|
|
287
332
|
throw err;
|
|
288
|
-
} finally {
|
|
289
|
-
inflight.delete(key);
|
|
290
333
|
}
|
|
291
|
-
}
|
|
334
|
+
};
|
|
335
|
+
// `.finally` always runs after the `set` below. A `finally` block inside
|
|
336
|
+
// `spawn` would not: when `spec()` throws synchronously it runs before
|
|
337
|
+
// the set, leaving the rejected promise in `inflight` for good.
|
|
338
|
+
const promise = spawn().finally(() => inflight.delete(key));
|
|
292
339
|
inflight.set(key, promise);
|
|
293
340
|
return promise;
|
|
294
341
|
}
|
|
@@ -340,7 +387,9 @@ export function retireAllChildren(): void {
|
|
|
340
387
|
function reapIdle(): void {
|
|
341
388
|
const cutoff = Date.now() - idleTtlMs();
|
|
342
389
|
for (const [key, entry] of children) {
|
|
343
|
-
|
|
390
|
+
// A call outliving the TTL is work, not idleness — closing the child
|
|
391
|
+
// would fail it mid-flight. The first sweep after it drains reaps.
|
|
392
|
+
if (entry.lastActivity >= cutoff || entry.pending > 0) continue;
|
|
344
393
|
children.delete(key);
|
|
345
394
|
entry.close().catch((err) => {
|
|
346
395
|
logError("gateway", `hub reap of ${describeKey(key)} failed`, err);
|
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MCP hub — daemon-hosted MCP over streamable HTTP, one endpoint for
|
|
3
|
-
* every backend.
|
|
4
|
-
*
|
|
5
|
-
* Motivation: every backend used to spawn its own stdio copy of every
|
|
6
|
-
* MCP server per chat (claude-sdk/codex per turn; openai-agents and
|
|
7
|
-
* kilo/opencode per chat, held indefinitely) — two processes per server
|
|
8
|
-
* per chat once the supervisor wrap is counted. Memory grew linearly
|
|
9
|
-
* with chats. The hub inverts the ownership: the daemon hosts
|
|
3
|
+
* every backend. The daemon hosts
|
|
10
4
|
*
|
|
11
5
|
* /mcp/talon/<frontend>/<chatId> Talon's own tools, IN-PROCESS
|
|
12
6
|
* (zero subprocesses; chat binding
|
|
@@ -20,13 +14,13 @@
|
|
|
20
14
|
* (claude-sdk `type:"http"`, openai-agents `MCPServerStreamableHttp`,
|
|
21
15
|
* codex `mcp_servers.<name>.url`, kilo/opencode `type:"remote"`).
|
|
22
16
|
*
|
|
23
|
-
*
|
|
17
|
+
* Invariants:
|
|
24
18
|
* - per-chat tool isolation (binding is per-session from the URL)
|
|
25
|
-
* -
|
|
19
|
+
* - chat-scoped plugin children keep TALON_CHAT_ID in their env
|
|
26
20
|
* - tool-surface trimming (disabledTools / disabledToolTags)
|
|
27
|
-
* - plugin
|
|
28
|
-
*
|
|
29
|
-
* -
|
|
21
|
+
* - plugin reload retires children; the next request respawns from
|
|
22
|
+
* the current registry (see reloadHubChildren)
|
|
23
|
+
* - children run under the supervisor wrap (orphan cleanup)
|
|
30
24
|
*
|
|
31
25
|
* Endpoints live on the gateway HTTP server (127.0.0.1-bound, behind the
|
|
32
26
|
* same token and Host/Origin guard as /action — see engine/gateway-auth.ts;
|
|
@@ -112,8 +106,7 @@ export function pluginHubUrl(
|
|
|
112
106
|
* Names of every hub-served plugin server — what backends enumerate to
|
|
113
107
|
* build their per-chat URL maps. Registry-backed, so it reflects plugin
|
|
114
108
|
* reloads immediately. (brave-search is served by the hub too, but each
|
|
115
|
-
* backend adds it explicitly alongside its frontend tools
|
|
116
|
-
* pre-hub structure.)
|
|
109
|
+
* backend adds it explicitly alongside its frontend tools.)
|
|
117
110
|
*/
|
|
118
111
|
export function hubPluginServerNames(only?: string[]): string[] {
|
|
119
112
|
// Specs are built with placeholder identity — only the names matter.
|
|
@@ -134,7 +127,6 @@ export async function listHubPluginToolNames(
|
|
|
134
127
|
const child = await acquireChild(childKey(serverName, chatId), () =>
|
|
135
128
|
pluginSpec(serverName, chatId, bridgeUrl),
|
|
136
129
|
);
|
|
137
|
-
child.touch();
|
|
138
130
|
return (await child.listTools()).map((tool) => tool.name);
|
|
139
131
|
}
|
|
140
132
|
|
|
@@ -253,6 +245,8 @@ function guestPluginDenied(target: { serverName: string; chatId: string }) {
|
|
|
253
245
|
type SessionEntry = {
|
|
254
246
|
transport: StreamableHTTPServerTransport;
|
|
255
247
|
lastSeen: number;
|
|
248
|
+
/** Requests still open on this session, SSE streams included. */
|
|
249
|
+
open: number;
|
|
256
250
|
};
|
|
257
251
|
|
|
258
252
|
const sessions = new Map<string, SessionEntry>();
|
|
@@ -260,7 +254,11 @@ const sessions = new Map<string, SessionEntry>();
|
|
|
260
254
|
/**
|
|
261
255
|
* Sessions whose client vanished without a DELETE (crashed subprocess,
|
|
262
256
|
* kill -9) are closed after this idle window. Every well-behaved client
|
|
263
|
-
* terminates explicitly, so this only catches stragglers.
|
|
257
|
+
* terminates explicitly, so this only catches stragglers. A session with
|
|
258
|
+
* a request still open is never idle: a client that holds its event
|
|
259
|
+
* stream between turns (openai-agents keeps one per chat for good) is
|
|
260
|
+
* alive however quiet the chat, and it does not re-initialize on a 404.
|
|
261
|
+
* The idle clock restarts when its last open request ends.
|
|
264
262
|
*/
|
|
265
263
|
const SESSION_IDLE_MS = 30 * 60_000;
|
|
266
264
|
const SESSION_REAP_INTERVAL_MS = 5 * 60_000;
|
|
@@ -271,7 +269,7 @@ function startSessionReaper(): void {
|
|
|
271
269
|
sessionReaper = setInterval(() => {
|
|
272
270
|
const cutoff = Date.now() - SESSION_IDLE_MS;
|
|
273
271
|
for (const [id, entry] of sessions) {
|
|
274
|
-
if (entry.lastSeen >= cutoff) continue;
|
|
272
|
+
if (entry.open > 0 || entry.lastSeen >= cutoff) continue;
|
|
275
273
|
sessions.delete(id);
|
|
276
274
|
entry.transport.close().catch(() => {});
|
|
277
275
|
log("gateway", `hub session reaped (idle): ${id.slice(0, 8)}…`);
|
|
@@ -330,6 +328,11 @@ export async function handleHubRequest(
|
|
|
330
328
|
return;
|
|
331
329
|
}
|
|
332
330
|
entry.lastSeen = Date.now();
|
|
331
|
+
entry.open++;
|
|
332
|
+
res.once("close", () => {
|
|
333
|
+
entry.open--;
|
|
334
|
+
entry.lastSeen = Date.now();
|
|
335
|
+
});
|
|
333
336
|
await entry.transport.handleRequest(req, res);
|
|
334
337
|
return;
|
|
335
338
|
}
|
|
@@ -369,7 +372,7 @@ export async function handleHubRequest(
|
|
|
369
372
|
enableDnsRebindingProtection: true,
|
|
370
373
|
allowedHosts: loopbackHosts(bridgeUrl),
|
|
371
374
|
onsessioninitialized: (id) => {
|
|
372
|
-
sessions.set(id, { transport, lastSeen: Date.now() });
|
|
375
|
+
sessions.set(id, { transport, lastSeen: Date.now(), open: 0 });
|
|
373
376
|
},
|
|
374
377
|
});
|
|
375
378
|
transport.onclose = () => {
|