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,425 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared harness for the approval-hold outcome tests (#3084 follow-up).
|
|
3
|
+
*
|
|
4
|
+
* gateway.ts has top-level side effects and is NOT unit-importable (the same
|
|
5
|
+
* constraint `permission-card-routing.test.ts:18` documents), so a literal
|
|
6
|
+
* "boot the gateway" integration test is not available. Instead this harness
|
|
7
|
+
* composes the REAL primitives the gateway composes, in the same order:
|
|
8
|
+
*
|
|
9
|
+
* - `createRetryApiCall` — the real retry policy, incl. its pre-call
|
|
10
|
+
* flood-window short-circuit (#3094)
|
|
11
|
+
* - `retryWithThreadFallback` — the real send wrapper
|
|
12
|
+
* - `makeFloodWaitRecorder` / `makeFloodWaitProbe` — the real on-disk window
|
|
13
|
+
* - `selectHeldForRedelivery` / `isHeldUndeliverable` — the real hold decisions
|
|
14
|
+
* - `createBlockedApprovalStore` — the real surface record
|
|
15
|
+
* - `ttlForTool` — the real TTL
|
|
16
|
+
*
|
|
17
|
+
* Only two things are fakes: the Telegram transport (a counting send spy) and
|
|
18
|
+
* the IPC verdict dispatch (a spy — this is what proves no `deny` was ever
|
|
19
|
+
* fabricated). Everything that makes a DECISION is production code.
|
|
20
|
+
*
|
|
21
|
+
* Deliberately free of `vi.useFakeTimers()` / `vi.setSystemTime()` and of any
|
|
22
|
+
* import from `fake-bot-api.ts` (which pulls in `vi` at module scope): this
|
|
23
|
+
* harness must work under BOTH vitest and bun. It uses an explicit virtual
|
|
24
|
+
* clock instead — the pattern from `typing-emitter.test.ts`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { mkdtempSync } from 'node:fs'
|
|
28
|
+
import { tmpdir } from 'node:os'
|
|
29
|
+
import { join } from 'node:path'
|
|
30
|
+
import { GrammyError } from 'grammy'
|
|
31
|
+
import { createRetryApiCall, retryWithThreadFallback, isFloodWaitActiveError, GIVE_UP_MESSAGE } from '../retry-api-call.js'
|
|
32
|
+
import {
|
|
33
|
+
floodStatePath,
|
|
34
|
+
makeFloodWaitRecorder,
|
|
35
|
+
makeFloodWaitProbe,
|
|
36
|
+
} from '../flood-circuit-breaker.js'
|
|
37
|
+
import {
|
|
38
|
+
createBlockedApprovalStore,
|
|
39
|
+
blockedApprovalsDir,
|
|
40
|
+
selectHeldForRedelivery,
|
|
41
|
+
selectOldestHeld,
|
|
42
|
+
safeActionForRecord,
|
|
43
|
+
holdReasonFor,
|
|
44
|
+
heldRetryBackoffMs,
|
|
45
|
+
type UndeliverableMark,
|
|
46
|
+
type BlockedApprovalStore,
|
|
47
|
+
} from '../gateway/approval-hold.js'
|
|
48
|
+
import { ttlForTool } from '../gateway/permission-timeout.js'
|
|
49
|
+
import { sweepPermissionTtl } from '../gateway/permission-ttl-sweep.js'
|
|
50
|
+
import { naturalAction } from '../permission-title.js'
|
|
51
|
+
|
|
52
|
+
/** A pending permission, shaped exactly like gateway.ts's `pendingPermissions` value. */
|
|
53
|
+
export interface Pending {
|
|
54
|
+
tool_name: string
|
|
55
|
+
description: string
|
|
56
|
+
input_preview: string
|
|
57
|
+
startedAt: number
|
|
58
|
+
card_text: string
|
|
59
|
+
cards: { chatId: string; messageId: number; threadId?: number | null }[]
|
|
60
|
+
undeliverable?: UndeliverableMark | null
|
|
61
|
+
redeliveryFailures?: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** An IPC verdict as `dispatchPermissionVerdict` would emit it. */
|
|
65
|
+
export interface Verdict {
|
|
66
|
+
requestId: string
|
|
67
|
+
behavior: 'allow' | 'deny'
|
|
68
|
+
message?: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Build the real 429 GrammyError Telegram sends during a flood ban. */
|
|
72
|
+
export function floodWait429(retryAfterSec: number): GrammyError {
|
|
73
|
+
return new GrammyError(
|
|
74
|
+
`Call to 'sendRichMessage' failed! (429: Too Many Requests: retry after ${retryAfterSec})`,
|
|
75
|
+
{
|
|
76
|
+
ok: false,
|
|
77
|
+
error_code: 429,
|
|
78
|
+
description: `Too Many Requests: retry after ${retryAfterSec}`,
|
|
79
|
+
parameters: { retry_after: retryAfterSec } as GrammyError['parameters'],
|
|
80
|
+
},
|
|
81
|
+
'sendRichMessage',
|
|
82
|
+
{},
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The REAL failures a card send actually hits. Each throws exactly what Telegram
|
|
88
|
+
* or the network would; `retryApiCall` then decides what surfaces to the caller.
|
|
89
|
+
* The point of driving real errors is that our classification of them is the
|
|
90
|
+
* thing under test.
|
|
91
|
+
*/
|
|
92
|
+
export type FaultKind = 'tg_502' | 'tg_500' | 'econnreset' | 'fetch_failed' | 'tg_400' | 'tg_403'
|
|
93
|
+
|
|
94
|
+
const grammyErr = (code: number, desc: string) => new GrammyError(
|
|
95
|
+
`Call to 'sendRichMessage' failed! (${code}: ${desc})`,
|
|
96
|
+
{ ok: false, error_code: code, description: desc } as GrammyError['payload'] & { ok: false },
|
|
97
|
+
'sendRichMessage',
|
|
98
|
+
{},
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
export const FAULTS: Record<FaultKind, () => unknown> = {
|
|
102
|
+
// TRANSIENT — the operator never got to answer. Must be HELD.
|
|
103
|
+
tg_502: () => grammyErr(502, 'Bad Gateway'),
|
|
104
|
+
tg_500: () => grammyErr(500, 'Internal Server Error'),
|
|
105
|
+
econnreset: () => new Error('socket hang up ECONNRESET'),
|
|
106
|
+
fetch_failed: () => new Error('fetch failed'),
|
|
107
|
+
// PERMANENT — this card will never render. Falls through to the TTL.
|
|
108
|
+
tg_400: () => grammyErr(400, "Bad Request: can't parse entities"),
|
|
109
|
+
tg_403: () => grammyErr(403, 'Forbidden: bot was blocked by the user'),
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface Harness {
|
|
113
|
+
stateDir: string
|
|
114
|
+
clock: { now: () => number; advance: (ms: number) => void }
|
|
115
|
+
/** Every send the transport actually saw. Length === card send count. */
|
|
116
|
+
sends: { chatId: string; requestId: string }[]
|
|
117
|
+
/** Every IPC verdict dispatched. A `deny` here for an unseen card is THE bug. */
|
|
118
|
+
verdicts: Verdict[]
|
|
119
|
+
/** True whenever the transport was invoked while the window was still open. */
|
|
120
|
+
sentIntoOpenWindow: boolean
|
|
121
|
+
pending: Map<string, Pending>
|
|
122
|
+
blockedStore: BlockedApprovalStore
|
|
123
|
+
/** Simulate Telegram flood-banning the bot for `ms`. */
|
|
124
|
+
banFor: (ms: number) => void
|
|
125
|
+
/** Make a target throw a REAL error. `kind` picks which real failure. */
|
|
126
|
+
breakTarget: (chatId: string, kind: FaultKind) => void
|
|
127
|
+
/** The channel recovers — the target starts working again. */
|
|
128
|
+
healTarget: (chatId: string) => void
|
|
129
|
+
/** Flood-ban ONE target, so another can succeed in the same fan-out. */
|
|
130
|
+
banTarget: (chatId: string, ms: number) => void
|
|
131
|
+
/** Hang one target's send until `releaseTarget` — lets a tick overlap it. */
|
|
132
|
+
hangTarget: (chatId: string) => void
|
|
133
|
+
releaseTarget: (chatId: string) => void
|
|
134
|
+
/** Make the NEXT postPermissionCard for `requestId` throw SYNCHRONOUSLY (one-shot). */
|
|
135
|
+
breakSync: (requestId: string) => void
|
|
136
|
+
/** Remaining ms of the open window, per the real probe. */
|
|
137
|
+
floodRemainingMs: () => number
|
|
138
|
+
/** The gateway's `postPermissionCard` — first delivery AND re-delivery. */
|
|
139
|
+
postPermissionCard: (requestId: string) => Promise<void>
|
|
140
|
+
/** Raise a permission request, as `onPermissionRequest` does. */
|
|
141
|
+
raise: (requestId: string, toolName: string, inputPreview: string) => Promise<void>
|
|
142
|
+
/** One 60s reaper tick: held-card re-delivery sweep, then the TTL sweep. */
|
|
143
|
+
tick: () => Promise<void>
|
|
144
|
+
/** Await every in-flight send (the reaper itself never awaits them). */
|
|
145
|
+
settle: () => Promise<void>
|
|
146
|
+
/** tick() then settle() — the common case. */
|
|
147
|
+
tickSettled: () => Promise<void>
|
|
148
|
+
/** Is this request still mid-send? (the in-flight guard, made observable) */
|
|
149
|
+
isInFlight: (requestId: string) => boolean
|
|
150
|
+
/** Drain the microtask queue so pending .then/.catch chains run to completion. */
|
|
151
|
+
flush: () => Promise<void>
|
|
152
|
+
/** Operator taps Allow on a live card. */
|
|
153
|
+
tap: (requestId: string, behavior: 'allow' | 'deny') => void
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function createHarness(opts: { cap?: number; targets?: string[] } = {}): Harness {
|
|
157
|
+
|
|
158
|
+
const stateDir = mkdtempSync(join(tmpdir(), 'approval-hold-'))
|
|
159
|
+
const floodPath = floodStatePath(stateDir)
|
|
160
|
+
|
|
161
|
+
// Anchored at real `Date.now()` so the virtual clock and the `untilTs` that
|
|
162
|
+
// retryApiCall stamps (which uses real Date.now()) stay coherent at T0.
|
|
163
|
+
let t = Date.now()
|
|
164
|
+
const clock = { now: () => t, advance: (ms: number) => { t += ms } }
|
|
165
|
+
|
|
166
|
+
const sends: { chatId: string; requestId: string }[] = []
|
|
167
|
+
const verdicts: Verdict[] = []
|
|
168
|
+
const pending = new Map<string, Pending>()
|
|
169
|
+
const heldInFlight = new Set<string>()
|
|
170
|
+
const blockedStore = createBlockedApprovalStore(blockedApprovalsDir(stateDir), 'overlord')
|
|
171
|
+
|
|
172
|
+
const targetChats = opts.targets ?? ['-100200']
|
|
173
|
+
/** Every send promise issued, so a test can await quiescence. */
|
|
174
|
+
const inFlightSends: Promise<void>[] = []
|
|
175
|
+
let banUntil = 0
|
|
176
|
+
let sentIntoOpenWindow = false
|
|
177
|
+
|
|
178
|
+
/** Per-chat fault: a factory for the REAL error the transport should throw. */
|
|
179
|
+
const faults = new Map<string, () => unknown>()
|
|
180
|
+
/** Chats banned individually (so one target can flood while another succeeds). */
|
|
181
|
+
const perChatBan = new Map<string, number>()
|
|
182
|
+
/** Requests whose NEXT postPermissionCard throws SYNCHRONOUSLY (one-shot). */
|
|
183
|
+
const syncFaults = new Set<string>()
|
|
184
|
+
/** Chats whose send HANGS until released — lets a tick overlap an in-flight send. */
|
|
185
|
+
const gates = new Map<string, Promise<void>>()
|
|
186
|
+
const gateReleases = new Map<string, () => void>()
|
|
187
|
+
|
|
188
|
+
const probe = makeFloodWaitProbe(floodPath, clock.now)
|
|
189
|
+
const robustApiCall = createRetryApiCall({
|
|
190
|
+
sleep: async () => {}, // never actually sleep — the clock is virtual
|
|
191
|
+
onFloodWait: makeFloodWaitRecorder(floodPath, clock.now),
|
|
192
|
+
floodWaitRemainingMs: probe,
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
/** The fake Telegram transport. Rejects with a real 429 while banned. */
|
|
196
|
+
async function transport(chatId: string, requestId: string): Promise<{ message_id: number }> {
|
|
197
|
+
// Did this request reach Telegram while we ALREADY KNEW the window was
|
|
198
|
+
// open? That is the amplifier #3094 exists to prevent, and the one way this
|
|
199
|
+
// design could re-earn the ban. (The very FIRST send is how the ban is
|
|
200
|
+
// discovered — the on-disk window is empty until a 429 records it — so it
|
|
201
|
+
// is legitimately not "into a known-open window".)
|
|
202
|
+
if (probe() > 0) sentIntoOpenWindow = true
|
|
203
|
+
|
|
204
|
+
// Hang here until the test releases us — models a send still in flight when
|
|
205
|
+
// the next 60s reaper tick fires.
|
|
206
|
+
const gate = gates.get(chatId)
|
|
207
|
+
if (gate != null) { await gate }
|
|
208
|
+
|
|
209
|
+
const fault = faults.get(chatId)
|
|
210
|
+
if (fault != null) {
|
|
211
|
+
// Throw the REAL error Telegram/the network would produce, and let the REAL
|
|
212
|
+
// retryApiCall decide what surfaces. The previous harness threw
|
|
213
|
+
// GIVE_UP_MESSAGE directly — the *conclusion* — which hard-coded a premise
|
|
214
|
+
// that turned out to be false (a 5xx is re-thrown RAW, not as a give-up) and
|
|
215
|
+
// made a live auto-deny bug pass green.
|
|
216
|
+
throw fault()
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const until = Math.max(banUntil, perChatBan.get(chatId) ?? 0)
|
|
220
|
+
const remaining = until - clock.now()
|
|
221
|
+
if (remaining > 0) throw floodWait429(Math.ceil(remaining / 1000))
|
|
222
|
+
sends.push({ chatId, requestId })
|
|
223
|
+
return { message_id: 1000 + sends.length }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Mirrors gateway.ts's reconcileBlockedApprovals — and shares its SELECTION
|
|
228
|
+
* with production via `selectOldestHeld` (extracted in #3107's late fix), so
|
|
229
|
+
* the harness cannot drift from the real oldest-held-wins rule.
|
|
230
|
+
*/
|
|
231
|
+
function reconcile(): void {
|
|
232
|
+
const oldest = selectOldestHeld(pending)
|
|
233
|
+
if (oldest == null) { blockedStore.clear(); return }
|
|
234
|
+
const { requestId, pend: p, mark: u } = oldest
|
|
235
|
+
blockedStore.write({
|
|
236
|
+
agent: 'overlord',
|
|
237
|
+
requestId,
|
|
238
|
+
toolName: p.tool_name,
|
|
239
|
+
action: safeActionForRecord(naturalAction, p.tool_name, p.input_preview),
|
|
240
|
+
blockedSince: p.startedAt,
|
|
241
|
+
undeliverableSince: u.since,
|
|
242
|
+
retryableAt: u.retryableAt,
|
|
243
|
+
reason: u.reason,
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── postPermissionCard — mirrors gateway.ts's extracted send ──────────────
|
|
248
|
+
// Fans out to N targets, exactly as `resolvePermissionCardTargets()` does (it
|
|
249
|
+
// ends in `allowFrom.map(...)`). N>1 is the COMMON case on the re-delivery
|
|
250
|
+
// path, so a harness pinned at N=1 cannot see the double-delivery and
|
|
251
|
+
// permanent-wedge bugs that live in the multi-target interleaving.
|
|
252
|
+
function postPermissionCard(requestId: string): Promise<void> {
|
|
253
|
+
const pend = pending.get(requestId)
|
|
254
|
+
if (pend == null) { heldInFlight.delete(requestId); return Promise.resolve() }
|
|
255
|
+
|
|
256
|
+
// Mirrors gateway.ts: the flag is registered at the TOP of postPermissionCard
|
|
257
|
+
// itself, so the initial-delivery caller is covered as well as the sweep —
|
|
258
|
+
// otherwise a tick landing while a slow INITIAL send is in flight re-posts.
|
|
259
|
+
heldInFlight.add(requestId)
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
// A sync throw in the gateway's prologue (resolveScopedAllowChoices /
|
|
263
|
+
// buildPermissionActionRow / resolvePermissionCardTargets), made drivable.
|
|
264
|
+
if (syncFaults.delete(requestId)) {
|
|
265
|
+
throw new Error(`sync fault building card for ${requestId}`)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// One increment per ATTEMPT, not per failing target (mirrors gateway.ts):
|
|
269
|
+
// every failing target in the same fan-out stamps the SAME attempt number,
|
|
270
|
+
// so N failing targets don't ratchet the backoff ladder N× faster.
|
|
271
|
+
const attemptFailures = (pend.redeliveryFailures ?? 0) + 1
|
|
272
|
+
|
|
273
|
+
const sends = targetChats.map(async (chatId) => {
|
|
274
|
+
try {
|
|
275
|
+
const sent = await retryWithThreadFallback<{ message_id: number }>(
|
|
276
|
+
robustApiCall,
|
|
277
|
+
() => transport(chatId, requestId),
|
|
278
|
+
{ threadId: undefined, chat_id: chatId, verb: 'permission_request' },
|
|
279
|
+
)
|
|
280
|
+
const live = pending.get(requestId)
|
|
281
|
+
if (live && sent) {
|
|
282
|
+
live.cards.push({ chatId, messageId: sent.message_id })
|
|
283
|
+
if (live.undeliverable != null) {
|
|
284
|
+
live.undeliverable = null
|
|
285
|
+
live.redeliveryFailures = 0
|
|
286
|
+
// PR 3: the operator's decision window starts NOW — until this
|
|
287
|
+
// moment they had nothing to answer.
|
|
288
|
+
live.startedAt = clock.now()
|
|
289
|
+
reconcile()
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
} catch (e) {
|
|
293
|
+
const live = pending.get(requestId)
|
|
294
|
+
if (live == null) return
|
|
295
|
+
// A card landed on ANOTHER target — the ask is NOT undeliverable.
|
|
296
|
+
if (live.cards.length > 0) return
|
|
297
|
+
const reason = holdReasonFor(e)
|
|
298
|
+
if (reason == null) return // permanent — falls through to the TTL
|
|
299
|
+
const failures = attemptFailures
|
|
300
|
+
live.redeliveryFailures = failures
|
|
301
|
+
const now = clock.now()
|
|
302
|
+
const retryableAt = isFloodWaitActiveError(e)
|
|
303
|
+
? (e as { untilTs: number }).untilTs
|
|
304
|
+
: now + heldRetryBackoffMs(failures)
|
|
305
|
+
live.undeliverable = { since: live.undeliverable?.since ?? now, retryableAt, reason }
|
|
306
|
+
reconcile()
|
|
307
|
+
}
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
// ONE settle across ALL targets — the flag must not release while any
|
|
311
|
+
// target is still in flight.
|
|
312
|
+
return Promise.allSettled(sends).then(() => { heldInFlight.delete(requestId) })
|
|
313
|
+
} catch (e) {
|
|
314
|
+
// Mirrors gateway.ts: a sync throw never reaches the settle above, so
|
|
315
|
+
// release the flag here and rethrow to the caller (raise, or tick's catch).
|
|
316
|
+
heldInFlight.delete(requestId)
|
|
317
|
+
throw e
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function raise(requestId: string, toolName: string, inputPreview: string): Promise<void> {
|
|
322
|
+
pending.set(requestId, {
|
|
323
|
+
tool_name: toolName,
|
|
324
|
+
description: '',
|
|
325
|
+
input_preview: inputPreview,
|
|
326
|
+
startedAt: clock.now(),
|
|
327
|
+
card_text: `card for ${requestId}`,
|
|
328
|
+
cards: [],
|
|
329
|
+
})
|
|
330
|
+
await postPermissionCard(requestId)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ── the 60s reaper tick ───────────────────────────────────────────────────
|
|
334
|
+
async function tick(): Promise<void> {
|
|
335
|
+
const now = clock.now()
|
|
336
|
+
|
|
337
|
+
// PR 2 — re-deliver held cards once the window closes.
|
|
338
|
+
const { send } = selectHeldForRedelivery(pending.entries(), {
|
|
339
|
+
floodRemainingMs: probe(),
|
|
340
|
+
inFlight: heldInFlight,
|
|
341
|
+
now,
|
|
342
|
+
...(opts.cap != null ? { cap: opts.cap } : {}),
|
|
343
|
+
})
|
|
344
|
+
// Fire-and-forget, exactly like the real reaper (`void postPermissionCard(...)`).
|
|
345
|
+
// Awaiting here would make an overlapping tick impossible — and an
|
|
346
|
+
// overlapping tick is precisely how a per-target in-flight clear
|
|
347
|
+
// double-delivers. Tests that need the send settled `await h.settle()`.
|
|
348
|
+
// postPermissionCard registers the in-flight flag itself; a SYNC throw is
|
|
349
|
+
// caught here (mirrors gateway's sweep) so it can't kill the tick, and the
|
|
350
|
+
// flag is deleted defensively so the entry stays re-selectable.
|
|
351
|
+
for (const id of send) {
|
|
352
|
+
try {
|
|
353
|
+
inFlightSends.push(postPermissionCard(id))
|
|
354
|
+
} catch {
|
|
355
|
+
heldInFlight.delete(id)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// The TTL sweep, as gateway.ts runs it.
|
|
360
|
+
// The TTL sweep — the REAL production function, not a copy. This is the whole
|
|
361
|
+
// point: the guard AND the auto-deny it gates live in ONE place that both the
|
|
362
|
+
// gateway and this harness call, so there is no private copy here that could
|
|
363
|
+
// silently compensate for a guard deleted from production.
|
|
364
|
+
sweepPermissionTtl({
|
|
365
|
+
entries: pending,
|
|
366
|
+
now,
|
|
367
|
+
ttlForTool,
|
|
368
|
+
onExpire: (k) => {
|
|
369
|
+
verdicts.push({ requestId: k, behavior: 'deny', message: 'timed out' })
|
|
370
|
+
pending.delete(k)
|
|
371
|
+
reconcile()
|
|
372
|
+
},
|
|
373
|
+
})
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function tap(requestId: string, behavior: 'allow' | 'deny'): void {
|
|
377
|
+
const pend = pending.get(requestId)
|
|
378
|
+
// A tap only resolves a card that actually EXISTS to be tapped.
|
|
379
|
+
if (pend == null || pend.cards.length === 0) return
|
|
380
|
+
verdicts.push({ requestId, behavior })
|
|
381
|
+
pending.delete(requestId)
|
|
382
|
+
reconcile()
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
stateDir,
|
|
387
|
+
clock,
|
|
388
|
+
sends,
|
|
389
|
+
verdicts,
|
|
390
|
+
get sentIntoOpenWindow() { return sentIntoOpenWindow },
|
|
391
|
+
pending,
|
|
392
|
+
blockedStore,
|
|
393
|
+
banFor: (ms: number) => { banUntil = clock.now() + ms },
|
|
394
|
+
breakTarget: (chatId: string, kind: FaultKind) => { faults.set(chatId, FAULTS[kind]) },
|
|
395
|
+
healTarget: (chatId: string) => { faults.delete(chatId) },
|
|
396
|
+
banTarget: (chatId: string, ms: number) => { perChatBan.set(chatId, clock.now() + ms) },
|
|
397
|
+
hangTarget: (chatId: string) => {
|
|
398
|
+
let release!: () => void
|
|
399
|
+
gates.set(chatId, new Promise<void>(res => { release = res }))
|
|
400
|
+
gateReleases.set(chatId, release)
|
|
401
|
+
},
|
|
402
|
+
releaseTarget: (chatId: string) => {
|
|
403
|
+
gateReleases.get(chatId)?.()
|
|
404
|
+
gates.delete(chatId)
|
|
405
|
+
gateReleases.delete(chatId)
|
|
406
|
+
},
|
|
407
|
+
breakSync: (requestId: string) => { syncFaults.add(requestId) },
|
|
408
|
+
floodRemainingMs: () => probe(),
|
|
409
|
+
postPermissionCard,
|
|
410
|
+
raise,
|
|
411
|
+
tick,
|
|
412
|
+
settle: async () => { await Promise.allSettled(inFlightSends.splice(0)) },
|
|
413
|
+
tickSettled: async () => {
|
|
414
|
+
await tick()
|
|
415
|
+
await Promise.allSettled(inFlightSends.splice(0))
|
|
416
|
+
},
|
|
417
|
+
isInFlight: (requestId: string) => heldInFlight.has(requestId),
|
|
418
|
+
flush: async () => {
|
|
419
|
+
// Generous microtask drain — retryApiCall's give-up path hops several
|
|
420
|
+
// awaits (3 attempts x noop sleep) before its catch finally runs.
|
|
421
|
+
for (let i = 0; i < 50; i++) await Promise.resolve()
|
|
422
|
+
},
|
|
423
|
+
tap,
|
|
424
|
+
}
|
|
425
|
+
}
|