talon-agent 3.33.4 → 3.34.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/package.json +5 -2
- package/src/app.ts +16 -8
- package/src/backend/claude-sdk/handler.ts +428 -415
- package/src/backend/claude-sdk/stream.ts +76 -53
- package/src/backend/codex/handler/message.ts +275 -366
- package/src/backend/codex/handler/rollout-accounting.ts +137 -0
- package/src/backend/openai-agents/handler/message.ts +282 -356
- package/src/backend/remote-server/chat-turn.ts +66 -122
- package/src/backend/remote-server/index.ts +4 -0
- package/src/backend/remote-server/mcp.ts +73 -9
- package/src/backend/remote-server/model-catalog/presentation.ts +269 -228
- package/src/backend/remote-server/sessions.ts +2 -2
- package/src/backend/remote-server/turn.ts +58 -55
- package/src/backend/shared/cache-telemetry.ts +17 -1
- package/src/backend/shared/handler-to-events.ts +11 -16
- package/src/backend/shared/index.ts +14 -15
- package/src/backend/shared/result-events.ts +30 -0
- package/src/backend/shared/turn-phases.ts +277 -0
- package/src/bootstrap.ts +120 -79
- package/src/cli/setup.ts +375 -349
- package/src/core/background/cron-spec.ts +273 -0
- package/src/core/background/heartbeat/agent.ts +167 -104
- package/src/core/background/triggers/exit.ts +19 -2
- package/src/core/background/triggers/resume.ts +14 -7
- package/src/core/background/triggers/state.ts +9 -0
- package/src/core/engine/backend-controller/pool.ts +2 -0
- package/src/core/engine/backend-controller/state.ts +25 -12
- package/src/core/engine/gateway-actions/cron.ts +28 -292
- package/src/core/engine/gateway-routes.ts +239 -0
- package/src/core/engine/gateway.ts +66 -238
- package/src/core/mcp-hub/child-transport.ts +215 -0
- package/src/core/mcp-hub/children.ts +72 -13
- package/src/core/mcp-hub/index.ts +32 -13
- package/src/core/models/active-model.ts +29 -6
- package/src/core/vfs/mounts/files.ts +128 -115
- package/src/core/weaver/shuttle.ts +33 -2
- package/src/core/weaver/weaver.ts +37 -6
- package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
- package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
- package/src/frontend/discord/callbacks/components/effort.ts +72 -0
- package/src/frontend/discord/callbacks/components/index.ts +120 -0
- package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
- package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
- package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
- package/src/frontend/discord/callbacks/components/model.ts +33 -0
- package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
- package/src/frontend/discord/callbacks/components/settings.ts +243 -0
- package/src/frontend/discord/callbacks/components/types.ts +34 -0
- package/src/frontend/discord/callbacks/index.ts +4 -4
- package/src/frontend/discord/connection.ts +36 -0
- package/src/frontend/discord/diagnostics.ts +62 -0
- package/src/frontend/discord/guild-policy.ts +89 -0
- package/src/frontend/discord/index.ts +44 -305
- package/src/frontend/discord/outbound.ts +61 -0
- package/src/frontend/discord/ready.ts +73 -0
- package/src/frontend/discord/runtime.ts +55 -0
- package/src/frontend/native/chat-lifecycle.ts +39 -0
- package/src/frontend/native/chat-wire.ts +69 -0
- package/src/frontend/native/context.ts +106 -0
- package/src/frontend/native/control.ts +78 -0
- package/src/frontend/native/emit.ts +167 -0
- package/src/frontend/native/empty-chat-sweep.ts +50 -0
- package/src/frontend/native/handlers.ts +121 -0
- package/src/frontend/native/history.ts +101 -0
- package/src/frontend/native/index.ts +90 -1293
- package/src/frontend/native/media.ts +43 -0
- package/src/frontend/native/models.ts +221 -0
- package/src/frontend/native/queue.ts +47 -0
- package/src/frontend/native/reset.ts +50 -0
- package/src/frontend/native/routes/chats.ts +115 -0
- package/src/frontend/native/routes/daemon.ts +70 -0
- package/src/frontend/native/routes/host.ts +151 -0
- package/src/frontend/native/routes/index.ts +22 -0
- package/src/frontend/native/routes/mesh.ts +72 -0
- package/src/frontend/native/routes/models.ts +54 -0
- package/src/frontend/native/routes/params.ts +29 -0
- package/src/frontend/native/routes/pre-auth.ts +94 -0
- package/src/frontend/native/routes/table.ts +92 -0
- package/src/frontend/native/runtime.ts +109 -0
- package/src/frontend/native/server.ts +30 -555
- package/src/frontend/native/status.ts +26 -0
- package/src/frontend/native/tool-result.ts +48 -0
- package/src/frontend/native/turn.ts +341 -0
- package/src/frontend/telegram/admin.ts +75 -52
- package/src/frontend/whatsapp/access.ts +67 -0
- package/src/frontend/whatsapp/connection.ts +280 -0
- package/src/frontend/whatsapp/inbound.ts +327 -0
- package/src/frontend/whatsapp/index.ts +28 -599
- package/src/frontend/whatsapp/runtime.ts +74 -0
- package/src/storage/metrics.ts +18 -0
- package/src/storage/session-record.ts +29 -0
- package/src/storage/sessions.ts +24 -0
- package/src/storage/trigger-store.ts +8 -0
- package/src/util/boot-timer.ts +31 -0
- package/src/util/concurrency.ts +28 -0
- package/src/util/watchdog.ts +32 -7
- package/src/frontend/discord/callbacks/components.ts +0 -793
- package/src/frontend/discord/callbacks/shared.ts +0 -22
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WhatsApp frontend runtime — the state every module shares.
|
|
3
|
+
*
|
|
4
|
+
* `createWhatsAppFrontend` used to hold all of this as closure variables
|
|
5
|
+
* with every handler nested inside it. It is now one explicit object,
|
|
6
|
+
* constructed once, that each module (access, inbound, connection) takes
|
|
7
|
+
* as its first parameter. The runtime carries the parsed settings, the
|
|
8
|
+
* allow-lists and the live socket state; the modules own the behaviour.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { WASocket } from "baileys";
|
|
12
|
+
import type { TalonConfig } from "../../util/config.js";
|
|
13
|
+
import type { Gateway } from "../../core/engine/gateway.js";
|
|
14
|
+
import { bareId } from "./identity.js";
|
|
15
|
+
|
|
16
|
+
type WhatsAppSettings = {
|
|
17
|
+
allowedJids: string[];
|
|
18
|
+
allowedGroups: string[];
|
|
19
|
+
groupPolicy: "listed" | "with-allowed-user" | "all";
|
|
20
|
+
respondMode: "mention" | "all";
|
|
21
|
+
pairingNumber?: string;
|
|
22
|
+
sendReadReceipts: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Reconnect backoff: WhatsApp throttles a client that hammers it. */
|
|
26
|
+
export const RECONNECT_BASE_MS = 2_000;
|
|
27
|
+
export const RECONNECT_MAX_MS = 60_000;
|
|
28
|
+
|
|
29
|
+
export type WhatsAppRuntime = {
|
|
30
|
+
readonly config: TalonConfig;
|
|
31
|
+
readonly gateway: Gateway;
|
|
32
|
+
readonly settings: WhatsAppSettings;
|
|
33
|
+
/** `allowedJids` / `allowedGroups`, normalised to bare ids. */
|
|
34
|
+
readonly allowedDms: ReadonlySet<string>;
|
|
35
|
+
readonly allowedGroups: ReadonlySet<string>;
|
|
36
|
+
/** Cached `groupPolicy` verdicts, keyed by group JID. */
|
|
37
|
+
readonly groupAllowCache: Map<string, { allowed: boolean; at: number }>;
|
|
38
|
+
/** The live socket. Reconnects replace it, so read it at each use. */
|
|
39
|
+
sock: WASocket | null;
|
|
40
|
+
stopping: boolean;
|
|
41
|
+
reconnectDelay: number;
|
|
42
|
+
/** One "not linked" admin note per outage, not one per QR window. */
|
|
43
|
+
unpairedNotified: boolean;
|
|
44
|
+
/** Our own ids (phone and LID), once connected — for mention detection. */
|
|
45
|
+
selfIds: string[];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export function createWhatsAppRuntime(
|
|
49
|
+
config: TalonConfig,
|
|
50
|
+
gateway: Gateway,
|
|
51
|
+
): WhatsAppRuntime {
|
|
52
|
+
const settings: WhatsAppSettings = {
|
|
53
|
+
allowedJids: [],
|
|
54
|
+
allowedGroups: [],
|
|
55
|
+
groupPolicy: "listed",
|
|
56
|
+
respondMode: "mention",
|
|
57
|
+
sendReadReceipts: true,
|
|
58
|
+
...((config as Record<string, unknown>).whatsapp as
|
|
59
|
+
Partial<WhatsAppSettings> | undefined),
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
config,
|
|
63
|
+
gateway,
|
|
64
|
+
settings,
|
|
65
|
+
allowedDms: new Set(settings.allowedJids.map(bareId)),
|
|
66
|
+
allowedGroups: new Set(settings.allowedGroups.map(bareId)),
|
|
67
|
+
groupAllowCache: new Map(),
|
|
68
|
+
sock: null,
|
|
69
|
+
stopping: false,
|
|
70
|
+
reconnectDelay: RECONNECT_BASE_MS,
|
|
71
|
+
unpairedNotified: false,
|
|
72
|
+
selfIds: [],
|
|
73
|
+
};
|
|
74
|
+
}
|
package/src/storage/metrics.ts
CHANGED
|
@@ -9,6 +9,16 @@ import {
|
|
|
9
9
|
type MetricsGrain,
|
|
10
10
|
type MetricsLatencyAgg,
|
|
11
11
|
} from "./sessions.js";
|
|
12
|
+
import { emptyPhases, TURN_PHASES, type TurnPhase } from "./session-record.js";
|
|
13
|
+
|
|
14
|
+
/** Snapshot key for each turn phase — same `_ms` suffix the panels key on. */
|
|
15
|
+
const PHASE_HISTOGRAM: Record<TurnPhase, string> = {
|
|
16
|
+
queueWait: "turn.queue_wait_ms",
|
|
17
|
+
warpResolve: "turn.warp_resolve_ms",
|
|
18
|
+
firstToken: "turn.first_token_ms",
|
|
19
|
+
stream: "turn.stream_ms",
|
|
20
|
+
delivery: "turn.delivery_ms",
|
|
21
|
+
};
|
|
12
22
|
|
|
13
23
|
const legacyCounters = new Map<string, number>();
|
|
14
24
|
|
|
@@ -64,8 +74,12 @@ function buildSnapshot(
|
|
|
64
74
|
const apiCallsPerTurn = emptyAgg();
|
|
65
75
|
const cacheHitPercent = emptyAgg();
|
|
66
76
|
const backendLatency = new Map<string, MetricsLatencyAgg>();
|
|
77
|
+
const phases = emptyPhases();
|
|
67
78
|
|
|
68
79
|
for (const grain of grains) {
|
|
80
|
+
for (const phase of TURN_PHASES) {
|
|
81
|
+
mergeAgg(phases[phase], grain.phases[phase]);
|
|
82
|
+
}
|
|
69
83
|
const c = grain.counters;
|
|
70
84
|
addCounter(counters, "queries_total", c.queries);
|
|
71
85
|
addCounter(counters, "turns_with_tools_total", c.turnsWithTools);
|
|
@@ -131,6 +145,10 @@ function buildSnapshot(
|
|
|
131
145
|
if (agg.count)
|
|
132
146
|
histograms[`backend.${backend}.response_latency_ms`] = snapshotAgg(agg);
|
|
133
147
|
}
|
|
148
|
+
for (const phase of TURN_PHASES) {
|
|
149
|
+
if (phases[phase].count)
|
|
150
|
+
histograms[PHASE_HISTOGRAM[phase]] = snapshotAgg(phases[phase]);
|
|
151
|
+
}
|
|
134
152
|
|
|
135
153
|
return { counters, histograms };
|
|
136
154
|
}
|
|
@@ -54,6 +54,21 @@ export type MetricsLatencyAgg = {
|
|
|
54
54
|
maxMs: number;
|
|
55
55
|
};
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Where a turn's wall-clock went, as seen from the Weaver: waiting in the
|
|
59
|
+
* chat's FIFO, resolving the warp, until the backend's first event, the
|
|
60
|
+
* whole stream, and the slice of it spent inside frontend delivery.
|
|
61
|
+
*/
|
|
62
|
+
export const TURN_PHASES = [
|
|
63
|
+
"queueWait",
|
|
64
|
+
"warpResolve",
|
|
65
|
+
"firstToken",
|
|
66
|
+
"stream",
|
|
67
|
+
"delivery",
|
|
68
|
+
] as const;
|
|
69
|
+
export type TurnPhase = (typeof TURN_PHASES)[number];
|
|
70
|
+
export type MetricsPhaseAggs = Record<TurnPhase, MetricsLatencyAgg>;
|
|
71
|
+
|
|
57
72
|
export type MetricsGrain = {
|
|
58
73
|
counters: MetricsCounterSet;
|
|
59
74
|
latency: MetricsLatencyAgg;
|
|
@@ -62,6 +77,7 @@ export type MetricsGrain = {
|
|
|
62
77
|
cacheHitPercent: MetricsLatencyAgg;
|
|
63
78
|
toolCallsPerTurn: MetricsLatencyAgg;
|
|
64
79
|
apiCallsPerTurn: MetricsLatencyAgg;
|
|
80
|
+
phases: MetricsPhaseAggs;
|
|
65
81
|
};
|
|
66
82
|
|
|
67
83
|
export type SessionMetrics = {
|
|
@@ -127,6 +143,14 @@ export const emptyLatency = (): MetricsLatencyAgg => ({
|
|
|
127
143
|
maxMs: 0,
|
|
128
144
|
});
|
|
129
145
|
|
|
146
|
+
export const emptyPhases = (): MetricsPhaseAggs => ({
|
|
147
|
+
queueWait: emptyLatency(),
|
|
148
|
+
warpResolve: emptyLatency(),
|
|
149
|
+
firstToken: emptyLatency(),
|
|
150
|
+
stream: emptyLatency(),
|
|
151
|
+
delivery: emptyLatency(),
|
|
152
|
+
});
|
|
153
|
+
|
|
130
154
|
export const emptyGrain = (): MetricsGrain => ({
|
|
131
155
|
counters: emptyCounters(),
|
|
132
156
|
latency: emptyLatency(),
|
|
@@ -135,6 +159,7 @@ export const emptyGrain = (): MetricsGrain => ({
|
|
|
135
159
|
cacheHitPercent: emptyLatency(),
|
|
136
160
|
toolCallsPerTurn: emptyLatency(),
|
|
137
161
|
apiCallsPerTurn: emptyLatency(),
|
|
162
|
+
phases: emptyPhases(),
|
|
138
163
|
});
|
|
139
164
|
|
|
140
165
|
export const emptyMetrics = (): SessionMetrics => ({
|
|
@@ -179,6 +204,10 @@ function normaliseGrain(raw: unknown): MetricsGrain {
|
|
|
179
204
|
grain.cacheHitPercent = normaliseLatency(r?.cacheHitPercent);
|
|
180
205
|
grain.toolCallsPerTurn = normaliseLatency(r?.toolCallsPerTurn);
|
|
181
206
|
grain.apiCallsPerTurn = normaliseLatency(r?.apiCallsPerTurn);
|
|
207
|
+
const phases = r?.phases as Partial<Record<TurnPhase, unknown>> | undefined;
|
|
208
|
+
for (const phase of TURN_PHASES) {
|
|
209
|
+
grain.phases[phase] = normaliseLatency(phases?.[phase]);
|
|
210
|
+
}
|
|
182
211
|
if (r?.toolCallsByName && typeof r.toolCallsByName === "object") {
|
|
183
212
|
for (const [key, value] of Object.entries(r.toolCallsByName)) {
|
|
184
213
|
if (typeof value === "number" && Number.isFinite(value)) {
|
package/src/storage/sessions.ts
CHANGED
|
@@ -42,6 +42,8 @@ import {
|
|
|
42
42
|
type SessionMetrics,
|
|
43
43
|
type SessionState,
|
|
44
44
|
type SessionUsage,
|
|
45
|
+
TURN_PHASES,
|
|
46
|
+
type TurnPhase,
|
|
45
47
|
} from "./session-record.js";
|
|
46
48
|
// In-memory cache over the sessions table. Reads serve live references
|
|
47
49
|
// from here; writes go through persist() so each mutation commits.
|
|
@@ -365,6 +367,28 @@ export function recordSessionMetrics(
|
|
|
365
367
|
persist(chatId, session);
|
|
366
368
|
}
|
|
367
369
|
|
|
370
|
+
/**
|
|
371
|
+
* Fold one turn's phase timings (ms) into the session's lifetime grain and
|
|
372
|
+
* today's bucket. Phases the caller could not measure are simply absent.
|
|
373
|
+
*/
|
|
374
|
+
export function recordSessionTurnPhases(
|
|
375
|
+
chatId: string,
|
|
376
|
+
phases: Partial<Record<TurnPhase, number>>,
|
|
377
|
+
day = todayUtc(),
|
|
378
|
+
): void {
|
|
379
|
+
const session = getSession(chatId);
|
|
380
|
+
for (const grain of [
|
|
381
|
+
session.metrics.lifetime,
|
|
382
|
+
metricBucket(session.metrics, day),
|
|
383
|
+
]) {
|
|
384
|
+
for (const phase of TURN_PHASES) {
|
|
385
|
+
const value = phases[phase];
|
|
386
|
+
if (value !== undefined) addLatency(grain.phases[phase], value);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
persist(chatId, session);
|
|
390
|
+
}
|
|
391
|
+
|
|
368
392
|
export function recordSessionMetricEvent(
|
|
369
393
|
chatId: string,
|
|
370
394
|
event: Partial<MetricsCounterSet> & {
|
|
@@ -73,6 +73,14 @@ function isTrigger(value: unknown): value is Trigger {
|
|
|
73
73
|
*/
|
|
74
74
|
export const RESTART_KILL_ERROR = "Talon restarted while trigger was running";
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* The lastError shutdownTriggers stamps on non-persistent triggers it kills
|
|
78
|
+
* during a clean shutdown. Their terminal wake is deliberately not dispatched
|
|
79
|
+
* while the daemon is tearing down — resume.ts keys the next boot's late-wake
|
|
80
|
+
* decision on this marker, exactly as it does for RESTART_KILL_ERROR.
|
|
81
|
+
*/
|
|
82
|
+
export const SHUTDOWN_KILL_ERROR = "Killed by Talon shutdown";
|
|
83
|
+
|
|
76
84
|
export function loadTriggers(): void {
|
|
77
85
|
try {
|
|
78
86
|
importLegacyJson({
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boot phase timer — one entry per awaited startup phase, reported once
|
|
3
|
+
* the daemon is serving, so "why did it take nine seconds to come up"
|
|
4
|
+
* has an answer in the log instead of a profiler session.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const phases: { label: string; ms: number }[] = [];
|
|
8
|
+
|
|
9
|
+
/** Run one startup phase and remember how long it took. */
|
|
10
|
+
export async function bootPhase<T>(
|
|
11
|
+
label: string,
|
|
12
|
+
fn: () => Promise<T> | T,
|
|
13
|
+
): Promise<T> {
|
|
14
|
+
const startedAt = Date.now();
|
|
15
|
+
try {
|
|
16
|
+
return await fn();
|
|
17
|
+
} finally {
|
|
18
|
+
phases.push({ label, ms: Date.now() - startedAt });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `4210ms (plugins 120ms, stores 15ms, frontends 300ms, …)` — total is process uptime. */
|
|
23
|
+
export function bootReport(now = Math.round(process.uptime() * 1000)): string {
|
|
24
|
+
const parts = phases.map((phase) => `${phase.label} ${phase.ms}ms`);
|
|
25
|
+
return parts.length ? `${now}ms (${parts.join(", ")})` : `${now}ms`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Test seam. */
|
|
29
|
+
export function resetBootPhases(): void {
|
|
30
|
+
phases.length = 0;
|
|
31
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded-concurrency map: run `fn` over `items` with at most `limit`
|
|
3
|
+
* in flight, preserving result order. Rejections propagate after the
|
|
4
|
+
* batch drains so a single failure does not orphan in-flight work.
|
|
5
|
+
*/
|
|
6
|
+
export async function mapConcurrent<T, R>(
|
|
7
|
+
items: readonly T[],
|
|
8
|
+
limit: number,
|
|
9
|
+
fn: (item: T, index: number) => Promise<R>,
|
|
10
|
+
): Promise<R[]> {
|
|
11
|
+
const results: R[] = new Array(items.length);
|
|
12
|
+
const errors: unknown[] = [];
|
|
13
|
+
let next = 0;
|
|
14
|
+
const worker = async (): Promise<void> => {
|
|
15
|
+
while (next < items.length) {
|
|
16
|
+
const index = next++;
|
|
17
|
+
try {
|
|
18
|
+
results[index] = await fn(items[index] as T, index);
|
|
19
|
+
} catch (err) {
|
|
20
|
+
errors.push(err);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
const width = Math.max(1, Math.min(limit, items.length));
|
|
25
|
+
await Promise.all(Array.from({ length: width }, worker));
|
|
26
|
+
if (errors.length > 0) throw errors[0];
|
|
27
|
+
return results;
|
|
28
|
+
}
|
package/src/util/watchdog.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { logWarn } from "./log.js";
|
|
|
10
10
|
|
|
11
11
|
let lastProcessedAt = Date.now();
|
|
12
12
|
let lastReceivedAt = 0;
|
|
13
|
+
let lastActivityAt = 0;
|
|
13
14
|
let totalMessagesProcessed = 0;
|
|
14
15
|
const startTime = Date.now();
|
|
15
16
|
|
|
@@ -30,6 +31,20 @@ export function recordMessageProcessed(): void {
|
|
|
30
31
|
resetStuckWarnBackoff();
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Record that a running turn showed signs of life: a backend event (tool
|
|
36
|
+
* call, text delta, result) flowed through the shuttle. A long agentic
|
|
37
|
+
* turn is not a wedged loop — an hour-long grind that keeps calling tools
|
|
38
|
+
* used to trip the stuck warning at the 10-minute mark and re-warn on
|
|
39
|
+
* backoff until it finished, because `lastProcessedAt` only advances at
|
|
40
|
+
* turn end. Stuck detection measures silence since the last event, not
|
|
41
|
+
* turn length.
|
|
42
|
+
*/
|
|
43
|
+
export function recordTurnActivity(): void {
|
|
44
|
+
lastActivityAt = Date.now();
|
|
45
|
+
resetStuckWarnBackoff();
|
|
46
|
+
}
|
|
47
|
+
|
|
33
48
|
/**
|
|
34
49
|
* Record that a message's turn settled in failure. The loop is moving —
|
|
35
50
|
* the error was reported and the queue slot freed — so advance the stuck
|
|
@@ -51,15 +66,22 @@ export function recordMessageSettled(): void {
|
|
|
51
66
|
export function resetWatchdogActivityForTests(): void {
|
|
52
67
|
lastProcessedAt = Date.now();
|
|
53
68
|
lastReceivedAt = 0;
|
|
69
|
+
lastActivityAt = 0;
|
|
54
70
|
resetStuckWarnBackoff();
|
|
55
71
|
}
|
|
56
72
|
|
|
57
73
|
/**
|
|
58
74
|
* How long the newest RECEIVED message has been waiting with no processing
|
|
59
|
-
* completed after it. Zero when idle
|
|
75
|
+
* completed after it AND no turn activity since. Zero when idle, keeping
|
|
76
|
+
* up, or mid-turn with events still flowing.
|
|
60
77
|
*/
|
|
61
78
|
function stuckMs(now: number): number {
|
|
62
79
|
if (lastReceivedAt === 0 || lastReceivedAt <= lastProcessedAt) return 0;
|
|
80
|
+
return now - Math.max(lastReceivedAt, lastActivityAt);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** How long the newest RECEIVED message has been waiting for completion. */
|
|
84
|
+
function pendingMs(now: number): number {
|
|
63
85
|
return now - lastReceivedAt;
|
|
64
86
|
}
|
|
65
87
|
|
|
@@ -113,17 +135,20 @@ export function startWatchdog(workspaceDir?: string): void {
|
|
|
113
135
|
if (watchdogTimer) return;
|
|
114
136
|
|
|
115
137
|
watchdogTimer = setInterval(() => {
|
|
116
|
-
// Warn only when work is actually stuck — a message arrived
|
|
117
|
-
// has finished processing since
|
|
118
|
-
//
|
|
119
|
-
//
|
|
138
|
+
// Warn only when work is actually stuck — a message arrived, nothing
|
|
139
|
+
// has finished processing since, and the turn has gone silent (no
|
|
140
|
+
// backend events) for the threshold. A quiet chat used to trip this
|
|
141
|
+
// every minute all night ("No messages processed for N minutes"), and a
|
|
142
|
+
// long tool-calling turn used to trip it at the 10-minute mark; both
|
|
143
|
+
// buried real warnings in noise. Repeat warnings back off exponentially.
|
|
120
144
|
const now = Date.now();
|
|
121
145
|
const stuck = stuckMs(now);
|
|
122
146
|
if (stuck > STUCK_WARN_MS && now >= nextStuckWarnAt) {
|
|
123
|
-
const
|
|
147
|
+
const silentMins = Math.round(stuck / 60000);
|
|
148
|
+
const pendingMins = Math.round(pendingMs(now) / 60000);
|
|
124
149
|
logWarn(
|
|
125
150
|
"watchdog",
|
|
126
|
-
`Message received ${
|
|
151
|
+
`Message received ${pendingMins} minutes ago is still unprocessed with no turn activity for ${silentMins} minutes — the message loop may be wedged`,
|
|
127
152
|
);
|
|
128
153
|
nextStuckWarnAt = now + stuckWarnBackoffMs;
|
|
129
154
|
stuckWarnBackoffMs = Math.min(
|