switchroom 0.18.15 → 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 +16 -0
- package/dist/auth-broker/index.js +445 -10
- package/dist/cli/notion-write-pretool.mjs +16 -0
- package/dist/cli/switchroom.js +654 -479
- package/dist/host-control/main.js +20 -1
- package/dist/vault/approvals/kernel-server.js +16 -0
- package/dist/vault/broker/server.js +16 -0
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +81 -139
- package/telegram-plugin/bridge/bridge.ts +7 -1
- package/telegram-plugin/dist/bridge/bridge.js +26 -1
- package/telegram-plugin/dist/gateway/gateway.js +1758 -661
- package/telegram-plugin/dist/server.js +26 -1
- package/telegram-plugin/draft-stream.ts +78 -3
- 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 +64 -22
- package/telegram-plugin/gateway/effort-command.ts +9 -7
- package/telegram-plugin/gateway/gateway.ts +627 -291
- package/telegram-plugin/gateway/linear-activity.ts +20 -4
- package/telegram-plugin/gateway/litellm-local-notice-wiring.ts +200 -0
- package/telegram-plugin/gateway/model-command.ts +96 -18
- package/telegram-plugin/gateway/pending-session-command.ts +10 -8
- package/telegram-plugin/gateway/premium-recovery-wiring.ts +122 -0
- package/telegram-plugin/gateway/session-model-file.ts +141 -172
- package/telegram-plugin/gateway/tier-downgrade-wiring.ts +121 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +14 -1
- package/telegram-plugin/litellm-local-notice.ts +189 -0
- 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/quota-watch.ts +16 -4
- package/telegram-plugin/raw-error-scrub.ts +73 -0
- package/telegram-plugin/retry-api-call.ts +8 -2
- package/telegram-plugin/runtime-metrics.ts +16 -0
- package/telegram-plugin/send-gate-degraded.test.ts +161 -8
- 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 +246 -23
- package/telegram-plugin/session-tail.ts +16 -0
- package/telegram-plugin/shared/local-time.ts +69 -0
- package/telegram-plugin/stream-controller.ts +143 -20
- package/telegram-plugin/stream-reply-handler.ts +12 -2
- 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/bot-api.harness.ts +7 -2
- package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +61 -0
- package/telegram-plugin/tests/draft-stream.test.ts +110 -1
- package/telegram-plugin/tests/effort-command.test.ts +4 -4
- package/telegram-plugin/tests/fleet-fallback-resume.test.ts +39 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +5 -4
- package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +33 -19
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +47 -127
- package/telegram-plugin/tests/linear-create-issue.test.ts +30 -2
- package/telegram-plugin/tests/litellm-local-notice.test.ts +417 -0
- package/telegram-plugin/tests/llm-error-present.test.ts +380 -0
- package/telegram-plugin/tests/model-command.test.ts +84 -1
- 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/quota-watch.test.ts +21 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +8 -3
- package/telegram-plugin/tests/retry-api-call.test.ts +21 -0
- package/telegram-plugin/tests/session-model-file.test.ts +7 -155
- package/telegram-plugin/tests/stream-controller-send-gate.test.ts +521 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +44 -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 +212 -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 +543 -368
|
@@ -16994,6 +16994,27 @@ var init_plugin_logger = __esm(() => {
|
|
|
16994
16994
|
DEFAULT_LOG_PATH2 = join2(homedir2(), ".switchroom", "logs", "telegram-plugin.log");
|
|
16995
16995
|
ROTATE_AT_BYTES2 = 50 * 1024 * 1024;
|
|
16996
16996
|
});
|
|
16997
|
+
// raw-error-scrub.ts
|
|
16998
|
+
function extractRequestId(raw) {
|
|
16999
|
+
if (typeof raw !== "string" || raw.length === 0)
|
|
17000
|
+
return;
|
|
17001
|
+
const m = raw.match(/["']request[_-]?id["']\s*:\s*["']([A-Za-z0-9._-]+)["']/i) ?? raw.match(/\brequest[_-]?id[=:]\s*([A-Za-z0-9._-]+)/i);
|
|
17002
|
+
return m ? m[1] : undefined;
|
|
17003
|
+
}
|
|
17004
|
+
function truncateDetailPreservingRequestId(detail, max) {
|
|
17005
|
+
if (typeof detail !== "string")
|
|
17006
|
+
return "";
|
|
17007
|
+
if (detail.length <= max)
|
|
17008
|
+
return detail;
|
|
17009
|
+
const rid = extractRequestId(detail);
|
|
17010
|
+
const head = detail.slice(0, max);
|
|
17011
|
+
if (rid == null || head.includes(rid))
|
|
17012
|
+
return head;
|
|
17013
|
+
const suffix = ` request_id=${rid}`;
|
|
17014
|
+
const headBudget = Math.max(0, max - suffix.length);
|
|
17015
|
+
return `${detail.slice(0, headBudget)}${suffix}`;
|
|
17016
|
+
}
|
|
17017
|
+
|
|
16997
17018
|
// operator-events.ts
|
|
16998
17019
|
function classifyClaudeError(raw) {
|
|
16999
17020
|
try {
|
|
@@ -17376,6 +17397,10 @@ function projectTranscriptLine(line) {
|
|
|
17376
17397
|
const content = message?.content;
|
|
17377
17398
|
if (!Array.isArray(content))
|
|
17378
17399
|
return [];
|
|
17400
|
+
if (obj.isApiErrorMessage === true) {
|
|
17401
|
+
const mainModel2 = message?.model;
|
|
17402
|
+
return typeof mainModel2 === "string" && !isModelSentinel(mainModel2) ? [{ kind: "model", model: mainModel2 }] : [];
|
|
17403
|
+
}
|
|
17379
17404
|
const events = [];
|
|
17380
17405
|
const mainModel = message?.model;
|
|
17381
17406
|
if (typeof mainModel === "string" && !isModelSentinel(mainModel)) {
|
|
@@ -25140,7 +25165,7 @@ var init_bridge = __esm(async () => {
|
|
|
25140
25165
|
type: "operator_event",
|
|
25141
25166
|
kind: ev.kind,
|
|
25142
25167
|
agent: AGENT_NAME,
|
|
25143
|
-
detail: ev.detail
|
|
25168
|
+
detail: truncateDetailPreservingRequestId(ev.detail, 1000),
|
|
25144
25169
|
chatId: ""
|
|
25145
25170
|
});
|
|
25146
25171
|
} catch (err) {
|
|
@@ -26,6 +26,40 @@
|
|
|
26
26
|
|
|
27
27
|
const TELEGRAM_MAX_CHARS = 32768
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Error a transport layer (stream-controller.ts) throws from its edit
|
|
31
|
+
* callback when the send gate SHED the edit — it did NOT land, and the
|
|
32
|
+
* stream must not record the snapshot as on-screen (#3110). Marked with a
|
|
33
|
+
* property (not a message pattern) so the classification is exact.
|
|
34
|
+
*
|
|
35
|
+
* `flush()` recognizes this and PRESERVES the snapshot in `shedText` so a
|
|
36
|
+
* later `finalize()` — including the argument-less finalize the gateway's
|
|
37
|
+
* cleanup paths use — re-flushes it as the stream's final state instead of
|
|
38
|
+
* silently losing the content (review F2). It deliberately does NOT restore
|
|
39
|
+
* `pendingText`: `flushLoop` drains while `pendingText != null`, and a
|
|
40
|
+
* restore there would busy-spin the loop against an open flood window.
|
|
41
|
+
*/
|
|
42
|
+
export interface DraftEditShedError extends Error {
|
|
43
|
+
draftEditShed: true
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Build the marker error a transport throws for a gate-shed edit. */
|
|
47
|
+
export function makeDraftEditShedError(messageId: number | null): DraftEditShedError {
|
|
48
|
+
return Object.assign(
|
|
49
|
+
new Error(
|
|
50
|
+
`draft edit shed by send gate (id=${messageId ?? 'unknown'}); snapshot preserved for re-flush`,
|
|
51
|
+
),
|
|
52
|
+
{ draftEditShed: true as const },
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** True when `err` is the shed marker thrown by a stream transport. */
|
|
57
|
+
export function isDraftEditShedError(err: unknown): err is DraftEditShedError {
|
|
58
|
+
return (
|
|
59
|
+
err instanceof Error && (err as Partial<DraftEditShedError>).draftEditShed === true
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
29
63
|
// Throttle defaults for the in-place engine.
|
|
30
64
|
// DM chats: 400 ms — slightly more responsive than groups while staying
|
|
31
65
|
// well under Telegram's practical ~1 edit/sec/message ceiling. This
|
|
@@ -116,8 +150,17 @@ export interface DraftStreamHandle {
|
|
|
116
150
|
* Mark the stream as final. Flushes any pending text and rejects all
|
|
117
151
|
* future update() calls. Returns a promise that resolves once the final
|
|
118
152
|
* edit has landed (or the initial send if no edits ever fired).
|
|
153
|
+
*
|
|
154
|
+
* When `finalText` is provided, it becomes the pending snapshot for the
|
|
155
|
+
* final flush (superseding any older pending draft — last-write-wins).
|
|
156
|
+
* Callers that know a text is the LAST one (e.g. `stream_reply`
|
|
157
|
+
* `done=true`) MUST pass it here instead of `update(text)` +
|
|
158
|
+
* `finalize()`: the flush then runs with the stream already final, so
|
|
159
|
+
* the transport layer (stream-controller) classifies the edit that
|
|
160
|
+
* renders the completed answer as `critical` for the send gate — never
|
|
161
|
+
* shed as a cosmetic draft under flood pressure (#3110).
|
|
119
162
|
*/
|
|
120
|
-
finalize(): Promise<void>
|
|
163
|
+
finalize(finalText?: string): Promise<void>
|
|
121
164
|
|
|
122
165
|
/** Returns the captured Telegram message_id, or null if nothing has sent yet. */
|
|
123
166
|
getMessageId(): number | null
|
|
@@ -163,6 +206,14 @@ export function createDraftStream(
|
|
|
163
206
|
let messageId: number | null = config.initialMessageId ?? null
|
|
164
207
|
let pendingText: string | null = null
|
|
165
208
|
let lastSentText: string | null = null
|
|
209
|
+
/**
|
|
210
|
+
* Snapshot of the newest text the transport reported as SHED (thrown
|
|
211
|
+
* `DraftEditShedError`) — content that never landed. Cleared on any
|
|
212
|
+
* successful flush (a newer snapshot superseded it) and consumed by
|
|
213
|
+
* `finalize()` so the stream's last state is re-delivered once the
|
|
214
|
+
* pressure clears instead of being lost (review F2).
|
|
215
|
+
*/
|
|
216
|
+
let shedText: string | null = null
|
|
166
217
|
let lastSentAt = 0
|
|
167
218
|
let inFlight: Promise<void> | null = null
|
|
168
219
|
// Observability — per-stream fire counters for the stream-end trace.
|
|
@@ -216,11 +267,21 @@ export function createDraftStream(
|
|
|
216
267
|
await sendViaMessage(textToSend)
|
|
217
268
|
lastSentText = textToSend
|
|
218
269
|
lastSentAt = Date.now()
|
|
270
|
+
shedText = null // a newer snapshot landed — drop any older shed one
|
|
219
271
|
} catch (err) {
|
|
220
272
|
const msg = (err as Error).message ?? String(err)
|
|
221
|
-
if (
|
|
273
|
+
if (isDraftEditShedError(err)) {
|
|
274
|
+
// #3110 review F2: the send gate shed this edit — it did NOT land.
|
|
275
|
+
// Preserve the snapshot for finalize()'s re-flush (as the stream's
|
|
276
|
+
// final state, sent critical) instead of silently losing it. Do NOT
|
|
277
|
+
// restore pendingText: flushLoop drains while pendingText != null
|
|
278
|
+
// and would busy-spin against an open flood window.
|
|
279
|
+
shedText = textToSend
|
|
280
|
+
log?.(`stream → shed by send gate (id: ${messageId}); snapshot preserved for re-flush`)
|
|
281
|
+
} else if (/\bmessage is not modified\b/i.test(msg)) {
|
|
222
282
|
lastSentText = textToSend
|
|
223
283
|
lastSentAt = Date.now()
|
|
284
|
+
shedText = null // on-screen text == this snapshot; nothing to recover
|
|
224
285
|
log?.(`stream → not modified (id: ${messageId})`)
|
|
225
286
|
} else if (
|
|
226
287
|
/\bmessage to edit not found\b/i.test(msg)
|
|
@@ -327,9 +388,14 @@ export function createDraftStream(
|
|
|
327
388
|
return waitPromise
|
|
328
389
|
},
|
|
329
390
|
|
|
330
|
-
async finalize(): Promise<void> {
|
|
391
|
+
async finalize(finalText?: string): Promise<void> {
|
|
331
392
|
if (final) return
|
|
332
393
|
final = true
|
|
394
|
+
// A caller-supplied final snapshot supersedes any pending draft
|
|
395
|
+
// (last-write-wins) and is flushed below with `final` already set,
|
|
396
|
+
// so the transport classifies this edit as the answer's final
|
|
397
|
+
// render, not a sheddable draft (#3110).
|
|
398
|
+
if (finalText != null && !stopped) pendingText = finalText
|
|
333
399
|
// Drain any pending updates
|
|
334
400
|
if (scheduledTimer != null) {
|
|
335
401
|
clearTimeout(scheduledTimer)
|
|
@@ -338,6 +404,15 @@ export function createDraftStream(
|
|
|
338
404
|
if (inFlight) {
|
|
339
405
|
await inFlight
|
|
340
406
|
}
|
|
407
|
+
// #3110 review F2: if the newest snapshot was SHED by the send gate
|
|
408
|
+
// (never landed) and nothing newer is pending, re-flush it as the
|
|
409
|
+
// stream's final state. Checked AFTER awaiting inFlight so a flush
|
|
410
|
+
// that sheds mid-finalize is recovered too. A provided finalText and
|
|
411
|
+
// any pending draft both outrank the shed snapshot (they are newer).
|
|
412
|
+
if (pendingText == null && shedText != null && !stopped) {
|
|
413
|
+
pendingText = shedText
|
|
414
|
+
}
|
|
415
|
+
shedText = null
|
|
341
416
|
if (pendingText != null && !stopped) {
|
|
342
417
|
await flush()
|
|
343
418
|
}
|
|
@@ -81,12 +81,24 @@ export interface FleetFallbackResumeGate {
|
|
|
81
81
|
/**
|
|
82
82
|
* Decide whether to resume the dead turn after a successful swap. Call ONLY
|
|
83
83
|
* when the swap outcome was `'switched'`. Records the arm time on a 'resume'
|
|
84
|
-
* verdict so a follow-on swap within `singleFlightMs` is suppressed.
|
|
84
|
+
* verdict so a follow-on swap within `singleFlightMs` is suppressed. Equivalent
|
|
85
|
+
* to `peek()` followed by `arm()` on a 'resume' verdict.
|
|
85
86
|
*
|
|
86
87
|
* @param failedTurnStartedAtMs epoch-ms the failed turn began, or null when
|
|
87
88
|
* unknown (then the staleness guard is deferred to the boot path).
|
|
88
89
|
*/
|
|
89
90
|
decide(failedTurnStartedAtMs: number | null): ResumeDecision;
|
|
91
|
+
/**
|
|
92
|
+
* Evaluate the resume verdict WITHOUT arming the single-flight latch. Use when
|
|
93
|
+
* the caller must perform fallible work (e.g. write a consume-once carrier)
|
|
94
|
+
* BEFORE committing to a restart: peek → do the writes → `arm()` only on
|
|
95
|
+
* success, so a throwing write can never leave an armed latch with no pending
|
|
96
|
+
* restart (which would suppress a legitimate later resume for `singleFlightMs`).
|
|
97
|
+
*/
|
|
98
|
+
peek(failedTurnStartedAtMs: number | null): ResumeDecision;
|
|
99
|
+
/** Commit the single-flight arm (records "now" as the arm time). Pair with a
|
|
100
|
+
* prior `peek()` that returned 'resume'. */
|
|
101
|
+
arm(): void;
|
|
90
102
|
/** Test seam — reset to fresh state. Production code should not call this. */
|
|
91
103
|
reset(): void;
|
|
92
104
|
/** Test/debug — current internal state. */
|
|
@@ -105,7 +117,7 @@ export function createFleetFallbackResumeGate(
|
|
|
105
117
|
// -Infinity = never resumed. A concrete value arms the single-flight window.
|
|
106
118
|
let lastResumedAtMs = Number.NEGATIVE_INFINITY;
|
|
107
119
|
|
|
108
|
-
function
|
|
120
|
+
function peek(failedTurnStartedAtMs: number | null): ResumeDecision {
|
|
109
121
|
const now = nowFn();
|
|
110
122
|
// Guard 2 — single-flight. A second swap within the window does not
|
|
111
123
|
// re-arm; the in-flight restart owns the resume.
|
|
@@ -115,12 +127,23 @@ export function createFleetFallbackResumeGate(
|
|
|
115
127
|
if (failedTurnStartedAtMs != null && now - failedTurnStartedAtMs > maxAgeMs) {
|
|
116
128
|
return 'skip-stale';
|
|
117
129
|
}
|
|
118
|
-
lastResumedAtMs = now;
|
|
119
130
|
return 'resume';
|
|
120
131
|
}
|
|
121
132
|
|
|
133
|
+
function arm(): void {
|
|
134
|
+
lastResumedAtMs = nowFn();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function decide(failedTurnStartedAtMs: number | null): ResumeDecision {
|
|
138
|
+
const verdict = peek(failedTurnStartedAtMs);
|
|
139
|
+
if (verdict === 'resume') arm();
|
|
140
|
+
return verdict;
|
|
141
|
+
}
|
|
142
|
+
|
|
122
143
|
return {
|
|
123
144
|
decide,
|
|
145
|
+
peek,
|
|
146
|
+
arm,
|
|
124
147
|
reset() {
|
|
125
148
|
lastResumedAtMs = Number.NEGATIVE_INFINITY;
|
|
126
149
|
},
|
|
@@ -516,6 +516,55 @@ export function isHeldUndeliverable(
|
|
|
516
516
|
return pend.undeliverable != null
|
|
517
517
|
}
|
|
518
518
|
|
|
519
|
+
/** The fields the on-delivery reset reads and mutates. */
|
|
520
|
+
export interface DeliveredPermissionEntry {
|
|
521
|
+
undeliverable?: UndeliverableMark | null
|
|
522
|
+
redeliveryFailures?: number
|
|
523
|
+
/** When the operator's decision window began — the TTL is measured from here. */
|
|
524
|
+
startedAt: number
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* A HELD card just LANDED — the operator can finally see and tap it. Clear the
|
|
529
|
+
* hold marks and RESTART the TTL clock, as ONE shared decision used by BOTH
|
|
530
|
+
* gateway.ts and the outcome test's harness.
|
|
531
|
+
*
|
|
532
|
+
* WHY THIS IS SHARED CODE AND NOT AN INLINE BLOCK (#3128):
|
|
533
|
+
*
|
|
534
|
+
* The harness used to carry a PRIVATE copy of this reset (its own
|
|
535
|
+
* `live.startedAt = clock.now()`), so the behavioural test that proves the
|
|
536
|
+
* operator gets a FULL window after re-delivery drove the double, not the real
|
|
537
|
+
* gateway. Deleting `startedAt = Date.now()` from gateway.ts would have left that
|
|
538
|
+
* test GREEN — only a source-text grep noticed, and greps drift. Same placebo-pin
|
|
539
|
+
* class as the leash (#3123) and the never-auto-approve pin (#3126). Now the reset
|
|
540
|
+
* lives here, in one importable place, and both callers drive it: delete
|
|
541
|
+
* `entry.startedAt = now` below and the `(d2)` outcome test goes RED, because there
|
|
542
|
+
* is only one implementation to delete.
|
|
543
|
+
*
|
|
544
|
+
* RESET THE TTL CLOCK is load-bearing, not cosmetic. `startedAt` is when the
|
|
545
|
+
* agent asked; the TTL measures how long the operator had to answer. Until the
|
|
546
|
+
* card lands they have NOTHING to answer — it did not exist in any chat. Without
|
|
547
|
+
* the reset, a card held through a 4.6h ban lands already-expired against a 60-min
|
|
548
|
+
* TTL and the very next reaper tick auto-denies it: the silent denial would be
|
|
549
|
+
* MOVED, not removed.
|
|
550
|
+
*
|
|
551
|
+
* Returns true when this was a HELD-card recovery — the caller should reconcile
|
|
552
|
+
* the off-Telegram surface and log the re-delivery. Returns false for a normal
|
|
553
|
+
* first delivery (the entry was never held), where there is nothing to reset.
|
|
554
|
+
*
|
|
555
|
+
* @see reference/invariants.md § no-self-escalation, § on-leash
|
|
556
|
+
*/
|
|
557
|
+
export function applyDeliveredHoldReset(
|
|
558
|
+
entry: DeliveredPermissionEntry,
|
|
559
|
+
now: number,
|
|
560
|
+
): boolean {
|
|
561
|
+
if (entry.undeliverable == null) return false
|
|
562
|
+
entry.undeliverable = null
|
|
563
|
+
entry.redeliveryFailures = 0
|
|
564
|
+
entry.startedAt = now
|
|
565
|
+
return true
|
|
566
|
+
}
|
|
567
|
+
|
|
519
568
|
/**
|
|
520
569
|
* Per-tick re-delivery cap (PR 2).
|
|
521
570
|
*
|
|
@@ -32,11 +32,15 @@
|
|
|
32
32
|
* claude itself down — where a bounce is either premature or
|
|
33
33
|
* futile); the timer re-arms so a slow-booting claude gets the full
|
|
34
34
|
* grace window again once it appears;
|
|
35
|
-
* - cron-session bridges (`<agent>-cron`)
|
|
36
|
-
* (recall.py one-shots, pre-handshake connects)
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
35
|
+
* - cron-session bridges (`<agent>-cron`), anonymous IPC clients
|
|
36
|
+
* (recall.py one-shots, pre-handshake connects), AND secondary/relay
|
|
37
|
+
* clients that register a DIFFERENT agent name than this gateway serves
|
|
38
|
+
* (e.g. an `overlord-relay` connecting into another agent's gateway
|
|
39
|
+
* socket — #3086) neither satisfy nor re-arm the watchdog: only the
|
|
40
|
+
* gateway's OWN primary bridge (agentName === $SWITCHROOM_AGENT_NAME)
|
|
41
|
+
* counts. The gating lives INSIDE noteBridge* so a gateway handler
|
|
42
|
+
* refactor can't silently reintroduce the bounce-after-cron-fire or the
|
|
43
|
+
* bounce-after-relay-disconnect false positive;
|
|
40
44
|
* - loud, structured escalation log line including a fresh
|
|
41
45
|
* bridge-crash.log tail (PR #3037 breadcrumbs) so the operator sees
|
|
42
46
|
* WHY from the supervisor log alone.
|
|
@@ -60,10 +64,9 @@ import { readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs'
|
|
|
60
64
|
import { isCronIdentity } from './cron-session.js'
|
|
61
65
|
import type { InboundMessage } from './ipc-protocol.js'
|
|
62
66
|
|
|
63
|
-
/** The distinct triggerSelfRestart reason for this escalation.
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* relaunch. */
|
|
67
|
+
/** The distinct triggerSelfRestart reason for this escalation. Like every
|
|
68
|
+
* other switchroom-managed relaunch it reverts any session `/model` override
|
|
69
|
+
* to the configured default (session-scoped, rev 4). */
|
|
67
70
|
export const BRIDGE_DEAD_RESTART_REASON = 'bridge-dead-resume'
|
|
68
71
|
|
|
69
72
|
/** Default grace window before a missing bridge is treated as dead.
|
|
@@ -353,6 +356,16 @@ export interface BridgeDeadWatchdogOpts {
|
|
|
353
356
|
* unref (the watchdog must never keep the gateway process alive). */
|
|
354
357
|
setTimer?: (fn: () => void, ms: number) => unknown
|
|
355
358
|
clearTimer?: (handle: unknown) => void
|
|
359
|
+
/** The agent identity THIS gateway serves ($SWITCHROOM_AGENT_NAME).
|
|
360
|
+
* Only a bridge client registering under this exact name is the
|
|
361
|
+
* primary bridge whose presence/absence drives the watchdog. Secondary
|
|
362
|
+
* or relay clients (a different named identity connecting into this
|
|
363
|
+
* gateway's socket — e.g. `overlord-relay`, #3086) are ignored on both
|
|
364
|
+
* the register and disconnect paths. When unset/empty (a misconfigured
|
|
365
|
+
* gateway with no agent name), the watchdog falls back to the pre-#3086
|
|
366
|
+
* test — any named non-cron client counts — so a genuine bridge death is
|
|
367
|
+
* still caught rather than silently un-guarded. */
|
|
368
|
+
selfAgentName?: string
|
|
356
369
|
/** Injectable clock (marker ts + crash-log freshness). */
|
|
357
370
|
nowMs?: () => number
|
|
358
371
|
/** Injectable marker writer (tests avoid real fs). */
|
|
@@ -366,15 +379,18 @@ export interface BridgeDeadWatchdog {
|
|
|
366
379
|
* when the cross-boot streak cap is already reached (stands down with
|
|
367
380
|
* an audit line instead). */
|
|
368
381
|
arm: () => void
|
|
369
|
-
/** A bridge client registered. Only
|
|
370
|
-
*
|
|
371
|
-
* anonymous clients (agentName null)
|
|
372
|
-
*
|
|
382
|
+
/** A bridge client registered. Only THIS gateway's OWN primary bridge
|
|
383
|
+
* (agentName === selfAgentName, named, non-cron) satisfies the watchdog
|
|
384
|
+
* — cron sessions (`<agent>-cron`), anonymous clients (agentName null),
|
|
385
|
+
* and secondary/relay clients registering a different name (#3086) are
|
|
386
|
+
* ignored HERE so a gateway handler refactor can't reorder the gating
|
|
387
|
+
* away (review finding 5). */
|
|
373
388
|
noteBridgeRegistered: (agentName: string | null | undefined) => void
|
|
374
|
-
/** A bridge client disconnected. Re-arms the grace window only for
|
|
375
|
-
*
|
|
376
|
-
* and never reconnects also escalates
|
|
377
|
-
*
|
|
389
|
+
/** A bridge client disconnected. Re-arms the grace window only for THIS
|
|
390
|
+
* gateway's own primary bridge (same internal gating), so a bridge that
|
|
391
|
+
* dies AFTER boot and never reconnects also escalates — while a
|
|
392
|
+
* transient relay/secondary client's disconnect (#3086) is a no-op.
|
|
393
|
+
* Still capped by the once-per-boot fuse. */
|
|
378
394
|
noteBridgeDisconnected: (agentName: string | null | undefined) => void
|
|
379
395
|
/** Evaluate now (the timer body — exposed for tests). Returns the
|
|
380
396
|
* decision taken. */
|
|
@@ -385,9 +401,34 @@ export interface BridgeDeadWatchdog {
|
|
|
385
401
|
hasEscalated: () => boolean
|
|
386
402
|
}
|
|
387
403
|
|
|
388
|
-
/**
|
|
389
|
-
|
|
390
|
-
|
|
404
|
+
/**
|
|
405
|
+
* Is this client identity THIS gateway's OWN primary main-agent bridge?
|
|
406
|
+
*
|
|
407
|
+
* A named, non-cron client whose name matches the gateway's own agent
|
|
408
|
+
* identity (`selfAgentName` = $SWITCHROOM_AGENT_NAME). A switchroom gateway
|
|
409
|
+
* serves exactly one agent, and its real bridge registers under exactly that
|
|
410
|
+
* name (see bridge/bridge.ts — `agentName: AGENT_NAME`). Secondary or relay
|
|
411
|
+
* clients (e.g. an `overlord-relay` that connects into another agent's
|
|
412
|
+
* gateway socket, #3086) register a DIFFERENT name; they are named and
|
|
413
|
+
* non-cron but are NOT this gateway's primary bridge, so their register /
|
|
414
|
+
* disconnect must neither satisfy nor re-arm the watchdog — otherwise a
|
|
415
|
+
* transient relay disconnect marks the (still-alive) primary bridge dead and
|
|
416
|
+
* bounces a healthy container.
|
|
417
|
+
*
|
|
418
|
+
* When `selfAgentName` is unset/empty (misconfigured gateway) we cannot
|
|
419
|
+
* identity-match, so fall back to the pre-#3086 test — any named non-cron
|
|
420
|
+
* client counts — keeping the watchdog protective rather than un-guarded.
|
|
421
|
+
*/
|
|
422
|
+
function isRealBridgeIdentity(
|
|
423
|
+
agentName: string | null | undefined,
|
|
424
|
+
selfAgentName: string | null | undefined,
|
|
425
|
+
): boolean {
|
|
426
|
+
if (agentName == null || agentName.length === 0) return false
|
|
427
|
+
if (isCronIdentity(agentName)) return false
|
|
428
|
+
if (selfAgentName != null && selfAgentName.length > 0) {
|
|
429
|
+
return agentName === selfAgentName
|
|
430
|
+
}
|
|
431
|
+
return true
|
|
391
432
|
}
|
|
392
433
|
|
|
393
434
|
export function createBridgeDeadWatchdog(opts: BridgeDeadWatchdogOpts): BridgeDeadWatchdog {
|
|
@@ -405,6 +446,7 @@ export function createBridgeDeadWatchdog(opts: BridgeDeadWatchdogOpts): BridgeDe
|
|
|
405
446
|
opts.readCrashTail ?? ((p: string, t: number) => readFreshCrashLogTail(p, { nowMs: t }))
|
|
406
447
|
const priorStreak = opts.priorStreak ?? 0
|
|
407
448
|
const maxConsecutive = opts.maxConsecutive ?? MAX_CONSECUTIVE_ESCALATIONS
|
|
449
|
+
const selfAgentName = opts.selfAgentName
|
|
408
450
|
|
|
409
451
|
let timer: unknown = null
|
|
410
452
|
let bridgeRegistered = false
|
|
@@ -529,13 +571,13 @@ export function createBridgeDeadWatchdog(opts: BridgeDeadWatchdogOpts): BridgeDe
|
|
|
529
571
|
return {
|
|
530
572
|
arm: armInternal,
|
|
531
573
|
noteBridgeRegistered: (agentName) => {
|
|
532
|
-
if (!isRealBridgeIdentity(agentName)) return
|
|
574
|
+
if (!isRealBridgeIdentity(agentName, selfAgentName)) return
|
|
533
575
|
bridgeRegistered = true
|
|
534
576
|
bridgeEverRegistered = true
|
|
535
577
|
cancel()
|
|
536
578
|
},
|
|
537
579
|
noteBridgeDisconnected: (agentName) => {
|
|
538
|
-
if (!isRealBridgeIdentity(agentName)) return
|
|
580
|
+
if (!isRealBridgeIdentity(agentName, selfAgentName)) return
|
|
539
581
|
bridgeRegistered = false
|
|
540
582
|
armInternal()
|
|
541
583
|
},
|
|
@@ -65,8 +65,9 @@ export function parseEffortCommand(text: string): ParsedEffortCommand | null {
|
|
|
65
65
|
}
|
|
66
66
|
const arg = parts[0]
|
|
67
67
|
if (arg.toLowerCase() === 'help') return { kind: 'help' }
|
|
68
|
-
// `/effort default` — explicit user action that clears the
|
|
69
|
-
//
|
|
68
|
+
// `/effort default` — explicit user action that clears the session
|
|
69
|
+
// override (in-memory + any leftover queued-command carrier) and restores
|
|
70
|
+
// the configured default (#3186, session-scoped).
|
|
70
71
|
if (arg.toLowerCase() === 'default') return { kind: 'default' }
|
|
71
72
|
if (!isValidEffortArg(arg)) {
|
|
72
73
|
return { kind: 'help', reason: `not a valid effort level: ${arg}` }
|
|
@@ -91,14 +92,15 @@ export interface EffortCommandDeps {
|
|
|
91
92
|
*/
|
|
92
93
|
getConfiguredEffort: () => string | null
|
|
93
94
|
/**
|
|
94
|
-
*
|
|
95
|
+
* Clear the session effort override (#3186: the in-memory live level plus
|
|
96
|
+
* any leftover queued-command `.session-effort` carrier). Optional so
|
|
95
97
|
* gateway-agnostic tests can omit it; the gateway always wires it.
|
|
96
98
|
*/
|
|
97
99
|
clearSessionEffort?: () => void
|
|
98
100
|
/**
|
|
99
|
-
* The active
|
|
100
|
-
*
|
|
101
|
-
*
|
|
101
|
+
* The active session effort override level, or null when none (#3186:
|
|
102
|
+
* in-memory, session-scoped — reverts on restart). Optional; used to mark
|
|
103
|
+
* the LIVE level in the menu and the show text honestly.
|
|
102
104
|
*/
|
|
103
105
|
getSessionEffort?: () => string | null
|
|
104
106
|
escapeHtml: (s: string) => string
|
|
@@ -110,7 +112,7 @@ export interface EffortCommandReply {
|
|
|
110
112
|
}
|
|
111
113
|
|
|
112
114
|
const PERSIST_NOTE =
|
|
113
|
-
'
|
|
115
|
+
'_Session-only — this override lasts until the agent’s next restart, then reverts to the configured \`thinking_effort:\`. \`/effort default\` clears it now. To change the default permanently, set \`thinking_effort:\` in switchroom.yaml._'
|
|
114
116
|
|
|
115
117
|
const LEVELS_INLINE = EFFORT_LEVELS.map(l => `\`${l}\``).join(' · ')
|
|
116
118
|
|