switchroom 0.18.11 → 0.18.13
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 +37 -5
- package/dist/auth-broker/index.js +116 -78
- package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +38 -6
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +3529 -4543
- package/dist/cli/ui/index.html +163 -17
- package/dist/host-control/main.js +605 -2847
- package/dist/vault/approvals/kernel-server.js +120 -13
- package/dist/vault/broker/server.js +259 -157
- 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 +122 -4
- package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
- package/telegram-plugin/dist/server.js +139 -5
- package/telegram-plugin/flood-circuit-breaker.ts +493 -21
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +602 -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 +88 -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 +1695 -230
- 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/history.ts +51 -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 +303 -2
- package/telegram-plugin/model-unavailable.ts +41 -11
- package/telegram-plugin/operator-events.ts +23 -0
- package/telegram-plugin/outbound-field-redact.ts +69 -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/render/render.ts +32 -14
- package/telegram-plugin/retry-api-call.ts +212 -2
- package/telegram-plugin/scoped-approval.ts +11 -2
- package/telegram-plugin/secret-detect/chunker.ts +18 -4
- package/telegram-plugin/secret-detect/index.ts +12 -56
- package/telegram-plugin/send-gate-degraded.test.ts +574 -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 +717 -0
- package/telegram-plugin/send-gate.ts +1056 -0
- package/telegram-plugin/session-tail.ts +82 -7
- 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 +173 -18
- 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 +327 -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/callback-query-handlers.test.ts +65 -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/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
- package/telegram-plugin/tests/history.test.ts +115 -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/inbound-message-types.test.ts +5 -1
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -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/operator-events-session-tail.test.ts +74 -0
- package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -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/reaction-gate-routing.test.ts +173 -0
- package/telegram-plugin/tests/render/render.test.ts +88 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
- package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
- package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
- package/telegram-plugin/tests/session-model-file.test.ts +50 -0
- package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -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/subagent-watcher-fd-leak.test.ts +275 -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/tests/worktree-watch-cwds.test.ts +215 -1
- 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/telegram-plugin/worktree-watch-cwds.ts +194 -5
- 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
- package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
- package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FD/timer-leak regression test for the session-tail PreToolUse sidecars
|
|
3
|
+
* (review finding M1).
|
|
4
|
+
*
|
|
5
|
+
* `ToolLabelSidecar`s (each holding a stat-poll timer + file handle) were only
|
|
6
|
+
* reaped in the global `stop()`. Every session rotation (`/clear`, compaction →
|
|
7
|
+
* new sessionId) and every finished sub-agent minted a fresh sidecar that then
|
|
8
|
+
* lived until process exit — hundreds of leaked timers/handles on a long-lived
|
|
9
|
+
* agent.
|
|
10
|
+
*
|
|
11
|
+
* We mock the sidecar factory so each instance is a cheap fake with a `stop`
|
|
12
|
+
* spy, then drive a real parent-session rotation and a real sub-agent reap and
|
|
13
|
+
* assert the ended session's sidecar was stopped. Both assertions FAIL on
|
|
14
|
+
* pre-fix code (stop only in `stop()`).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
18
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from 'fs'
|
|
19
|
+
import { tmpdir } from 'os'
|
|
20
|
+
import { join } from 'path'
|
|
21
|
+
import { startSessionTail, getProjectsDirForCwd } from '../session-tail.js'
|
|
22
|
+
import type { SidecarOptions, ToolLabelSidecar } from '../tool-label-sidecar.js'
|
|
23
|
+
|
|
24
|
+
// ─── Sidecar factory injection: record every fake instance keyed by sessionId ─
|
|
25
|
+
// We do NOT `vi.mock('../tool-label-sidecar.js')`. Under bun's vitest-compat
|
|
26
|
+
// layer `vi.mock` is PROCESS-GLOBAL (not file-scoped like vitest), and CI's
|
|
27
|
+
// bun-test-run shard runs this whole `tests/` dir in ONE process — so a module
|
|
28
|
+
// mock here would leak into the real `tool-label-sidecar.test.ts` suite and
|
|
29
|
+
// replace the module under test, failing its 7 assertions. Instead we inject a
|
|
30
|
+
// fake sidecar factory through `startSessionTail`'s `createSidecar` seam (the
|
|
31
|
+
// repo's bun-safe DI precedent — see `vault-write-posture.test.ts`), so the
|
|
32
|
+
// shared module cache is never touched. Sibling `subagent-watcher-fd-leak.test.ts`
|
|
33
|
+
// uses the same injected-fake style.
|
|
34
|
+
interface FakeSidecar extends ToolLabelSidecar {
|
|
35
|
+
sessionId: string
|
|
36
|
+
stop: ReturnType<typeof vi.fn>
|
|
37
|
+
}
|
|
38
|
+
const created = { instances: [] as FakeSidecar[] }
|
|
39
|
+
const fakeSidecarFactory = (opts: SidecarOptions): ToolLabelSidecar => {
|
|
40
|
+
const inst: FakeSidecar = {
|
|
41
|
+
sessionId: opts.sessionId,
|
|
42
|
+
stop: vi.fn(),
|
|
43
|
+
getLabel: () => undefined,
|
|
44
|
+
onLabel: () => () => {},
|
|
45
|
+
poll: () => {},
|
|
46
|
+
}
|
|
47
|
+
created.instances.push(inst)
|
|
48
|
+
return inst
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
52
|
+
const tempDirs: string[] = []
|
|
53
|
+
let prevStateDir: string | undefined
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
created.instances.length = 0
|
|
57
|
+
prevStateDir = process.env.TELEGRAM_STATE_DIR
|
|
58
|
+
const stateDir = mkdtempSync(join(tmpdir(), 'sidecar-state-'))
|
|
59
|
+
tempDirs.push(stateDir)
|
|
60
|
+
process.env.TELEGRAM_STATE_DIR = stateDir
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
if (prevStateDir === undefined) delete process.env.TELEGRAM_STATE_DIR
|
|
65
|
+
else process.env.TELEGRAM_STATE_DIR = prevStateDir
|
|
66
|
+
for (const d of tempDirs) {
|
|
67
|
+
try { rmSync(d, { recursive: true, force: true }) } catch { /* ignore */ }
|
|
68
|
+
}
|
|
69
|
+
tempDirs.length = 0
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
function mkProjectsDir(): { claudeHome: string; cwd: string; projectsDir: string } {
|
|
73
|
+
const base = mkdtempSync(join(tmpdir(), 'session-tail-sidecar-'))
|
|
74
|
+
tempDirs.push(base)
|
|
75
|
+
const cwd = join(base, 'agent')
|
|
76
|
+
const claudeHome = join(base, 'claude-home')
|
|
77
|
+
const projectsDir = getProjectsDirForCwd(cwd, claudeHome)
|
|
78
|
+
mkdirSync(projectsDir, { recursive: true })
|
|
79
|
+
return { claudeHome, cwd, projectsDir }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const setMtime = (path: string, seconds: number): void => utimesSync(path, seconds, seconds)
|
|
83
|
+
const wait = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
|
|
84
|
+
const assistantText = (text: string): string =>
|
|
85
|
+
JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text }] } }) + '\n'
|
|
86
|
+
const toolUseLine = (name: string, id: string): string =>
|
|
87
|
+
JSON.stringify({ type: 'assistant', message: { content: [{ type: 'tool_use', name, id, input: {} }] } }) + '\n'
|
|
88
|
+
|
|
89
|
+
const findSidecar = (sessionId: string): FakeSidecar | undefined =>
|
|
90
|
+
created.instances.find((i) => i.sessionId === sessionId)
|
|
91
|
+
|
|
92
|
+
describe('session-tail sidecar reap (M1) — parent session rotation', () => {
|
|
93
|
+
it('stops the prior session sidecar when the active file rotates', async () => {
|
|
94
|
+
const { claudeHome, cwd, projectsDir } = mkProjectsDir()
|
|
95
|
+
const first = join(projectsDir, 'session-one.jsonl')
|
|
96
|
+
const second = join(projectsDir, 'session-two.jsonl')
|
|
97
|
+
|
|
98
|
+
writeFileSync(first, assistantText('parent one'))
|
|
99
|
+
setMtime(first, 1_000_000)
|
|
100
|
+
|
|
101
|
+
const handle = startSessionTail({ cwd, claudeHome, rescanIntervalMs: 30, onEvent: () => {}, createSidecar: fakeSidecarFactory })
|
|
102
|
+
try {
|
|
103
|
+
await wait(120) // initial attach → sidecar 'session-one' created
|
|
104
|
+
expect(findSidecar('session-one')).toBeDefined()
|
|
105
|
+
expect(findSidecar('session-one')!.stop).not.toHaveBeenCalled()
|
|
106
|
+
|
|
107
|
+
// A newer session file appears (compaction / clear rotation).
|
|
108
|
+
const nowSec = Math.floor(Date.now() / 1000)
|
|
109
|
+
writeFileSync(second, assistantText('parent two'))
|
|
110
|
+
setMtime(second, nowSec + 20)
|
|
111
|
+
setMtime(first, nowSec + 5)
|
|
112
|
+
await wait(200) // rescan flips to session-two → session-one must be reaped
|
|
113
|
+
|
|
114
|
+
expect(findSidecar('session-two')).toBeDefined()
|
|
115
|
+
// The rotated-away session's sidecar is stopped at runtime, not left for stop().
|
|
116
|
+
expect(findSidecar('session-one')!.stop).toHaveBeenCalledTimes(1)
|
|
117
|
+
// The now-active session's sidecar stays alive.
|
|
118
|
+
expect(findSidecar('session-two')!.stop).not.toHaveBeenCalled()
|
|
119
|
+
} finally {
|
|
120
|
+
handle.stop()
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe('session-tail sidecar reap (M1) — idle sub-agent', () => {
|
|
126
|
+
it('stops a sub-agent sidecar when its idle sub-tail is reaped', async () => {
|
|
127
|
+
const { claudeHome, cwd, projectsDir } = mkProjectsDir()
|
|
128
|
+
const parent = join(projectsDir, 'parent.jsonl')
|
|
129
|
+
writeFileSync(parent, assistantText('parent'))
|
|
130
|
+
setMtime(parent, Math.floor(Date.now() / 1000))
|
|
131
|
+
|
|
132
|
+
// Sub-agents live under <parentSessionId>/subagents/agent-<id>.jsonl.
|
|
133
|
+
const subDir = join(projectsDir, 'parent', 'subagents')
|
|
134
|
+
mkdirSync(subDir, { recursive: true })
|
|
135
|
+
const subFile = join(subDir, 'agent-sub123.jsonl')
|
|
136
|
+
// A tool_use line makes the sub-tail lazily create its sidecar (keyed by
|
|
137
|
+
// the file stem 'agent-sub123').
|
|
138
|
+
writeFileSync(subFile, toolUseLine('Bash', 'toolu_1'))
|
|
139
|
+
|
|
140
|
+
const handle = startSessionTail({
|
|
141
|
+
cwd,
|
|
142
|
+
claudeHome,
|
|
143
|
+
rescanIntervalMs: 30,
|
|
144
|
+
// Reap sub-tails idle for >1000ms so the test doesn't wait 5 minutes but
|
|
145
|
+
// still respects the L1 floor clamp (Math.max(1000, …) — a smaller value
|
|
146
|
+
// is silently lifted to 1s) and leaves a window to observe the
|
|
147
|
+
// not-yet-reaped state.
|
|
148
|
+
subTailIdleReapMs: 1000,
|
|
149
|
+
onEvent: () => {},
|
|
150
|
+
createSidecar: fakeSidecarFactory,
|
|
151
|
+
})
|
|
152
|
+
try {
|
|
153
|
+
await wait(100) // attach parent + sub, read tool_use → sidecar created
|
|
154
|
+
const sub = findSidecar('agent-sub123')
|
|
155
|
+
expect(sub).toBeDefined()
|
|
156
|
+
expect(sub!.stop).not.toHaveBeenCalled()
|
|
157
|
+
|
|
158
|
+
// Let the sub-tail go idle past the reap window; a rescan tick reaps it.
|
|
159
|
+
await wait(1300)
|
|
160
|
+
expect(sub!.stop).toHaveBeenCalledTimes(1)
|
|
161
|
+
} finally {
|
|
162
|
+
handle.stop()
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
describe('session-tail sidecar reap (L1) — reap window is floor-clamped', () => {
|
|
168
|
+
it('does NOT instant-reap a live sub-tail under a zero (or negative) idle window', async () => {
|
|
169
|
+
// A `subTailIdleReapMs` of 0 (or negative) would, without the
|
|
170
|
+
// Math.max(1000, …) clamp, make `reapIdleSubTails` compute
|
|
171
|
+
// `cutoff = Date.now() - 0`, which is ALWAYS ≥ a live sub-tail's slightly-
|
|
172
|
+
// earlier `lastActivityAt` — so every live sub-agent sidecar would be
|
|
173
|
+
// reaped on the very first rescan tick. The clamp lifts the effective
|
|
174
|
+
// window to the 1s floor, so a sub-tail active moments ago survives.
|
|
175
|
+
const { claudeHome, cwd, projectsDir } = mkProjectsDir()
|
|
176
|
+
const parent = join(projectsDir, 'parent.jsonl')
|
|
177
|
+
writeFileSync(parent, assistantText('parent'))
|
|
178
|
+
setMtime(parent, Math.floor(Date.now() / 1000))
|
|
179
|
+
|
|
180
|
+
const subDir = join(projectsDir, 'parent', 'subagents')
|
|
181
|
+
mkdirSync(subDir, { recursive: true })
|
|
182
|
+
const subFile = join(subDir, 'agent-subzero.jsonl')
|
|
183
|
+
writeFileSync(subFile, toolUseLine('Bash', 'toolu_z'))
|
|
184
|
+
|
|
185
|
+
const handle = startSessionTail({
|
|
186
|
+
cwd,
|
|
187
|
+
claudeHome,
|
|
188
|
+
rescanIntervalMs: 30,
|
|
189
|
+
subTailIdleReapMs: 0, // pathological: pre-clamp this instant-reaps
|
|
190
|
+
onEvent: () => {},
|
|
191
|
+
createSidecar: fakeSidecarFactory,
|
|
192
|
+
})
|
|
193
|
+
try {
|
|
194
|
+
await wait(100) // attach parent + sub → sidecar 'agent-subzero' created
|
|
195
|
+
const sub = findSidecar('agent-subzero')
|
|
196
|
+
expect(sub).toBeDefined()
|
|
197
|
+
expect(sub!.stop).not.toHaveBeenCalled()
|
|
198
|
+
|
|
199
|
+
// Several rescan ticks pass (well under the 1s clamp floor). A live
|
|
200
|
+
// sub-tail whose activity is <1s old must NOT be reaped — so its sidecar
|
|
201
|
+
// stays alive. Pre-clamp, the first tick reaps it (sub.stop called once).
|
|
202
|
+
await wait(200)
|
|
203
|
+
expect(sub!.stop).not.toHaveBeenCalled()
|
|
204
|
+
} finally {
|
|
205
|
+
handle.stop()
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
describe('session-tail sidecar reap (M1) — namespace safety', () => {
|
|
211
|
+
it('a parent-session rotation does NOT stop a concurrently-live sub-agent sidecar', async () => {
|
|
212
|
+
// The rotation reap in attachToFile stops the sidecar keyed by the
|
|
213
|
+
// rotated-away parent's JSONL stem (a UUID). A live sub-agent's sidecar is
|
|
214
|
+
// keyed by its OWN file stem ('agent-<id>'). Because the two key namespaces
|
|
215
|
+
// never collide, rotating the parent must leave the sub-agent's sidecar
|
|
216
|
+
// untouched — otherwise a `/clear` or compaction mid-worker would silently
|
|
217
|
+
// kill the live worker's label sidecar. Reviewer verified by code-reading
|
|
218
|
+
// only; this locks it in.
|
|
219
|
+
const { claudeHome, cwd, projectsDir } = mkProjectsDir()
|
|
220
|
+
const first = join(projectsDir, 'session-one.jsonl')
|
|
221
|
+
const second = join(projectsDir, 'session-two.jsonl')
|
|
222
|
+
|
|
223
|
+
writeFileSync(first, assistantText('parent one'))
|
|
224
|
+
setMtime(first, 1_000_000)
|
|
225
|
+
|
|
226
|
+
// A live sub-agent under the CURRENT (session-one) parent. Its tool_use
|
|
227
|
+
// line makes the sub-tail create a sidecar keyed 'agent-subns'.
|
|
228
|
+
const subDir = join(projectsDir, 'session-one', 'subagents')
|
|
229
|
+
mkdirSync(subDir, { recursive: true })
|
|
230
|
+
const subFile = join(subDir, 'agent-subns.jsonl')
|
|
231
|
+
writeFileSync(subFile, toolUseLine('Bash', 'toolu_ns'))
|
|
232
|
+
|
|
233
|
+
const handle = startSessionTail({
|
|
234
|
+
cwd,
|
|
235
|
+
claudeHome,
|
|
236
|
+
rescanIntervalMs: 30,
|
|
237
|
+
// Large window so the sub-tail is never idle-reaped during the test —
|
|
238
|
+
// we're isolating the rotation path, not the idle path.
|
|
239
|
+
subTailIdleReapMs: 60_000,
|
|
240
|
+
onEvent: () => {},
|
|
241
|
+
createSidecar: fakeSidecarFactory,
|
|
242
|
+
})
|
|
243
|
+
try {
|
|
244
|
+
await wait(120) // attach parent + sub → both sidecars created
|
|
245
|
+
const parentSidecar = findSidecar('session-one')
|
|
246
|
+
const subSidecar = findSidecar('agent-subns')
|
|
247
|
+
expect(parentSidecar).toBeDefined()
|
|
248
|
+
expect(subSidecar).toBeDefined()
|
|
249
|
+
expect(parentSidecar!.stop).not.toHaveBeenCalled()
|
|
250
|
+
expect(subSidecar!.stop).not.toHaveBeenCalled()
|
|
251
|
+
|
|
252
|
+
// Parent session rotates (compaction / clear → new sessionId).
|
|
253
|
+
const nowSec = Math.floor(Date.now() / 1000)
|
|
254
|
+
writeFileSync(second, assistantText('parent two'))
|
|
255
|
+
setMtime(second, nowSec + 20)
|
|
256
|
+
setMtime(first, nowSec + 5)
|
|
257
|
+
await wait(200) // rescan flips active file to session-two
|
|
258
|
+
|
|
259
|
+
// The rotated-away PARENT sidecar is reaped …
|
|
260
|
+
expect(parentSidecar!.stop).toHaveBeenCalledTimes(1)
|
|
261
|
+
// … but the concurrently-live SUB-agent sidecar is NOT — different stem
|
|
262
|
+
// namespace, so the rotation reap never targets it.
|
|
263
|
+
expect(subSidecar!.stop).not.toHaveBeenCalled()
|
|
264
|
+
} finally {
|
|
265
|
+
handle.stop()
|
|
266
|
+
}
|
|
267
|
+
})
|
|
268
|
+
})
|
|
@@ -232,18 +232,43 @@ describe("status-pin boot cleanup is mutex-gated (structural)", () => {
|
|
|
232
232
|
);
|
|
233
233
|
});
|
|
234
234
|
|
|
235
|
-
it("every
|
|
236
|
-
//
|
|
235
|
+
it("every boot pin-cleanup call site follows the won-lock check", () => {
|
|
236
|
+
// #3026: boot cleanup is now invoked via the mutex-gated orchestrator
|
|
237
|
+
// runBootPinCleanupAndDmSweep(), which awaits statusPinBootCleanup()
|
|
238
|
+
// (+ the other reapers) and then runs the DM stale-pin sweep. The
|
|
239
|
+
// invariant is unchanged: cleanup must only run after the lock is won.
|
|
237
240
|
const lines = gatewaySrc.split("\n");
|
|
238
241
|
const lockIdx = lines.findIndex((l) => l.includes("acquireStartupLock({"));
|
|
239
242
|
expect(lockIdx).toBeGreaterThan(-1);
|
|
240
243
|
|
|
244
|
+
// Every `void runBootPinCleanupAndDmSweep()` invocation must come AFTER
|
|
245
|
+
// the acquireStartupLock outcome is available (the boot block the winner
|
|
246
|
+
// — or the link()-unsupported fallback — reaches).
|
|
247
|
+
const callIdxs = lines
|
|
248
|
+
.map((l, i) => ({ l, i }))
|
|
249
|
+
.filter(
|
|
250
|
+
({ l }) =>
|
|
251
|
+
/void runBootPinCleanupAndDmSweep\(\)/.test(l) &&
|
|
252
|
+
!l.trimStart().startsWith("//"),
|
|
253
|
+
)
|
|
254
|
+
.map(({ i }) => i);
|
|
255
|
+
expect(callIdxs.length).toBeGreaterThan(0);
|
|
256
|
+
for (const idx of callIdxs) {
|
|
257
|
+
expect(idx).toBeGreaterThan(lockIdx);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// statusPinBootCleanup() itself must only ever be invoked from INSIDE
|
|
261
|
+
// that orchestrator — never at a bare post-import call site that a
|
|
262
|
+
// losing double-boot could reach.
|
|
241
263
|
const declIdx = lines.findIndex((l) =>
|
|
242
264
|
/async function statusPinBootCleanup/.test(l),
|
|
243
265
|
);
|
|
244
266
|
expect(declIdx).toBeGreaterThan(-1);
|
|
245
|
-
|
|
246
|
-
|
|
267
|
+
const orchestratorIdx = lines.findIndex((l) =>
|
|
268
|
+
/async function runBootPinCleanupAndDmSweep/.test(l),
|
|
269
|
+
);
|
|
270
|
+
expect(orchestratorIdx).toBeGreaterThan(-1);
|
|
271
|
+
const invocationIdxs = lines
|
|
247
272
|
.map((l, i) => ({ l, i }))
|
|
248
273
|
.filter(
|
|
249
274
|
({ l, i }) =>
|
|
@@ -253,25 +278,21 @@ describe("status-pin boot cleanup is mutex-gated (structural)", () => {
|
|
|
253
278
|
!l.includes("statusPinBootCleanup() is deliberately NOT"),
|
|
254
279
|
)
|
|
255
280
|
.map(({ i }) => i);
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
// block that only the winner reaches).
|
|
260
|
-
expect(callIdxs.length).toBeGreaterThan(0);
|
|
261
|
-
for (const idx of callIdxs) {
|
|
262
|
-
expect(idx).toBeGreaterThan(lockIdx);
|
|
281
|
+
expect(invocationIdxs.length).toBeGreaterThan(0);
|
|
282
|
+
for (const idx of invocationIdxs) {
|
|
283
|
+
expect(idx).toBeGreaterThan(orchestratorIdx);
|
|
263
284
|
}
|
|
264
285
|
});
|
|
265
286
|
|
|
266
287
|
it("the blocked (losing) boot exits before reaching cleanup", () => {
|
|
267
288
|
// In the mutex block, `outcome.status === 'blocked'` must lead to
|
|
268
|
-
// process.exit(1) BEFORE
|
|
269
|
-
// so a loser never touches the shared store.
|
|
289
|
+
// process.exit(1) BEFORE the winner's runBootPinCleanupAndDmSweep() call
|
|
290
|
+
// in that block — so a loser never touches the shared store.
|
|
270
291
|
const blockedIdx = gatewaySrc.indexOf("outcome.status === 'blocked'");
|
|
271
292
|
expect(blockedIdx).toBeGreaterThan(-1);
|
|
272
293
|
const afterBlocked = gatewaySrc.slice(blockedIdx);
|
|
273
294
|
const exitIdx = afterBlocked.indexOf("process.exit(1)");
|
|
274
|
-
const cleanupIdx = afterBlocked.indexOf("void
|
|
295
|
+
const cleanupIdx = afterBlocked.indexOf("void runBootPinCleanupAndDmSweep()");
|
|
275
296
|
expect(exitIdx).toBeGreaterThan(-1);
|
|
276
297
|
expect(cleanupIdx).toBeGreaterThan(-1);
|
|
277
298
|
// The exit for the blocked branch appears before the winner's cleanup call.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
2
|
import { GrammyError } from 'grammy'
|
|
3
|
-
import { decidePinAction } from '../status-pin.js'
|
|
3
|
+
import { decidePinAction, isPinRightsError, PinRightsCache } from '../status-pin.js'
|
|
4
4
|
import { reconcilePin, type PinBotApi } from '../status-pin-driver.js'
|
|
5
5
|
import type { PinState } from '../status-pin.js'
|
|
6
6
|
|
|
@@ -200,3 +200,277 @@ describe('reconcilePin — pin-rights 400 must never crash (marko 2026-07-01)',
|
|
|
200
200
|
}
|
|
201
201
|
})
|
|
202
202
|
})
|
|
203
|
+
|
|
204
|
+
// ── #3024: rights-aware negative cache ─────────────────────────────────────
|
|
205
|
+
// marko logged 41 identical `pinChatMessage` "not enough rights" rejections in
|
|
206
|
+
// 48h — one per auto status-pin attempt in a group where the bot isn't a pin
|
|
207
|
+
// admin. The permanent-rights class is now cached per-chat so the SECOND and
|
|
208
|
+
// later attempts skip the API call entirely and stop spamming the log. Synthetic
|
|
209
|
+
// chat IDs only (the real ones in the issue are operator PII).
|
|
210
|
+
describe('isPinRightsError (pure classifier)', () => {
|
|
211
|
+
it('matches the permanent pin-rights 400 (grammy .description)', () => {
|
|
212
|
+
expect(
|
|
213
|
+
isPinRightsError(
|
|
214
|
+
grammyError(400, 'Bad Request: not enough rights to manage pinned messages in the chat'),
|
|
215
|
+
),
|
|
216
|
+
).toBe(true)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('matches a plain Error carrying the rights text', () => {
|
|
220
|
+
expect(isPinRightsError(new Error('not enough rights to manage pinned messages'))).toBe(true)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('does NOT match transient classes (429 flood-wait, network)', () => {
|
|
224
|
+
expect(isPinRightsError(grammyError(429, 'Too Many Requests: retry after 5'))).toBe(false)
|
|
225
|
+
expect(isPinRightsError(new Error('fetch failed'))).toBe(false)
|
|
226
|
+
expect(isPinRightsError(grammyError(400, 'Bad Request: message to edit not found'))).toBe(false)
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
describe('PinRightsCache (pure)', () => {
|
|
231
|
+
it('block() returns true only the first time a chat is added (log-once)', () => {
|
|
232
|
+
const cache = new PinRightsCache()
|
|
233
|
+
expect(cache.isBlocked('-1009000000001')).toBe(false)
|
|
234
|
+
expect(cache.block('-1009000000001')).toBe(true)
|
|
235
|
+
expect(cache.block('-1009000000001')).toBe(false)
|
|
236
|
+
expect(cache.isBlocked('-1009000000001')).toBe(true)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('clear() forgets a chat (rights re-granted)', () => {
|
|
240
|
+
const cache = new PinRightsCache()
|
|
241
|
+
cache.block('-1009000000001')
|
|
242
|
+
expect(cache.clear('-1009000000001')).toBe(true)
|
|
243
|
+
expect(cache.isBlocked('-1009000000001')).toBe(false)
|
|
244
|
+
expect(cache.clear('-1009000000001')).toBe(false)
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
/** A fake Bot API counting pin calls, throwing a chosen error on pin. */
|
|
249
|
+
function countingApi(pinError?: unknown) {
|
|
250
|
+
let pinCalls = 0
|
|
251
|
+
let unpinCalls = 0
|
|
252
|
+
const api: PinBotApi = {
|
|
253
|
+
pinChatMessage: async () => {
|
|
254
|
+
pinCalls += 1
|
|
255
|
+
if (pinError) throw pinError
|
|
256
|
+
},
|
|
257
|
+
unpinChatMessage: async () => {
|
|
258
|
+
unpinCalls += 1
|
|
259
|
+
},
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
api,
|
|
263
|
+
get pinCalls() {
|
|
264
|
+
return pinCalls
|
|
265
|
+
},
|
|
266
|
+
get unpinCalls() {
|
|
267
|
+
return unpinCalls
|
|
268
|
+
},
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
describe('reconcilePin — rights-aware negative cache (#3024)', () => {
|
|
273
|
+
const rightsErr = () =>
|
|
274
|
+
grammyError(400, 'Bad Request: not enough rights to manage pinned messages in the chat')
|
|
275
|
+
|
|
276
|
+
it('first rights failure: attempts once, logs once, records the chat', async () => {
|
|
277
|
+
const cache = new PinRightsCache()
|
|
278
|
+
const disabled: string[] = []
|
|
279
|
+
const pinFails: string[] = []
|
|
280
|
+
const t = countingApi(rightsErr())
|
|
281
|
+
|
|
282
|
+
const next = await reconcilePin({
|
|
283
|
+
api: t.api,
|
|
284
|
+
chatId: '-1009000000001',
|
|
285
|
+
prevState: null,
|
|
286
|
+
desired: { pinned: true, messageId: 4 },
|
|
287
|
+
rightsCache: cache,
|
|
288
|
+
onPinRightsDisabled: (chat) => disabled.push(chat),
|
|
289
|
+
onError: (phase) => pinFails.push(phase),
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
expect(t.pinCalls).toBe(1) // it did try
|
|
293
|
+
expect(next).toBeNull() // no claim taken
|
|
294
|
+
expect(disabled).toEqual(['-1009000000001']) // logged once
|
|
295
|
+
expect(pinFails).toEqual([]) // NOT routed through the per-attempt onError
|
|
296
|
+
expect(cache.isBlocked('-1009000000001')).toBe(true)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('second attempt in the same chat makes NO api call and logs nothing', async () => {
|
|
300
|
+
const cache = new PinRightsCache()
|
|
301
|
+
const disabled: string[] = []
|
|
302
|
+
const pinFails: string[] = []
|
|
303
|
+
const t = countingApi(rightsErr())
|
|
304
|
+
|
|
305
|
+
const common = {
|
|
306
|
+
chatId: '-1009000000001',
|
|
307
|
+
prevState: null,
|
|
308
|
+
desired: { pinned: true, messageId: 4 } as const,
|
|
309
|
+
rightsCache: cache,
|
|
310
|
+
onPinRightsDisabled: (chat: string) => disabled.push(chat),
|
|
311
|
+
onError: (phase: 'pin' | 'unpin') => pinFails.push(phase),
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
await reconcilePin({ api: t.api, ...common })
|
|
315
|
+
await reconcilePin({ api: t.api, ...common })
|
|
316
|
+
|
|
317
|
+
expect(t.pinCalls).toBe(1) // second attempt short-circuited before the API
|
|
318
|
+
expect(disabled).toEqual(['-1009000000001']) // still logged exactly once
|
|
319
|
+
expect(pinFails).toEqual([])
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
it('a DIFFERENT chat is unaffected by another chat being blocked', async () => {
|
|
323
|
+
const cache = new PinRightsCache()
|
|
324
|
+
cache.block('-1009000000001')
|
|
325
|
+
const t = countingApi() // pin succeeds here
|
|
326
|
+
|
|
327
|
+
const next = await reconcilePin({
|
|
328
|
+
api: t.api,
|
|
329
|
+
chatId: '-1009000000002',
|
|
330
|
+
prevState: null,
|
|
331
|
+
desired: { pinned: true, messageId: 7 },
|
|
332
|
+
rightsCache: cache,
|
|
333
|
+
onPinRightsDisabled: () => {
|
|
334
|
+
throw new Error('should not disable a healthy chat')
|
|
335
|
+
},
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
expect(t.pinCalls).toBe(1)
|
|
339
|
+
expect(next).toEqual({ messageId: 7 })
|
|
340
|
+
expect(cache.isBlocked('-1009000000002')).toBe(false)
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it('a 429 (transient) does NOT enter the cache — retry behaviour preserved', async () => {
|
|
344
|
+
const cache = new PinRightsCache()
|
|
345
|
+
const disabled: string[] = []
|
|
346
|
+
const pinFails: string[] = []
|
|
347
|
+
const t = countingApi(grammyError(429, 'Too Many Requests: retry after 5'))
|
|
348
|
+
|
|
349
|
+
await reconcilePin({
|
|
350
|
+
api: t.api,
|
|
351
|
+
chatId: '-1009000000003',
|
|
352
|
+
prevState: null,
|
|
353
|
+
desired: { pinned: true, messageId: 9 },
|
|
354
|
+
rightsCache: cache,
|
|
355
|
+
onPinRightsDisabled: (chat) => disabled.push(chat),
|
|
356
|
+
onError: (phase) => pinFails.push(phase),
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
expect(cache.isBlocked('-1009000000003')).toBe(false) // NOT cached
|
|
360
|
+
expect(disabled).toEqual([]) // no rights-disable log
|
|
361
|
+
expect(pinFails).toEqual(['pin']) // routed through onError → normal retry path
|
|
362
|
+
|
|
363
|
+
// A follow-up attempt still hits the API (no negative cache in the way).
|
|
364
|
+
await reconcilePin({
|
|
365
|
+
api: t.api,
|
|
366
|
+
chatId: '-1009000000003',
|
|
367
|
+
prevState: null,
|
|
368
|
+
desired: { pinned: true, messageId: 9 },
|
|
369
|
+
rightsCache: cache,
|
|
370
|
+
onError: (phase) => pinFails.push(phase),
|
|
371
|
+
})
|
|
372
|
+
expect(t.pinCalls).toBe(2)
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('a later successful pin clears a previously-blocked chat', async () => {
|
|
376
|
+
const cache = new PinRightsCache()
|
|
377
|
+
cache.block('-1009000000004')
|
|
378
|
+
const t = countingApi() // succeeds
|
|
379
|
+
|
|
380
|
+
// While blocked, the pin is skipped (no claim, no API call).
|
|
381
|
+
const skipped = await reconcilePin({
|
|
382
|
+
api: t.api,
|
|
383
|
+
chatId: '-1009000000004',
|
|
384
|
+
prevState: null,
|
|
385
|
+
desired: { pinned: true, messageId: 3 },
|
|
386
|
+
rightsCache: cache,
|
|
387
|
+
})
|
|
388
|
+
expect(skipped).toBeNull()
|
|
389
|
+
expect(t.pinCalls).toBe(0)
|
|
390
|
+
|
|
391
|
+
// Simulate rights granted: unblock, then a pin succeeds and stays clear.
|
|
392
|
+
cache.clear('-1009000000004')
|
|
393
|
+
const next = await reconcilePin({
|
|
394
|
+
api: t.api,
|
|
395
|
+
chatId: '-1009000000004',
|
|
396
|
+
prevState: null,
|
|
397
|
+
desired: { pinned: true, messageId: 3 },
|
|
398
|
+
rightsCache: cache,
|
|
399
|
+
})
|
|
400
|
+
expect(next).toEqual({ messageId: 3 })
|
|
401
|
+
expect(cache.isBlocked('-1009000000004')).toBe(false)
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
it('UNPIN rights-failure (rights revoked mid-session) blocks the chat, logs once, second unpin makes no API call', async () => {
|
|
405
|
+
const cache = new PinRightsCache()
|
|
406
|
+
const disabled: string[] = []
|
|
407
|
+
const unpinFails: string[] = []
|
|
408
|
+
let unpinCalls = 0
|
|
409
|
+
const api: PinBotApi = {
|
|
410
|
+
pinChatMessage: async () => {},
|
|
411
|
+
unpinChatMessage: async () => {
|
|
412
|
+
unpinCalls += 1
|
|
413
|
+
throw rightsErr()
|
|
414
|
+
},
|
|
415
|
+
}
|
|
416
|
+
const common = {
|
|
417
|
+
api,
|
|
418
|
+
chatId: '-1009000000007',
|
|
419
|
+
desired: { pinned: false } as const,
|
|
420
|
+
rightsCache: cache,
|
|
421
|
+
onPinRightsDisabled: (chat: string) => disabled.push(chat),
|
|
422
|
+
onError: (phase: 'pin' | 'unpin') => unpinFails.push(phase),
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const first = await reconcilePin({ ...common, prevState: { messageId: 21 } })
|
|
426
|
+
expect(first).toBeNull() // claim dropped regardless (drop-on-unpin contract)
|
|
427
|
+
expect(unpinCalls).toBe(1) // it did try once
|
|
428
|
+
expect(disabled).toEqual(['-1009000000007']) // logged exactly once
|
|
429
|
+
expect(unpinFails).toEqual([]) // NOT routed through per-attempt onError
|
|
430
|
+
expect(cache.isBlocked('-1009000000007')).toBe(true)
|
|
431
|
+
|
|
432
|
+
const second = await reconcilePin({ ...common, prevState: { messageId: 22 } })
|
|
433
|
+
expect(second).toBeNull() // claim still dropped
|
|
434
|
+
expect(unpinCalls).toBe(1) // second attempt short-circuited before the API
|
|
435
|
+
expect(disabled).toEqual(['-1009000000007']) // no second log
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
it('a blocked chat skips the UNPIN api call too (drops the claim silently)', async () => {
|
|
439
|
+
const cache = new PinRightsCache()
|
|
440
|
+
cache.block('-1009000000005')
|
|
441
|
+
const t = countingApi()
|
|
442
|
+
|
|
443
|
+
const next = await reconcilePin({
|
|
444
|
+
api: t.api,
|
|
445
|
+
chatId: '-1009000000005',
|
|
446
|
+
prevState: { messageId: 12 },
|
|
447
|
+
desired: { pinned: false },
|
|
448
|
+
rightsCache: cache,
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
expect(next).toBeNull() // claim dropped
|
|
452
|
+
expect(t.unpinCalls).toBe(0) // but no wasted API call
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
it('fire-and-forget with the cache leaks NO unhandledRejection', async () => {
|
|
456
|
+
const rejections: unknown[] = []
|
|
457
|
+
const onUnhandled = (err: unknown) => rejections.push(err)
|
|
458
|
+
process.on('unhandledRejection', onUnhandled)
|
|
459
|
+
try {
|
|
460
|
+
const cache = new PinRightsCache()
|
|
461
|
+
const t = countingApi(rightsErr())
|
|
462
|
+
void reconcilePin({
|
|
463
|
+
api: t.api,
|
|
464
|
+
chatId: '-1009000000006',
|
|
465
|
+
prevState: null,
|
|
466
|
+
desired: { pinned: true, messageId: 4 },
|
|
467
|
+
rightsCache: cache,
|
|
468
|
+
onPinRightsDisabled: () => {},
|
|
469
|
+
})
|
|
470
|
+
await new Promise((r) => setTimeout(r, 20))
|
|
471
|
+
expect(rejections).toEqual([])
|
|
472
|
+
} finally {
|
|
473
|
+
process.off('unhandledRejection', onUnhandled)
|
|
474
|
+
}
|
|
475
|
+
})
|
|
476
|
+
})
|