switchroom 0.18.8 → 0.18.10
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/agent-scheduler/index.js +1 -0
- package/dist/auth-broker/index.js +198 -13
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/switchroom.js +28 -4
- package/dist/host-control/main.js +3 -2
- package/dist/vault/approvals/kernel-server.js +2 -1
- package/dist/vault/broker/server.js +2 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +119 -37
- package/profiles/_shared/dev-protocol.md.hbs +42 -0
- package/skills/dev-protocol/SKILL.md +131 -0
- package/telegram-plugin/README.md +2 -1
- package/telegram-plugin/admin-commands/dispatch.test.ts +40 -2
- package/telegram-plugin/admin-commands/index.ts +6 -1
- package/telegram-plugin/bridge/bridge.ts +23 -1
- package/telegram-plugin/bridge/crash-breadcrumb.ts +42 -0
- package/telegram-plugin/chat-lock.ts +13 -0
- package/telegram-plugin/dist/bridge/bridge.js +24 -1
- package/telegram-plugin/dist/gateway/gateway.js +80236 -77465
- package/telegram-plugin/dist/server.js +29 -2
- package/telegram-plugin/fallback-card-collapse.ts +131 -0
- package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
- package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
- package/telegram-plugin/gateway/bridge-dead-watchdog.ts +546 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
- package/telegram-plugin/gateway/effort-command.ts +47 -3
- package/telegram-plugin/gateway/gateway.ts +2051 -3180
- 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/model-command.ts +94 -8
- package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
- package/telegram-plugin/gateway/pending-session-command.ts +365 -0
- package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -0
- package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
- package/telegram-plugin/gateway/resume-inbound-builder.ts +23 -3
- package/telegram-plugin/gateway/session-model-file.ts +166 -23
- package/telegram-plugin/gateway/stop-command.ts +56 -0
- package/telegram-plugin/photo-precheck.ts +201 -0
- package/telegram-plugin/quota-watch.ts +141 -2
- package/telegram-plugin/registry/subagents-schema.ts +26 -3
- package/telegram-plugin/registry/subagents.test.ts +67 -0
- package/telegram-plugin/retry-api-call.ts +31 -0
- package/telegram-plugin/subagent-watcher.ts +392 -1
- package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
- package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +576 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +11 -5
- package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
- package/telegram-plugin/tests/chat-lock-unhandled-rejection.test.ts +101 -0
- package/telegram-plugin/tests/crash-breadcrumb.test.ts +57 -0
- package/telegram-plugin/tests/effort-command.test.ts +59 -2
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
- package/telegram-plugin/tests/fallback-card-collapse.test.ts +104 -0
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
- package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +124 -0
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +7 -1
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +19 -11
- 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-propose-callback-gate.test.ts +8 -1
- package/telegram-plugin/tests/model-command.test.ts +46 -3
- 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-session-command.test.ts +322 -0
- package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +26 -0
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +16 -0
- package/telegram-plugin/tests/photo-dimension-fallback.test.ts +129 -0
- package/telegram-plugin/tests/photo-precheck.test.ts +240 -0
- package/telegram-plugin/tests/photo-reroute-wiring.test.ts +85 -0
- package/telegram-plugin/tests/quota-watch.test.ts +225 -0
- package/telegram-plugin/tests/session-model-file.test.ts +101 -2
- package/telegram-plugin/tests/stop-command.test.ts +234 -0
- package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +27 -9
- package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +398 -0
- package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +172 -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/worker-activity-feed.test.ts +37 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +18 -4
- package/telegram-plugin/welcome-text.ts +4 -3
- package/telegram-plugin/worker-activity-feed.ts +27 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract pins for the mid-turn ack-queue-apply-confirm slots (#3017/#3018)
|
|
3
|
+
* that back `/model` + `/effort` when the agent is busy.
|
|
4
|
+
*
|
|
5
|
+
* The gateway wiring (busy-gate enqueue, idle-gate drain, reaper drain-cap,
|
|
6
|
+
* shutdown resolution) is exercised end-to-end by the UAT harness and pinned
|
|
7
|
+
* structurally in gateway-pending-command-wiring.test.ts; these lock the PURE
|
|
8
|
+
* contract the gateway relies on:
|
|
9
|
+
*
|
|
10
|
+
* - one slot PER KIND (model, effort): same-kind last-write-wins (a rapid
|
|
11
|
+
* re-issue queues only the latest and REPORTS the displaced one so it's
|
|
12
|
+
* never silently lost); cross-kind requests COEXIST (#3018 finding 2)
|
|
13
|
+
* - take(kind)/takeAll() are the atomic drain reads (empty the slots)
|
|
14
|
+
* - size mirrors the `pendingRestarts.size` gate shape (0..2)
|
|
15
|
+
* - the reaper's drainCapDecision DEFERS while a turn is in flight — the
|
|
16
|
+
* 60s cap must never force a mid-turn drain (#3018 finding 1)
|
|
17
|
+
* - shutdownResolutionActions empties the slots and yields, per queued
|
|
18
|
+
* command, the durable persist the gateway should perform so the choice
|
|
19
|
+
* still applies across the bounce (#3039) — with a re-issue fallback only
|
|
20
|
+
* for the unresolvable menu-tag case
|
|
21
|
+
* - the ack / superseded / restart-superseded text names the target so the
|
|
22
|
+
* operator always sees their choice was captured, replaced, or deferred —
|
|
23
|
+
* never dropped.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { describe, it, expect } from 'vitest'
|
|
27
|
+
import {
|
|
28
|
+
createPendingSessionCommandSlots,
|
|
29
|
+
drainCapDecision,
|
|
30
|
+
shutdownResolutionActions,
|
|
31
|
+
resolveForRestart,
|
|
32
|
+
drainTakenCommands,
|
|
33
|
+
type DrainIo,
|
|
34
|
+
ackText,
|
|
35
|
+
supersededText,
|
|
36
|
+
restartSupersededText,
|
|
37
|
+
type PendingSessionCommand,
|
|
38
|
+
} from '../gateway/pending-session-command.js'
|
|
39
|
+
|
|
40
|
+
const ident = (s: string) => s
|
|
41
|
+
|
|
42
|
+
function cmd(overrides: Partial<PendingSessionCommand> = {}): PendingSessionCommand {
|
|
43
|
+
return {
|
|
44
|
+
kind: 'model',
|
|
45
|
+
origin: 'typed',
|
|
46
|
+
arg: 'fable',
|
|
47
|
+
targetLabel: 'fable',
|
|
48
|
+
chatId: '100',
|
|
49
|
+
threadId: undefined,
|
|
50
|
+
ackChatId: '100',
|
|
51
|
+
ackMessageId: 42,
|
|
52
|
+
requestedAt: 1_000,
|
|
53
|
+
...overrides,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('pending-session-command slots', () => {
|
|
58
|
+
it('start empty (size 0, get null, take null, takeAll empty)', () => {
|
|
59
|
+
const slots = createPendingSessionCommandSlots()
|
|
60
|
+
expect(slots.size).toBe(0)
|
|
61
|
+
expect(slots.get('model')).toBeNull()
|
|
62
|
+
expect(slots.get('effort')).toBeNull()
|
|
63
|
+
expect(slots.take('model')).toBeNull()
|
|
64
|
+
expect(slots.takeAll()).toEqual([])
|
|
65
|
+
expect(slots.list()).toEqual([])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('set on an empty slot displaces nothing and becomes gettable', () => {
|
|
69
|
+
const slots = createPendingSessionCommandSlots()
|
|
70
|
+
const a = cmd({ arg: 'opus', targetLabel: 'opus' })
|
|
71
|
+
expect(slots.set(a)).toBeNull()
|
|
72
|
+
expect(slots.size).toBe(1)
|
|
73
|
+
expect(slots.get('model')).toBe(a)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('SAME-KIND last-write-wins: a second set returns the displaced command', () => {
|
|
77
|
+
const slots = createPendingSessionCommandSlots()
|
|
78
|
+
const first = cmd({ arg: 'fable', targetLabel: 'fable', ackMessageId: 1 })
|
|
79
|
+
const second = cmd({ arg: 'opus', targetLabel: 'opus', ackMessageId: 2 })
|
|
80
|
+
expect(slots.set(first)).toBeNull()
|
|
81
|
+
const displaced = slots.set(second)
|
|
82
|
+
expect(displaced).toBe(first) // caller edits THIS ack card to "superseded"
|
|
83
|
+
expect(slots.get('model')).toBe(second) // only the latest is queued
|
|
84
|
+
expect(slots.size).toBe(1)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('CROSS-KIND commands coexist: /effort never displaces a queued /model (#3018 finding 2)', () => {
|
|
88
|
+
const slots = createPendingSessionCommandSlots()
|
|
89
|
+
const model = cmd({ kind: 'model', arg: 'fable', targetLabel: 'fable', ackMessageId: 1 })
|
|
90
|
+
const effort = cmd({ kind: 'effort', arg: 'high', targetLabel: 'high', ackMessageId: 2 })
|
|
91
|
+
expect(slots.set(model)).toBeNull()
|
|
92
|
+
expect(slots.set(effort)).toBeNull() // NOT a displacement
|
|
93
|
+
expect(slots.size).toBe(2)
|
|
94
|
+
expect(slots.get('model')).toBe(model)
|
|
95
|
+
expect(slots.get('effort')).toBe(effort)
|
|
96
|
+
// …and vice versa: a later model re-issue displaces only the model slot.
|
|
97
|
+
const model2 = cmd({ kind: 'model', arg: 'opus', targetLabel: 'opus', ackMessageId: 3 })
|
|
98
|
+
expect(slots.set(model2)).toBe(model)
|
|
99
|
+
expect(slots.get('effort')).toBe(effort)
|
|
100
|
+
expect(slots.size).toBe(2)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('take(kind) atomically empties only that slot (the per-kind drain read)', () => {
|
|
104
|
+
const slots = createPendingSessionCommandSlots()
|
|
105
|
+
const model = cmd({ kind: 'model' })
|
|
106
|
+
const effort = cmd({ kind: 'effort', arg: 'high', targetLabel: 'high' })
|
|
107
|
+
slots.set(model)
|
|
108
|
+
slots.set(effort)
|
|
109
|
+
expect(slots.take('model')).toBe(model)
|
|
110
|
+
expect(slots.size).toBe(1)
|
|
111
|
+
expect(slots.get('model')).toBeNull()
|
|
112
|
+
expect(slots.get('effort')).toBe(effort)
|
|
113
|
+
expect(slots.take('model')).toBeNull() // second drain is a no-op
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('takeAll() empties everything in enqueue order; a same-kind re-enqueue moves to the back', () => {
|
|
117
|
+
const slots = createPendingSessionCommandSlots()
|
|
118
|
+
const model1 = cmd({ kind: 'model', targetLabel: 'fable', ackMessageId: 1 })
|
|
119
|
+
const effort = cmd({ kind: 'effort', arg: 'high', targetLabel: 'high', ackMessageId: 2 })
|
|
120
|
+
const model2 = cmd({ kind: 'model', targetLabel: 'opus', ackMessageId: 3 })
|
|
121
|
+
slots.set(model1)
|
|
122
|
+
slots.set(effort)
|
|
123
|
+
slots.set(model2) // displaces model1 AND moves the model slot behind effort
|
|
124
|
+
const all = slots.takeAll()
|
|
125
|
+
expect(all).toEqual([effort, model2])
|
|
126
|
+
expect(slots.size).toBe(0)
|
|
127
|
+
expect(slots.takeAll()).toEqual([])
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('clear() drops every queued command without returning them', () => {
|
|
131
|
+
const slots = createPendingSessionCommandSlots()
|
|
132
|
+
slots.set(cmd({ kind: 'model' }))
|
|
133
|
+
slots.set(cmd({ kind: 'effort', arg: 'high', targetLabel: 'high' }))
|
|
134
|
+
slots.clear()
|
|
135
|
+
expect(slots.size).toBe(0)
|
|
136
|
+
expect(slots.get('model')).toBeNull()
|
|
137
|
+
expect(slots.get('effort')).toBeNull()
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe('drainCapDecision (#3018 finding 1: the cap must never force mid-turn)', () => {
|
|
142
|
+
const CAP = 60_000
|
|
143
|
+
|
|
144
|
+
it('waits when nothing is queued', () => {
|
|
145
|
+
expect(drainCapDecision([], 1_000_000, CAP, false)).toBe('wait')
|
|
146
|
+
expect(drainCapDecision([], 1_000_000, CAP, true)).toBe('wait')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('waits while the queued command is younger than the cap', () => {
|
|
150
|
+
const q = [cmd({ requestedAt: 100_000 })]
|
|
151
|
+
expect(drainCapDecision(q, 100_000 + CAP, CAP, false)).toBe('wait')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('forces once overdue AND the session is idle (missed idle gate)', () => {
|
|
155
|
+
const q = [cmd({ requestedAt: 100_000 })]
|
|
156
|
+
expect(drainCapDecision(q, 100_000 + CAP + 1, CAP, false)).toBe('force')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('DEFERS (keeps waiting) when overdue but a turn is in flight — a forced mid-turn drain would destroy the queued command', () => {
|
|
160
|
+
const q = [cmd({ requestedAt: 100_000 })]
|
|
161
|
+
expect(drainCapDecision(q, 100_000 + CAP + 1, CAP, true)).toBe('defer-turn-in-flight')
|
|
162
|
+
// …even hours overdue: the idle gate at turn end is the drain point.
|
|
163
|
+
expect(drainCapDecision(q, 100_000 + 100 * CAP, CAP, true)).toBe('defer-turn-in-flight')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('any one overdue command trips the cap (mixed ages)', () => {
|
|
167
|
+
const q = [
|
|
168
|
+
cmd({ kind: 'model', requestedAt: 100_000 }),
|
|
169
|
+
cmd({ kind: 'effort', requestedAt: 100_000 + CAP }),
|
|
170
|
+
]
|
|
171
|
+
expect(drainCapDecision(q, 100_000 + CAP + 1, CAP, false)).toBe('force')
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe('shutdownResolutionActions (#3018 finding 3 + #3039: SIGTERM persists the queued choice)', () => {
|
|
176
|
+
it('empties the slots and yields a persist action per queued command', () => {
|
|
177
|
+
const slots = createPendingSessionCommandSlots()
|
|
178
|
+
slots.set(cmd({ kind: 'model', targetLabel: 'fable', arg: 'fable', ackChatId: '100', ackMessageId: 7 }))
|
|
179
|
+
slots.set(cmd({ kind: 'effort', targetLabel: 'high', arg: 'high', ackChatId: '100', ackMessageId: 9 }))
|
|
180
|
+
const actions = shutdownResolutionActions(slots, ident)
|
|
181
|
+
expect(actions).toHaveLength(2)
|
|
182
|
+
expect(slots.size).toBe(0) // the queue is RESOLVED, not left behind
|
|
183
|
+
const model = actions.find(a => a.cmd.ackMessageId === 7)!
|
|
184
|
+
expect(model.persist).toBe('model')
|
|
185
|
+
expect(model.arg).toBe('fable')
|
|
186
|
+
expect(model.persistedText).toContain('`fable`')
|
|
187
|
+
expect(model.persistedText).toContain('saved')
|
|
188
|
+
// The fallback (persist failed) still names the choice + how to recover.
|
|
189
|
+
expect(model.reissueText).toContain('re-issue')
|
|
190
|
+
const effort = actions.find(a => a.cmd.ackMessageId === 9)!
|
|
191
|
+
expect(effort.persist).toBe('effort')
|
|
192
|
+
expect(effort.arg).toBe('high')
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('typed /model default → clear-model; typed /effort default → clear-effort', () => {
|
|
196
|
+
expect(resolveForRestart(cmd({ kind: 'model', arg: 'default', targetLabel: 'default' }), ident).persist).toBe('clear-model')
|
|
197
|
+
expect(resolveForRestart(cmd({ kind: 'effort', arg: 'default', targetLabel: 'default' }), ident).persist).toBe('clear-effort')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('menu model selection (mdl:s:<tag>) is not offline-resolvable → persist null, re-issue fallback', () => {
|
|
201
|
+
const a = resolveForRestart(cmd({ kind: 'model', origin: 'menu', arg: 'mdl:s:abcd1234', targetLabel: 'the selected model' }), ident)
|
|
202
|
+
expect(a.persist).toBeNull()
|
|
203
|
+
expect(a.reissueText).toContain('re-issue')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('menu effort tap (eff:s:<level>) IS resolvable → persist effort with the level', () => {
|
|
207
|
+
const a = resolveForRestart(cmd({ kind: 'effort', origin: 'menu', arg: 'eff:s:xhigh', targetLabel: 'xhigh' }), ident)
|
|
208
|
+
expect(a.persist).toBe('effort')
|
|
209
|
+
expect(a.arg).toBe('xhigh')
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('returns no actions when nothing is queued', () => {
|
|
213
|
+
expect(shutdownResolutionActions(createPendingSessionCommandSlots(), ident)).toEqual([])
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
describe('ackText', () => {
|
|
218
|
+
it('names the model target with a mid-turn framing', () => {
|
|
219
|
+
const t = ackText('model', 'fable', ident)
|
|
220
|
+
expect(t).toContain('mid-turn')
|
|
221
|
+
expect(t).toContain('`fable`')
|
|
222
|
+
expect(t).toContain('switch to')
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('uses the effort verb for effort commands', () => {
|
|
226
|
+
const t = ackText('effort', 'high', ident)
|
|
227
|
+
expect(t).toContain('set effort to')
|
|
228
|
+
expect(t).toContain('`high`')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('routes the label through escapeHtml', () => {
|
|
232
|
+
const seen: string[] = []
|
|
233
|
+
ackText('model', 'sr-glm-5', (s) => { seen.push(s); return s })
|
|
234
|
+
expect(seen).toContain('sr-glm-5')
|
|
235
|
+
})
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
describe('supersededText', () => {
|
|
239
|
+
it('names BOTH the replaced and the new target', () => {
|
|
240
|
+
const displaced = cmd({ kind: 'model', targetLabel: 'fable' })
|
|
241
|
+
const next = cmd({ kind: 'model', targetLabel: 'opus' })
|
|
242
|
+
const t = supersededText(displaced, next, ident)
|
|
243
|
+
expect(t).toContain('`opus`')
|
|
244
|
+
expect(t).toContain('`fable`')
|
|
245
|
+
expect(t).toContain('replaced')
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
describe('restartSupersededText', () => {
|
|
250
|
+
it('tells the operator to re-issue after the restart', () => {
|
|
251
|
+
const t = restartSupersededText(cmd({ kind: 'effort', targetLabel: 'max' }), ident)
|
|
252
|
+
expect(t).toContain('restarting')
|
|
253
|
+
expect(t).toContain('/effort max')
|
|
254
|
+
expect(t).toContain('`max`')
|
|
255
|
+
})
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
// ─── #3042 blocker 1: drain loss-safety across a two-command batch ───────────
|
|
260
|
+
|
|
261
|
+
describe('drainTakenCommands (#3042 blocker 1: an early stop must not drop the rest of the batch)', () => {
|
|
262
|
+
function makeIo(overrides: Partial<DrainIo> = {}) {
|
|
263
|
+
const events: string[] = []
|
|
264
|
+
const io: DrainIo = {
|
|
265
|
+
restartPending: () => false,
|
|
266
|
+
turnInFlight: () => false,
|
|
267
|
+
apply: async c => { events.push(`apply:${c.kind}`); return `✅ ${c.kind} done` },
|
|
268
|
+
isBusyRefusal: t => t.includes('BUSY'),
|
|
269
|
+
resolveForRestartText: c => `persisted:${c.kind}`,
|
|
270
|
+
editCard: async (c, text) => { events.push(`edit:${c.kind}:${text}`) },
|
|
271
|
+
reEnqueue: c => { events.push(`requeue:${c.kind}`) },
|
|
272
|
+
failureText: (c, err) => `fail:${c.kind}:${(err as Error).message}`,
|
|
273
|
+
...overrides,
|
|
274
|
+
}
|
|
275
|
+
return { io, events }
|
|
276
|
+
}
|
|
277
|
+
const batch = () => [cmd({ kind: 'model' }), cmd({ kind: 'effort', arg: 'high', targetLabel: 'high' })]
|
|
278
|
+
|
|
279
|
+
it('happy path applies and confirms both commands in order', async () => {
|
|
280
|
+
const { io, events } = makeIo()
|
|
281
|
+
await drainTakenCommands(batch(), io)
|
|
282
|
+
expect(events).toEqual(['apply:model', 'edit:model:✅ model done', 'apply:effort', 'edit:effort:✅ effort done'])
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('turn races in before the FIRST command → BOTH are re-enqueued, none applied or confirmed', async () => {
|
|
286
|
+
const { io, events } = makeIo({ turnInFlight: () => true })
|
|
287
|
+
await drainTakenCommands(batch(), io)
|
|
288
|
+
expect(events).toEqual(['requeue:model', 'requeue:effort'])
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it('turn races in before the SECOND command → first applied, second re-enqueued (not lost)', async () => {
|
|
292
|
+
let calls = 0
|
|
293
|
+
const { io, events } = makeIo({ turnInFlight: () => ++calls > 1 })
|
|
294
|
+
await drainTakenCommands(batch(), io)
|
|
295
|
+
expect(events).toEqual(['apply:model', 'edit:model:✅ model done', 'requeue:effort'])
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('handler busy-refusal on the FIRST command → the refusal never reaches the ack card and BOTH are re-enqueued', async () => {
|
|
299
|
+
const { io, events } = makeIo({
|
|
300
|
+
apply: async c => (c.kind === 'model' ? 'BUSY — mid-turn' : '✅ effort done'),
|
|
301
|
+
})
|
|
302
|
+
await drainTakenCommands(batch(), io)
|
|
303
|
+
expect(events).toEqual(['requeue:model', 'requeue:effort'])
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('apply throwing on the first command does NOT stop the second (failure is per-command)', async () => {
|
|
307
|
+
const { io, events } = makeIo({
|
|
308
|
+
apply: async c => {
|
|
309
|
+
if (c.kind === 'model') throw new Error('boom')
|
|
310
|
+
return '✅ effort done'
|
|
311
|
+
},
|
|
312
|
+
})
|
|
313
|
+
await drainTakenCommands(batch(), io)
|
|
314
|
+
expect(events).toEqual(['edit:model:fail:model:boom', 'edit:effort:✅ effort done'])
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('restart pending → every command is resolved via the carriers, none applied live', async () => {
|
|
318
|
+
const { io, events } = makeIo({ restartPending: () => true })
|
|
319
|
+
await drainTakenCommands(batch(), io)
|
|
320
|
+
expect(events).toEqual(['edit:model:persisted:model', 'edit:effort:persisted:effort'])
|
|
321
|
+
})
|
|
322
|
+
})
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract + race/ordering pins for the long-tail pending-state stores
|
|
3
|
+
* (gateway/pending-state-stores.ts), extracted in #2996 Phase 3 step 2.
|
|
4
|
+
*
|
|
5
|
+
* Written BEFORE the long-tail Maps moved behind the store module. They lock:
|
|
6
|
+
*
|
|
7
|
+
* 1. Each family's EXACT `isExpired` comparison direction survives verbatim —
|
|
8
|
+
* TTL directions differ across families (`now - staged_at > TTL`,
|
|
9
|
+
* `now - startedAt > TTL`, `now - createdAt > TTL`, `now - armed_at > TTL`,
|
|
10
|
+
* and the absolute `now > expiresAt`), and a store must delete exactly the
|
|
11
|
+
* entries the old open-coded loop deleted, not one tick early or late.
|
|
12
|
+
* 2. `sweep` is delete-during-iteration safe (JS Map iterators tolerate
|
|
13
|
+
* deleting the current key — the raw reaper loops relied on this).
|
|
14
|
+
* 3. The gateway-visible EAGER-SWEEP-AT-ADD pattern (the sweep called right
|
|
15
|
+
* after a `.set` at a staging site, e.g. `sweepSecretRequests()` after the
|
|
16
|
+
* `pendingSecretRequests.set` in executeRequestSecret) is single-shot: an
|
|
17
|
+
* add followed by an eager sweep expires only entries already past TTL and
|
|
18
|
+
* leaves the just-added fresh entry intact.
|
|
19
|
+
* 4. Map-surface parity for every operation the call sites use, including the
|
|
20
|
+
* LRU eviction pattern `agentButtonMeta` uses (`keys().next().value`) and
|
|
21
|
+
* the live `size` getter.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { describe, it, expect } from 'vitest'
|
|
25
|
+
import {
|
|
26
|
+
createSweepableStore,
|
|
27
|
+
createPlainStore,
|
|
28
|
+
} from '../gateway/pending-state-stores.js'
|
|
29
|
+
|
|
30
|
+
// TTL constants mirror the gateway family values so the direction pins are
|
|
31
|
+
// meaningful (values themselves are not the contract — the direction is).
|
|
32
|
+
const VAULT_INPUT_TTL_MS = 5 * 60 * 1000
|
|
33
|
+
const VAULT_PASSPHRASE_TTL_MS = 30 * 60 * 1000
|
|
34
|
+
const DEFERRED_SECRET_TTL_MS = 5 * 60 * 1000
|
|
35
|
+
const ARMED_SECRET_CAPTURE_TTL_MS = 10 * 60_000
|
|
36
|
+
const ALWAYS_ALLOW_CORRELATION_TTL_MS = 30_000
|
|
37
|
+
const MENTAL_MODEL_CORRELATION_TTL_MS = 720_000
|
|
38
|
+
const REAUTH_INTERCEPT_TTL_MS = 10 * 60_000
|
|
39
|
+
|
|
40
|
+
describe('pending-state-stores: per-family isExpired direction (byte-identical)', () => {
|
|
41
|
+
it('pendingVaultOps — now - startedAt > VAULT_INPUT_TTL_MS', () => {
|
|
42
|
+
const store = createSweepableStore<{ startedAt: number }>(
|
|
43
|
+
(v, now) => now - v.startedAt > VAULT_INPUT_TTL_MS,
|
|
44
|
+
)
|
|
45
|
+
const now = 10_000_000
|
|
46
|
+
store.set('fresh', { startedAt: now - VAULT_INPUT_TTL_MS }) // exactly TTL old: NOT expired (> is strict)
|
|
47
|
+
store.set('stale', { startedAt: now - VAULT_INPUT_TTL_MS - 1 })
|
|
48
|
+
store.sweep(now)
|
|
49
|
+
expect(store.has('fresh')).toBe(true)
|
|
50
|
+
expect(store.has('stale')).toBe(false)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('vaultPassphraseCache — absolute now > expiresAt', () => {
|
|
54
|
+
const store = createSweepableStore<{ expiresAt: number }>(
|
|
55
|
+
(v, now) => now > v.expiresAt,
|
|
56
|
+
)
|
|
57
|
+
const now = 10_000_000
|
|
58
|
+
store.set('atExpiry', { expiresAt: now }) // now > now is false → NOT expired
|
|
59
|
+
store.set('past', { expiresAt: now - 1 })
|
|
60
|
+
store.sweep(now)
|
|
61
|
+
expect(store.has('atExpiry')).toBe(true)
|
|
62
|
+
expect(store.has('past')).toBe(false)
|
|
63
|
+
void VAULT_PASSPHRASE_TTL_MS
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('deferredSecrets — now - staged_at > DEFERRED_SECRET_TTL_MS', () => {
|
|
67
|
+
const store = createSweepableStore<{ staged_at: number }>(
|
|
68
|
+
(v, now) => now - v.staged_at > DEFERRED_SECRET_TTL_MS,
|
|
69
|
+
)
|
|
70
|
+
const now = 10_000_000
|
|
71
|
+
store.set('fresh', { staged_at: now - DEFERRED_SECRET_TTL_MS })
|
|
72
|
+
store.set('stale', { staged_at: now - DEFERRED_SECRET_TTL_MS - 1 })
|
|
73
|
+
store.sweep(now)
|
|
74
|
+
expect(store.has('fresh')).toBe(true)
|
|
75
|
+
expect(store.has('stale')).toBe(false)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('armedSecretCaptures — now - armed_at > ARMED_SECRET_CAPTURE_TTL_MS', () => {
|
|
79
|
+
const store = createSweepableStore<{ armed_at: number }>(
|
|
80
|
+
(v, now) => now - v.armed_at > ARMED_SECRET_CAPTURE_TTL_MS,
|
|
81
|
+
)
|
|
82
|
+
const now = 10_000_000
|
|
83
|
+
store.set('fresh', { armed_at: now - ARMED_SECRET_CAPTURE_TTL_MS })
|
|
84
|
+
store.set('stale', { armed_at: now - ARMED_SECRET_CAPTURE_TTL_MS - 1 })
|
|
85
|
+
store.sweep(now)
|
|
86
|
+
expect(store.has('fresh')).toBe(true)
|
|
87
|
+
expect(store.has('stale')).toBe(false)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('pendingAlwaysAllowCorrelations — 30s now - createdAt > TTL', () => {
|
|
91
|
+
const store = createSweepableStore<{ createdAt: number }>(
|
|
92
|
+
(v, now) => now - v.createdAt > ALWAYS_ALLOW_CORRELATION_TTL_MS,
|
|
93
|
+
)
|
|
94
|
+
const now = 10_000_000
|
|
95
|
+
store.set('fresh', { createdAt: now - ALWAYS_ALLOW_CORRELATION_TTL_MS })
|
|
96
|
+
store.set('stale', { createdAt: now - ALWAYS_ALLOW_CORRELATION_TTL_MS - 1 })
|
|
97
|
+
store.sweep(now)
|
|
98
|
+
expect(store.has('fresh')).toBe(true)
|
|
99
|
+
expect(store.has('stale')).toBe(false)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('pendingMentalModelCorrelations — 720s now - createdAt > TTL (outlives the 30s window)', () => {
|
|
103
|
+
const store = createSweepableStore<{ createdAt: number }>(
|
|
104
|
+
(v, now) => now - v.createdAt > MENTAL_MODEL_CORRELATION_TTL_MS,
|
|
105
|
+
)
|
|
106
|
+
const now = 10_000_000
|
|
107
|
+
// A slow-but-valid tap 10 min in must NOT be swept (the whole point of the
|
|
108
|
+
// dedicated 720s TTL vs the 30s always-allow window).
|
|
109
|
+
store.set('slowTap', { createdAt: now - 10 * 60_000 })
|
|
110
|
+
store.set('stale', { createdAt: now - MENTAL_MODEL_CORRELATION_TTL_MS - 1 })
|
|
111
|
+
store.sweep(now)
|
|
112
|
+
expect(store.has('slowTap')).toBe(true)
|
|
113
|
+
expect(store.has('stale')).toBe(false)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('pendingReauthFlows — now - startedAt > REAUTH_INTERCEPT_TTL_MS', () => {
|
|
117
|
+
const store = createSweepableStore<{ startedAt: number }>(
|
|
118
|
+
(v, now) => now - v.startedAt > REAUTH_INTERCEPT_TTL_MS,
|
|
119
|
+
)
|
|
120
|
+
const now = 10_000_000
|
|
121
|
+
store.set('fresh', { startedAt: now - REAUTH_INTERCEPT_TTL_MS })
|
|
122
|
+
store.set('stale', { startedAt: now - REAUTH_INTERCEPT_TTL_MS - 1 })
|
|
123
|
+
store.sweep(now)
|
|
124
|
+
expect(store.has('fresh')).toBe(true)
|
|
125
|
+
expect(store.has('stale')).toBe(false)
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('pending-state-stores: sweep safety', () => {
|
|
130
|
+
it('deletes every past-TTL entry across a multi-entry map (delete-during-iteration safe)', () => {
|
|
131
|
+
const store = createSweepableStore<{ staged_at: number }>(
|
|
132
|
+
(v, now) => now - v.staged_at > 1000,
|
|
133
|
+
)
|
|
134
|
+
for (let i = 0; i < 10; i++) store.set(`e${i}`, { staged_at: i % 2 === 0 ? 0 : 9_999_999 })
|
|
135
|
+
store.sweep(1_000_000) // even entries stale, odd entries fresh
|
|
136
|
+
for (let i = 0; i < 10; i++) {
|
|
137
|
+
expect(store.has(`e${i}`)).toBe(i % 2 === 1)
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('is single-shot across two sweep ticks', () => {
|
|
142
|
+
const store = createSweepableStore<{ staged_at: number }>(
|
|
143
|
+
(v, now) => now - v.staged_at > 1000,
|
|
144
|
+
)
|
|
145
|
+
store.set('one', { staged_at: 0 })
|
|
146
|
+
store.sweep(1_000_000)
|
|
147
|
+
expect(store.size).toBe(0)
|
|
148
|
+
expect(() => store.sweep(1_000_000)).not.toThrow() // second tick — nothing left
|
|
149
|
+
expect(store.size).toBe(0)
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
describe('pending-state-stores: eager-sweep-at-add pattern (gateway-visible)', () => {
|
|
154
|
+
// Mirrors executeRequestSecret: dedupe-drop prior stage for the same target,
|
|
155
|
+
// .set the new one, then eagerly sweep. The just-added fresh entry survives;
|
|
156
|
+
// a stale sibling is reaped in the same eager pass.
|
|
157
|
+
it('add + eager sweep expires only stale siblings, keeps the fresh add', () => {
|
|
158
|
+
const TTL = 30 * 60_000
|
|
159
|
+
const store = createSweepableStore<{ chat_id: string; key: string; staged_at: number }>(
|
|
160
|
+
(v, now) => now - v.staged_at > TTL,
|
|
161
|
+
)
|
|
162
|
+
const now = 100_000_000
|
|
163
|
+
store.set('old', { chat_id: 'c', key: 'k1', staged_at: now - TTL - 1 }) // stale
|
|
164
|
+
// Dedupe pass (same target) would delete a live dup; here targets differ.
|
|
165
|
+
const fresh = { chat_id: 'c', key: 'k2', staged_at: now }
|
|
166
|
+
store.set('new', fresh)
|
|
167
|
+
store.sweep(now) // eager sweep at the add site
|
|
168
|
+
expect(store.has('new')).toBe(true) // fresh add never self-expires
|
|
169
|
+
expect(store.get('new')).toBe(fresh)
|
|
170
|
+
expect(store.has('old')).toBe(false) // stale sibling reaped in the eager pass
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('dedupe-then-add-then-sweep leaves exactly the new entry', () => {
|
|
174
|
+
const TTL = 30 * 60_000
|
|
175
|
+
const store = createSweepableStore<{ chat_id: string; key: string; staged_at: number }>(
|
|
176
|
+
(v, now) => now - v.staged_at > TTL,
|
|
177
|
+
)
|
|
178
|
+
const now = 100_000_000
|
|
179
|
+
store.set('dup', { chat_id: 'c', key: 'k', staged_at: now - 1000 })
|
|
180
|
+
// dedupe: drop prior stage for the same (chat, key)
|
|
181
|
+
for (const [sid, p] of store) {
|
|
182
|
+
if (p.chat_id === 'c' && p.key === 'k') store.delete(sid)
|
|
183
|
+
}
|
|
184
|
+
store.set('fresh', { chat_id: 'c', key: 'k', staged_at: now })
|
|
185
|
+
store.sweep(now)
|
|
186
|
+
expect([...store.keys()]).toEqual(['fresh'])
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
describe('pending-state-stores: plain store (no sweep)', () => {
|
|
191
|
+
it('has no sweep method and preserves Map surface', () => {
|
|
192
|
+
const store = createPlainStore<{ n: number }>()
|
|
193
|
+
expect((store as { sweep?: unknown }).sweep).toBeUndefined()
|
|
194
|
+
store.set('a', { n: 1 })
|
|
195
|
+
store.set('b', { n: 2 })
|
|
196
|
+
expect(store.size).toBe(2)
|
|
197
|
+
expect(store.get('a')?.n).toBe(1)
|
|
198
|
+
expect(store.has('b')).toBe(true)
|
|
199
|
+
expect(store.delete('a')).toBe(true)
|
|
200
|
+
expect(store.has('a')).toBe(false)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('agentButtonMeta LRU eviction — keys().next().value is the oldest insertion', () => {
|
|
204
|
+
const AGENT_BUTTON_META_MAX = 3
|
|
205
|
+
const store = createPlainStore<Map<string, { ack: string }>>()
|
|
206
|
+
const remember = (key: string) => {
|
|
207
|
+
store.set(key, new Map([['cb', { ack: key }]]))
|
|
208
|
+
while (store.size > AGENT_BUTTON_META_MAX) {
|
|
209
|
+
const oldest = store.keys().next().value
|
|
210
|
+
if (oldest === undefined) break
|
|
211
|
+
store.delete(oldest)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
remember('m1')
|
|
215
|
+
remember('m2')
|
|
216
|
+
remember('m3')
|
|
217
|
+
remember('m4') // evicts m1 (oldest insertion)
|
|
218
|
+
expect(store.has('m1')).toBe(false)
|
|
219
|
+
expect([...store.keys()]).toEqual(['m2', 'm3', 'm4'])
|
|
220
|
+
expect(store.get('m4')?.get('cb')?.ack).toBe('m4')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('pendingAskUser surface — values()/entries()/get/delete used by call sites', () => {
|
|
224
|
+
const store = createPlainStore<{ chatId: string; messageId: number | null }>()
|
|
225
|
+
store.set('ask1', { chatId: 'c1', messageId: 10 })
|
|
226
|
+
store.set('ask2', { chatId: 'c2', messageId: null })
|
|
227
|
+
expect([...store.values()].map((v) => v.chatId).sort()).toEqual(['c1', 'c2'])
|
|
228
|
+
const collected: string[] = []
|
|
229
|
+
for (const [askId] of store.entries()) collected.push(askId)
|
|
230
|
+
expect(collected.sort()).toEqual(['ask1', 'ask2'])
|
|
231
|
+
expect(store.get('ask1')?.messageId).toBe(10)
|
|
232
|
+
store.delete('ask1')
|
|
233
|
+
expect(store.size).toBe(1)
|
|
234
|
+
})
|
|
235
|
+
})
|
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
HOSTD_PERMISSION_TTL_MS,
|
|
20
20
|
ttlForTool,
|
|
21
21
|
buildTimedOutCardEdits,
|
|
22
|
+
buildCancelledCardEdits,
|
|
23
|
+
CANCELLED_FOOTER,
|
|
22
24
|
isStaleTap,
|
|
23
25
|
STALE_TAP_NOTICE,
|
|
24
26
|
TIMED_OUT_FOOTER,
|
|
@@ -178,6 +180,30 @@ describe('buildTimedOutCardEdits (Bug 2 fix #1 — strip stale keyboard)', () =>
|
|
|
178
180
|
})
|
|
179
181
|
})
|
|
180
182
|
|
|
183
|
+
describe('buildCancelledCardEdits (#3020 — halt cancels pending cards)', () => {
|
|
184
|
+
it('marks every recorded card for keyboard-strip and appends the cancelled footer', () => {
|
|
185
|
+
const cards = [
|
|
186
|
+
{ chatId: '111', messageId: 5 },
|
|
187
|
+
{ chatId: '222', messageId: 9 },
|
|
188
|
+
]
|
|
189
|
+
const edits = buildCancelledCardEdits('🔐 **Overlord** wants to roll the fleet', cards)
|
|
190
|
+
expect(edits).toHaveLength(2)
|
|
191
|
+
for (const [i, edit] of edits.entries()) {
|
|
192
|
+
// Same contract as the timeout path: a live Approve after the halt
|
|
193
|
+
// would dispatch a verdict into an idle session.
|
|
194
|
+
expect(edit.stripKeyboard).toBe(true)
|
|
195
|
+
expect(edit.chatId).toBe(cards[i]!.chatId)
|
|
196
|
+
expect(edit.messageId).toBe(cards[i]!.messageId)
|
|
197
|
+
expect(edit.text).toContain('roll the fleet')
|
|
198
|
+
expect(edit.text.endsWith(CANCELLED_FOOTER)).toBe(true)
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('returns no edits when there were no recorded cards', () => {
|
|
203
|
+
expect(buildCancelledCardEdits('body', [])).toEqual([])
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
181
207
|
describe('isStaleTap (Bug 2 fix #3 — do not dispatch a verdict for a dead id)', () => {
|
|
182
208
|
it('is stale when no pending entry exists for the tapped request_id', () => {
|
|
183
209
|
// hasPending=false ⇒ the reaper already auto-denied + deleted it.
|
|
@@ -79,6 +79,18 @@ const isSilentNoCardVerdict = (idx: number): boolean =>
|
|
|
79
79
|
const isCardFoldedResume = (idx: number): boolean =>
|
|
80
80
|
LINES.slice(idx, idx + POST_WINDOW + 1).some((l) => /card-folded-resume/.test(l))
|
|
81
81
|
|
|
82
|
+
// A HALTED-TURN verdict (#3020 `executeHaltNow` → `cancelPendingPermissionsForHalt`)
|
|
83
|
+
// denies the pending call because the operator is KILLING the turn (/stop,
|
|
84
|
+
// bare "stop", empty `!`). There is nothing to resume — the turn is about to
|
|
85
|
+
// be C-c'd and its busy state torn down — so neither the glyph flip nor the
|
|
86
|
+
// "got it, continuing" message applies. The card is still visibly terminal:
|
|
87
|
+
// the halt path edits it to "⏹ Cancelled" with the keyboard stripped, and the
|
|
88
|
+
// /stop reply acks the operator. Such callsites carry the
|
|
89
|
+
// `halted-turn-verdict` sentinel within the 5 lines above the dispatch and
|
|
90
|
+
// are exempt from BOTH pairings.
|
|
91
|
+
const isHaltedTurnVerdict = (idx: number): boolean =>
|
|
92
|
+
LINES.slice(Math.max(0, idx - 5), idx + 1).some((l) => /halted-turn-verdict/.test(l))
|
|
93
|
+
|
|
82
94
|
// How far below the dispatch the resume call is allowed to live. The
|
|
83
95
|
// widest real gap today is ~9 lines (the slash-command path); 15 gives
|
|
84
96
|
// refactor headroom without letting an unrelated resume "cover" a
|
|
@@ -99,6 +111,8 @@ describe('permission verdict → resume reaction wiring', () => {
|
|
|
99
111
|
const unpaired: number[] = []
|
|
100
112
|
for (const idx of dispatchCallsites) {
|
|
101
113
|
if (isSilentNoCardVerdict(idx)) continue
|
|
114
|
+
// Exempt: the halt path kills the turn — nothing resumes (#3020).
|
|
115
|
+
if (isHaltedTurnVerdict(idx)) continue
|
|
102
116
|
const window = LINES.slice(idx, idx + RESUME_WINDOW + 1).join('\n')
|
|
103
117
|
if (!/\bresumeReactionAfterVerdict\s*\(\s*\)/.test(window)) {
|
|
104
118
|
// 1-based line number for a human-readable failure.
|
|
@@ -125,6 +139,8 @@ describe('permission verdict → resume reaction wiring', () => {
|
|
|
125
139
|
const unpaired: number[] = []
|
|
126
140
|
for (const idx of dispatchCallsites) {
|
|
127
141
|
if (isSilentNoCardVerdict(idx)) continue
|
|
142
|
+
// Exempt: the halt path kills the turn — nothing resumes (#3020).
|
|
143
|
+
if (isHaltedTurnVerdict(idx)) continue
|
|
128
144
|
// Exempt: card-ux fix 3 folds the resume line into the card edit
|
|
129
145
|
// instead of a separate message (still visibly resumes the operator).
|
|
130
146
|
if (isCardFoldedResume(idx)) continue
|