switchroom 0.18.7 → 0.18.9
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/README.md +2 -2
- package/dist/cli/switchroom.js +905 -758
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +111 -34
- package/skills/switchroom-runtime/SKILL.md +2 -0
- package/telegram-plugin/dist/gateway/gateway.js +46273 -44324
- package/telegram-plugin/flood-circuit-breaker.ts +123 -0
- package/telegram-plugin/gateway/activity-card-store.ts +63 -18
- package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
- package/telegram-plugin/gateway/boot-card.ts +27 -0
- package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
- package/telegram-plugin/gateway/busy-ack.ts +106 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
- package/telegram-plugin/gateway/gateway.ts +1169 -3043
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
- package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
- package/telegram-plugin/gateway/model-command.ts +23 -11
- package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
- package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
- package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
- package/telegram-plugin/gateway/session-model-file.ts +198 -0
- package/telegram-plugin/gateway/status-pin-store.ts +82 -22
- package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
- package/telegram-plugin/hooks/hooks.json +10 -10
- package/telegram-plugin/hooks/run-hook.sh +84 -0
- package/telegram-plugin/model-unavailable.ts +26 -0
- package/telegram-plugin/pty-partial-handler.ts +39 -0
- package/telegram-plugin/render/rich-render.ts +79 -1
- package/telegram-plugin/retry-api-call.ts +62 -0
- package/telegram-plugin/shared/bot-runtime.ts +8 -1
- package/telegram-plugin/silence-poke.ts +14 -0
- package/telegram-plugin/stream-controller.ts +156 -38
- package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
- package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
- package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
- package/telegram-plugin/tests/busy-ack.test.ts +121 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
- package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +177 -25
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
- package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
- package/telegram-plugin/tests/model-command.test.ts +2 -2
- package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
- package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
- package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
- package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
- package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
- package/telegram-plugin/tests/session-model-file.test.ts +132 -0
- package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
- package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
- package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +8 -4
- package/telegram-plugin/tests/voice-send.test.ts +308 -0
- package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
- package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
- package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
- package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
- package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
- package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
- package/telegram-plugin/voice-ondemand.ts +25 -1
- package/telegram-plugin/voice-send.ts +154 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outcome-based composition test for the approval/restart continuation
|
|
3
|
+
* contract in `reference/jobs/approve-what-my-agent-can-touch.md`:
|
|
4
|
+
*
|
|
5
|
+
* - "An approval card survives a gateway restart. A tap on a still-valid
|
|
6
|
+
* card after a restart works; a card that expired during the outage is
|
|
7
|
+
* re-offered on the operator's return."
|
|
8
|
+
* - "Cards time out (60-min default) and the timeout wakes the agent as a
|
|
9
|
+
* timeout, not a denial ... An expired card is re-offered when the
|
|
10
|
+
* operator returns."
|
|
11
|
+
*
|
|
12
|
+
* The sibling suites pin each module in isolation (pending-card-store.test.ts,
|
|
13
|
+
* pending-card-expiry.test.ts with mocked deps, approval-timeout-inbound-
|
|
14
|
+
* builders.test.ts) and pending-card-durability-wiring.test.ts pins the
|
|
15
|
+
* gateway wiring at the source-text level. This suite wires the REAL modules
|
|
16
|
+
* together — file-backed durable store + real missed-approvals store + real
|
|
17
|
+
* expiry sweep + real timeout builders — across a simulated gateway bounce
|
|
18
|
+
* (store re-instantiation from the same state dir), asserting the promised
|
|
19
|
+
* OUTCOMES rather than any one module's contract.
|
|
20
|
+
*
|
|
21
|
+
* SCOPE CAVEAT: the expiry predicate / remove / deliver / recordMiss closures
|
|
22
|
+
* below are TEST-SUPPLIED stand-ins for gateway.ts's inline closures (which
|
|
23
|
+
* close over module-level gateway state and can't be imported without a
|
|
24
|
+
* production refactor). This suite therefore proves the modules COMPOSE
|
|
25
|
+
* correctly; that gateway.ts actually wires them this way is pinned
|
|
26
|
+
* separately by pending-card-durability-wiring.test.ts's source greps. The
|
|
27
|
+
* user-visible acceptance twin is
|
|
28
|
+
* `uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts`.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
32
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
33
|
+
import { tmpdir } from 'node:os'
|
|
34
|
+
import { join } from 'node:path'
|
|
35
|
+
|
|
36
|
+
import {
|
|
37
|
+
createPendingCardStore,
|
|
38
|
+
type PersistedApprovalCard,
|
|
39
|
+
type PersistedVaultAccessCard,
|
|
40
|
+
} from '../gateway/pending-card-store.js'
|
|
41
|
+
import { createMissedApprovalsStore } from '../gateway/missed-approvals-store.js'
|
|
42
|
+
import { expirePendingCard, sweepExpiredEntries } from '../gateway/pending-card-expiry.js'
|
|
43
|
+
import { buildVaultAccessTimeoutInbound } from '../gateway/approval-timeout-inbound-builders.js'
|
|
44
|
+
import type { InboundMessage } from '../gateway/ipc-protocol.js'
|
|
45
|
+
|
|
46
|
+
// Test-local stand-in for the 60-min default the spec names. The production
|
|
47
|
+
// TTL comes from approvalTtlMs() in gateway/permission-timeout.ts; this suite
|
|
48
|
+
// does NOT assert that config plumbing — only how the modules behave around
|
|
49
|
+
// whatever TTL the sweep predicate encodes.
|
|
50
|
+
const TTL_MS = 60 * 60_000
|
|
51
|
+
const NOW = 1_700_000_000_000
|
|
52
|
+
|
|
53
|
+
function accessCard(overrides: Partial<PersistedVaultAccessCard> = {}): PersistedVaultAccessCard {
|
|
54
|
+
return {
|
|
55
|
+
family: 'vault_request_access',
|
|
56
|
+
stageId: 'stage-1',
|
|
57
|
+
agent: 'test-agent',
|
|
58
|
+
chatId: '12345',
|
|
59
|
+
cardMessageId: 777,
|
|
60
|
+
stagedAt: NOW - 60_000, // staged a minute ago — still valid
|
|
61
|
+
key: 'example/api-key',
|
|
62
|
+
scope: 'read',
|
|
63
|
+
ttlSeconds: 30 * 86_400,
|
|
64
|
+
...overrides,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('approval/restart continuation — spec outcomes across a gateway bounce', () => {
|
|
69
|
+
let stateDir: string
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
stateDir = mkdtempSync(join(tmpdir(), 'approval-card-restart-outcome-'))
|
|
73
|
+
})
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
rmSync(stateDir, { recursive: true, force: true })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('a still-valid card record survives the bounce intact, and removing it clears the durable store', () => {
|
|
79
|
+
// Gateway process #1 stages the card.
|
|
80
|
+
const before = createPendingCardStore(stateDir)
|
|
81
|
+
const card = accessCard()
|
|
82
|
+
before.add(card)
|
|
83
|
+
|
|
84
|
+
// ── gateway restarts: all in-memory maps are gone ──
|
|
85
|
+
const after = createPendingCardStore(stateDir)
|
|
86
|
+
const restored = after.loadAll()
|
|
87
|
+
expect(restored).toHaveLength(1)
|
|
88
|
+
// Metadata roundtrips byte-identical — stagedAt included, so the expiry
|
|
89
|
+
// deadline the sweep computes from it is unchanged by the bounce.
|
|
90
|
+
expect(restored[0]).toEqual(card)
|
|
91
|
+
|
|
92
|
+
// Post-restart resolution path (what a tap handler does with the store):
|
|
93
|
+
// remove() drops the durable record so a later boot can't resurrect it.
|
|
94
|
+
// The tap handler's Telegram/grant side is NOT exercised here — that's
|
|
95
|
+
// the UAT twin's job.
|
|
96
|
+
after.remove(card.stageId)
|
|
97
|
+
expect(after.loadAll()).toHaveLength(0)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('a card that expired during the outage wakes the agent ONCE as timeout-not-denial and records the re-offer', () => {
|
|
101
|
+
// Staged well past the TTL before the bounce.
|
|
102
|
+
const expired = accessCard({ stageId: 'stage-expired', stagedAt: NOW - TTL_MS - 60_000 })
|
|
103
|
+
createPendingCardStore(stateDir).add(expired)
|
|
104
|
+
|
|
105
|
+
// ── restart: restore into the in-memory map, then the reaper sweeps ──
|
|
106
|
+
const store = createPendingCardStore(stateDir)
|
|
107
|
+
const missed = createMissedApprovalsStore(stateDir)
|
|
108
|
+
const map = new Map<string, PersistedVaultAccessCard>(
|
|
109
|
+
store.loadAll().map(e => [e.stageId, e as PersistedVaultAccessCard]),
|
|
110
|
+
)
|
|
111
|
+
expect(map.size).toBe(1)
|
|
112
|
+
|
|
113
|
+
const delivered: InboundMessage[] = []
|
|
114
|
+
const sweep = () =>
|
|
115
|
+
sweepExpiredEntries(
|
|
116
|
+
map,
|
|
117
|
+
(v, now) => v.stagedAt < now - TTL_MS,
|
|
118
|
+
(stageId, v) => {
|
|
119
|
+
expirePendingCard({
|
|
120
|
+
remove: () => {
|
|
121
|
+
map.delete(stageId)
|
|
122
|
+
store.remove(stageId)
|
|
123
|
+
},
|
|
124
|
+
editCard: () => {},
|
|
125
|
+
buildInbound: () =>
|
|
126
|
+
buildVaultAccessTimeoutInbound({
|
|
127
|
+
agent: v.agent,
|
|
128
|
+
chatId: v.chatId,
|
|
129
|
+
stageId,
|
|
130
|
+
timeoutMinutes: Math.round(TTL_MS / 60_000),
|
|
131
|
+
nowMs: NOW,
|
|
132
|
+
key: v.key,
|
|
133
|
+
scope: v.scope,
|
|
134
|
+
}),
|
|
135
|
+
deliver: m => {
|
|
136
|
+
delivered.push(m)
|
|
137
|
+
return true
|
|
138
|
+
},
|
|
139
|
+
recordMiss: () =>
|
|
140
|
+
missed.add({
|
|
141
|
+
requestId: `approval-card:${stageId}`,
|
|
142
|
+
toolName: 'vault_request_access',
|
|
143
|
+
action: `vault access to ${v.key}`,
|
|
144
|
+
chatId: v.chatId,
|
|
145
|
+
timedOutAt: NOW,
|
|
146
|
+
}),
|
|
147
|
+
log: () => {},
|
|
148
|
+
})
|
|
149
|
+
},
|
|
150
|
+
NOW,
|
|
151
|
+
() => {},
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
sweep()
|
|
155
|
+
|
|
156
|
+
// Exactly one wake, and it is a TIMEOUT, never a denial.
|
|
157
|
+
expect(delivered).toHaveLength(1)
|
|
158
|
+
expect(delivered[0].text).toMatch(/TIMEOUT, not a denial/)
|
|
159
|
+
expect(delivered[0].text).toContain('example/api-key')
|
|
160
|
+
expect(delivered[0].meta?.source).toBe('vault_grant_timeout')
|
|
161
|
+
|
|
162
|
+
// The re-offer is durably recorded for the operator's return.
|
|
163
|
+
const pending = missed.listPending()
|
|
164
|
+
expect(pending).toHaveLength(1)
|
|
165
|
+
expect(pending[0].requestId).toBe('approval-card:stage-expired')
|
|
166
|
+
|
|
167
|
+
// Durable record is gone — a later boot can't resurrect the card.
|
|
168
|
+
expect(store.loadAll()).toHaveLength(0)
|
|
169
|
+
|
|
170
|
+
// A second boot's sweep restores from the (now empty) durable store and
|
|
171
|
+
// therefore fires nothing — the remove() step cleared BOTH the in-memory
|
|
172
|
+
// map and the store, so no later process can re-deliver the wake. (The
|
|
173
|
+
// intra-process single-shot ordering guarantee — remove() before any
|
|
174
|
+
// fallible side effect — is pinned behaviorally in
|
|
175
|
+
// pending-card-expiry.test.ts, not here.)
|
|
176
|
+
const rebootMap = new Map<string, PersistedVaultAccessCard>(
|
|
177
|
+
createPendingCardStore(stateDir)
|
|
178
|
+
.loadAll()
|
|
179
|
+
.map(e => [e.stageId, e as PersistedVaultAccessCard]),
|
|
180
|
+
)
|
|
181
|
+
expect(rebootMap.size).toBe(0)
|
|
182
|
+
let secondBootExpiries = 0
|
|
183
|
+
sweepExpiredEntries(
|
|
184
|
+
rebootMap,
|
|
185
|
+
(v, now) => v.stagedAt < now - TTL_MS,
|
|
186
|
+
() => {
|
|
187
|
+
secondBootExpiries += 1
|
|
188
|
+
},
|
|
189
|
+
NOW,
|
|
190
|
+
() => {},
|
|
191
|
+
)
|
|
192
|
+
expect(secondBootExpiries).toBe(0)
|
|
193
|
+
expect(delivered).toHaveLength(1)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('the post-restart sweep never expires a still-valid card (no false timeout on bounce)', () => {
|
|
197
|
+
const valid = accessCard({ stageId: 'stage-valid', stagedAt: NOW - 60_000 })
|
|
198
|
+
createPendingCardStore(stateDir).add(valid)
|
|
199
|
+
|
|
200
|
+
const store = createPendingCardStore(stateDir)
|
|
201
|
+
const map = new Map<string, PersistedApprovalCard>(store.loadAll().map(e => [e.stageId, e]))
|
|
202
|
+
|
|
203
|
+
let expiredCount = 0
|
|
204
|
+
sweepExpiredEntries(
|
|
205
|
+
map,
|
|
206
|
+
(v, now) => v.stagedAt < now - TTL_MS,
|
|
207
|
+
() => {
|
|
208
|
+
expiredCount += 1
|
|
209
|
+
},
|
|
210
|
+
NOW,
|
|
211
|
+
() => {},
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
expect(expiredCount).toBe(0)
|
|
215
|
+
expect(map.size).toBe(1) // the card stays live and tappable
|
|
216
|
+
expect(store.loadAll()).toHaveLength(1)
|
|
217
|
+
})
|
|
218
|
+
})
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Race/ordering pins for the in-memory approval-card stores
|
|
3
|
+
* (gateway/approval-card-stores.ts) extracted in #2996 Phase 3 step 1.
|
|
4
|
+
*
|
|
5
|
+
* These lock the exact orderings the gateway's reaper + callback handlers +
|
|
6
|
+
* boot restore depend on, BEFORE the storage moved behind the store module:
|
|
7
|
+
*
|
|
8
|
+
* 1. verdict-during-sweep is single-shot — an operator tap (`.delete`) that
|
|
9
|
+
* lands while the sweep is iterating fires the expiry synthetic AT MOST
|
|
10
|
+
* once for a given card; a second sweep tick never double-wakes it.
|
|
11
|
+
* 2. verdict-during-restore is desync-free — a `.set` (restore) then a
|
|
12
|
+
* `.delete` (tap) operate on one Map instance, so a tap can't resurrect a
|
|
13
|
+
* restored entry nor a restore resurrect a tapped one.
|
|
14
|
+
* 3. sweep cadence/fault-isolation is byte-identical to the old inline
|
|
15
|
+
* `sweepExpiredEntries(...)`: only past-TTL entries expire, and one
|
|
16
|
+
* throwing expiry never skips the remaining entries.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it, expect } from 'vitest'
|
|
20
|
+
import {
|
|
21
|
+
createSweepableCardStore,
|
|
22
|
+
type SweepableCardStore,
|
|
23
|
+
} from '../gateway/approval-card-stores.js'
|
|
24
|
+
|
|
25
|
+
interface Card {
|
|
26
|
+
staged_at: number
|
|
27
|
+
agent: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const TTL = 1000
|
|
31
|
+
|
|
32
|
+
function makeStore(
|
|
33
|
+
expire: (stageId: string, v: Card, now: number) => void,
|
|
34
|
+
log: (msg: string) => void = () => {},
|
|
35
|
+
): SweepableCardStore<Card> {
|
|
36
|
+
return createSweepableCardStore<Card>({
|
|
37
|
+
isExpired: (v, now) => now - v.staged_at > TTL,
|
|
38
|
+
expire: () => expire,
|
|
39
|
+
log: () => log,
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('approval-card-stores: sweep cadence', () => {
|
|
44
|
+
it('expires only entries past TTL', () => {
|
|
45
|
+
const expired: string[] = []
|
|
46
|
+
const store = makeStore((id) => {
|
|
47
|
+
expired.push(id)
|
|
48
|
+
store.delete(id) // mirror the gateway's expire.remove()
|
|
49
|
+
})
|
|
50
|
+
store.set('fresh', { staged_at: 5000, agent: 'a' })
|
|
51
|
+
store.set('stale', { staged_at: 1000, agent: 'b' })
|
|
52
|
+
store.sweep(5000) // fresh: 0ms old, stale: 4000ms old > TTL
|
|
53
|
+
expect(expired).toEqual(['stale'])
|
|
54
|
+
expect(store.has('fresh')).toBe(true)
|
|
55
|
+
expect(store.has('stale')).toBe(false)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('is single-shot across two reaper ticks (verdict-during-sweep safe)', () => {
|
|
59
|
+
const wakes: string[] = []
|
|
60
|
+
const store = makeStore((id) => {
|
|
61
|
+
// The pure expire core removes BEFORE the fallible wake; model that here.
|
|
62
|
+
store.delete(id)
|
|
63
|
+
wakes.push(id)
|
|
64
|
+
})
|
|
65
|
+
store.set('c1', { staged_at: 0, agent: 'a' })
|
|
66
|
+
store.sweep(5000)
|
|
67
|
+
store.sweep(5000) // second tick — entry already gone
|
|
68
|
+
expect(wakes).toEqual(['c1']) // exactly one wake, never two
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('a throwing expiry never skips the remaining entries', () => {
|
|
72
|
+
const seen: string[] = []
|
|
73
|
+
const store = makeStore((id) => {
|
|
74
|
+
seen.push(id)
|
|
75
|
+
if (id === 'boom') throw new Error('dead socket')
|
|
76
|
+
store.delete(id)
|
|
77
|
+
})
|
|
78
|
+
store.set('boom', { staged_at: 0, agent: 'a' })
|
|
79
|
+
store.set('ok', { staged_at: 0, agent: 'b' })
|
|
80
|
+
expect(() => store.sweep(5000)).not.toThrow()
|
|
81
|
+
expect(seen).toContain('boom')
|
|
82
|
+
expect(seen).toContain('ok')
|
|
83
|
+
expect(store.has('ok')).toBe(false) // 'ok' still expired despite 'boom' throwing
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('approval-card-stores: verdict-during-restore desync-free', () => {
|
|
88
|
+
it('a tap after restore removes the restored entry (no resurrection)', () => {
|
|
89
|
+
const store = makeStore(() => {})
|
|
90
|
+
store.set('r1', { staged_at: 0, agent: 'a' }) // restore populates
|
|
91
|
+
expect(store.get('r1')).toBeDefined()
|
|
92
|
+
const removed = store.delete('r1') // operator tap resolves it
|
|
93
|
+
expect(removed).toBe(true)
|
|
94
|
+
expect(store.has('r1')).toBe(false)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('a sweep after a tap on the same instance is a no-op for that card', () => {
|
|
98
|
+
const wakes: string[] = []
|
|
99
|
+
const store = makeStore((id) => {
|
|
100
|
+
store.delete(id)
|
|
101
|
+
wakes.push(id)
|
|
102
|
+
})
|
|
103
|
+
store.set('r2', { staged_at: 0, agent: 'a' })
|
|
104
|
+
store.delete('r2') // tap resolves before the reaper runs
|
|
105
|
+
store.sweep(5000)
|
|
106
|
+
expect(wakes).toEqual([]) // already resolved — no timeout wake
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe('approval-card-stores: Map surface parity', () => {
|
|
111
|
+
it('supports get/set/delete/has/size/iteration used by call sites', () => {
|
|
112
|
+
const store = makeStore(() => {})
|
|
113
|
+
store.set('a', { staged_at: 1, agent: 'x' })
|
|
114
|
+
store.set('b', { staged_at: 2, agent: 'y' })
|
|
115
|
+
expect(store.size).toBe(2)
|
|
116
|
+
expect(store.get('a')?.agent).toBe('x')
|
|
117
|
+
const seen: string[] = []
|
|
118
|
+
for (const [id] of store) seen.push(id)
|
|
119
|
+
expect(seen.sort()).toEqual(['a', 'b'])
|
|
120
|
+
expect([...store.values()].map((v) => v.agent).sort()).toEqual(['x', 'y'])
|
|
121
|
+
expect(store.delete('a')).toBe(true)
|
|
122
|
+
expect(store.has('a')).toBe(false)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2923 — boot-card flood-wait suppression. When a Telegram per-bot flood ban
|
|
3
|
+
* is active, a restart's boot card is a NON-ESSENTIAL send straight into the
|
|
4
|
+
* open window that can reset/extend the ban. startBootCard must SKIP the send
|
|
5
|
+
* (and log) while the flood-wait is active, and post normally once it lifts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
9
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
10
|
+
import { tmpdir } from 'node:os'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { startBootCard } from '../gateway/boot-card.js'
|
|
13
|
+
import type { BotApiForBootCard } from '../gateway/boot-card.js'
|
|
14
|
+
import { computeFloodWait, writeFloodState, floodStatePath, makeFloodWaitRecorder } from '../flood-circuit-breaker.js'
|
|
15
|
+
import { createRetryApiCall } from '../retry-api-call.js'
|
|
16
|
+
import { errors } from './fake-bot-api.js'
|
|
17
|
+
|
|
18
|
+
let dir: string
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
dir = mkdtempSync(join(tmpdir(), 'boot-card-flood-'))
|
|
22
|
+
})
|
|
23
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }))
|
|
24
|
+
|
|
25
|
+
function makeBot() {
|
|
26
|
+
const sends: string[] = []
|
|
27
|
+
const bot: BotApiForBootCard = {
|
|
28
|
+
sendMessage: async (chatId, _text, _opts) => {
|
|
29
|
+
sends.push(chatId)
|
|
30
|
+
return { message_id: 1001 }
|
|
31
|
+
},
|
|
32
|
+
editMessageText: async () => ({}),
|
|
33
|
+
}
|
|
34
|
+
return { bot, sends }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function mkOpts(floodPath: string, nowMs: number, overrides: Record<string, unknown> = {}) {
|
|
38
|
+
return {
|
|
39
|
+
agentName: 'TestAgent',
|
|
40
|
+
agentSlug: 'test-agent',
|
|
41
|
+
version: 'v0.0.0-test',
|
|
42
|
+
agentDir: dir,
|
|
43
|
+
gatewayInfo: { pid: 1, startedAtMs: nowMs },
|
|
44
|
+
restartReason: 'graceful' as const,
|
|
45
|
+
agentLiveWindowMs: 0,
|
|
46
|
+
settleWindowMs: 1_000_000,
|
|
47
|
+
floodStatePath: floodPath,
|
|
48
|
+
nowMs: () => nowMs,
|
|
49
|
+
...overrides,
|
|
50
|
+
} as unknown as Parameters<typeof startBootCard>[3]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
it('SUPPRESSES the boot card while a flood-wait is active', async () => {
|
|
54
|
+
const p = floodStatePath(dir)
|
|
55
|
+
const now = 1_000_000
|
|
56
|
+
writeFloodState(p, computeFloodWait(null, 68 * 60, now)) // ~68 min ban
|
|
57
|
+
const logs: string[] = []
|
|
58
|
+
const { bot, sends } = makeBot()
|
|
59
|
+
const handle = await startBootCard('chat1', undefined, bot, mkOpts(p, now), undefined, (l) =>
|
|
60
|
+
logs.push(l),
|
|
61
|
+
)
|
|
62
|
+
expect(sends).toEqual([]) // nothing sent into the open ban window
|
|
63
|
+
expect(handle.messageId).toBe(-1)
|
|
64
|
+
expect(logs.join('')).toMatch(/SUPPRESSED/)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('POSTS the boot card once the flood-wait has lifted', async () => {
|
|
68
|
+
const p = floodStatePath(dir)
|
|
69
|
+
const banStart = 1_000_000
|
|
70
|
+
writeFloodState(p, computeFloodWait(null, 60, banStart))
|
|
71
|
+
const afterBan = banStart + 60_000 + 1
|
|
72
|
+
const { bot, sends } = makeBot()
|
|
73
|
+
await startBootCard('chat1', undefined, bot, mkOpts(p, afterBan), undefined, () => {})
|
|
74
|
+
expect(sends).toEqual(['chat1'])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('POSTS normally when no flood state file exists (back-compat)', async () => {
|
|
78
|
+
const p = floodStatePath(dir) // never written
|
|
79
|
+
const { bot, sends } = makeBot()
|
|
80
|
+
await startBootCard('chat1', undefined, bot, mkOpts(p, 1_000_000), undefined, () => {})
|
|
81
|
+
expect(sends).toEqual(['chat1'])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('INTEGRATION: a 429 through the real retry path suppresses a later boot card', async () => {
|
|
85
|
+
// Wire the two halves exactly as gateway.ts does: the retry wrapper's
|
|
86
|
+
// onFloodWait recorder and the boot card BOTH point at the same file.
|
|
87
|
+
const p = floodStatePath(dir)
|
|
88
|
+
const now = 5_000_000
|
|
89
|
+
const retry = createRetryApiCall({
|
|
90
|
+
sleep: async () => {}, // don't actually wait out the flood
|
|
91
|
+
onFloodWait: makeFloodWaitRecorder(p, () => now),
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
// Drive a real GrammyError 429 through the wrapper (first call floods, then
|
|
95
|
+
// succeeds) — this is what records the window on disk.
|
|
96
|
+
let n = 0
|
|
97
|
+
await retry(async () => {
|
|
98
|
+
if (n++ === 0) throw errors.floodWait(4116) // ~68 min ban
|
|
99
|
+
return 'ok'
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
// Now a restart's boot card must be suppressed via the SAME file.
|
|
103
|
+
const { bot, sends } = makeBot()
|
|
104
|
+
const logs: string[] = []
|
|
105
|
+
const handle = await startBootCard('chat1', undefined, bot, mkOpts(p, now), undefined, (l) =>
|
|
106
|
+
logs.push(l),
|
|
107
|
+
)
|
|
108
|
+
expect(sends).toEqual([]) // not posted into the open ban window
|
|
109
|
+
expect(handle.messageId).toBe(-1)
|
|
110
|
+
expect(logs.join('')).toMatch(/SUPPRESSED/)
|
|
111
|
+
})
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2995 — mid-flight busy ack: gateway wiring guards.
|
|
3
|
+
*
|
|
4
|
+
* The gateway IIFE is too entangled to instantiate in-process, so these
|
|
5
|
+
* are source-level assertions (the established pattern —
|
|
6
|
+
* multitopic-routing-wiring.test.ts, buffer-gate-broadened.test.ts). They
|
|
7
|
+
* pin the load-bearing wiring: the buffered-inbound and steer call sites,
|
|
8
|
+
* the silent send, the shared queuedStatusMsgIds lifecycle (promote/reap
|
|
9
|
+
* cleanup for free), the per-turn dedupe reset, and the kill switch. The
|
|
10
|
+
* POLICY and the rendered text are behaviourally tested in
|
|
11
|
+
* busy-ack.test.ts; the live end-to-end shape in
|
|
12
|
+
* uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect } from 'vitest'
|
|
16
|
+
import { readFileSync } from 'node:fs'
|
|
17
|
+
import { resolve } from 'node:path'
|
|
18
|
+
|
|
19
|
+
const gatewaySrc = readFileSync(
|
|
20
|
+
resolve(__dirname, '..', 'gateway', 'gateway.ts'),
|
|
21
|
+
'utf-8',
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
describe('#2995 mid-flight busy ack — gateway wiring', () => {
|
|
25
|
+
it('the buffer-until-idle branch calls maybePostBusyAck for the inbound own chat/topic', () => {
|
|
26
|
+
expect(gatewaySrc).toMatch(
|
|
27
|
+
/maybePostBusyAck\('buffer-until-idle', chat_id, messageThreadId \?\? undefined\)/,
|
|
28
|
+
)
|
|
29
|
+
// …AFTER the pendingInboundBuffer.push (the ack narrates a real queue).
|
|
30
|
+
const branch = gatewaySrc.split("deliveryGate.decision === 'buffer-until-idle'")[1] ?? ''
|
|
31
|
+
const pushIdx = branch.indexOf('pendingInboundBuffer.push(selfAgent, inboundMsg)')
|
|
32
|
+
const ackIdx = branch.indexOf("maybePostBusyAck('buffer-until-idle'")
|
|
33
|
+
expect(pushIdx).toBeGreaterThanOrEqual(0)
|
|
34
|
+
expect(ackIdx).toBeGreaterThan(pushIdx)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('cross-topic queued status and the busy ack are mutually exclusive (no double-card race)', () => {
|
|
38
|
+
// Both helpers record into queuedStatusMsgIds only AFTER their
|
|
39
|
+
// sendMessage awaits resolve, so calling both in the same handler pass
|
|
40
|
+
// would race past each other's has(key) check and double-card the
|
|
41
|
+
// topic. The buffer branch must pick exactly one.
|
|
42
|
+
expect(gatewaySrc).toMatch(
|
|
43
|
+
/if \(crossTopicQueuedCard\) \{\s*postQueuedStatus\(chat_id, messageThreadId, inFlightThread\)\s*\} else \{/,
|
|
44
|
+
)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('a mid-turn steer gets the steer-worded variant only AFTER successful bridge dispatch', () => {
|
|
48
|
+
// A "Steer noted" card for a send that missed (bridge offline) would
|
|
49
|
+
// be untrue — the ack lives inside the `if (delivered)` branch.
|
|
50
|
+
expect(gatewaySrc).toMatch(
|
|
51
|
+
/if \(delivered\) \{[\s\S]{0,700}if \(isSteering\) \{\s*maybePostBusyAck\('steer', chat_id, messageThreadId \?\? undefined\)/,
|
|
52
|
+
)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('an under-threshold miss arms ONE deferred re-check pinned to the running turn', () => {
|
|
56
|
+
const fn = gatewaySrc.split('function maybePostBusyAck')[1]?.split('\nfunction ')[0] ?? ''
|
|
57
|
+
// Armed only for the young-step miss, for the remaining age gap.
|
|
58
|
+
expect(fn).toMatch(/stepAgeMs < BUSY_ACK_STEP_AGE_THRESHOLD_MS &&\s*!busyAckRecheckTimers\.has\(key\)/)
|
|
59
|
+
expect(fn).toMatch(/BUSY_ACK_STEP_AGE_THRESHOLD_MS - stepAgeMs \+ 250/)
|
|
60
|
+
// The re-fire is guarded on the SAME turn still running.
|
|
61
|
+
expect(fn).toMatch(/currentTurn\?\.turnId !== turnIdAtSchedule\) return/)
|
|
62
|
+
// A posted card cancels the pending re-check.
|
|
63
|
+
expect(fn).toMatch(/clearTimeout\(pendingRecheck\)/)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('the decision is fed from live tool-flight + step-age readings (pure module owns policy)', () => {
|
|
67
|
+
const fn = gatewaySrc.split('function maybePostBusyAck')[1]?.split('\nfunction ')[0] ?? ''
|
|
68
|
+
expect(fn).toMatch(/shouldPostBusyAck\(\{ gateDecision, midToolCall, stepAgeMs, alreadyAcked \}\)/)
|
|
69
|
+
expect(fn).toMatch(/const midToolCall = toolFlightTracker\.isMidToolCall\(\)/)
|
|
70
|
+
expect(fn).toMatch(/silencePoke\.longestInFlightTool\(/)
|
|
71
|
+
expect(fn).toMatch(/const stepAgeMs = step\?\.durationMs \?\? null/)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('dedupe: alreadyAcked couples the live card map AND the per-turn key set', () => {
|
|
75
|
+
const fn = gatewaySrc.split('function maybePostBusyAck')[1]?.split('\nfunction ')[0] ?? ''
|
|
76
|
+
expect(fn).toMatch(
|
|
77
|
+
/const alreadyAcked = queuedStatusMsgIds\.has\(key\) \|\| busyAckPostedKeys\.has\(key\)/,
|
|
78
|
+
)
|
|
79
|
+
expect(fn).toMatch(/busyAckPostedKeys\.add\(key\)/)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('the card is sent SILENT (disable_notification: true) through the swallowing wrapper', () => {
|
|
83
|
+
const fn = gatewaySrc.split('function postBusyAck')[1]?.split('\nfunction ')[0] ?? ''
|
|
84
|
+
expect(fn).toMatch(/swallowingApiCall\(/)
|
|
85
|
+
expect(fn).toMatch(/disable_notification: true/)
|
|
86
|
+
// Thread-optional: a DM send must NOT pass message_thread_id.
|
|
87
|
+
expect(fn).toMatch(/\.\.\.\(threadId != null \? \{ message_thread_id: threadId \} : \{\}\)/)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('the card shares queuedStatusMsgIds — promote/reap lifecycle cleans it up', () => {
|
|
91
|
+
const fn = gatewaySrc.split('function postBusyAck')[1]?.split('\nfunction ')[0] ?? ''
|
|
92
|
+
// Idempotent against the shared key (never stacks on the cross-topic card).
|
|
93
|
+
expect(fn).toMatch(/if \(queuedStatusMsgIds\.has\(key\)\) return/)
|
|
94
|
+
expect(fn).toMatch(/queuedStatusMsgIds\.set\(key, \{ chatId, threadId: threadId \?\? null, messageId \}\)/)
|
|
95
|
+
// Post-race orphan cleanup, same pattern as postQueuedStatus.
|
|
96
|
+
expect(fn).toMatch(/busy-ack\.post-race-cleanup/)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('turn-end cleanup is PER-KEY: dedupe entry deleted and re-check timer cancelled for the ending turn only', () => {
|
|
100
|
+
const purge = gatewaySrc.split('function purgeReactionTracking')[1]?.split('\nfunction ')[0] ?? ''
|
|
101
|
+
// Per-key delete — a purge for topic A must not reset topic B's dedupe.
|
|
102
|
+
expect(purge).toMatch(/busyAckPostedKeys\.delete\(key\)/)
|
|
103
|
+
expect(purge).not.toMatch(/busyAckPostedKeys\.clear\(\)/)
|
|
104
|
+
expect(purge).toMatch(/busyAckRecheckTimers\.delete\(key\)/)
|
|
105
|
+
expect(purge).toMatch(/clearTimeout\(busyAckRecheck\)/)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('DM cards are promoted too (promoteQueuedStatus no longer early-returns on a null thread)', () => {
|
|
109
|
+
const fn = gatewaySrc.split('function promoteQueuedStatus')[1]?.split('\nfunction ')[0] ?? ''
|
|
110
|
+
expect(fn).not.toMatch(/if \(thread == null\) return/)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('kill switch defaults ON, independently disableable', () => {
|
|
114
|
+
expect(gatewaySrc).toMatch(/SWITCHROOM_MIDFLIGHT_BUSY_ACK !== '0'/)
|
|
115
|
+
const fn = gatewaySrc.split('function maybePostBusyAck')[1]?.split('\nfunction ')[0] ?? ''
|
|
116
|
+
expect(fn).toMatch(/if \(!MIDFLIGHT_BUSY_ACK_ENABLED\) return/)
|
|
117
|
+
})
|
|
118
|
+
})
|