switchroom 0.18.17 → 0.18.18
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/dist/agent-scheduler/index.js +13 -0
- package/dist/auth-broker/index.js +13 -0
- package/dist/cli/notion-write-pretool.mjs +13 -0
- package/dist/cli/switchroom.js +605 -479
- package/dist/host-control/main.js +17 -1
- package/dist/vault/approvals/kernel-server.js +13 -0
- package/dist/vault/broker/server.js +13 -0
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +7 -1
- package/telegram-plugin/dist/bridge/bridge.js +26 -1
- package/telegram-plugin/dist/gateway/gateway.js +1401 -431
- package/telegram-plugin/dist/server.js +26 -1
- package/telegram-plugin/fleet-fallback-resume.ts +26 -3
- package/telegram-plugin/gateway/approval-hold.ts +49 -0
- package/telegram-plugin/gateway/bridge-dead-watchdog.ts +61 -18
- package/telegram-plugin/gateway/gateway.ts +362 -71
- package/telegram-plugin/gateway/linear-activity.ts +20 -4
- package/telegram-plugin/gateway/premium-recovery-wiring.ts +122 -0
- package/telegram-plugin/gateway/session-model-file.ts +103 -0
- package/telegram-plugin/gateway/tier-downgrade-wiring.ts +121 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +14 -1
- package/telegram-plugin/llm-error-present.ts +436 -0
- package/telegram-plugin/operator-events.ts +7 -1
- package/telegram-plugin/permission-title.ts +172 -10
- package/telegram-plugin/premium-recovery.ts +101 -0
- package/telegram-plugin/raw-error-scrub.ts +73 -0
- package/telegram-plugin/retry-api-call.ts +8 -2
- package/telegram-plugin/send-gate-degraded.test.ts +152 -1
- package/telegram-plugin/send-gate-observability.test.ts +140 -0
- package/telegram-plugin/send-gate-observability.ts +65 -20
- package/telegram-plugin/send-gate.test.ts +143 -1
- package/telegram-plugin/send-gate.ts +212 -19
- package/telegram-plugin/session-tail.ts +16 -0
- package/telegram-plugin/shared/local-time.ts +69 -0
- package/telegram-plugin/tests/approval-hold-harness.ts +6 -6
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +10 -2
- package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +61 -0
- package/telegram-plugin/tests/fleet-fallback-resume.test.ts +39 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +3 -2
- package/telegram-plugin/tests/linear-create-issue.test.ts +30 -2
- package/telegram-plugin/tests/llm-error-present.test.ts +380 -0
- package/telegram-plugin/tests/permission-title.test.ts +167 -4
- package/telegram-plugin/tests/premium-recovery-wiring.test.ts +150 -0
- package/telegram-plugin/tests/premium-recovery.test.ts +165 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +6 -1
- package/telegram-plugin/tests/retry-api-call.test.ts +21 -0
- package/telegram-plugin/tests/tier-downgrade-wiring.test.ts +165 -0
- package/telegram-plugin/tests/tier-downgrade.test.ts +141 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +27 -1
- package/telegram-plugin/tests/worker-activity-feed.test.ts +5 -2
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +492 -0
- package/telegram-plugin/tier-downgrade.ts +198 -0
- package/telegram-plugin/tool-activity-summary.ts +99 -0
- package/telegram-plugin/worker-activity-feed.ts +509 -409
|
@@ -297,11 +297,20 @@ export async function emitLinearAgentActivity(
|
|
|
297
297
|
return { content: [{ type: 'text', text: `Linear ${type} emitted on session ${sessionId}` }] }
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
+
/** The exact HTML-comment carrying a capture's dedup key. Both the embed
|
|
301
|
+
* (captureDedupMarker) and the dedup lookup match on this precise string, so a
|
|
302
|
+
* re-capture is confirmed by exact-key equality, not fuzzy search relevance.
|
|
303
|
+
* The trailing ` -->` and leading `: ` anchor the key so `abc` never matches
|
|
304
|
+
* `abcd`. */
|
|
305
|
+
export function captureDedupComment(dedupKey: string): string {
|
|
306
|
+
return `<!-- switchroom-capture: ${dedupKey} -->`
|
|
307
|
+
}
|
|
308
|
+
|
|
300
309
|
/** Hidden marker appended to a captured issue's description so a re-capture of
|
|
301
310
|
* the same Telegram message can be detected (dedup backstop; the gateway-side
|
|
302
311
|
* seen-set is the primary, race-free guard). */
|
|
303
312
|
export function captureDedupMarker(dedupKey: string): string {
|
|
304
|
-
return `\n\n
|
|
313
|
+
return `\n\n${captureDedupComment(dedupKey)}`
|
|
305
314
|
}
|
|
306
315
|
|
|
307
316
|
/**
|
|
@@ -390,19 +399,26 @@ export async function createLinearIssue(
|
|
|
390
399
|
}
|
|
391
400
|
|
|
392
401
|
// Dedup backstop: search for a prior capture of the same Telegram message.
|
|
402
|
+
// `searchIssues` is a relevance-ranked full-text search, so its top hit for a
|
|
403
|
+
// key can be an unrelated issue that merely shares tokens. We therefore only
|
|
404
|
+
// treat a result as a dedup match when its description carries the EXACT
|
|
405
|
+
// capture marker for this key, never the raw top hit.
|
|
393
406
|
if (dedupKey) {
|
|
407
|
+
const marker = captureDedupComment(dedupKey)
|
|
394
408
|
const search = await gql(
|
|
395
|
-
'query($term: String!) { searchIssues(term: $term) { nodes { id url title } } }',
|
|
409
|
+
'query($term: String!) { searchIssues(term: $term, first: 25) { nodes { id url title description } } }',
|
|
396
410
|
{ term: dedupKey },
|
|
397
411
|
)
|
|
398
412
|
if (search.ok) {
|
|
399
|
-
const
|
|
413
|
+
const nodes = (search.data?.searchIssues?.nodes ?? []) as Array<{ url?: string; description?: string }>
|
|
414
|
+
const hit = nodes.find((n) => typeof n.description === 'string' && n.description.includes(marker))
|
|
400
415
|
if (hit?.url) {
|
|
401
416
|
log(`telegram gateway: linear_create_issue: dedup hit key=${dedupKey} agent=${agent}\n`)
|
|
402
417
|
return { content: [{ type: 'text', text: `Already filed: ${hit.url}` }] }
|
|
403
418
|
}
|
|
404
419
|
}
|
|
405
|
-
// a failed search is non-fatal — fall through to
|
|
420
|
+
// a failed search or no exact-marker match is non-fatal — fall through to
|
|
421
|
+
// create (gateway seen-set is the primary, race-free guard).
|
|
406
422
|
}
|
|
407
423
|
|
|
408
424
|
// Resolve the team.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* premium-recovery-wiring.ts — the gateway GLUE for the "premium model
|
|
3
|
+
* recovered" ping, behind injected deps so the never-storm orchestration is
|
|
4
|
+
* unit-testable without importing the whole gateway (mirrors
|
|
5
|
+
* tier-downgrade-wiring.ts).
|
|
6
|
+
*
|
|
7
|
+
* The pure recovery predicate (`decidePremiumRecovery`), the honest wording +
|
|
8
|
+
* button (`renderPremiumRecoveryPing`), and the fleet-dedup key
|
|
9
|
+
* (`premiumRecoveryClaimKey`) all live in premium-recovery.ts. This module owns
|
|
10
|
+
* the ORDER-SENSITIVE side effects the gateway performs off that verdict — the
|
|
11
|
+
* P0 at-most-once guarantee a review cares about:
|
|
12
|
+
*
|
|
13
|
+
* - the marker is READ first; a missing/corrupt marker is a no-op (no send);
|
|
14
|
+
* - the pure decision gates the send: `fire === false` → NO clear, NO send
|
|
15
|
+
* (the marker survives for a later tick once the tier actually recovers);
|
|
16
|
+
* - a fleet-wide `claim-notification` gates against a bounce / concurrent
|
|
17
|
+
* tick; on `!granted` the marker is cleared (so it can't linger and
|
|
18
|
+
* re-attempt every tick) and we bail with NO send;
|
|
19
|
+
* - on a GRANTED claim the marker is cleared BEFORE the send (never-storm:
|
|
20
|
+
* at-most-once is the hard requirement — a transient send fault is covered
|
|
21
|
+
* by the caller's retry policy, a double-send is not recoverable);
|
|
22
|
+
* - EXACTLY ONE send per recorded chat, each carrying the session-scoped
|
|
23
|
+
* `mdl:alias:<premium>` switch-back button (the SAME apply path as the model
|
|
24
|
+
* menu — it does not bypass handleModelMenuCallback).
|
|
25
|
+
*
|
|
26
|
+
* Behaviour is a faithful extraction of the former inline
|
|
27
|
+
* `maybePremiumRecoveryPing`; the ordering above is preserved exactly.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
renderPremiumRecoveryPing,
|
|
32
|
+
premiumRecoveryClaimKey,
|
|
33
|
+
type PremiumRecoveryPing,
|
|
34
|
+
} from '../premium-recovery.js'
|
|
35
|
+
import { MODEL_CALLBACK_ALIAS } from './model-command.js'
|
|
36
|
+
|
|
37
|
+
/** The one-tap switch-back keyboard (single callback button). */
|
|
38
|
+
export interface PremiumRecoveryKeyboard {
|
|
39
|
+
inline_keyboard: Array<Array<{ text: string; callback_data: string }>>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The minimal marker view the ping orchestration needs. */
|
|
43
|
+
export interface PremiumRecoveryMarkerView {
|
|
44
|
+
/** The dropped premium `/model` token to offer switching back to. */
|
|
45
|
+
premiumModel: string
|
|
46
|
+
/** Chat ids to ping (the downgrade notice's allowFrom); may be empty → fallback. */
|
|
47
|
+
chats: string[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface PremiumRecoveryPingDeps {
|
|
51
|
+
/** Bind-mounted agent state dir, or null when unresolvable (→ no-op). */
|
|
52
|
+
getAgentDir: () => string | null
|
|
53
|
+
/** Read the parsed `.premium-recovery` marker, or null when absent/corrupt. */
|
|
54
|
+
readMarker: (agentDir: string) => PremiumRecoveryMarkerView | null
|
|
55
|
+
/** Delete the marker (consume-once). Best-effort. */
|
|
56
|
+
clearMarker: (agentDir: string) => void
|
|
57
|
+
/** The agent name (for the fleet-dedup claim key). */
|
|
58
|
+
getAgent: () => string
|
|
59
|
+
/** The pure recovery predicate, bound to THIS tick's live accounts. Returns
|
|
60
|
+
* whether to fire (the marker is already known present when this is called). */
|
|
61
|
+
decide: () => boolean
|
|
62
|
+
/** Fleet-wide at-most-once claim for this premium token. Returns granted
|
|
63
|
+
* (fail-open: true on any broker error → degrades to at-least-once). */
|
|
64
|
+
claimNotification: (claimKey: string) => Promise<boolean>
|
|
65
|
+
/** Fallback chats when the marker records none (parity with the inline path). */
|
|
66
|
+
fallbackChats: () => string[]
|
|
67
|
+
/** Send the recovery ping (with the switch-back keyboard) to ONE chat. */
|
|
68
|
+
sendToChat: (
|
|
69
|
+
chatId: string,
|
|
70
|
+
ping: PremiumRecoveryPing,
|
|
71
|
+
keyboard: PremiumRecoveryKeyboard,
|
|
72
|
+
) => void
|
|
73
|
+
/** Structured logger (stderr in prod, captured in test). */
|
|
74
|
+
log: (msg: string) => void
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Run the premium-recovery ping glue. No-op unless a marker is pending, the
|
|
79
|
+
* pure decision says fire, and the fleet claim is granted — then clears the
|
|
80
|
+
* marker BEFORE fanning out exactly one send per chat. Async because the claim
|
|
81
|
+
* is a broker round-trip; the caller `.catch`es (a convenience ping is never
|
|
82
|
+
* allowed to throw the quota-watch tick).
|
|
83
|
+
*/
|
|
84
|
+
export async function runPremiumRecoveryPing(deps: PremiumRecoveryPingDeps): Promise<void> {
|
|
85
|
+
const agentDir = deps.getAgentDir()
|
|
86
|
+
if (!agentDir) return
|
|
87
|
+
const marker = deps.readMarker(agentDir)
|
|
88
|
+
if (marker == null) return
|
|
89
|
+
// Pure decision gate: on `false` leave the marker in place (a later tick fires
|
|
90
|
+
// once the tier recovers) — no clear, no claim, no send.
|
|
91
|
+
if (!deps.decide()) return
|
|
92
|
+
const agent = deps.getAgent()
|
|
93
|
+
// Fleet-wide dedup: a fresh gateway boot or a second tick inside the window
|
|
94
|
+
// must not re-send. Fail-open — a convenience ping degrades to at-least-once,
|
|
95
|
+
// never lost.
|
|
96
|
+
const granted = await deps.claimNotification(
|
|
97
|
+
premiumRecoveryClaimKey(agent, marker.premiumModel),
|
|
98
|
+
)
|
|
99
|
+
if (!granted) {
|
|
100
|
+
// Another gateway already owns this recovery ping — consume our marker so it
|
|
101
|
+
// can't linger and re-attempt every tick, and bail (NO send).
|
|
102
|
+
deps.clearMarker(agentDir)
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
// Consume the marker BEFORE sending (never-storm: at-most-once is the hard
|
|
106
|
+
// requirement for this ping; a transient send fault is covered by the
|
|
107
|
+
// caller's retry policy, whereas a double-send is not recoverable).
|
|
108
|
+
deps.clearMarker(agentDir)
|
|
109
|
+
const ping = renderPremiumRecoveryPing(marker.premiumModel)
|
|
110
|
+
const keyboard: PremiumRecoveryKeyboard = {
|
|
111
|
+
inline_keyboard: [
|
|
112
|
+
[{ text: ping.buttonText, callback_data: `${MODEL_CALLBACK_ALIAS}${marker.premiumModel}` }],
|
|
113
|
+
],
|
|
114
|
+
}
|
|
115
|
+
const chats = marker.chats.length > 0 ? marker.chats : deps.fallbackChats()
|
|
116
|
+
for (const chatId of chats) {
|
|
117
|
+
deps.sendToChat(chatId, ping, keyboard)
|
|
118
|
+
}
|
|
119
|
+
deps.log(
|
|
120
|
+
`[premium-recovery] ${marker.premiumModel} servable again — ping sent agent=${agent} chats=${chats.length}`,
|
|
121
|
+
)
|
|
122
|
+
}
|
|
@@ -230,3 +230,106 @@ export function clearSessionEffortFile(agentDir: string): void {
|
|
|
230
230
|
/* best-effort */
|
|
231
231
|
}
|
|
232
232
|
}
|
|
233
|
+
|
|
234
|
+
// ─── Consume-once premium-recovery marker (tier-downgrade companion) ─────────
|
|
235
|
+
//
|
|
236
|
+
// A DURABLE marker the tier-downgrade writes when it walls a premium `/model`
|
|
237
|
+
// selection fleet-wide. It records the DROPPED premium token + the chats to
|
|
238
|
+
// notify, and survives the downgrade self-restart (start.sh never touches this
|
|
239
|
+
// name — unlike `.session-model`, which is consumed at boot). The gateway's
|
|
240
|
+
// `runQuotaWatch` tick reads it, and when the broker's `list-state` shows the
|
|
241
|
+
// premium tier servable again (deterministic per-account eligibility), fires
|
|
242
|
+
// EXACTLY ONE "available again" ping with a one-tap switch-back button, then
|
|
243
|
+
// clears the marker (at-most-once). Also cleared the instant the user re-issues
|
|
244
|
+
// `/model <premium>` manually (no stale ping). Shape-gated like the carriers
|
|
245
|
+
// above — the model token passes the same MODEL_ARG_RE gate.
|
|
246
|
+
|
|
247
|
+
export const PREMIUM_RECOVERY_FILE = '.premium-recovery'
|
|
248
|
+
|
|
249
|
+
export interface PremiumRecoveryRecord {
|
|
250
|
+
/** The dropped premium `/model` token (e.g. `fable`) to offer switching back to. */
|
|
251
|
+
premiumModel: string
|
|
252
|
+
/** Telegram chat ids to ping on recovery (the downgrade notice's allowFrom). */
|
|
253
|
+
chats: string[]
|
|
254
|
+
ts: number
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Parse `.premium-recovery` content. Null on corrupt JSON, a missing/wrong-typed
|
|
259
|
+
* field, a model token that fails the MODEL_ARG_RE shape gate, or a `chats`
|
|
260
|
+
* array that is not a non-empty list of non-empty strings.
|
|
261
|
+
*/
|
|
262
|
+
export function parsePremiumRecovery(text: string): PremiumRecoveryRecord | null {
|
|
263
|
+
try {
|
|
264
|
+
const raw = JSON.parse(text) as Partial<PremiumRecoveryRecord>
|
|
265
|
+
if (
|
|
266
|
+
typeof raw.premiumModel !== 'string' ||
|
|
267
|
+
!isValidModelArg(raw.premiumModel) ||
|
|
268
|
+
typeof raw.ts !== 'number' ||
|
|
269
|
+
!Array.isArray(raw.chats) ||
|
|
270
|
+
raw.chats.length === 0 ||
|
|
271
|
+
!raw.chats.every((c) => typeof c === 'string' && c.length > 0)
|
|
272
|
+
) {
|
|
273
|
+
return null
|
|
274
|
+
}
|
|
275
|
+
return { premiumModel: raw.premiumModel, chats: raw.chats, ts: raw.ts }
|
|
276
|
+
} catch {
|
|
277
|
+
return null
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Write the premium-recovery marker. Throws on a non-canonical token (parity
|
|
283
|
+
* with the carriers) or an empty chat list — a marker with nowhere to ping is a
|
|
284
|
+
* bug, not a silent no-op.
|
|
285
|
+
*/
|
|
286
|
+
export function writePremiumRecoveryFile(
|
|
287
|
+
agentDir: string,
|
|
288
|
+
premiumModel: string,
|
|
289
|
+
chats: string[],
|
|
290
|
+
): void {
|
|
291
|
+
if (!isValidModelArg(premiumModel)) {
|
|
292
|
+
throw new Error(`refusing to persist non-canonical premium-recovery token: ${JSON.stringify(premiumModel)}`)
|
|
293
|
+
}
|
|
294
|
+
const clean = chats.filter((c) => typeof c === 'string' && c.length > 0)
|
|
295
|
+
if (clean.length === 0) {
|
|
296
|
+
throw new Error('refusing to persist premium-recovery marker with no chats to notify')
|
|
297
|
+
}
|
|
298
|
+
atomicWrite(
|
|
299
|
+
join(agentDir, PREMIUM_RECOVERY_FILE),
|
|
300
|
+
`${JSON.stringify({ premiumModel, chats: clean, ts: Date.now() })}\n`,
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Parsed premium-recovery marker, or null when absent/corrupt. A present-but-
|
|
306
|
+
* corrupt marker (null parse of a file that exists) is SWEPT on read: neither
|
|
307
|
+
* the ping path nor `clearPremiumRecoveryOnManualSwitch` can act on a null
|
|
308
|
+
* parse, so a garbled file would otherwise linger forever. Deleting it here is
|
|
309
|
+
* best-effort hygiene — the read still returns null either way.
|
|
310
|
+
*/
|
|
311
|
+
export function readPremiumRecoveryFile(agentDir: string): PremiumRecoveryRecord | null {
|
|
312
|
+
let raw: string
|
|
313
|
+
try {
|
|
314
|
+
raw = readFileSync(join(agentDir, PREMIUM_RECOVERY_FILE), 'utf8')
|
|
315
|
+
} catch {
|
|
316
|
+
return null // absent / unreadable — nothing on disk to sweep.
|
|
317
|
+
}
|
|
318
|
+
const parsed = parsePremiumRecovery(raw)
|
|
319
|
+
if (parsed == null) {
|
|
320
|
+
// Corrupt marker present on disk — garbage-collect it so it can't wedge the
|
|
321
|
+
// recovery path (best-effort; a failed unlink still returns null).
|
|
322
|
+
clearPremiumRecoveryFile(agentDir)
|
|
323
|
+
return null
|
|
324
|
+
}
|
|
325
|
+
return parsed
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Delete the premium-recovery marker (consumed on ping / manual re-issue). Best-effort. */
|
|
329
|
+
export function clearPremiumRecoveryFile(agentDir: string): void {
|
|
330
|
+
try {
|
|
331
|
+
rmSync(join(agentDir, PREMIUM_RECOVERY_FILE), { force: true })
|
|
332
|
+
} catch {
|
|
333
|
+
/* best-effort */
|
|
334
|
+
}
|
|
335
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tier-downgrade-wiring.ts — the gateway GLUE for the model-tier downgrade,
|
|
3
|
+
* behind injected deps so the orchestration is unit-testable without importing
|
|
4
|
+
* the whole gateway (mirrors throttle-tier-wiring.ts).
|
|
5
|
+
*
|
|
6
|
+
* The pure decision (`decideTierDowngrade`), the give-up/suppress routing
|
|
7
|
+
* (`planTierDowngrade`), and the user-facing wording (`renderTierDowngradeNotice`)
|
|
8
|
+
* all live in tier-downgrade.ts. This module owns the ORDER-SENSITIVE side
|
|
9
|
+
* effects the gateway performs off those verdicts — and the invariants a review
|
|
10
|
+
* cares about:
|
|
11
|
+
*
|
|
12
|
+
* - the resume gate is PEEKed (not armed) first, so the fallible carrier write
|
|
13
|
+
* happens before the single-flight latch is committed;
|
|
14
|
+
* - the consume-once `.session-model` carrier is written BEFORE the arm; a
|
|
15
|
+
* throwing write aborts the downgrade and leaves NO armed latch;
|
|
16
|
+
* - the latch is armed only AFTER a successful carrier write;
|
|
17
|
+
* - the durable premium-recovery marker write is best-effort (a failure never
|
|
18
|
+
* aborts the downgrade — the recovery ping simply doesn't arm);
|
|
19
|
+
* - the broadcast notice is the HONEST resume-on-default text (never a
|
|
20
|
+
* revert-to-premium promise) and is sent only on a real downgrade;
|
|
21
|
+
* - a `skip-inflight` resume-gate verdict returns `restart-pending` and emits
|
|
22
|
+
* NO notice and NO restart (the concurrent turn's armed restart resumes).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
decideTierDowngrade,
|
|
27
|
+
planTierDowngrade,
|
|
28
|
+
type ResumeGateVerdict,
|
|
29
|
+
} from '../tier-downgrade.js'
|
|
30
|
+
|
|
31
|
+
export type TierDowngradeOutcome = 'downgraded' | 'restart-pending' | 'skip'
|
|
32
|
+
|
|
33
|
+
export interface TierDowngradeRunnerDeps {
|
|
34
|
+
/** Bind-mounted agent state dir, or null when unresolvable (→ skip). */
|
|
35
|
+
getAgentDir: () => string | null
|
|
36
|
+
/** Resolved configured-default token; '' / null → unresolved (never downgrade blind). */
|
|
37
|
+
getConfiguredDefault: () => string | null
|
|
38
|
+
/** Live session `/model` override, or null on the plain configured default. */
|
|
39
|
+
getSessionOverride: () => string | null
|
|
40
|
+
/** Model canonicalizer (the gateway passes resolveMainModel). */
|
|
41
|
+
resolve: (token: string) => string
|
|
42
|
+
/** PEEK the resume gate WITHOUT arming it. */
|
|
43
|
+
peekResumeGate: () => ResumeGateVerdict
|
|
44
|
+
/** Write the consume-once `.session-model` carrier for the default. May throw. */
|
|
45
|
+
writeCarrier: (agentDir: string, toModel: string, configuredDefault: string) => void
|
|
46
|
+
/** Commit the single-flight arm — called ONLY after a successful carrier write. */
|
|
47
|
+
armResumeGate: () => void
|
|
48
|
+
/** Persist the durable premium-recovery marker (best-effort; may throw — caught). */
|
|
49
|
+
writeRecoveryMarker: (agentDir: string, premiumModel: string) => void
|
|
50
|
+
/** Broadcast the honest downgrade notice to the operator chats. */
|
|
51
|
+
broadcastNotice: (markdown: string) => void
|
|
52
|
+
/** Fire the resume self-restart for `agent`. */
|
|
53
|
+
selfRestart: (agent: string) => void
|
|
54
|
+
/** The agent whose session self-restarts (SWITCHROOM_AGENT_NAME ?? triggerAgent). */
|
|
55
|
+
selfAgent: (triggerAgent: string) => string
|
|
56
|
+
/** Structured logger (stderr in prod, captured in test). */
|
|
57
|
+
log: (msg: string) => void
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Run the tier-downgrade glue. Returns:
|
|
62
|
+
* 'downgraded' — carrier written, latch armed, notice broadcast, restart fired.
|
|
63
|
+
* 'restart-pending' — a resume restart is already armed (concurrent turn); no
|
|
64
|
+
* notice, no restart (its restart replays the dead turn).
|
|
65
|
+
* 'skip' — not applicable / carrier write failed; caller falls
|
|
66
|
+
* through to its all-blocked give-up card.
|
|
67
|
+
*/
|
|
68
|
+
export function runTierDowngrade(
|
|
69
|
+
triggerAgent: string,
|
|
70
|
+
deps: TierDowngradeRunnerDeps,
|
|
71
|
+
): TierDowngradeOutcome {
|
|
72
|
+
const agentDir = deps.getAgentDir()
|
|
73
|
+
if (!agentDir) return 'skip'
|
|
74
|
+
const configuredDefault = deps.getConfiguredDefault() ?? ''
|
|
75
|
+
const decision = decideTierDowngrade({
|
|
76
|
+
sessionOverride: deps.getSessionOverride(),
|
|
77
|
+
configuredDefault,
|
|
78
|
+
resolve: deps.resolve,
|
|
79
|
+
})
|
|
80
|
+
// PEEK (no arm): the fallible carrier write must precede the latch commit.
|
|
81
|
+
const gateVerdict = deps.peekResumeGate()
|
|
82
|
+
const plan = planTierDowngrade(decision, gateVerdict, triggerAgent)
|
|
83
|
+
if (plan.kind === 'skip') {
|
|
84
|
+
if (decision.action === 'downgrade') {
|
|
85
|
+
deps.log(`[tier-downgrade] restart suppressed (${gateVerdict}) agent=${triggerAgent}`)
|
|
86
|
+
}
|
|
87
|
+
return 'skip'
|
|
88
|
+
}
|
|
89
|
+
if (plan.kind === 'suppress') {
|
|
90
|
+
deps.log(
|
|
91
|
+
`[tier-downgrade] give-up suppressed — a resume restart is already armed agent=${triggerAgent}`,
|
|
92
|
+
)
|
|
93
|
+
return 'restart-pending'
|
|
94
|
+
}
|
|
95
|
+
// plan.kind === 'downgrade'. Carrier BEFORE arm: a throwing write must not
|
|
96
|
+
// leave an armed latch with no pending restart.
|
|
97
|
+
try {
|
|
98
|
+
deps.writeCarrier(agentDir, plan.toModel, configuredDefault)
|
|
99
|
+
} catch (err) {
|
|
100
|
+
deps.log(
|
|
101
|
+
`[tier-downgrade] failed to write session-model carrier — aborting downgrade: ${(err as Error)?.message ?? err}`,
|
|
102
|
+
)
|
|
103
|
+
return 'skip'
|
|
104
|
+
}
|
|
105
|
+
deps.armResumeGate()
|
|
106
|
+
deps.log(
|
|
107
|
+
`[tier-downgrade] downgrading ${plan.fromModel} → ${plan.toModel} and resuming via self-restart agent=${triggerAgent}`,
|
|
108
|
+
)
|
|
109
|
+
// Durable premium-recovery marker — best-effort (survives the restart; the
|
|
110
|
+
// downgrade is the critical path and must not abort on a marker failure).
|
|
111
|
+
try {
|
|
112
|
+
deps.writeRecoveryMarker(agentDir, plan.fromModel)
|
|
113
|
+
} catch (err) {
|
|
114
|
+
deps.log(
|
|
115
|
+
`[tier-downgrade] premium-recovery marker write failed (non-fatal): ${(err as Error)?.message ?? err}`,
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
deps.broadcastNotice(plan.notice)
|
|
119
|
+
deps.selfRestart(deps.selfAgent(triggerAgent))
|
|
120
|
+
return 'downgraded'
|
|
121
|
+
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { GrammyError, HttpError } from 'grammy'
|
|
14
14
|
|
|
15
|
-
import { FLOOD_WAIT_ACTIVE } from '../retry-api-call.js'
|
|
15
|
+
import { FLOOD_WAIT_ACTIVE, LOCAL_RESOURCE_EXHAUSTED } from '../retry-api-call.js'
|
|
16
16
|
|
|
17
17
|
export type RejectionAction = 'shutdown' | 'log_only'
|
|
18
18
|
|
|
@@ -78,6 +78,19 @@ export function classifyRejection(
|
|
|
78
78
|
// exact amplification the #2923 circuit breaker exists to stop.
|
|
79
79
|
if (err instanceof Error && err.message === FLOOD_WAIT_ACTIVE) return 'log_only'
|
|
80
80
|
|
|
81
|
+
// LOCAL_RESOURCE_EXHAUSTED (#3099, sibling of FLOOD_WAIT_ACTIVE above):
|
|
82
|
+
// retry-api-call throws this plain Error marker when a send fails on a LOCAL
|
|
83
|
+
// disk/memory exhaustion (ENOSPC/EDQUOT/EIO/ENOMEM) rather than retrying it
|
|
84
|
+
// (#2923) — retrying a local-resource failure in a tight loop is what tripped
|
|
85
|
+
// the per-bot flood ban in the first place. A leaked one (a fire-and-forget
|
|
86
|
+
// send that wasn't wrapped in swallowingApiCall) must NOT crash the gateway:
|
|
87
|
+
// the box is ALREADY out of disk/memory, and a crash→restart drives a fresh
|
|
88
|
+
// round of boot-time sends and staging writes at a resource that is already
|
|
89
|
+
// exhausted — the exact amplification the #2923 marker exists to avoid. The
|
|
90
|
+
// degraded-state marker already carries this signal; a crash loop is the
|
|
91
|
+
// wrong way to surface a full disk. Same log_only posture as its sibling.
|
|
92
|
+
if (err instanceof Error && err.message === LOCAL_RESOURCE_EXHAUSTED) return 'log_only'
|
|
93
|
+
|
|
81
94
|
if (!isGrammy) return 'shutdown'
|
|
82
95
|
|
|
83
96
|
const e = err as { error_code?: number; description?: string }
|