switchroom 0.18.10 → 0.18.12
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 +29 -5
- package/dist/auth-broker/index.js +53 -13
- package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
- package/dist/cli/notion-write-pretool.mjs +29 -5
- package/dist/cli/switchroom.js +2636 -1369
- package/dist/cli/ui/apple-touch-icon.png +0 -0
- package/dist/cli/ui/favicon-32.png +0 -0
- package/dist/cli/ui/favicon.ico +0 -0
- package/dist/cli/ui/index.html +163 -17
- package/dist/host-control/main.js +1248 -342
- package/dist/vault/approvals/kernel-server.js +54 -13
- package/dist/vault/broker/server.js +163 -114
- package/package.json +3 -4
- package/profiles/_base/start.sh.hbs +65 -0
- package/profiles/_shared/vault-protocol.md.hbs +3 -1
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/bridge/bridge.ts +37 -0
- package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
- package/telegram-plugin/dist/bridge/bridge.js +73 -1
- package/telegram-plugin/dist/gateway/gateway.js +3603 -1007
- package/telegram-plugin/dist/server.js +74 -2
- package/telegram-plugin/flood-circuit-breaker.ts +493 -21
- package/telegram-plugin/gateway/approval-hold.ts +583 -0
- package/telegram-plugin/gateway/auth-command.ts +92 -2
- package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
- package/telegram-plugin/gateway/boot-card.ts +12 -5
- package/telegram-plugin/gateway/callback-query-handlers.ts +76 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
- package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
- package/telegram-plugin/gateway/gateway.ts +1482 -165
- package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
- package/telegram-plugin/gateway/idle-clear.ts +90 -6
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
- package/telegram-plugin/gateway/inject-handler.ts +8 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
- package/telegram-plugin/gateway/ipc-server.ts +43 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
- package/telegram-plugin/gateway/model-command.ts +9 -3
- package/telegram-plugin/gateway/pending-session-command.ts +13 -1
- package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
- package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
- package/telegram-plugin/gateway/queued-card-store.ts +217 -0
- package/telegram-plugin/gateway/session-model-file.ts +26 -1
- package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
- package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
- package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
- package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
- package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
- package/telegram-plugin/hooks/hooks.json +9 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +209 -2
- package/telegram-plugin/operator-events.ts +23 -0
- package/telegram-plugin/package.json +0 -1
- package/telegram-plugin/permission-rule.ts +1 -0
- package/telegram-plugin/permission-title.ts +1 -0
- package/telegram-plugin/retry-api-call.ts +212 -2
- package/telegram-plugin/send-gate-degraded.test.ts +443 -0
- package/telegram-plugin/send-gate-observability.test.ts +470 -0
- package/telegram-plugin/send-gate-observability.ts +355 -0
- package/telegram-plugin/send-gate.test.ts +698 -0
- package/telegram-plugin/send-gate.ts +982 -0
- package/telegram-plugin/shared/bot-runtime.ts +17 -5
- package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
- package/telegram-plugin/status-pin-driver.ts +52 -7
- package/telegram-plugin/status-pin.ts +81 -0
- package/telegram-plugin/subagent-watcher.ts +102 -2
- package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
- package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +296 -0
- package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
- package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
- package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
- package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
- package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
- package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
- package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
- package/telegram-plugin/tests/idle-clear.test.ts +233 -3
- package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +284 -0
- package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
- package/telegram-plugin/tests/model-command.test.ts +14 -0
- package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
- package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
- package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
- package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
- package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
- package/telegram-plugin/tests/session-model-file.test.ts +50 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
- package/telegram-plugin/tests/status-pin.test.ts +275 -1
- package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
- package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
- package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
- package/telegram-plugin/typing-emitter.ts +224 -0
- package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
- package/telegram-plugin/welcome-text.ts +42 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
- package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
- package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
- package/vendor/hindsight-memory/scripts/session_end.py +14 -3
- package/vendor/hindsight-memory/scripts/session_start.py +21 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
- package/vendor/hindsight-memory/tests/test_pending.py +44 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
- package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
- package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
- package/telegram-plugin/channel-envelope-safety.ts +0 -56
|
@@ -29,8 +29,9 @@ import { createHash } from 'crypto'
|
|
|
29
29
|
import { AsyncLocalStorage } from 'async_hooks'
|
|
30
30
|
import { clearStaleTelegramPollingState } from '../startup-reset.js'
|
|
31
31
|
import { createRetryApiCall } from '../retry-api-call.js'
|
|
32
|
-
import { makeFloodWaitRecorder } from '../flood-circuit-breaker.js'
|
|
32
|
+
import { makeFloodWaitRecorder, makeFloodWaitProbe } from '../flood-circuit-breaker.js'
|
|
33
33
|
import { RICH_MESSAGE_MAX_CHARS } from '../format.js'
|
|
34
|
+
import { shouldEmitTgPost } from './gw-trace-gate.js'
|
|
34
35
|
|
|
35
36
|
// ─── tg-post tag plumbing ─────────────────────────────────────────────────
|
|
36
37
|
|
|
@@ -115,9 +116,14 @@ export function installTgPostLogger(bot: Bot): void {
|
|
|
115
116
|
const tagSuffix = formatTgPostTags(_getTgPostTags())
|
|
116
117
|
try {
|
|
117
118
|
const res = await prev(method, payload, signal)
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
// #3025: suppress zero-signal per-poll heartbeats (getUpdates/getMe
|
|
120
|
+
// status=ok, one line per ~30s long-poll tick) unless the operator
|
|
121
|
+
// set SWITCHROOM_GW_TRACE. Errors and all other methods still log.
|
|
122
|
+
if (shouldEmitTgPost(method, 'ok')) {
|
|
123
|
+
process.stderr.write(
|
|
124
|
+
`tg-post method=${method} chat=${chat} thread=${thread} parse_mode=${parseMode} bytes=${bytes} hash=${hash} status=ok err=- code=- desc=-${tagSuffix}\n`,
|
|
125
|
+
)
|
|
126
|
+
}
|
|
121
127
|
return res
|
|
122
128
|
} catch (err) {
|
|
123
129
|
const errClass = err instanceof GrammyError
|
|
@@ -156,8 +162,14 @@ export function createRobustApiCall(opts: { floodStatePath?: string } = {}) {
|
|
|
156
162
|
// #2923: persist every observed 429 flood-wait window so a restart during
|
|
157
163
|
// the ban can suppress non-essential sends (boot cards) instead of feeding
|
|
158
164
|
// the per-bot flood counter and prolonging the ban.
|
|
165
|
+
// #3084: and refuse to issue a call while that window is still open —
|
|
166
|
+
// otherwise the card heartbeats re-drive a request into the ban every
|
|
167
|
+
// few seconds once the policy stops sleeping long waits.
|
|
159
168
|
...(opts.floodStatePath
|
|
160
|
-
? {
|
|
169
|
+
? {
|
|
170
|
+
onFloodWait: makeFloodWaitRecorder(opts.floodStatePath),
|
|
171
|
+
floodWaitRemainingMs: makeFloodWaitProbe(opts.floodStatePath),
|
|
172
|
+
}
|
|
161
173
|
: {}),
|
|
162
174
|
})
|
|
163
175
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway trace verbosity gate (#3025).
|
|
3
|
+
*
|
|
4
|
+
* The gateway emits two families of high-frequency stderr trace lines that
|
|
5
|
+
* are pure per-poll noise on a healthy, idle agent:
|
|
6
|
+
*
|
|
7
|
+
* - `tg-post method=getUpdates ... status=ok` / `method=getMe ... status=ok`
|
|
8
|
+
* — one line per long-poll tick (~every 30s) from the tg-post
|
|
9
|
+
* observability transformer in `bot-runtime.ts`.
|
|
10
|
+
* - `gw-trace shadow event=tick effects=[] global=bridge_alive_idle ...`
|
|
11
|
+
* — one line per delivery-machine TTL tick (~every 30s) from
|
|
12
|
+
* `inbound-delivery-machine-shadow.ts`.
|
|
13
|
+
*
|
|
14
|
+
* With no log rotation these ungated lines grew gateway-supervisor.log to
|
|
15
|
+
* 555MB on clerk / 162MB on klanker and filled the host disk. They carry
|
|
16
|
+
* zero signal when nothing happened: a successful empty long-poll and an
|
|
17
|
+
* idle no-op tick.
|
|
18
|
+
*
|
|
19
|
+
* This module centralises the "should I emit a zero-signal trace line?"
|
|
20
|
+
* decision behind a single debug env flag so operators can turn the full
|
|
21
|
+
* firehose back on when actually debugging the delivery/poll path.
|
|
22
|
+
*
|
|
23
|
+
* Flag: set `SWITCHROOM_GW_TRACE=1` (or `true`/`yes`/`on`, any case) to
|
|
24
|
+
* restore every trace line unconditionally. Unset / any other value → zero-signal polling
|
|
25
|
+
* success + idle-tick lines are suppressed, while ALL error lines, all
|
|
26
|
+
* non-poll methods (sendMessage, editMessageText, ...), and every tick or
|
|
27
|
+
* event that produced a real effect or state change are still emitted.
|
|
28
|
+
*
|
|
29
|
+
* Read once at module init — the flag is a boot-time debug switch, not a
|
|
30
|
+
* hot-reloadable knob, so a cached read keeps the per-line cost at a
|
|
31
|
+
* single boolean check.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Pure env-flag parse — exported so tests can exercise the parsing
|
|
36
|
+
* directly. Kept separate from the module-init read because the dual
|
|
37
|
+
* vitest/bun runner boundary means module-cache tricks
|
|
38
|
+
* (`vi.resetModules()` + re-import) don't work under bun's vitest shim;
|
|
39
|
+
* flag variants must be testable via explicit arguments.
|
|
40
|
+
*/
|
|
41
|
+
export function computeGwTraceVerbose(flag: string | undefined): boolean {
|
|
42
|
+
const v = (flag ?? '').trim().toLowerCase()
|
|
43
|
+
return v === '1' || v === 'true' || v === 'yes' || v === 'on'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** True when the operator opted into the full gateway trace firehose. */
|
|
47
|
+
export const gwTraceVerbose: boolean = computeGwTraceVerbose(
|
|
48
|
+
process.env.SWITCHROOM_GW_TRACE,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Bot API methods whose successful long-poll ticks are zero-signal:
|
|
53
|
+
* `getUpdates` is the long-poll itself and `getMe` is the periodic
|
|
54
|
+
* identity refresh. A `status=ok` on either says only "polling is alive",
|
|
55
|
+
* which the gateway already reports once at startup. Errors on these
|
|
56
|
+
* methods are NOT suppressed (see `shouldEmitTgPost`).
|
|
57
|
+
*/
|
|
58
|
+
const ZERO_SIGNAL_POLL_METHODS = new Set(['getUpdates', 'getMe'])
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Decide whether a `tg-post` line should be written.
|
|
62
|
+
*
|
|
63
|
+
* Suppressed (unless `SWITCHROOM_GW_TRACE` is set):
|
|
64
|
+
* - `status=ok` for `getUpdates` / `getMe` — the per-poll heartbeat.
|
|
65
|
+
*
|
|
66
|
+
* Always emitted:
|
|
67
|
+
* - any `status=err` (real failures — the whole point of the log),
|
|
68
|
+
* - every other method's `ok` line (sendMessage/editMessageText/... are
|
|
69
|
+
* genuine outbound observability, #656/#657).
|
|
70
|
+
*/
|
|
71
|
+
export function shouldEmitTgPost(
|
|
72
|
+
method: string,
|
|
73
|
+
status: 'ok' | 'err',
|
|
74
|
+
verbose: boolean = gwTraceVerbose,
|
|
75
|
+
): boolean {
|
|
76
|
+
if (verbose) return true
|
|
77
|
+
if (status === 'err') return true
|
|
78
|
+
return !ZERO_SIGNAL_POLL_METHODS.has(method)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Decide whether a `gw-trace shadow` line should be written.
|
|
83
|
+
*
|
|
84
|
+
* Suppressed (unless `SWITCHROOM_GW_TRACE` is set):
|
|
85
|
+
* - `event=tick` that produced NO effects AND left the machine in an
|
|
86
|
+
* idle global state (`bridge_alive_idle` / `bridge_dead`) — the
|
|
87
|
+
* no-op TTL heartbeat.
|
|
88
|
+
*
|
|
89
|
+
* Always emitted:
|
|
90
|
+
* - any non-tick event (real inbound/turn/bridge activity),
|
|
91
|
+
* - any tick that produced effects (e.g. a TTL turn expiry) or that
|
|
92
|
+
* landed the machine in `bridge_alive_in_turn`.
|
|
93
|
+
*/
|
|
94
|
+
export function shouldEmitShadowTrace(
|
|
95
|
+
eventKind: string,
|
|
96
|
+
effectCount: number,
|
|
97
|
+
globalKind: string,
|
|
98
|
+
verbose: boolean = gwTraceVerbose,
|
|
99
|
+
): boolean {
|
|
100
|
+
if (verbose) return true
|
|
101
|
+
if (eventKind !== 'tick') return true
|
|
102
|
+
if (effectCount > 0) return true
|
|
103
|
+
const idle = globalKind === 'bridge_alive_idle' || globalKind === 'bridge_dead'
|
|
104
|
+
return !idle
|
|
105
|
+
}
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
* job spec.
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
-
import type { PinState, DesiredPin } from './status-pin.js'
|
|
33
|
-
import { decidePinAction } from './status-pin.js'
|
|
32
|
+
import type { PinState, DesiredPin, PinRightsCache } from './status-pin.js'
|
|
33
|
+
import { decidePinAction, isPinRightsError } from './status-pin.js'
|
|
34
34
|
|
|
35
35
|
/** Minimal subset of grammy's `bot.api` the pin driver depends on.
|
|
36
36
|
* Lets tests swap in a fake without dragging in the full Bot type. */
|
|
@@ -55,6 +55,16 @@ export interface ReconcilePinArgs {
|
|
|
55
55
|
desired: DesiredPin
|
|
56
56
|
/** Optional API-failure observer. Default: silent. */
|
|
57
57
|
onError?: (phase: 'pin' | 'unpin', err: unknown) => void
|
|
58
|
+
/** Optional per-process rights-aware negative cache (issue #3024). When a
|
|
59
|
+
* pin attempt fails with the permanent "not enough rights" 400, the chat is
|
|
60
|
+
* recorded and all subsequent pin attempts in it are skipped (no API call,
|
|
61
|
+
* no log). Omit to disable the cache entirely (the pre-#3024 behaviour). */
|
|
62
|
+
rightsCache?: PinRightsCache
|
|
63
|
+
/** Called EXACTLY ONCE per chat, the first time that chat is recorded as
|
|
64
|
+
* pin-incapable. Lets the caller emit a single warn line instead of the
|
|
65
|
+
* per-attempt `status-pin pin failed` spam. Only fires when `rightsCache`
|
|
66
|
+
* is supplied. */
|
|
67
|
+
onPinRightsDisabled?: (chatId: string) => void
|
|
58
68
|
}
|
|
59
69
|
|
|
60
70
|
/**
|
|
@@ -77,10 +87,26 @@ export async function reconcilePin(
|
|
|
77
87
|
if (action.kind === 'noop') return args.prevState
|
|
78
88
|
|
|
79
89
|
if (action.kind === 'unpin') {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
90
|
+
// Skip the unpin API call in a chat the bot can't manage pins in — the
|
|
91
|
+
// call would fail with the same rights 400 and spam the log. The claim is
|
|
92
|
+
// dropped either way (below), so skipping is safe.
|
|
93
|
+
if (!args.rightsCache?.isBlocked(args.chatId)) {
|
|
94
|
+
try {
|
|
95
|
+
await args.api.unpinChatMessage(args.chatId, action.messageId)
|
|
96
|
+
} catch (err) {
|
|
97
|
+
// Symmetric with the pin path below: a permanent rights 400 on UNPIN
|
|
98
|
+
// (rights revoked mid-session after we pinned) also enters the
|
|
99
|
+
// negative cache and logs once via onPinRightsDisabled — otherwise
|
|
100
|
+
// every later unpin attempt would burn an API call and spam
|
|
101
|
+
// `status-pin unpin failed` per attempt, the exact class this cache
|
|
102
|
+
// exists to kill (#3073 review finding). Claim is dropped regardless.
|
|
103
|
+
if (args.rightsCache && isPinRightsError(err)) {
|
|
104
|
+
const firstTime = args.rightsCache.block(args.chatId)
|
|
105
|
+
if (firstTime) args.onPinRightsDisabled?.(args.chatId)
|
|
106
|
+
} else {
|
|
107
|
+
args.onError?.('unpin', err)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
84
110
|
}
|
|
85
111
|
// Drop the claim regardless of the unpin outcome. A stuck claim would
|
|
86
112
|
// leave a permanent pin on a crash / out-of-band unpin — the exact
|
|
@@ -89,14 +115,33 @@ export async function reconcilePin(
|
|
|
89
115
|
}
|
|
90
116
|
|
|
91
117
|
// action.kind === 'pin' — pin an EXISTING message, silently.
|
|
118
|
+
// Rights-aware negative cache (issue #3024): if a prior attempt in this chat
|
|
119
|
+
// already failed with the permanent "not enough rights" 400, skip silently —
|
|
120
|
+
// no API call, no log. Don't claim the message (the pin never happened), so a
|
|
121
|
+
// later reconcile after a restart (cache cleared) can retry.
|
|
122
|
+
if (args.rightsCache?.isBlocked(args.chatId)) {
|
|
123
|
+
return args.prevState
|
|
124
|
+
}
|
|
92
125
|
try {
|
|
93
126
|
await args.api.pinChatMessage(args.chatId, action.messageId, {
|
|
94
127
|
disable_notification: true,
|
|
95
128
|
})
|
|
96
129
|
} catch (err) {
|
|
97
|
-
|
|
130
|
+
// A permanent pin-rights failure enters the negative cache and logs ONCE
|
|
131
|
+
// (via onPinRightsDisabled) — every subsequent attempt in this chat is then
|
|
132
|
+
// skipped above. Transient failures (429 / 5xx / network) are NOT cached
|
|
133
|
+
// and route through onError as before, preserving retry behaviour.
|
|
134
|
+
if (args.rightsCache && isPinRightsError(err)) {
|
|
135
|
+
const firstTime = args.rightsCache.block(args.chatId)
|
|
136
|
+
if (firstTime) args.onPinRightsDisabled?.(args.chatId)
|
|
137
|
+
} else {
|
|
138
|
+
args.onError?.('pin', err)
|
|
139
|
+
}
|
|
98
140
|
// Don't claim a message we failed to pin — the next reconcile retries.
|
|
99
141
|
return args.prevState
|
|
100
142
|
}
|
|
143
|
+
// Pin succeeded — if this chat was previously cached as pin-incapable, rights
|
|
144
|
+
// were granted since; forget it so we resume normal behaviour immediately.
|
|
145
|
+
args.rightsCache?.clear(args.chatId)
|
|
101
146
|
return { messageId: action.messageId }
|
|
102
147
|
}
|
|
@@ -74,3 +74,84 @@ export function decidePinAction(
|
|
|
74
74
|
// re-pins the new one on the next reconcile.
|
|
75
75
|
return { kind: 'unpin', messageId: prev.messageId }
|
|
76
76
|
}
|
|
77
|
+
|
|
78
|
+
/** Extract a lowercased human description from any thrown value, preferring
|
|
79
|
+
* grammy's structured `.description` (the wire text Telegram returned) and
|
|
80
|
+
* falling back to `.message` / String(). Kept dependency-free — matches on
|
|
81
|
+
* the wire text, never on a grammy class import. */
|
|
82
|
+
function errorDescription(err: unknown): string {
|
|
83
|
+
if (err != null && typeof err === 'object') {
|
|
84
|
+
const o = err as { description?: unknown; message?: unknown }
|
|
85
|
+
if (typeof o.description === 'string' && o.description.length > 0) {
|
|
86
|
+
return o.description.toLowerCase()
|
|
87
|
+
}
|
|
88
|
+
if (typeof o.message === 'string' && o.message.length > 0) {
|
|
89
|
+
return o.message.toLowerCase()
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return String(err).toLowerCase()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* True when a pin/unpin failure is the PERMANENT "the bot lacks pin rights in
|
|
97
|
+
* this chat" class — Telegram returns this as a 400 (not a 403):
|
|
98
|
+
* "not enough rights to manage pinned messages in the chat". This is the one
|
|
99
|
+
* error class that will NOT self-heal on retry: the bot is simply not an admin
|
|
100
|
+
* (or lacks the "Pin messages" right) in that supergroup, and every subsequent
|
|
101
|
+
* pin attempt in the same chat fails identically until an admin grants the
|
|
102
|
+
* right (which only takes effect after a gateway restart re-reads chat perms).
|
|
103
|
+
*
|
|
104
|
+
* Transient classes (429 flood-wait, 5xx, network HttpError) are deliberately
|
|
105
|
+
* EXCLUDED — they must keep the existing retry behaviour, never enter the
|
|
106
|
+
* negative cache. Only this permanent-rights class is cacheable.
|
|
107
|
+
*
|
|
108
|
+
* The single source of truth for the pin-rights concept: the gateway's
|
|
109
|
+
* `unhandled-rejection-policy.ts` and the various edit-error classifiers each
|
|
110
|
+
* fold `not enough rights` into a broader boolean for their own purpose; this
|
|
111
|
+
* is the one helper dedicated to the pin negative-cache decision.
|
|
112
|
+
*/
|
|
113
|
+
export function isPinRightsError(err: unknown): boolean {
|
|
114
|
+
return errorDescription(err).includes('not enough rights')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Per-process, per-chat negative cache for chats the bot cannot pin in.
|
|
119
|
+
*
|
|
120
|
+
* When an auto status-pin attempt fails with the permanent pin-rights 400
|
|
121
|
+
* (`isPinRightsError`), the chat is recorded here as pin-incapable so the
|
|
122
|
+
* driver skips every subsequent auto-pin attempt in that chat — no wasted API
|
|
123
|
+
* call, no repeated `status-pin pin failed` log line (issue #3024: marko logged
|
|
124
|
+
* 41 identical pin-rights rejections in 48h, one per attempt).
|
|
125
|
+
*
|
|
126
|
+
* Deliberately IN-MEMORY / per-boot only: pin rights may be granted later, and
|
|
127
|
+
* Telegram surfaces the new permission to the bot only on a fresh chat-member
|
|
128
|
+
* fetch — a gateway restart. Clearing the cache on restart is therefore the
|
|
129
|
+
* correct re-enable trigger. An explicit `pin_message` tool success in a chat
|
|
130
|
+
* also clears its entry (rights were granted mid-session).
|
|
131
|
+
*
|
|
132
|
+
* Scope: the AUTO status-pin path only. The explicit `pin_message` MCP tool
|
|
133
|
+
* never consults this cache — it always attempts and surfaces the error to the
|
|
134
|
+
* agent as a normal tool error.
|
|
135
|
+
*/
|
|
136
|
+
export class PinRightsCache {
|
|
137
|
+
private readonly blocked = new Set<string>()
|
|
138
|
+
|
|
139
|
+
/** True when auto-pin should be skipped for this chat (already known bad). */
|
|
140
|
+
isBlocked(chatId: string): boolean {
|
|
141
|
+
return this.blocked.has(chatId)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Record a chat as pin-incapable. Returns `true` only the FIRST time a chat
|
|
145
|
+
* is added, so the caller can log the warn line exactly once per chat. */
|
|
146
|
+
block(chatId: string): boolean {
|
|
147
|
+
if (this.blocked.has(chatId)) return false
|
|
148
|
+
this.blocked.add(chatId)
|
|
149
|
+
return true
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Forget a chat (rights re-granted, e.g. an explicit pin later succeeded).
|
|
153
|
+
* Returns `true` if an entry was actually removed. */
|
|
154
|
+
clear(chatId: string): boolean {
|
|
155
|
+
return this.blocked.delete(chatId)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -155,6 +155,25 @@ export interface WorkerEntry {
|
|
|
155
155
|
* every real long-runner always carries a `toolu_…` id.
|
|
156
156
|
*/
|
|
157
157
|
inflightToolUseIds: Set<string>
|
|
158
|
+
/**
|
|
159
|
+
* Wall-clock ms of the most recent EMITTED "terminal synthesis deferred"
|
|
160
|
+
* log line for this entry, or null if the entry is not currently in the
|
|
161
|
+
* deferred state (never entered it, or resumed out of it). Drives the
|
|
162
|
+
* per-worker log rate-limit (#3092): null ⇒ this is a fresh entry into the
|
|
163
|
+
* deferred state and MUST log; non-null ⇒ log only once
|
|
164
|
+
* `deferralLogIntervalMs` has elapsed. Reset to null on the un-stall path
|
|
165
|
+
* so a stall → resume → stall cycle logs its first deferral again.
|
|
166
|
+
*/
|
|
167
|
+
deferralLoggedAt: number | null
|
|
168
|
+
/**
|
|
169
|
+
* Count of deferral ticks whose log line was SUPPRESSED by the rate-limit
|
|
170
|
+
* since this entry entered the deferred state (#3092). Carried into the
|
|
171
|
+
* next emitted line (and into the resume / cap-crossing transition lines)
|
|
172
|
+
* so the suppression is itself visible and the operator can see the true
|
|
173
|
+
* tick volume without reading 1,547 lines. Reset alongside
|
|
174
|
+
* `deferralLoggedAt`.
|
|
175
|
+
*/
|
|
176
|
+
deferralSuppressedTicks: number
|
|
158
177
|
/**
|
|
159
178
|
* True if the underlying JSONL file existed before the watcher started.
|
|
160
179
|
* Historical entries are tracked for late state transitions but are
|
|
@@ -330,6 +349,22 @@ export interface SubagentWatcherConfig {
|
|
|
330
349
|
* `SWITCHROOM_SUBAGENT_INFLIGHT_TERMINAL_CAP_MS`.
|
|
331
350
|
*/
|
|
332
351
|
inflightTerminalCapMs?: number
|
|
352
|
+
/**
|
|
353
|
+
* Rate-limit (ms) on the repeated "terminal synthesis deferred" log line
|
|
354
|
+
* for a single worker (#3092). The deferral decision is re-evaluated on
|
|
355
|
+
* every ~1s rescan tick; without this gate each re-evaluation logged,
|
|
356
|
+
* emitting ~1 line/sec for as long as the worker stayed blocked (1,547
|
|
357
|
+
* lines in 35 min on the live overlord gateway, drowning the concurrent
|
|
358
|
+
* 429 flood-ban lines of #3084).
|
|
359
|
+
*
|
|
360
|
+
* Semantics: the FIRST tick that enters the deferred state always logs;
|
|
361
|
+
* subsequent ticks log only once this many ms have passed since the last
|
|
362
|
+
* emitted line. State CHANGES are never suppressed — the cap-crossing
|
|
363
|
+
* ("proceeding with terminal synthesis") and the resume/un-stall lines
|
|
364
|
+
* fire regardless. Default 60_000 (1 min). Env override
|
|
365
|
+
* `SWITCHROOM_SUBAGENT_DEFERRAL_LOG_INTERVAL_MS`.
|
|
366
|
+
*/
|
|
367
|
+
deferralLogIntervalMs?: number
|
|
333
368
|
/**
|
|
334
369
|
* Freshness window (ms) for promoting a running-at-boot worker file to
|
|
335
370
|
* live. A file whose last write (mtime) is older than this is treated as
|
|
@@ -600,6 +635,16 @@ const DEFAULT_SILENT_STALL_TERMINAL_MS = 300_000
|
|
|
600
635
|
// reaper TTL, so the watcher — not the reaper — still owns the terminal
|
|
601
636
|
// transition for a worker that died mid-tool.
|
|
602
637
|
const DEFAULT_INFLIGHT_TERMINAL_CAP_MS = 45 * 60_000
|
|
638
|
+
// Minimum wall-clock gap between two "terminal synthesis deferred" log lines
|
|
639
|
+
// for the SAME worker (#3092). `checkStalls` runs on the ~1s rescan tick, and
|
|
640
|
+
// the deferral is re-evaluated (and, before this gate, re-logged) on every one
|
|
641
|
+
// of them. A single worker legitimately blocked inside a long tool call
|
|
642
|
+
// produced 1,547 near-identical lines in 35 minutes on the overlord gateway,
|
|
643
|
+
// burying the concurrent Telegram 429 flood-ban lines (#3084). The DECISION is
|
|
644
|
+
// re-made every tick — only the LOGGING is rate-limited: first entry into the
|
|
645
|
+
// deferred state logs immediately, then at most once per this interval, and
|
|
646
|
+
// every state CHANGE (resume, cap-crossing) logs unconditionally.
|
|
647
|
+
const DEFAULT_DEFERRAL_LOG_INTERVAL_MS = 60_000
|
|
603
648
|
|
|
604
649
|
/**
|
|
605
650
|
* Tools that legitimately run for minutes with ZERO intervening JSONL
|
|
@@ -1183,6 +1228,18 @@ export function readSubTail(
|
|
|
1183
1228
|
log?.(`subagent-watcher: onUnstall callback error ${entry.agentId}: ${(cbErr as Error).message}`)
|
|
1184
1229
|
}
|
|
1185
1230
|
}
|
|
1231
|
+
// STATE CHANGE out of the deferred state (#3092) — never rate-
|
|
1232
|
+
// limited. A worker that was deferring terminal synthesis behind an
|
|
1233
|
+
// in-flight tool call has RESURRECTED; that transition is the
|
|
1234
|
+
// payoff of the deferral and must be visible even though the
|
|
1235
|
+
// intervening per-tick deferral lines were suppressed. Report the
|
|
1236
|
+
// suppressed volume so the quiet window is accounted for, then
|
|
1237
|
+
// re-arm so a subsequent re-stall logs its first deferral again.
|
|
1238
|
+
if (entry.deferralLoggedAt != null) {
|
|
1239
|
+
log?.(`subagent-watcher: in-flight deferral resolved for ${entry.agentId} (worker resumed after ${idleSecBeforeBump}s idle — deferral was correct, not a dead worker; ${entry.deferralSuppressedTicks} deferral tick(s) suppressed since the last deferral line)`)
|
|
1240
|
+
entry.deferralLoggedAt = null
|
|
1241
|
+
entry.deferralSuppressedTicks = 0
|
|
1242
|
+
}
|
|
1186
1243
|
log?.(`subagent-watcher: stall cleared for ${entry.agentId} (activity resumed after ${idleSecBeforeBump}s — re-arming detection)`)
|
|
1187
1244
|
}
|
|
1188
1245
|
if (ev.kind === 'sub_agent_model') {
|
|
@@ -1457,6 +1514,10 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1457
1514
|
config.inflightTerminalCapMs
|
|
1458
1515
|
?? parseEnvMs('SWITCHROOM_SUBAGENT_INFLIGHT_TERMINAL_CAP_MS')
|
|
1459
1516
|
?? DEFAULT_INFLIGHT_TERMINAL_CAP_MS
|
|
1517
|
+
const deferralLogIntervalMs =
|
|
1518
|
+
config.deferralLogIntervalMs
|
|
1519
|
+
?? parseEnvMs('SWITCHROOM_SUBAGENT_DEFERRAL_LOG_INTERVAL_MS')
|
|
1520
|
+
?? DEFAULT_DEFERRAL_LOG_INTERVAL_MS
|
|
1460
1521
|
const inflightPromoteMaxAgeMs =
|
|
1461
1522
|
config.inflightPromoteMaxAgeMs
|
|
1462
1523
|
?? parseEnvMs('SWITCHROOM_SUBAGENT_INFLIGHT_MAX_AGE_MS')
|
|
@@ -1603,6 +1664,8 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1603
1664
|
stalledAt: null,
|
|
1604
1665
|
completionNotified: false,
|
|
1605
1666
|
stallTerminalSynthesised: false,
|
|
1667
|
+
deferralLoggedAt: null,
|
|
1668
|
+
deferralSuppressedTicks: 0,
|
|
1606
1669
|
lastSummaryLine: '',
|
|
1607
1670
|
lastResultText: '',
|
|
1608
1671
|
lastProgressBucketIdx: null,
|
|
@@ -1987,6 +2050,19 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1987
2050
|
existing.stallNotified = false
|
|
1988
2051
|
existing.stalledAt = null
|
|
1989
2052
|
existing.stallTerminalSynthesised = false
|
|
2053
|
+
// Re-arm the deferral log gate alongside its stall siblings (#3092).
|
|
2054
|
+
// Unreachable with stale state TODAY (both routes out of the deferred
|
|
2055
|
+
// state to `done` — the cap-crossing and the un-stall drain — already
|
|
2056
|
+
// null `deferralLoggedAt`), so this is belt-and-braces, not a live fix.
|
|
2057
|
+
// Reset it here anyway: a resurrected entry must be indistinguishable
|
|
2058
|
+
// from a fresh one, so a genuinely NEW stall always logs its FIRST
|
|
2059
|
+
// deferral line immediately. Leaving these to a reachability argument
|
|
2060
|
+
// makes the gate fragile by construction — a later reorder of the drain,
|
|
2061
|
+
// or a third path to `done`, would silently suppress that first line for
|
|
2062
|
+
// up to `deferralLogIntervalMs`, losing the exact signal this gate
|
|
2063
|
+
// exists to preserve.
|
|
2064
|
+
existing.deferralLoggedAt = null
|
|
2065
|
+
existing.deferralSuppressedTicks = 0
|
|
1990
2066
|
// Re-arm the completion notification INTENTIONALLY (issue #3023). The
|
|
1991
2067
|
// false synthesized finish already fired `onFinish` once, delivering a
|
|
1992
2068
|
// (possibly wrong / incomplete) synthesized handback to the parent.
|
|
@@ -2212,10 +2288,34 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
2212
2288
|
if (entry.inflightToolUseIds.size > 0) {
|
|
2213
2289
|
const totalIdleMs = n - entry.lastActivityAt
|
|
2214
2290
|
if (totalIdleMs < inflightTerminalCapMs) {
|
|
2215
|
-
|
|
2291
|
+
// Log-rate gate (#3092). The deferral DECISION above is re-made on
|
|
2292
|
+
// every ~1s rescan tick and is correct; only its LOGGING is gated.
|
|
2293
|
+
// Emit on first entry into the deferred state, then at most once per
|
|
2294
|
+
// `deferralLogIntervalMs`. Everything else is counted, not printed —
|
|
2295
|
+
// the suppressed count rides the next emitted line so no volume is
|
|
2296
|
+
// lost, just the 1/sec repetition (1,547 lines / 35 min on overlord,
|
|
2297
|
+
// burying the concurrent #3084 flood-ban lines).
|
|
2298
|
+
const lastLoggedAt = entry.deferralLoggedAt
|
|
2299
|
+
const firstEntry = lastLoggedAt == null
|
|
2300
|
+
if (firstEntry || n - lastLoggedAt >= deferralLogIntervalMs) {
|
|
2301
|
+
const suppressed = entry.deferralSuppressedTicks
|
|
2302
|
+
const suffix = firstEntry
|
|
2303
|
+
? ` — further deferral ticks for this worker are rate-limited to 1 line / ${Math.floor(deferralLogIntervalMs / 1000)}s until it resumes or crosses the cap`
|
|
2304
|
+
: ` — still deferred (${suppressed} tick(s) suppressed since the last line)`
|
|
2305
|
+
log?.(`subagent-watcher: silent-stall terminal synthesis deferred for ${entry.agentId} — ${entry.inflightToolUseIds.size} tool call(s) still in flight, ${Math.floor(totalIdleMs / 1000)}s idle < ${Math.floor(inflightTerminalCapMs / 1000)}s cap (long-running tool, not a dead worker)${suffix}`)
|
|
2306
|
+
entry.deferralLoggedAt = n
|
|
2307
|
+
entry.deferralSuppressedTicks = 0
|
|
2308
|
+
} else {
|
|
2309
|
+
entry.deferralSuppressedTicks++
|
|
2310
|
+
}
|
|
2216
2311
|
continue
|
|
2217
2312
|
}
|
|
2218
|
-
|
|
2313
|
+
// STATE CHANGE — never rate-limited. The deferral ends here and a
|
|
2314
|
+
// terminal synthesis fires; this is the transition an operator needs.
|
|
2315
|
+
const suppressedTotal = entry.deferralSuppressedTicks
|
|
2316
|
+
log?.(`subagent-watcher: in-flight deferral cap reached for ${entry.agentId} (${Math.floor(totalIdleMs / 1000)}s idle >= ${Math.floor(inflightTerminalCapMs / 1000)}s cap with ${entry.inflightToolUseIds.size} tool call(s) still unresolved) — treating as died-mid-tool, proceeding with terminal synthesis (${suppressedTotal} deferral tick(s) suppressed since the last deferral line)`)
|
|
2317
|
+
entry.deferralLoggedAt = null
|
|
2318
|
+
entry.deferralSuppressedTicks = 0
|
|
2219
2319
|
}
|
|
2220
2320
|
// TODO(#3023/PR #3029): the cap-reached path above is a SECOND source of
|
|
2221
2321
|
// possibly-false terminal synthesis (a worker mid-very-long-tool that is
|
|
@@ -66,14 +66,27 @@ describe('activity-card durability wiring', () => {
|
|
|
66
66
|
})
|
|
67
67
|
|
|
68
68
|
it('the boot reaper runs ONLY after the startup mutex is won (never at import time)', () => {
|
|
69
|
-
//
|
|
70
|
-
//
|
|
69
|
+
// #3026: the reaper is now invoked via the mutex-gated orchestrator
|
|
70
|
+
// runBootPinCleanupAndDmSweep() (which awaits it alongside
|
|
71
|
+
// statusPinBootCleanup + queuedCardBootReaper, then runs the DM
|
|
72
|
+
// stale-pin sweep). Invariant unchanged: reachable only post-lock.
|
|
73
|
+
// (1) The orchestrator awaits the reaper.
|
|
74
|
+
const orchestrator = between(
|
|
75
|
+
gatewaySrc,
|
|
76
|
+
'async function runBootPinCleanupAndDmSweep()',
|
|
77
|
+
'dmPinSweepEligible = true',
|
|
78
|
+
)
|
|
79
|
+
expect(orchestrator).toMatch(/await activityCardBootReaper\(\)/)
|
|
80
|
+
// (2) Both orchestrator invocation sites (mutex-won + mutex-fallback)
|
|
81
|
+
// sit AFTER acquireStartupLock.
|
|
71
82
|
const afterLock = between(gatewaySrc, 'await acquireStartupLock({', 'catch (err)')
|
|
72
|
-
expect(afterLock).toMatch(/void
|
|
73
|
-
//
|
|
74
|
-
// boot hazard the NOTE at
|
|
83
|
+
expect(afterLock).toMatch(/void runBootPinCleanupAndDmSweep\(\)/)
|
|
84
|
+
// (3) Neither the reaper nor the orchestrator is invoked at module import
|
|
85
|
+
// time — the same losing-double-boot hazard the NOTE at
|
|
86
|
+
// statusPinBootCleanup documents.
|
|
75
87
|
const beforeMain = gatewaySrc.split('await acquireStartupLock({')[0] ?? ''
|
|
76
88
|
expect(beforeMain).not.toMatch(/^\s*void activityCardBootReaper\(\)/m)
|
|
89
|
+
expect(beforeMain).not.toMatch(/^\s*void runBootPinCleanupAndDmSweep\(\)/m)
|
|
77
90
|
})
|
|
78
91
|
|
|
79
92
|
it('the reaper wrapper counts a benign-400 as vanished, not finalized (honest boot log)', () => {
|