talon-agent 3.34.0 → 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 +4 -2
- package/src/backend/claude-sdk/handler.ts +428 -415
- 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/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/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/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/weaver/shuttle.ts +4 -0
- package/src/frontend/telegram/admin.ts +75 -52
- package/src/storage/trigger-store.ts +8 -0
- package/src/util/watchdog.ts +32 -7
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
* - `stream-state` — backend-agnostic accumulator for stream loops.
|
|
25
25
|
* - `turn-interrupt` — user-driven mid-turn interrupt registry (the
|
|
26
26
|
* shared `ChatBackend.interruptChatTurn` for callback backends).
|
|
27
|
+
* - `turn-phases` — the post-stream phases (accounting, session name,
|
|
28
|
+
* trailing-prose contract, result tail) every handler runs.
|
|
27
29
|
*
|
|
28
30
|
* What's NOT here (intentionally):
|
|
29
31
|
* - SDK-specific event types — those live in each backend.
|
|
@@ -34,11 +36,6 @@
|
|
|
34
36
|
|
|
35
37
|
export { captureDeliveredText } from "./delivered-text.js";
|
|
36
38
|
|
|
37
|
-
export {
|
|
38
|
-
FLOW_VIOLATION_MAX_RETRIES,
|
|
39
|
-
detectFlowViolation,
|
|
40
|
-
} from "./flow-violation.js";
|
|
41
|
-
|
|
42
39
|
export { registerTurnInterrupt } from "./turn-interrupt.js";
|
|
43
40
|
|
|
44
41
|
export { formatUserPrompt } from "./prompt-format.js";
|
|
@@ -49,8 +46,6 @@ export {
|
|
|
49
46
|
buildFirstTurnReminder,
|
|
50
47
|
} from "./delivery-contract.js";
|
|
51
48
|
|
|
52
|
-
export { extractSessionName } from "../../util/session-name.js";
|
|
53
|
-
|
|
54
49
|
export { summarizeUsage } from "./usage.js";
|
|
55
50
|
|
|
56
51
|
// Only what is consumed THROUGH the barrel. Everything else in
|
|
@@ -66,8 +61,6 @@ export {
|
|
|
66
61
|
|
|
67
62
|
export { prepareSystemPrompt, appendBackendSuffix } from "./system-prompt.js";
|
|
68
63
|
|
|
69
|
-
export { classifyRetry } from "./model-retry.js";
|
|
70
|
-
|
|
71
64
|
export {
|
|
72
65
|
createStreamState,
|
|
73
66
|
appendText,
|
|
@@ -88,11 +81,17 @@ export {
|
|
|
88
81
|
|
|
89
82
|
export { sleep } from "./sleep.js";
|
|
90
83
|
|
|
91
|
-
export {
|
|
92
|
-
recordToolCall,
|
|
93
|
-
recordTurnMetrics,
|
|
94
|
-
recordFailedTurnAccounting,
|
|
95
|
-
recordFlowViolation,
|
|
96
|
-
} from "./metrics.js";
|
|
84
|
+
export { recordToolCall } from "./metrics.js";
|
|
97
85
|
|
|
98
86
|
export { applyRetryDecision } from "./handle-retry.js";
|
|
87
|
+
|
|
88
|
+
export {
|
|
89
|
+
accountTurn,
|
|
90
|
+
accountFailedTurn,
|
|
91
|
+
nameSessionFromFirstMessage,
|
|
92
|
+
enforceTrailingProse,
|
|
93
|
+
finishCallbackTurn,
|
|
94
|
+
turnUsageSnapshot,
|
|
95
|
+
} from "./turn-phases.js";
|
|
96
|
+
|
|
97
|
+
export { buildResultEvents } from "./result-events.js";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `usage` + `completed` pair that closes every successful
|
|
3
|
+
* `runChatTurn` stream, whether the backend emits events natively
|
|
4
|
+
* (Claude SDK) or through `handlerToEvents`. Kept free of storage and
|
|
5
|
+
* logging imports so the adapter stays a pure event translator.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { AgentEvent } from "../../core/agent-runtime/events.js";
|
|
9
|
+
import type { TokenUsageSnapshot } from "./usage.js";
|
|
10
|
+
|
|
11
|
+
export function buildResultEvents(inputs: {
|
|
12
|
+
text: string;
|
|
13
|
+
durationMs: number;
|
|
14
|
+
usage: TokenUsageSnapshot;
|
|
15
|
+
modelId: string;
|
|
16
|
+
}): [AgentEvent, AgentEvent] {
|
|
17
|
+
const usage = { ...inputs.usage, modelId: inputs.modelId };
|
|
18
|
+
return [
|
|
19
|
+
{ type: "usage", usage },
|
|
20
|
+
{
|
|
21
|
+
type: "completed",
|
|
22
|
+
result: {
|
|
23
|
+
text: inputs.text,
|
|
24
|
+
durationMs: inputs.durationMs,
|
|
25
|
+
usage,
|
|
26
|
+
modelId: inputs.modelId,
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The phases every chat turn runs once its SDK stream loop has ended.
|
|
3
|
+
*
|
|
4
|
+
* Each backend handler owns its options build, its `for await` loop and
|
|
5
|
+
* its event translation — everything that depends on the SDK's shape.
|
|
6
|
+
* What comes after is the same four steps in every backend:
|
|
7
|
+
*
|
|
8
|
+
* 1. `accountTurn` — per-turn metrics, session id, session usage
|
|
9
|
+
* (or `accountFailedTurn` on the terminal-failure path).
|
|
10
|
+
* 2. `nameSessionFromFirstMessage` — the session title.
|
|
11
|
+
* 3. `enforceTrailingProse` — the tool-only delivery contract and the
|
|
12
|
+
* flow-violation re-prompt decision.
|
|
13
|
+
* 4. `finishCallbackTurn` — the delivery summary log lines and the
|
|
14
|
+
* `QueryResult` (the event-stream tail is `result-events.ts`).
|
|
15
|
+
*
|
|
16
|
+
* The functions take the stream state and explicit config bits and return
|
|
17
|
+
* explicit results; the handler decides what to do with a retry decision
|
|
18
|
+
* because recursion is the handler's own entry point.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
getSession,
|
|
23
|
+
recordUsage,
|
|
24
|
+
setSessionId,
|
|
25
|
+
setSessionName,
|
|
26
|
+
} from "../../storage/sessions.js";
|
|
27
|
+
import { log } from "../../util/log.js";
|
|
28
|
+
import { extractSessionName } from "../../util/session-name.js";
|
|
29
|
+
import { traceMessage } from "../../util/trace.js";
|
|
30
|
+
import {
|
|
31
|
+
FLOW_VIOLATION_MAX_RETRIES,
|
|
32
|
+
detectFlowViolation,
|
|
33
|
+
type FlowViolationResult,
|
|
34
|
+
} from "./flow-violation.js";
|
|
35
|
+
import type { QueryResult } from "./handler-types.js";
|
|
36
|
+
import {
|
|
37
|
+
recordFailedTurnAccounting,
|
|
38
|
+
recordFlowViolation,
|
|
39
|
+
recordTurnMetrics,
|
|
40
|
+
} from "./metrics.js";
|
|
41
|
+
import type { StreamState } from "./stream-state.js";
|
|
42
|
+
import { summarizeUsage, type TokenUsageSnapshot } from "./usage.js";
|
|
43
|
+
|
|
44
|
+
// ── Usage snapshot ──────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
/** The slice of a stream state the post-loop phases read. */
|
|
47
|
+
export type TurnUsageState = Pick<
|
|
48
|
+
StreamState,
|
|
49
|
+
| "sdkInputTokens"
|
|
50
|
+
| "sdkOutputTokens"
|
|
51
|
+
| "sdkCacheRead"
|
|
52
|
+
| "sdkCacheWrite"
|
|
53
|
+
| "toolCalls"
|
|
54
|
+
| "numApiCalls"
|
|
55
|
+
| "contextTokens"
|
|
56
|
+
| "contextWindow"
|
|
57
|
+
>;
|
|
58
|
+
|
|
59
|
+
/** The turn's token totals as the metrics + session layers consume them. */
|
|
60
|
+
export function turnUsageSnapshot(state: TurnUsageState): TokenUsageSnapshot {
|
|
61
|
+
return {
|
|
62
|
+
inputTokens: state.sdkInputTokens,
|
|
63
|
+
outputTokens: state.sdkOutputTokens,
|
|
64
|
+
cacheRead: state.sdkCacheRead,
|
|
65
|
+
cacheWrite: state.sdkCacheWrite,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Phase 1: accounting ─────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
/** Context-fill fields persisted alongside the token totals. */
|
|
72
|
+
type TurnContextUsage = {
|
|
73
|
+
contextTokens?: number;
|
|
74
|
+
contextWindow?: number;
|
|
75
|
+
numApiCalls?: number;
|
|
76
|
+
costUsd?: number;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export type AccountTurnInputs = {
|
|
80
|
+
chatId: string;
|
|
81
|
+
/** Backend id — the `backend.<id>.*` metric dimension. */
|
|
82
|
+
backend: string;
|
|
83
|
+
state: TurnUsageState;
|
|
84
|
+
durationMs: number;
|
|
85
|
+
model: string;
|
|
86
|
+
/** Provider session id to persist; skipped when absent or unchanged. */
|
|
87
|
+
sessionId?: string;
|
|
88
|
+
/** True when the turn ended in a delivered failure (Codex `turn.failed`). */
|
|
89
|
+
failed?: boolean;
|
|
90
|
+
/** Override for backends that count tool calls outside the stream state. */
|
|
91
|
+
toolCalls?: number;
|
|
92
|
+
/**
|
|
93
|
+
* Context-fill fields for `recordUsage`. Only backends whose stream
|
|
94
|
+
* reports them pass this; the others leave the session's context
|
|
95
|
+
* display untouched (zeroed) as they always have.
|
|
96
|
+
*/
|
|
97
|
+
context?: TurnContextUsage;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Record the per-turn metric rollup, persist the provider session id and
|
|
102
|
+
* fold the turn's usage into the session. Each write is independent, so
|
|
103
|
+
* the order here is not load-bearing.
|
|
104
|
+
*/
|
|
105
|
+
export function accountTurn(inputs: AccountTurnInputs): void {
|
|
106
|
+
const { chatId, state, durationMs } = inputs;
|
|
107
|
+
const usage = turnUsageSnapshot(state);
|
|
108
|
+
recordTurnMetrics({
|
|
109
|
+
chatId,
|
|
110
|
+
backend: inputs.backend,
|
|
111
|
+
durationMs,
|
|
112
|
+
toolCalls: inputs.toolCalls ?? state.toolCalls,
|
|
113
|
+
apiCalls: state.numApiCalls,
|
|
114
|
+
...(inputs.failed !== undefined ? { failed: inputs.failed } : {}),
|
|
115
|
+
usage,
|
|
116
|
+
});
|
|
117
|
+
persistSessionId(chatId, inputs.sessionId);
|
|
118
|
+
recordUsage(chatId, {
|
|
119
|
+
...usage,
|
|
120
|
+
durationMs,
|
|
121
|
+
model: inputs.model,
|
|
122
|
+
...inputs.context,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function persistSessionId(chatId: string, sessionId: string | undefined): void {
|
|
127
|
+
if (!sessionId) return;
|
|
128
|
+
if (getSession(chatId).sessionId === sessionId) return;
|
|
129
|
+
setSessionId(chatId, sessionId);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export type AccountFailedTurnInputs = {
|
|
133
|
+
chatId: string;
|
|
134
|
+
backend: string;
|
|
135
|
+
state: TurnUsageState;
|
|
136
|
+
durationMs: number;
|
|
137
|
+
model: string;
|
|
138
|
+
/** Overrides for backends that count outside the stream state. */
|
|
139
|
+
toolCalls?: number;
|
|
140
|
+
apiCalls?: number;
|
|
141
|
+
usage?: TokenUsageSnapshot;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Terminal-failure accounting from the stream state — the tokens a
|
|
146
|
+
* failed turn burned still count. Not for retry paths: the recursive
|
|
147
|
+
* attempt accounts for itself.
|
|
148
|
+
*/
|
|
149
|
+
export function accountFailedTurn(inputs: AccountFailedTurnInputs): void {
|
|
150
|
+
const { state } = inputs;
|
|
151
|
+
recordFailedTurnAccounting({
|
|
152
|
+
backend: inputs.backend,
|
|
153
|
+
chatId: inputs.chatId,
|
|
154
|
+
durationMs: inputs.durationMs,
|
|
155
|
+
toolCalls: inputs.toolCalls ?? state.toolCalls,
|
|
156
|
+
apiCalls: inputs.apiCalls ?? state.numApiCalls,
|
|
157
|
+
model: inputs.model,
|
|
158
|
+
usage: inputs.usage ?? turnUsageSnapshot(state),
|
|
159
|
+
contextTokens: state.contextTokens,
|
|
160
|
+
contextWindow: state.contextWindow,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── Phase 2: session name ───────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Title the session from the user's first message. Skipped on retries,
|
|
168
|
+
* whose `text` is a synthetic reminder rather than what the user said.
|
|
169
|
+
*/
|
|
170
|
+
export function nameSessionFromFirstMessage(inputs: {
|
|
171
|
+
chatId: string;
|
|
172
|
+
text: string;
|
|
173
|
+
previousTurns: number;
|
|
174
|
+
isRetry?: boolean;
|
|
175
|
+
}): void {
|
|
176
|
+
if (inputs.previousTurns !== 0 || inputs.isRetry) return;
|
|
177
|
+
const name = extractSessionName(inputs.text);
|
|
178
|
+
if (name) setSessionName(inputs.chatId, name);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Phase 3: trailing-prose contract ────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
export type TrailingProseInputs = {
|
|
184
|
+
chatId: string;
|
|
185
|
+
state: Pick<
|
|
186
|
+
StreamState,
|
|
187
|
+
"lastTrailingText" | "turnTerminated" | "deliveredTextNorms" | "toolCalls"
|
|
188
|
+
>;
|
|
189
|
+
/** Synthetic flow-violation retries already spent on this message. */
|
|
190
|
+
flowRetries: number;
|
|
191
|
+
/** Frontend-aware reminder; omit for the default telegram-shaped text. */
|
|
192
|
+
reminder?: string;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Apply the tool-only delivery contract to a finished turn: detect a
|
|
197
|
+
* flow violation, count it, log it, and say whether the handler should
|
|
198
|
+
* re-prompt with `reminder`. Callers gate this on the contract actually
|
|
199
|
+
* being in force (a messaging frontend with delivery tools registered).
|
|
200
|
+
*/
|
|
201
|
+
export function enforceTrailingProse(
|
|
202
|
+
inputs: TrailingProseInputs,
|
|
203
|
+
): FlowViolationResult {
|
|
204
|
+
const { chatId, state, flowRetries } = inputs;
|
|
205
|
+
const violation = detectFlowViolation({
|
|
206
|
+
trailingText: state.lastTrailingText,
|
|
207
|
+
turnTerminated: state.turnTerminated,
|
|
208
|
+
deliveredTextNorms: state.deliveredTextNorms,
|
|
209
|
+
toolCalls: state.toolCalls,
|
|
210
|
+
retried: flowRetries > 0,
|
|
211
|
+
retryCount: flowRetries,
|
|
212
|
+
maxRetries: FLOW_VIOLATION_MAX_RETRIES,
|
|
213
|
+
...(inputs.reminder !== undefined ? { reminder: inputs.reminder } : {}),
|
|
214
|
+
});
|
|
215
|
+
if (!violation.violated) return violation;
|
|
216
|
+
|
|
217
|
+
recordFlowViolation(
|
|
218
|
+
chatId,
|
|
219
|
+
violation.shouldRetry ? "retried" : "cap_exhausted",
|
|
220
|
+
);
|
|
221
|
+
log(
|
|
222
|
+
"agent",
|
|
223
|
+
`[${chatId}] flow violation: ${violation.reason}. ${
|
|
224
|
+
violation.shouldRetry
|
|
225
|
+
? "Re-prompting with reminder."
|
|
226
|
+
: `Retry cap (${FLOW_VIOLATION_MAX_RETRIES}) exhausted — accepting silent drop.`
|
|
227
|
+
}`,
|
|
228
|
+
);
|
|
229
|
+
return violation;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── Phase 4: result ─────────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
export type FinishCallbackTurnInputs = {
|
|
235
|
+
chatId: string;
|
|
236
|
+
state: TurnUsageState &
|
|
237
|
+
Pick<StreamState, "turnTerminated" | "deliveredTextNorms">;
|
|
238
|
+
responseText: string;
|
|
239
|
+
durationMs: number;
|
|
240
|
+
setupMs: number;
|
|
241
|
+
turnMs: number;
|
|
242
|
+
/** `routeDelivery`'s decision, or a backend's own route (`silent`). */
|
|
243
|
+
delivery: { route: string; chars: number };
|
|
244
|
+
/** Extra `key=value` diagnostics appended to the summary line. */
|
|
245
|
+
detail?: string;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The end-of-turn log lines and trace for a callback-shaped handler, and
|
|
250
|
+
* the `QueryResult` it returns.
|
|
251
|
+
*/
|
|
252
|
+
export function finishCallbackTurn(
|
|
253
|
+
inputs: FinishCallbackTurnInputs,
|
|
254
|
+
): QueryResult {
|
|
255
|
+
const { chatId, state, responseText, durationMs, delivery } = inputs;
|
|
256
|
+
const usage = turnUsageSnapshot(state);
|
|
257
|
+
log(
|
|
258
|
+
"agent",
|
|
259
|
+
`[${chatId}] delivery: ${delivery.route} (${delivery.chars} chars)`,
|
|
260
|
+
);
|
|
261
|
+
log(
|
|
262
|
+
"agent",
|
|
263
|
+
`[${chatId}] -> (${summarizeUsage(usage, {
|
|
264
|
+
durationMs,
|
|
265
|
+
toolCalls: state.toolCalls,
|
|
266
|
+
})} terminator=${state.turnTerminated ? "yes" : "no"} ` +
|
|
267
|
+
`delivered=${state.deliveredTextNorms.length} ` +
|
|
268
|
+
`respLen=${responseText.length} ` +
|
|
269
|
+
`setup=${inputs.setupMs}ms turn=${inputs.turnMs}ms` +
|
|
270
|
+
`${inputs.detail ? ` ${inputs.detail}` : ""})`,
|
|
271
|
+
);
|
|
272
|
+
traceMessage(chatId, "out", responseText, {
|
|
273
|
+
durationMs,
|
|
274
|
+
toolCalls: state.toolCalls,
|
|
275
|
+
});
|
|
276
|
+
return { text: responseText, durationMs, ...usage };
|
|
277
|
+
}
|
|
@@ -8,16 +8,18 @@ import type { ChildProcess } from "node:child_process";
|
|
|
8
8
|
import {
|
|
9
9
|
getTrigger,
|
|
10
10
|
updateTrigger,
|
|
11
|
+
SHUTDOWN_KILL_ERROR,
|
|
11
12
|
type Trigger,
|
|
12
13
|
type TriggerStatus,
|
|
13
14
|
} from "../../../storage/trigger-store.js";
|
|
14
|
-
import { log, logError } from "../../../util/log.js";
|
|
15
|
+
import { log, logDebug, logError } from "../../../util/log.js";
|
|
15
16
|
import { appendDailyLog } from "../../../storage/daily-log.js";
|
|
16
17
|
import {
|
|
17
18
|
children,
|
|
18
19
|
timeouts,
|
|
19
20
|
logStreams,
|
|
20
21
|
lineBuffers,
|
|
22
|
+
lifecycle,
|
|
21
23
|
wardened,
|
|
22
24
|
SIGTERM_GRACE_MS,
|
|
23
25
|
WARDEN_GRACE_SLACK_MS,
|
|
@@ -53,6 +55,9 @@ export function cancelTrigger(id: string): boolean {
|
|
|
53
55
|
|
|
54
56
|
/** Kill all running children — called during shutdown. */
|
|
55
57
|
export async function shutdownTriggers(): Promise<void> {
|
|
58
|
+
// Flag first, unconditionally: a child that exits on its own from here on
|
|
59
|
+
// must not dispatch a wake either — the backend pool is going away.
|
|
60
|
+
lifecycle.shuttingDown = true;
|
|
56
61
|
if (children.size === 0) return;
|
|
57
62
|
log("triggers", `Shutting down ${children.size} running trigger(s)`);
|
|
58
63
|
const ids = Array.from(children.keys());
|
|
@@ -70,7 +75,7 @@ export async function shutdownTriggers(): Promise<void> {
|
|
|
70
75
|
} else {
|
|
71
76
|
updateTrigger(id, {
|
|
72
77
|
status: "terminated",
|
|
73
|
-
lastError:
|
|
78
|
+
lastError: SHUTDOWN_KILL_ERROR,
|
|
74
79
|
});
|
|
75
80
|
}
|
|
76
81
|
killChild(id, c);
|
|
@@ -188,6 +193,18 @@ export async function finalizeExit(
|
|
|
188
193
|
status === "cancelled" ||
|
|
189
194
|
status === "terminated"
|
|
190
195
|
) {
|
|
196
|
+
// During shutdown the backend pool is being torn down alongside us, so a
|
|
197
|
+
// dispatch here can only fail ("Backend role not bound"). Skip it; the
|
|
198
|
+
// record keeps status=terminated + SHUTDOWN_KILL_ERROR and no lastFireAt
|
|
199
|
+
// bump, which is exactly what resumeAfterRestart's late death notice
|
|
200
|
+
// keys on — the chat hears about it on the next boot instead.
|
|
201
|
+
if (lifecycle.shuttingDown) {
|
|
202
|
+
logDebug(
|
|
203
|
+
"triggers",
|
|
204
|
+
`Skipped ${status} wake for "${t.name}" [${id}] — shutting down; deferred to next boot`,
|
|
205
|
+
);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
191
208
|
await fireWake(id, status, payload, /* terminal */ true);
|
|
192
209
|
}
|
|
193
210
|
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
getAllTriggers,
|
|
10
10
|
updateTrigger,
|
|
11
11
|
RESTART_KILL_ERROR,
|
|
12
|
+
SHUTDOWN_KILL_ERROR,
|
|
12
13
|
type Trigger,
|
|
13
14
|
} from "../../../storage/trigger-store.js";
|
|
14
15
|
import { log, logError } from "../../../util/log.js";
|
|
@@ -50,19 +51,25 @@ export async function resumeAfterRestart(): Promise<void> {
|
|
|
50
51
|
// Late death notice. Two cases earn one:
|
|
51
52
|
// - never fired at all (the old rule) — the chat heard nothing
|
|
52
53
|
// from this trigger, so its termination is news; and
|
|
53
|
-
// - killed by THIS restart
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
54
|
+
// - killed by THIS restart — either the crash path
|
|
55
|
+
// (recoverInterrupted stamped RESTART_KILL_ERROR) or the clean
|
|
56
|
+
// path (shutdownTriggers stamped SHUTDOWN_KILL_ERROR and
|
|
57
|
+
// finalizeExit deliberately skipped the wake because the
|
|
58
|
+
// backend pool was already going away). Even a multi-fire
|
|
59
|
+
// watcher that signalled mid-run was still an active promise
|
|
60
|
+
// when the process died, and without this wake the chat never
|
|
61
|
+
// learns its watcher is gone. (Previously gated on
|
|
62
|
+
// lastFireAt === undefined alone, which silently dropped
|
|
63
|
+
// exactly those watchers.)
|
|
59
64
|
// Triggers that exited on their own already fired their terminal
|
|
60
65
|
// wake (lastFireAt set, no marker) — they stay silent here.
|
|
61
66
|
if (
|
|
62
67
|
t.status === "terminated" &&
|
|
63
68
|
t.endedAt &&
|
|
64
69
|
Date.now() - t.endedAt < 5 * 60_000 &&
|
|
65
|
-
(t.lastFireAt === undefined ||
|
|
70
|
+
(t.lastFireAt === undefined ||
|
|
71
|
+
t.lastError === RESTART_KILL_ERROR ||
|
|
72
|
+
t.lastError === SHUTDOWN_KILL_ERROR)
|
|
66
73
|
) {
|
|
67
74
|
await fireWake(t.id, "terminated", t.lastError, /* terminal */ true);
|
|
68
75
|
}
|
|
@@ -20,6 +20,14 @@ export type TriggerDeps = {
|
|
|
20
20
|
/** Reassignable on a holder object so submodules see the injected deps. */
|
|
21
21
|
export const depsHolder: { deps: TriggerDeps | null } = { deps: null };
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Set by shutdownTriggers for the rest of the process lifetime. finalizeExit
|
|
25
|
+
* consults it so the children it kills don't dispatch wakes into a backend
|
|
26
|
+
* pool that is being torn down at the same time. initTriggers clears it so a
|
|
27
|
+
* fresh lifecycle (next boot, or the next test) starts clean.
|
|
28
|
+
*/
|
|
29
|
+
export const lifecycle = { shuttingDown: false };
|
|
30
|
+
|
|
23
31
|
/** Live child handles, keyed by trigger id. */
|
|
24
32
|
export const children = new Map<string, ChildProcess>();
|
|
25
33
|
export const timeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
|
@@ -43,6 +51,7 @@ export const WARDEN_GRACE_SLACK_MS = 2_000;
|
|
|
43
51
|
|
|
44
52
|
export function initTriggers(d: TriggerDeps): void {
|
|
45
53
|
depsHolder.deps = d;
|
|
54
|
+
lifecycle.shuttingDown = false;
|
|
46
55
|
log("triggers", "Initialized");
|
|
47
56
|
}
|
|
48
57
|
|