switchroom 0.12.19 → 0.12.21
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 +5 -1
- package/dist/auth-broker/index.js +5 -1
- package/dist/cli/switchroom.js +927 -639
- package/dist/host-control/main.js +5 -1
- package/dist/vault/approvals/kernel-server.js +5 -1
- package/dist/vault/broker/server.js +11 -7
- package/package.json +3 -2
- package/telegram-plugin/dist/gateway/gateway.js +63 -23
- package/telegram-plugin/gateway/chat-key.ts +67 -0
- package/telegram-plugin/gateway/gateway.ts +79 -14
- package/telegram-plugin/gateway/turn-state-purge.ts +71 -0
- package/telegram-plugin/pty-partial-handler.ts +4 -1
- package/telegram-plugin/stream-reply-handler.ts +6 -1
- package/telegram-plugin/tests/e2e.test.ts +5 -1
- package/telegram-plugin/tests/races.test.ts +5 -1
- package/telegram-plugin/tests/turn-state-purge.test.ts +109 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* turn-state-purge.ts — defense-in-depth cleanup for the silence-poke
|
|
3
|
+
* framework-fallback (gateway.ts).
|
|
4
|
+
*
|
|
5
|
+
* The fallback's `purgeReactionTracking(fbKey)` clears the canonical
|
|
6
|
+
* `statusKey(chatId, threadId)` for the exact chat+thread that armed
|
|
7
|
+
* the silence-poke. But `activeTurnStartedAt` (and its siblings) can
|
|
8
|
+
* legitimately hold MORE than one entry for the same chat — e.g.:
|
|
9
|
+
*
|
|
10
|
+
* - a normal turn-end handler null'd `currentTurn` but skipped
|
|
11
|
+
* `purgeReactionTracking` on a code-path that didn't reach it
|
|
12
|
+
* (gateway.ts:5887/5921/6193 — purgeReactionTracking calls in
|
|
13
|
+
* those handlers are conditional/not on every branch), so
|
|
14
|
+
* `activeTurnStartedAt[oldKey]` is dangling when the fallback
|
|
15
|
+
* fires for a NEW key
|
|
16
|
+
* - a multi-thread chat (topic-based group, or a `null` vs
|
|
17
|
+
* `undefined` thread variant) has entries under different keys of
|
|
18
|
+
* the SAME chat
|
|
19
|
+
*
|
|
20
|
+
* In either case, the fallback purges `fbKey` but a sibling key for
|
|
21
|
+
* the same chat remains → `activeTurnStartedAt.size > 0` persists →
|
|
22
|
+
* `#1556`'s turn-gate holds every new inbound forever → user sees
|
|
23
|
+
* "not responding" while claude is actually idle (gymbro + klanker,
|
|
24
|
+
* 2026-05-20).
|
|
25
|
+
*
|
|
26
|
+
* This helper sweeps any sibling keys for the firing chat after the
|
|
27
|
+
* canonical `purgeReactionTracking(fbKey)`, via the same purger.
|
|
28
|
+
* Multi-chat safe: it only touches keys whose chatId matches — other
|
|
29
|
+
* chats' active turns are untouched, preserving the deliberate
|
|
30
|
+
* multi-chat safety #1546 kept.
|
|
31
|
+
*
|
|
32
|
+
* Key shape: `statusKey(chatId, threadId)` emits
|
|
33
|
+
* `${chatId}:${threadId ?? '_'}`. Chat ids are numeric strings (no
|
|
34
|
+
* `:` inside), so `indexOf(':')` is a safe prefix delimiter. A
|
|
35
|
+
* "prefix-without-separator" false positive (e.g. chatId `123` vs key
|
|
36
|
+
* `1234:_`) is structurally impossible because the helper splits on
|
|
37
|
+
* `:` and equates the prefix, NOT a string-startsWith.
|
|
38
|
+
*
|
|
39
|
+
* Pure / dependency-free so the multi-chat-safety and key-shape logic
|
|
40
|
+
* are unit-testable without standing up a gateway — mirrors the
|
|
41
|
+
* `#1544 / #1546 / #1549 / #1558` pure-seam idiom.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
export interface PurgeStaleTurnsResult {
|
|
45
|
+
/** Keys for `chatId` that the helper purged via the callback. */
|
|
46
|
+
purged: string[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function purgeStaleTurnsForChat(
|
|
50
|
+
chatId: string,
|
|
51
|
+
keys: Iterable<string>,
|
|
52
|
+
purger: (key: string) => void,
|
|
53
|
+
): PurgeStaleTurnsResult {
|
|
54
|
+
if (!chatId) return { purged: [] }
|
|
55
|
+
const purged: string[] = []
|
|
56
|
+
// Snapshot first — `purger` typically deletes from the same Map the
|
|
57
|
+
// caller is iterating; mutating during iteration is correct on JS
|
|
58
|
+
// Maps but a snapshot makes the contract explicit and lets the
|
|
59
|
+
// helper accept any iterable.
|
|
60
|
+
const snapshot: string[] = []
|
|
61
|
+
for (const k of keys) snapshot.push(k)
|
|
62
|
+
for (const key of snapshot) {
|
|
63
|
+
const sep = key.indexOf(':')
|
|
64
|
+
if (sep < 0) continue // malformed / non-statusKey shape — skip
|
|
65
|
+
const keyChat = key.slice(0, sep)
|
|
66
|
+
if (keyChat !== chatId) continue
|
|
67
|
+
purger(key)
|
|
68
|
+
purged.push(key)
|
|
69
|
+
}
|
|
70
|
+
return { purged }
|
|
71
|
+
}
|
|
@@ -94,7 +94,10 @@ export interface PtyHandlerDeps {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
function streamKey(chatId: string, threadId?: number): string {
|
|
97
|
-
|
|
97
|
+
// Canonical chat-key derivation lives in gateway/chat-key.ts — keep this
|
|
98
|
+
// expression in lockstep (treats 0/null/undefined the same). See #1564.
|
|
99
|
+
const t = threadId == null || threadId === 0 ? '_' : String(threadId)
|
|
100
|
+
return `${chatId}:${t}`
|
|
98
101
|
}
|
|
99
102
|
|
|
100
103
|
/**
|
|
@@ -313,7 +313,12 @@ function streamKey(
|
|
|
313
313
|
lane?: string,
|
|
314
314
|
turnKey?: string,
|
|
315
315
|
): string {
|
|
316
|
-
|
|
316
|
+
// Canonical chat-key derivation lives in gateway/chat-key.ts — keep this
|
|
317
|
+
// expression in lockstep with that helper (treats 0/null/undefined the
|
|
318
|
+
// same), but inline here so this file doesn't introduce a cross-package
|
|
319
|
+
// import for one expression. See #1564 for the sibling-key bug class.
|
|
320
|
+
const t = threadId == null || threadId === 0 ? '_' : String(threadId)
|
|
321
|
+
const base = `${chatId}:${t}`
|
|
317
322
|
const withLane = lane != null && lane.length > 0 ? `${base}:${lane}` : base
|
|
318
323
|
return turnKey != null && turnKey.length > 0 ? `${withLane}:${turnKey}` : withLane
|
|
319
324
|
}
|
|
@@ -50,7 +50,11 @@ function statusKey(chatId: string, threadId?: number): string {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
function streamKey(chatId: string, threadId?: number): string {
|
|
53
|
-
|
|
53
|
+
// Mirrors production's canonical chat-key derivation (gateway/chat-key.ts):
|
|
54
|
+
// 0/null/undefined thread IDs all collapse to '_'. Diverging sentinels
|
|
55
|
+
// here would let the harness pass with a key shape production rejects.
|
|
56
|
+
const t = threadId == null || threadId === 0 ? '_' : String(threadId)
|
|
57
|
+
return `${chatId}:${t}`
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
interface PluginState {
|
|
@@ -29,7 +29,11 @@ function statusKey(chatId: string, threadId?: number): string {
|
|
|
29
29
|
return `${chatId}:${threadId ?? '_'}`
|
|
30
30
|
}
|
|
31
31
|
function streamKey(chatId: string, threadId?: number): string {
|
|
32
|
-
|
|
32
|
+
// Mirrors production's canonical chat-key derivation (gateway/chat-key.ts):
|
|
33
|
+
// 0/null/undefined thread IDs all collapse to '_'. Diverging sentinels
|
|
34
|
+
// here would let the harness pass with a key shape production rejects.
|
|
35
|
+
const t = threadId == null || threadId === 0 ? '_' : String(threadId)
|
|
36
|
+
return `${chatId}:${t}`
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
interface PluginState {
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* turn-state-purge — the silence-poke fallback's defense-in-depth
|
|
3
|
+
* cleanup. Pins the multi-chat safety + key-shape correctness that
|
|
4
|
+
* the gymbro/klanker held-mid-turn fix (2026-05-20) depends on.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect } from 'vitest'
|
|
8
|
+
import { purgeStaleTurnsForChat } from '../gateway/turn-state-purge.js'
|
|
9
|
+
|
|
10
|
+
function statusKey(chatId: string, threadId?: number): string {
|
|
11
|
+
return `${chatId}:${threadId ?? '_'}`
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('purgeStaleTurnsForChat', () => {
|
|
15
|
+
it('returns [] and calls nothing when keys is empty', () => {
|
|
16
|
+
let calls = 0
|
|
17
|
+
const r = purgeStaleTurnsForChat('123', [], () => calls++)
|
|
18
|
+
expect(r.purged).toEqual([])
|
|
19
|
+
expect(calls).toBe(0)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('returns [] and calls nothing when chatId is empty (guard)', () => {
|
|
23
|
+
let calls = 0
|
|
24
|
+
const r = purgeStaleTurnsForChat('', [statusKey('123')], () => calls++)
|
|
25
|
+
expect(r.purged).toEqual([])
|
|
26
|
+
expect(calls).toBe(0)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('purges the canonical DM key for the firing chat (typical case)', () => {
|
|
30
|
+
const purged: string[] = []
|
|
31
|
+
const r = purgeStaleTurnsForChat(
|
|
32
|
+
'12345',
|
|
33
|
+
[statusKey('12345')],
|
|
34
|
+
(k) => purged.push(k),
|
|
35
|
+
)
|
|
36
|
+
expect(r.purged).toEqual(['12345:_'])
|
|
37
|
+
expect(purged).toEqual(['12345:_'])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('purges every sibling key for the same chat (multi-thread / dangling)', () => {
|
|
41
|
+
// This is the gymbro/klanker symptom: a stale activeTurnStartedAt
|
|
42
|
+
// entry under a different thread of the same chat survives the
|
|
43
|
+
// canonical purgeReactionTracking(fbKey).
|
|
44
|
+
const purged: string[] = []
|
|
45
|
+
const r = purgeStaleTurnsForChat(
|
|
46
|
+
'12345',
|
|
47
|
+
[
|
|
48
|
+
statusKey('12345'), // DM, no thread
|
|
49
|
+
statusKey('12345', 42), // thread 42
|
|
50
|
+
statusKey('12345', 99), // thread 99
|
|
51
|
+
],
|
|
52
|
+
(k) => purged.push(k),
|
|
53
|
+
)
|
|
54
|
+
expect(r.purged.sort()).toEqual(['12345:42', '12345:99', '12345:_'])
|
|
55
|
+
expect(purged).toHaveLength(3)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('NEVER touches keys for OTHER chats (multi-chat safety — the #1546 guard)', () => {
|
|
59
|
+
const purged: string[] = []
|
|
60
|
+
const r = purgeStaleTurnsForChat(
|
|
61
|
+
'12345',
|
|
62
|
+
[
|
|
63
|
+
statusKey('12345'), // target chat — purge
|
|
64
|
+
statusKey('-1001234567890'), // different chat — must NOT touch
|
|
65
|
+
statusKey('-1001234567890', 7), // different chat thread — must NOT touch
|
|
66
|
+
],
|
|
67
|
+
(k) => purged.push(k),
|
|
68
|
+
)
|
|
69
|
+
expect(r.purged).toEqual(['12345:_'])
|
|
70
|
+
expect(purged).toEqual(['12345:_']) // ONLY the target chat
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('does not false-match on a chatId-prefix superstring (e.g. 123 vs 1234)', () => {
|
|
74
|
+
// If we used a naive startsWith on the WHOLE key, chatId "123"
|
|
75
|
+
// would falsely match key "1234:_". The helper splits on `:` and
|
|
76
|
+
// equates the chatId slice, which prevents that.
|
|
77
|
+
const purged: string[] = []
|
|
78
|
+
const r = purgeStaleTurnsForChat(
|
|
79
|
+
'123',
|
|
80
|
+
[statusKey('123'), statusKey('1234'), statusKey('12')],
|
|
81
|
+
(k) => purged.push(k),
|
|
82
|
+
)
|
|
83
|
+
expect(r.purged).toEqual(['123:_']) // ONLY the exact match
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('skips malformed keys (no `:`)', () => {
|
|
87
|
+
let calls = 0
|
|
88
|
+
const r = purgeStaleTurnsForChat(
|
|
89
|
+
'123',
|
|
90
|
+
['malformed', '', 'no-colon-here', statusKey('123')],
|
|
91
|
+
() => calls++,
|
|
92
|
+
)
|
|
93
|
+
expect(r.purged).toEqual(['123:_'])
|
|
94
|
+
expect(calls).toBe(1)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('accepts any iterable (snapshots before iteration — purger may delete)', () => {
|
|
98
|
+
// The real call site iterates `activeTurnStartedAt.keys()` and
|
|
99
|
+
// the purger deletes from that same Map. Snapshotting in the
|
|
100
|
+
// helper prevents iterator skew.
|
|
101
|
+
const map = new Map<string, number>()
|
|
102
|
+
map.set('123:_', 1)
|
|
103
|
+
map.set('123:7', 2)
|
|
104
|
+
map.set('999:_', 3)
|
|
105
|
+
const r = purgeStaleTurnsForChat('123', map.keys(), (k) => map.delete(k))
|
|
106
|
+
expect(r.purged.sort()).toEqual(['123:7', '123:_'])
|
|
107
|
+
expect([...map.keys()]).toEqual(['999:_']) // multi-chat safety preserved
|
|
108
|
+
})
|
|
109
|
+
})
|