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.
Files changed (54) hide show
  1. package/dist/agent-scheduler/index.js +13 -0
  2. package/dist/auth-broker/index.js +13 -0
  3. package/dist/cli/notion-write-pretool.mjs +13 -0
  4. package/dist/cli/switchroom.js +605 -479
  5. package/dist/host-control/main.js +17 -1
  6. package/dist/vault/approvals/kernel-server.js +13 -0
  7. package/dist/vault/broker/server.js +13 -0
  8. package/package.json +1 -1
  9. package/telegram-plugin/bridge/bridge.ts +7 -1
  10. package/telegram-plugin/dist/bridge/bridge.js +26 -1
  11. package/telegram-plugin/dist/gateway/gateway.js +1401 -431
  12. package/telegram-plugin/dist/server.js +26 -1
  13. package/telegram-plugin/fleet-fallback-resume.ts +26 -3
  14. package/telegram-plugin/gateway/approval-hold.ts +49 -0
  15. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +61 -18
  16. package/telegram-plugin/gateway/gateway.ts +362 -71
  17. package/telegram-plugin/gateway/linear-activity.ts +20 -4
  18. package/telegram-plugin/gateway/premium-recovery-wiring.ts +122 -0
  19. package/telegram-plugin/gateway/session-model-file.ts +103 -0
  20. package/telegram-plugin/gateway/tier-downgrade-wiring.ts +121 -0
  21. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +14 -1
  22. package/telegram-plugin/llm-error-present.ts +436 -0
  23. package/telegram-plugin/operator-events.ts +7 -1
  24. package/telegram-plugin/permission-title.ts +172 -10
  25. package/telegram-plugin/premium-recovery.ts +101 -0
  26. package/telegram-plugin/raw-error-scrub.ts +73 -0
  27. package/telegram-plugin/retry-api-call.ts +8 -2
  28. package/telegram-plugin/send-gate-degraded.test.ts +152 -1
  29. package/telegram-plugin/send-gate-observability.test.ts +140 -0
  30. package/telegram-plugin/send-gate-observability.ts +65 -20
  31. package/telegram-plugin/send-gate.test.ts +143 -1
  32. package/telegram-plugin/send-gate.ts +212 -19
  33. package/telegram-plugin/session-tail.ts +16 -0
  34. package/telegram-plugin/shared/local-time.ts +69 -0
  35. package/telegram-plugin/tests/approval-hold-harness.ts +6 -6
  36. package/telegram-plugin/tests/approval-hold-outcome.test.ts +10 -2
  37. package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +61 -0
  38. package/telegram-plugin/tests/fleet-fallback-resume.test.ts +39 -0
  39. package/telegram-plugin/tests/flood-windows-persistence.test.ts +3 -2
  40. package/telegram-plugin/tests/linear-create-issue.test.ts +30 -2
  41. package/telegram-plugin/tests/llm-error-present.test.ts +380 -0
  42. package/telegram-plugin/tests/permission-title.test.ts +167 -4
  43. package/telegram-plugin/tests/premium-recovery-wiring.test.ts +150 -0
  44. package/telegram-plugin/tests/premium-recovery.test.ts +165 -0
  45. package/telegram-plugin/tests/reaction-gate-routing.test.ts +6 -1
  46. package/telegram-plugin/tests/retry-api-call.test.ts +21 -0
  47. package/telegram-plugin/tests/tier-downgrade-wiring.test.ts +165 -0
  48. package/telegram-plugin/tests/tier-downgrade.test.ts +141 -0
  49. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +27 -1
  50. package/telegram-plugin/tests/worker-activity-feed.test.ts +5 -2
  51. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +492 -0
  52. package/telegram-plugin/tier-downgrade.ts +198 -0
  53. package/telegram-plugin/tool-activity-summary.ts +99 -0
  54. 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<!-- switchroom-capture: ${dedupKey} -->`
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 hit = (search.data?.searchIssues?.nodes ?? [])[0] as { url?: string } | undefined
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 create (gateway seen-set is primary).
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 }