switchroom 0.19.48 → 0.20.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/bin/handoff-briefing.sh +213 -74
- package/dist/agent-scheduler/index.js +18 -1
- package/dist/auth-broker/index.js +19 -2
- package/dist/buzz-gateway/index.js +9367 -0
- package/dist/cli/notion-write-pretool.mjs +18 -1
- package/dist/cli/switchroom.js +24734 -16371
- package/dist/host-control/main.js +59 -9
- package/dist/vault/approvals/kernel-server.js +19 -2
- package/dist/vault/broker/server.js +19 -2
- package/package.json +6 -4
- package/profiles/_base/start.sh.hbs +148 -2
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/skills/dev-protocol/SKILL.md +30 -1
- package/skills/switchroom-architecture/SKILL.md +5 -0
- package/skills/switchroom-cli/SKILL.md +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +7 -4
- package/telegram-plugin/dist/gateway/gateway.js +2376 -1039
- package/telegram-plugin/dist/server.js +7 -4
- package/telegram-plugin/gateway/access-store.test.ts +234 -0
- package/telegram-plugin/gateway/access-store.ts +194 -0
- package/telegram-plugin/gateway/boot-briefing-builder.ts +586 -0
- package/telegram-plugin/gateway/boot-briefing-capability.ts +31 -0
- package/telegram-plugin/gateway/boot-briefing-wiring.ts +332 -0
- package/telegram-plugin/gateway/buzz-mirror-correlation-store.ts +285 -0
- package/telegram-plugin/gateway/buzz-mirror.ts +494 -0
- package/telegram-plugin/gateway/buzz-type-guards.ts +34 -0
- package/telegram-plugin/gateway/channel-route.ts +272 -0
- package/telegram-plugin/gateway/gateway.ts +115 -203
- package/telegram-plugin/gateway/inbound-router.ts +93 -3
- package/telegram-plugin/gateway/inbound-spool.ts +33 -1
- package/telegram-plugin/gateway/ipc-protocol.ts +81 -2
- package/telegram-plugin/gateway/ipc-server.ts +197 -2
- package/telegram-plugin/gateway/outbound-send-path.ts +85 -2
- package/telegram-plugin/gateway/pending-turn-env.ts +70 -0
- package/telegram-plugin/gateway/stream-render.ts +21 -0
- package/telegram-plugin/gateway/subagent-handback-marker.ts +12 -0
- package/telegram-plugin/gateway/user-failure-notices.ts +172 -0
- package/telegram-plugin/history.ts +15 -0
- package/telegram-plugin/llm-error-present.ts +9 -4
- package/telegram-plugin/model-unavailable.ts +4 -0
- package/telegram-plugin/operator-events.fixtures.json +12 -12
- package/telegram-plugin/operator-events.ts +81 -9
- package/telegram-plugin/session-tail.ts +7 -1
- package/telegram-plugin/tests/boot-briefing-builder.test.ts +995 -0
- package/telegram-plugin/tests/buzz-mirror-correlation-store.test.ts +173 -0
- package/telegram-plugin/tests/buzz-mirror.test.ts +538 -0
- package/telegram-plugin/tests/buzz-origin-stamp-gate.test.ts +159 -0
- package/telegram-plugin/tests/channel-route.test.ts +306 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +47 -0
- package/telegram-plugin/tests/ipc-server-buzz-dedup.test.ts +124 -0
- package/telegram-plugin/tests/ipc-server-buzz-peer.test.ts +269 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +63 -0
- package/telegram-plugin/tests/operator-events.test.ts +71 -7
- package/telegram-plugin/tests/outbound-send-path.test.ts +24 -0
- package/telegram-plugin/tests/reply-to-buffer-fallback.test.ts +273 -0
- package/telegram-plugin/tests/reply-to-buffer-history.test.ts +134 -0
- package/telegram-plugin/tests/user-failure-notices.test.ts +165 -0
- package/telegram-plugin/voice-normalize-text.ts +5 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +4 -0
- package/vendor/hindsight-memory/scripts/recall.py +7 -2
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* user-failure-notices.ts — gateway-side handling of the plain-language
|
|
3
|
+
* "couldn't complete that" user notice, plus the `transport-transient`
|
|
4
|
+
* classifier kind (a mid-response stream abort reaching Anthropic).
|
|
5
|
+
*
|
|
6
|
+
* Two concerns, one subsystem (the plain user-failure-notice send side):
|
|
7
|
+
*
|
|
8
|
+
* 1. `emitTransportTransientEvent` — the calm path for a `transport-transient`
|
|
9
|
+
* operator event. Claude Code emits `error: "server_error"` with NO HTTP
|
|
10
|
+
* status on a mid-response stream abort; that is a transport failure, not an
|
|
11
|
+
* account/auth/quota fault, and Claude retries it internally. So there is NO
|
|
12
|
+
* broadcast card (a Reauth card would be the wrong remedy and reach every
|
|
13
|
+
* chat). Instead: record for `/status` history, and DEFER one plain user
|
|
14
|
+
* notice to the turn-end gate (`pending-user-notice.ts`) so a genuinely
|
|
15
|
+
* reply-less dead turn still gets one calm line while a recovered retry
|
|
16
|
+
* sends nothing. A BURST — >=3 terminal transport-transient events for one
|
|
17
|
+
* agent within ~10min — escalates to exactly ONE operator-only, Dismiss-only
|
|
18
|
+
* card ("repeated stream failures reaching Anthropic"), so honest
|
|
19
|
+
* suppression never hides a genuinely degraded pipe.
|
|
20
|
+
*
|
|
21
|
+
* 2. `flushDeferredUserNotices` — the turn-end send loop for notices the gate
|
|
22
|
+
* released (moved here from the gateway; see #3293 finding 1).
|
|
23
|
+
*
|
|
24
|
+
* Pure orchestration: every side effect (record, schedule, resolve, send, log,
|
|
25
|
+
* clock) crosses in through `UserFailureNoticeDeps`, so the outcomes are
|
|
26
|
+
* unit-testable with fakes and no gateway/grammy/IPC coupling leaks in.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { escapeMarkdown } from '../format.js'
|
|
30
|
+
import type { InlineKeyboardMarkup, OperatorEvent } from '../operator-events.js'
|
|
31
|
+
import type { PendingUserNotice } from '../pending-user-notice.js'
|
|
32
|
+
|
|
33
|
+
// ─── transport-transient escalation counter (pure) ───────────────────────────
|
|
34
|
+
|
|
35
|
+
const ESCALATION_WINDOW_MS = 10 * 60_000
|
|
36
|
+
const ESCALATION_THRESHOLD = 3
|
|
37
|
+
|
|
38
|
+
/** Per-agent sliding window of terminal transport-transient event timestamps. */
|
|
39
|
+
const transportEscalation = new Map<string, number[]>()
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Record one terminal transport-transient event for `agent` at `now` and decide
|
|
43
|
+
* whether it crosses the escalation threshold. Returns `true` at most once per
|
|
44
|
+
* burst: crossing the threshold RESETS the window so a sustained outage emits
|
|
45
|
+
* one operator card per ~`windowMs`, not one per event.
|
|
46
|
+
*/
|
|
47
|
+
export function noteTransportTransientAndShouldEscalate(
|
|
48
|
+
agent: string,
|
|
49
|
+
now: number,
|
|
50
|
+
opts?: { windowMs?: number; threshold?: number },
|
|
51
|
+
): boolean {
|
|
52
|
+
const windowMs = opts?.windowMs ?? ESCALATION_WINDOW_MS
|
|
53
|
+
const threshold = opts?.threshold ?? ESCALATION_THRESHOLD
|
|
54
|
+
const recent = (transportEscalation.get(agent) ?? []).filter((t) => now - t < windowMs)
|
|
55
|
+
recent.push(now)
|
|
56
|
+
if (recent.length >= threshold) {
|
|
57
|
+
transportEscalation.set(agent, [])
|
|
58
|
+
return true
|
|
59
|
+
}
|
|
60
|
+
transportEscalation.set(agent, recent)
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Test/reauth-recovery helper: forget the escalation window (all or one agent). */
|
|
65
|
+
export function resetTransportTransientEscalation(agent?: string): void {
|
|
66
|
+
if (agent == null) transportEscalation.clear()
|
|
67
|
+
else transportEscalation.delete(agent)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The ONE operator-only card a transport-transient BURST escalates to. Dismiss-
|
|
72
|
+
* only — no Reauth (a dropped stream is not a credential fault), no failover
|
|
73
|
+
* (every account hits the same degraded pipe).
|
|
74
|
+
*/
|
|
75
|
+
export function renderTransportEscalationCard(agent: string): {
|
|
76
|
+
text: string
|
|
77
|
+
keyboard: InlineKeyboardMarkup
|
|
78
|
+
} {
|
|
79
|
+
const a = escapeMarkdown(agent)
|
|
80
|
+
return {
|
|
81
|
+
text: [
|
|
82
|
+
`🔌 **Repeated stream failures** reaching Anthropic for **${a}**.`,
|
|
83
|
+
`3+ mid-response aborts within ~10 min. Turns retry automatically; if this persists, Anthropic's API may be degraded.`,
|
|
84
|
+
].join('\n'),
|
|
85
|
+
keyboard: {
|
|
86
|
+
inline_keyboard: [
|
|
87
|
+
[{ text: '❌ Dismiss', callback_data: `op:dismiss:${encodeURIComponent(agent)}` }],
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─── Deps + orchestration ────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
export interface UserFailureNoticeDeps {
|
|
96
|
+
/** Injectable clock. */
|
|
97
|
+
now(): number
|
|
98
|
+
/** Allowlist chats (broadcast audience). */
|
|
99
|
+
allowFrom(): readonly string[]
|
|
100
|
+
/** Topic key of the live turn, or undefined (agent-level / between turns). */
|
|
101
|
+
liveTurnKey(): string | undefined
|
|
102
|
+
/** Persist to /status history (best-effort — must swallow its own errors). */
|
|
103
|
+
record(event: OperatorEvent): void
|
|
104
|
+
/** Schedule the turn-end-gated plain user notice for `chatIds`. */
|
|
105
|
+
scheduleUserNotice(input: {
|
|
106
|
+
chatIds: string[]
|
|
107
|
+
agent: string
|
|
108
|
+
kind: string
|
|
109
|
+
key: string | undefined
|
|
110
|
+
atMs: number
|
|
111
|
+
}): void
|
|
112
|
+
/** Release the notices resolved by this turn end (may be empty). */
|
|
113
|
+
resolveNotices(turnDeliveredReply: boolean, turnKey: string): PendingUserNotice[]
|
|
114
|
+
/** Topic-aware raw send (fire-and-forget). `keyboard` omitted → plain message. */
|
|
115
|
+
send(chatId: string, text: string, keyboard?: InlineKeyboardMarkup): void
|
|
116
|
+
/** stderr line (the gateway prefixes "telegram gateway: "). */
|
|
117
|
+
log(msg: string): void
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Handle a `transport-transient` operator event: record it, defer the plain
|
|
122
|
+
* user notice to turn-end, and — on a burst — post exactly one operator-only
|
|
123
|
+
* escalation card. Never sends a broadcast card.
|
|
124
|
+
*/
|
|
125
|
+
export function emitTransportTransientEvent(
|
|
126
|
+
event: OperatorEvent,
|
|
127
|
+
deps: UserFailureNoticeDeps,
|
|
128
|
+
): void {
|
|
129
|
+
const now = deps.now()
|
|
130
|
+
deps.record(event)
|
|
131
|
+
const escalate = noteTransportTransientAndShouldEscalate(event.agent, now)
|
|
132
|
+
deps.log(
|
|
133
|
+
`transport-transient agent=${event.agent} escalate=${escalate} ` +
|
|
134
|
+
`(no broadcast card; user-notice deferred to turn-end)`,
|
|
135
|
+
)
|
|
136
|
+
const allowFrom = deps.allowFrom()
|
|
137
|
+
if (allowFrom.length === 0) return
|
|
138
|
+
deps.scheduleUserNotice({
|
|
139
|
+
chatIds: [...allowFrom],
|
|
140
|
+
agent: event.agent,
|
|
141
|
+
kind: event.kind,
|
|
142
|
+
key: deps.liveTurnKey(),
|
|
143
|
+
atMs: now,
|
|
144
|
+
})
|
|
145
|
+
if (escalate) {
|
|
146
|
+
const card = renderTransportEscalationCard(event.agent)
|
|
147
|
+
deps.send(allowFrom[0], card.text, card.keyboard)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Turn-end resolution of deferred user failure notices (#3293 finding 1, moved
|
|
153
|
+
* out of gateway.ts). `turnDeliveredReply` is `finalAnswerDelivered ||
|
|
154
|
+
* replyCalled` — a delivered reply DROPS the notices (turn recovered); a
|
|
155
|
+
* reply-less end flushes them (turn genuinely died). `turnKey` scopes resolution
|
|
156
|
+
* to the ending turn's topic under keyed liveness.
|
|
157
|
+
*/
|
|
158
|
+
export function flushDeferredUserNotices(
|
|
159
|
+
turnDeliveredReply: boolean,
|
|
160
|
+
turnKey: string,
|
|
161
|
+
deps: UserFailureNoticeDeps,
|
|
162
|
+
): void {
|
|
163
|
+
const notices = deps.resolveNotices(turnDeliveredReply, turnKey)
|
|
164
|
+
if (notices.length === 0) return
|
|
165
|
+
for (const notice of notices) {
|
|
166
|
+
deps.log(
|
|
167
|
+
`user-notice flush (turn died reply-less) agent=${notice.agent} ` +
|
|
168
|
+
`kind=${notice.kind} chats=${notice.chatIds.length}`,
|
|
169
|
+
)
|
|
170
|
+
for (const chatId of notice.chatIds) deps.send(chatId, notice.text)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -373,6 +373,21 @@ export function verifyHistoryWritable(): { ok: boolean; error?: string } {
|
|
|
373
373
|
}
|
|
374
374
|
}
|
|
375
375
|
|
|
376
|
+
/**
|
|
377
|
+
* Read-only handle for the gateway boot-briefing builder
|
|
378
|
+
* (`gateway/boot-briefing-wiring.ts`). Exposes only the `prepare(...).all`
|
|
379
|
+
* subset the builder's `BriefingDb` seam needs; returns null before
|
|
380
|
+
* `initHistory` (or when history is disabled) so the briefing degrades to
|
|
381
|
+
* empty instead of throwing. Do NOT use for writes — every write path goes
|
|
382
|
+
* through the record* functions above so redaction and validity checks
|
|
383
|
+
* cannot be bypassed.
|
|
384
|
+
*/
|
|
385
|
+
export function getHistoryDbForBriefing(): {
|
|
386
|
+
prepare(sql: string): { all(...params: unknown[]): unknown[] }
|
|
387
|
+
} | null {
|
|
388
|
+
return db
|
|
389
|
+
}
|
|
390
|
+
|
|
376
391
|
/**
|
|
377
392
|
* For tests — close the singleton and forget it. Production code never
|
|
378
393
|
* needs this; the DB is held open for the lifetime of the process.
|
|
@@ -252,10 +252,15 @@ function classifyKindAndSource(text: string): { kind: LlmErrorKind; source: LlmE
|
|
|
252
252
|
if (claudeKind === 'rate-limited') {
|
|
253
253
|
return { kind: 'rate_limit', source: 'anthropic' }
|
|
254
254
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
}
|
|
258
|
-
|
|
255
|
+
// NOTE: we deliberately do NOT map the taxonomy's `unknown-5xx` to
|
|
256
|
+
// `overload_529` here. `classifyClaudeError` is called above with only
|
|
257
|
+
// {message, type} (no HTTP status), so `unknown-5xx` at this point is the
|
|
258
|
+
// taxonomy's neutral no-status DEFAULT — not evidence of a genuine 529/5xx.
|
|
259
|
+
// A real Anthropic overload/5xx always carries recognizable wording and is
|
|
260
|
+
// resolved earlier by detectModelUnavailable (step 3). Fabricating
|
|
261
|
+
// "Anthropic is overloaded (529) — retrying automatically" for an
|
|
262
|
+
// unrecognized error would be a false diagnosis, so it falls through to the
|
|
263
|
+
// honest `unknown` kind.
|
|
259
264
|
return { kind: 'unknown', source: 'anthropic' }
|
|
260
265
|
}
|
|
261
266
|
|
|
@@ -474,6 +474,10 @@ export function detectModelUnavailable(
|
|
|
474
474
|
'socket hang up',
|
|
475
475
|
'request timed out',
|
|
476
476
|
'connection refused',
|
|
477
|
+
// Mid-response stream aborts reaching Anthropic surface these markers
|
|
478
|
+
// (a transport failure, not an account/quota fault) — treat as network.
|
|
479
|
+
'connection closed',
|
|
480
|
+
'mid-response',
|
|
477
481
|
'getaddrinfo',
|
|
478
482
|
]
|
|
479
483
|
if (networkSignals.some(s => lower.includes(s))) {
|
|
@@ -119,18 +119,6 @@
|
|
|
119
119
|
"_source": "Bare 403 with no recognised type",
|
|
120
120
|
"status": 403,
|
|
121
121
|
"message": "Forbidden"
|
|
122
|
-
},
|
|
123
|
-
{
|
|
124
|
-
"_source": "null input — always unknown-4xx",
|
|
125
|
-
"_value": null
|
|
126
|
-
},
|
|
127
|
-
{
|
|
128
|
-
"_source": "Completely empty object",
|
|
129
|
-
"_value": {}
|
|
130
|
-
},
|
|
131
|
-
{
|
|
132
|
-
"_source": "Non-object string",
|
|
133
|
-
"_value": "something went wrong"
|
|
134
122
|
}
|
|
135
123
|
],
|
|
136
124
|
"unknown-5xx": [
|
|
@@ -146,6 +134,18 @@
|
|
|
146
134
|
"type": "service_unavailable",
|
|
147
135
|
"message": "Service temporarily unavailable"
|
|
148
136
|
}
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
"_source": "null input — no status, neutral 5xx default (never a fabricated 4xx)",
|
|
140
|
+
"_value": null
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
"_source": "Completely empty object — no status → unknown-5xx",
|
|
144
|
+
"_value": {}
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
"_source": "Non-object string — no status → unknown-5xx",
|
|
148
|
+
"_value": "something went wrong"
|
|
149
149
|
}
|
|
150
150
|
]
|
|
151
151
|
}
|
|
@@ -73,6 +73,17 @@ export const OPERATOR_EVENT_KINDS = [
|
|
|
73
73
|
'mcp-dependency-blocked',
|
|
74
74
|
'quota-exhausted',
|
|
75
75
|
'rate-limited',
|
|
76
|
+
/**
|
|
77
|
+
* A TRANSPORT / network failure reaching Anthropic — Claude Code emits
|
|
78
|
+
* `error: "server_error"` (or `api_error`, which Anthropic documents as an
|
|
79
|
+
* HTTP 500) with NO `apiErrorStatus` on a mid-response stream abort. It is a
|
|
80
|
+
* pipe/transport failure, NOT an account, auth, or quota fault: retryable, and
|
|
81
|
+
* recovered by Claude's own internal retry. Handled by the gateway's dedicated
|
|
82
|
+
* calm path (no broadcast card; record for /status + a turn-end-gated plain
|
|
83
|
+
* user notice; a burst escalates to ONE operator-only card) — deliberately
|
|
84
|
+
* NEVER a Reauth card. See `gateway/user-failure-notices.ts`.
|
|
85
|
+
*/
|
|
86
|
+
'transport-transient',
|
|
76
87
|
'agent-crashed',
|
|
77
88
|
'agent-restarted-unexpectedly',
|
|
78
89
|
'unknown-4xx',
|
|
@@ -110,19 +121,21 @@ export interface InlineKeyboardMarkup {
|
|
|
110
121
|
* Classify an error value from any source (Anthropic SDK throw, JSONL error
|
|
111
122
|
* field, etc.) into an OperatorEventKind.
|
|
112
123
|
*
|
|
113
|
-
* CONTRACT: never throws. Unfamiliar shapes fall through to unknown-
|
|
114
|
-
* unknown-
|
|
124
|
+
* CONTRACT: never throws. Unfamiliar shapes fall through to unknown-5xx or
|
|
125
|
+
* unknown-4xx based on HTTP status. With NO status the neutral default is
|
|
126
|
+
* unknown-5xx — an unrecognized shape with no HTTP status must NOT fabricate a
|
|
127
|
+
* "4xx" label (whose card historically carried a wrong Reauth remedy).
|
|
115
128
|
*/
|
|
116
129
|
export function classifyClaudeError(raw: unknown): OperatorEventKind {
|
|
117
130
|
try {
|
|
118
131
|
return classifyInner(raw)
|
|
119
132
|
} catch {
|
|
120
|
-
return 'unknown-
|
|
133
|
+
return 'unknown-5xx'
|
|
121
134
|
}
|
|
122
135
|
}
|
|
123
136
|
|
|
124
137
|
function classifyInner(raw: unknown): OperatorEventKind {
|
|
125
|
-
if (raw == null) return 'unknown-
|
|
138
|
+
if (raw == null) return 'unknown-5xx'
|
|
126
139
|
|
|
127
140
|
// Extract common fields defensively — never throw on bad shapes.
|
|
128
141
|
const obj = typeof raw === 'object' ? (raw as Record<string, unknown>) : {}
|
|
@@ -258,13 +271,48 @@ function classifyInner(raw: unknown): OperatorEventKind {
|
|
|
258
271
|
return 'agent-restarted-unexpectedly'
|
|
259
272
|
}
|
|
260
273
|
|
|
274
|
+
// TRANSPORT / network failure reaching Anthropic. Claude Code emits
|
|
275
|
+
// `error: "server_error"` (and `api_error`, documented as HTTP 500) with NO
|
|
276
|
+
// `apiErrorStatus` on a mid-response stream abort — a pipe/transport failure,
|
|
277
|
+
// not an account/auth/quota fault. Without this branch it fell through to the
|
|
278
|
+
// status fallback below and FABRICATED `unknown-4xx`, whose card carried a
|
|
279
|
+
// wrong Reauth remedy and broadcast to every chat. It is retryable (Claude
|
|
280
|
+
// retries internally), so it maps to the calm `transport-transient` kind.
|
|
281
|
+
//
|
|
282
|
+
// Placed AFTER the auth-wording matches above ON PURPOSE: a genuine auth
|
|
283
|
+
// failure that upstream mislabels `server_error` still classifies as a
|
|
284
|
+
// credential fault. Matched only on EXACT type/code equality (never a message
|
|
285
|
+
// substring) so an unrelated error whose text merely mentions "server error"
|
|
286
|
+
// is untouched.
|
|
287
|
+
//
|
|
288
|
+
// GUARDED to status-less OR 5xx shapes (`status == null || status >= 500`): a
|
|
289
|
+
// mid-response abort carries NO status, and a genuine 500 is server-side. But
|
|
290
|
+
// LiteLLM stamps a generic `type: "api_error"` onto WRAPPED upstream faults
|
|
291
|
+
// that DO carry a 4xx status (e.g. a 401), and session-tail threads that
|
|
292
|
+
// `apiErrorStatus` through as `status`. Without this guard such a 401 would be
|
|
293
|
+
// swallowed as transport-transient (silent, no card) when it must reach the
|
|
294
|
+
// status fallback below and surface an operator card. The status wins.
|
|
295
|
+
if (
|
|
296
|
+
(status == null || status >= 500) &&
|
|
297
|
+
(errorType === 'server_error' ||
|
|
298
|
+
errorCode === 'server_error' ||
|
|
299
|
+
sdkCode === 'server_error' ||
|
|
300
|
+
errorType === 'api_error' ||
|
|
301
|
+
errorCode === 'api_error' ||
|
|
302
|
+
sdkCode === 'api_error')
|
|
303
|
+
) {
|
|
304
|
+
return 'transport-transient'
|
|
305
|
+
}
|
|
306
|
+
|
|
261
307
|
// Fallback: HTTP status-based.
|
|
262
308
|
if (status != null) {
|
|
263
309
|
if (status >= 400 && status < 500) return 'unknown-4xx'
|
|
264
310
|
if (status >= 500 && status < 600) return 'unknown-5xx'
|
|
265
311
|
}
|
|
266
312
|
|
|
267
|
-
|
|
313
|
+
// No recognized shape and NO status: neutral 5xx default. Do NOT claim 4xx —
|
|
314
|
+
// a fabricated client-error label mis-routed a Reauth card (the bug this fixes).
|
|
315
|
+
return 'unknown-5xx'
|
|
268
316
|
}
|
|
269
317
|
|
|
270
318
|
function extractString(obj: Record<string, unknown>, key: string): string | null {
|
|
@@ -508,6 +556,12 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
|
|
|
508
556
|
},
|
|
509
557
|
}
|
|
510
558
|
|
|
559
|
+
// Dismiss-only, matching unknown-5xx / proxy-misconfig. The Reauth button
|
|
560
|
+
// was REMOVED: unknown-4xx is a catch-all for unrecognized client-error
|
|
561
|
+
// shapes, and re-auth is the wrong remedy for almost all of them. Post-RFC-H
|
|
562
|
+
// the auth-broker is the SOLE writer of `.credentials.json`, so a per-agent
|
|
563
|
+
// reauth is re-mirrored over anyway. Reauth buttons now live ONLY on the
|
|
564
|
+
// credentials-* cards, where the diagnosis IS a login fault.
|
|
511
565
|
case 'unknown-4xx':
|
|
512
566
|
return {
|
|
513
567
|
text: [
|
|
@@ -518,10 +572,28 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
|
|
|
518
572
|
.join('\n'),
|
|
519
573
|
keyboard: {
|
|
520
574
|
inline_keyboard: [
|
|
521
|
-
[
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
575
|
+
[{ text: '❌ Dismiss', callback_data: `op:dismiss:${encodeURIComponent(ev.agent)}` }],
|
|
576
|
+
],
|
|
577
|
+
},
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// transport-transient is normally handled by the gateway's dedicated calm
|
|
581
|
+
// path (no broadcast card; a burst escalates via renderTransportEscalationCard
|
|
582
|
+
// in gateway/user-failure-notices.ts). This case exists so the switch stays
|
|
583
|
+
// exhaustive and any incidental render is calm + Dismiss-only — NEVER a
|
|
584
|
+
// Reauth button (a dropped stream is not a credential fault).
|
|
585
|
+
case 'transport-transient':
|
|
586
|
+
return {
|
|
587
|
+
text: [
|
|
588
|
+
`🔌 **Transient transport error** for **${agent}**.`,
|
|
589
|
+
detail ? `\`${detail}\`` : '',
|
|
590
|
+
`A mid-response stream to Anthropic dropped. Will retry automatically.`,
|
|
591
|
+
]
|
|
592
|
+
.filter(Boolean)
|
|
593
|
+
.join('\n'),
|
|
594
|
+
keyboard: {
|
|
595
|
+
inline_keyboard: [
|
|
596
|
+
[{ text: '❌ Dismiss', callback_data: `op:dismiss:${encodeURIComponent(ev.agent)}` }],
|
|
525
597
|
],
|
|
526
598
|
},
|
|
527
599
|
}
|
|
@@ -1075,7 +1075,13 @@ export function detectErrorInTranscriptLine(
|
|
|
1075
1075
|
// with no retry state is ambiguous → treat as in-flight and suppress
|
|
1076
1076
|
// (the silence-poke covers a genuinely stuck turn; a false card is
|
|
1077
1077
|
// the bug we are fixing, a missed ambiguous card costs nothing).
|
|
1078
|
-
|
|
1078
|
+
// `transport-transient` joins the transient family so Claude's retry
|
|
1079
|
+
// annotations gate `terminal`: a mid-response abort Claude is still retrying
|
|
1080
|
+
// (retryAttempt < maxRetries) is in-flight, not terminal, and must NOT count
|
|
1081
|
+
// toward the operator escalation counter (which is bounded to ≥3 TERMINAL
|
|
1082
|
+
// events). The primary bug shape (`isApiErrorMessage`) returns earlier with
|
|
1083
|
+
// terminal:true by construction, so it is unaffected by this.
|
|
1084
|
+
const transient = kind === 'rate-limited' || kind === 'transport-transient'
|
|
1079
1085
|
const retry = extractRetryState(obj)
|
|
1080
1086
|
const terminal = !transient
|
|
1081
1087
|
? true
|