switchroom 0.18.26 → 0.18.28

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.
Files changed (35) hide show
  1. package/README.md +6 -2
  2. package/dist/cli/ms-365-write-pretool.mjs +4953 -14
  3. package/dist/cli/switchroom.js +1 -1
  4. package/dist/host-control/main.js +1 -1
  5. package/package.json +1 -1
  6. package/profiles/_base/start.sh.hbs +16 -0
  7. package/telegram-plugin/dist/gateway/gateway.js +571 -43
  8. package/telegram-plugin/flushed-turn-supersede.ts +58 -0
  9. package/telegram-plugin/gateway/derive-turn-id.ts +32 -0
  10. package/telegram-plugin/gateway/gateway.ts +358 -53
  11. package/telegram-plugin/gateway/handback-preturn-signal.ts +442 -0
  12. package/telegram-plugin/gateway/model-command.ts +68 -0
  13. package/telegram-plugin/gateway/ms365-write-approval.test.ts +101 -0
  14. package/telegram-plugin/gateway/ms365-write-approval.ts +65 -3
  15. package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +12 -0
  16. package/telegram-plugin/gateway/turn-active-marker.ts +35 -0
  17. package/telegram-plugin/gateway/worker-pin-reaper.ts +54 -0
  18. package/telegram-plugin/send-gate.test.ts +138 -0
  19. package/telegram-plugin/send-gate.ts +104 -1
  20. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +14 -4
  21. package/telegram-plugin/tests/effort-command.test.ts +47 -0
  22. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +60 -0
  23. package/telegram-plugin/tests/handback-preturn-adoption-roundtrip.test.ts +211 -0
  24. package/telegram-plugin/tests/handback-preturn-signal.test.ts +346 -0
  25. package/telegram-plugin/tests/model-command.test.ts +112 -0
  26. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +14 -2
  27. package/telegram-plugin/tests/outbound-send-chunks.test.ts +57 -0
  28. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +18 -11
  29. package/telegram-plugin/tests/reply-owner-resolve.test.ts +90 -0
  30. package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +5 -0
  31. package/telegram-plugin/tests/turn-active-marker.test.ts +29 -0
  32. package/telegram-plugin/tests/worker-activity-feed.test.ts +121 -0
  33. package/telegram-plugin/tests/worker-feed-migration-eviction.test.ts +140 -0
  34. package/telegram-plugin/tests/worker-pin-reaper.test.ts +78 -0
  35. package/telegram-plugin/worker-activity-feed.ts +169 -6
@@ -0,0 +1,442 @@
1
+ /**
2
+ * handback-preturn-signal.ts — close the sub-agent-handback "dead-air" gap.
3
+ *
4
+ * THE GAP. When a *background* sub-agent (worker / researcher) finishes, the
5
+ * gateway synthesises a `subagent_handback` inbound and buffers it
6
+ * (`pendingInboundBuffer`). That inbound is RELEASED for delivery only at an
7
+ * idle prompt (the buffer drain), travels bridge → claude, and eventually
8
+ * mints a fresh turn at the `enqueue` session-event. Between the RELEASE and
9
+ * the turn's first tool/narration render there is a stretch of dead air: no
10
+ * `typing…` indicator and no activity card. The user — who dispatched the work
11
+ * and is waiting — sees nothing until the turn is well underway. Worse, a
12
+ * handback turn historically never even got a turn-long typing loop
13
+ * (`startTurnTypingLoop` has a single caller on the real-inbound path), so the
14
+ * dark stretch extended past turn start.
15
+ *
16
+ * THE FIX (one continuous card lifecycle, NOT an orphan). The moment the
17
+ * buffered handback is RELEASED, emit a pre-turn signal for its topic:
18
+ *
19
+ * 1. a turn-long `typing…` loop (the same mechanism the real-inbound path
20
+ * starts), and
21
+ * 2. a "reading the worker's results…" activity CARD.
22
+ *
23
+ * When the handback's turn later mints at `enqueue`, it ADOPTS that exact card
24
+ * (its `activityMessageId` is seeded onto the new turn, `activityEverOpened`
25
+ * set) so the turn EDITS the existing card instead of opening a second one —
26
+ * one card, one lifecycle, finalized by the turn's normal end-of-turn
27
+ * `clearActivitySummary`. The typing loop keeps running across the seam (no
28
+ * zero-gap), stopped by the canonical turn-end (`purgeReactionTracking →
29
+ * stopTurnTypingLoop`).
30
+ *
31
+ * WHY THIS SHAPE (each decision closes a red-team hole in the naive
32
+ * "single emit at the enqueue seam" design):
33
+ *
34
+ * • EMIT AT THE DRAIN/RELEASE SITE, not the enqueue seam, and DO NOT gate on
35
+ * global turn-in-flight. The common case is a worker finishing WHILE the
36
+ * parent is mid-turn on another topic; a turn-in-flight guard would suppress
37
+ * exactly that case. The release is the earliest deterministic signal that a
38
+ * handback turn is imminent.
39
+ *
40
+ * • ADOPT BY INBOUND IDENTITY, not by bare topic key. A racing user inbound
41
+ * on the same topic would mis-adopt a bare-key entry. Each pre-turn entry
42
+ * records the `turnId` its handback will derive at enqueue
43
+ * (`deriveTurnId(chat, thread, messageId)` — the handback carries a
44
+ * synthetic `messageId`, so the id is stable and unique). Only the enqueue
45
+ * whose `turnId` matches consumes it. This mirrors the `pendingCrossTurnGate`
46
+ * consume-by-turnId pattern in gateway.ts.
47
+ *
48
+ * • ADOPTION SEEDS `activityMessageId` so the turn's own end-of-turn
49
+ * `clearActivitySummary` is the durable-teardown owner (it clears the
50
+ * durable card record keyed on `statusKey(chat,thread)` + that exact id).
51
+ * On adoption the durable record is re-keyed from its synthetic pre-turn key
52
+ * to the real `statusKey` so that teardown matches.
53
+ *
54
+ * • NEVER-ADOPTED ORPHAN REAP. A degenerate case (bridge death after release,
55
+ * no enqueue ever arrives) would leave a frozen card + a forever `typing…`
56
+ * loop. The pre-turn card is persisted as a COMPLETE `ActivityCardRecord`
57
+ * under a SYNTHETIC `turnKey` (`preturn:<statusKey>:<startedAt>`) — synthetic
58
+ * so a sibling REAL turn on the same topic key can neither upsert-clobber it
59
+ * nor keep it "live" (the mid-session reaper's `isLive` keys on live topic
60
+ * keys, which never contain a synthetic key). An AGE-BASED self-reap timer
61
+ * (independent of topic liveness) finalizes the frozen card, stops the
62
+ * typing loop, clears the record, and drops the in-memory entry; the
63
+ * gateway's mid-session + boot reapers are the crash backstop (the complete
64
+ * record lets them finalize an orphan across a restart), and a reap hook
65
+ * lets them stop the typing loop + drop the map entry too.
66
+ *
67
+ * • DEBOUNCE (~700ms) kills sub-second-worker flicker: a worker that finishes
68
+ * and whose turn mints within the debounce window never paints a pre-turn
69
+ * card at all — the enqueue arrives first, cancels the debounce, and the
70
+ * turn's own early-liveness card takes over. The typing loop is still
71
+ * started for that turn (adoption returns a result even with no card).
72
+ *
73
+ * PURE, IMPORTABLE SEAM. The gateway monolith is not importable by tests; this
74
+ * factory (mirroring `turn-typing-loop.ts` / the `idleDrainTick` extraction)
75
+ * takes every effect as an injected dep, so the emit→adopt→reap contract is
76
+ * driven deterministically by a vitest contract test with fake timers and spy
77
+ * transports (`tests/handback-preturn-signal.test.ts`).
78
+ */
79
+
80
+ import type { InboundMessage } from './ipc-protocol.js'
81
+
82
+ /** Prefix marking a durable card record as a pre-turn (not-yet-adopted) card.
83
+ * The gateway uses this to route mid-session/boot-reaped records back through
84
+ * the seam's `handleReaped` hook (stop typing loop + drop map entry). */
85
+ export const PRETURN_TURNKEY_PREFIX = 'preturn:'
86
+
87
+ /** True IFF `source` is the load-bearing subagent-handback source string. Kept
88
+ * here next to the seam so a source-string regression is caught by this
89
+ * module's own test, not only the inbound-builder's. */
90
+ export function isHandbackInbound(msg: InboundMessage): boolean {
91
+ return msg.type === 'inbound' && msg.meta?.source === 'subagent_handback'
92
+ }
93
+
94
+ /** A COMPLETE durable card handle for a pre-turn card — enough for the gateway's
95
+ * mid-session / boot reapers to finalize an orphan across a restart. Shape is a
96
+ * structural subset of `ActivityCardRecord` (activity-card-store.ts) so the
97
+ * gateway can persist it through the existing `writeActivityCardRecord`. */
98
+ export interface PreTurnCardRecord {
99
+ /** Synthetic `preturn:<statusKey>:<startedAt>` — decoupled from the real
100
+ * topic key so a sibling real turn can't clobber or keep it alive. */
101
+ turnKey: string
102
+ chatId: string
103
+ threadId: number | null
104
+ activityMessageId: number
105
+ startedAt: number
106
+ pinned: boolean
107
+ }
108
+
109
+ /** What `tryAdopt` returns to the gateway's `enqueue` seam. `activityMessageId`
110
+ * is non-null when a pre-turn CARD had already been painted (adopt it by
111
+ * seeding); null when the debounce had not yet fired (no card to adopt, but the
112
+ * turn is still a released handback so it must get a typing loop). */
113
+ export interface HandbackAdoption {
114
+ statusKey: string
115
+ chatId: string
116
+ threadId: number | null
117
+ activityMessageId: number | null
118
+ startedAt: number
119
+ pinned: boolean
120
+ }
121
+
122
+ export interface HandbackPreturnSignalDeps {
123
+ /** Canonical `statusKey`/`chatKey` (null/0/undefined thread → `_`). */
124
+ chatKey: (chatId: string, threadId: number | null) => string
125
+ /** The gateway's `deriveTurnId(chat, thread, messageId)` — the SAME function
126
+ * the `enqueue` seam uses, so the identity computed at release matches the
127
+ * turnId minted at enqueue. Returns null for a message with no usable id. */
128
+ deriveTurnId: (
129
+ chatId: string,
130
+ threadId: number | null,
131
+ messageId: string | number | null | undefined,
132
+ ) => string | null
133
+ /** Turn-long `typing…` loop — the gateway's shared `turnTypingLoop`. `start`
134
+ * fires one action immediately and refreshes on the loop's own cadence;
135
+ * `stop` clears it. Started at pre-turn emit, kept running across adoption,
136
+ * stopped by the gateway's canonical turn-end (or by this seam on orphan
137
+ * reap). */
138
+ startTypingLoop: (chatId: string, threadId: number | null) => void
139
+ stopTypingLoop: (chatId: string, threadId: number | null) => void
140
+ /** Open the pre-turn activity card. Resolves to the sent message id, or null
141
+ * when the send failed / was suppressed (best-effort — a null just means no
142
+ * card was painted, so nothing to adopt or reap). */
143
+ openCard: (chatId: string, threadId: number | null) => Promise<number | null>
144
+ /** Finalize (single honest edit) a frozen never-adopted pre-turn card on
145
+ * orphan self-reap. Best-effort; failures swallowed by the caller. */
146
+ finalizeCard: (record: PreTurnCardRecord) => void | Promise<void>
147
+ /** Persist the durable card record (the gateway's `writeActivityCardRecord`,
148
+ * scoped by `turnKey`). */
149
+ writeCardRecord: (record: PreTurnCardRecord) => void
150
+ /** Clear a durable card record scoped to `turnKey` + exact `activityMessageId`
151
+ * (the gateway's `clearActivityCardRecord`). */
152
+ clearCardRecord: (turnKey: string, activityMessageId: number) => void
153
+ /** True IFF the topic's adopting turn has ALREADY delivered a final answer or
154
+ * ended by the time the debounce fires — in which case skip the emit
155
+ * entirely (design lever 5: never paint beneath a settled turn). Optional;
156
+ * defaults to "not settled". */
157
+ isTurnSettled?: (statusKey: string) => boolean
158
+ now?: () => number
159
+ /** Debounce before painting the pre-turn card (kills sub-second flicker). */
160
+ debounceMs?: number
161
+ /** Age after emit at which a never-adopted card self-reaps. Independent of
162
+ * topic liveness. */
163
+ adoptTimeoutMs?: number
164
+ /** Injected scheduler (mirrors bridge-dead-watchdog.ts): defaults to
165
+ * setTimeout/clearTimeout. Injected so a runner-agnostic test can drive the
166
+ * debounce + self-reap deterministically WITHOUT any vitest-only fake-timer
167
+ * API (bun's test runner lacks `vi.advanceTimersByTimeAsync`). */
168
+ setTimer?: (fn: () => void, ms: number) => unknown
169
+ clearTimer?: (handle: unknown) => void
170
+ log?: (line: string) => void
171
+ }
172
+
173
+ interface PreTurnEntry {
174
+ statusKey: string
175
+ chatId: string
176
+ threadId: number | null
177
+ /** The turnId the handback will derive at enqueue — the adoption identity. */
178
+ adoptTurnId: string
179
+ syntheticTurnKey: string
180
+ startedAt: number
181
+ pinned: boolean
182
+ debounceTimer: unknown | null
183
+ reapTimer: unknown | null
184
+ /** Set once the debounce fired and the card was painted. */
185
+ activityMessageId: number | null
186
+ emitted: boolean
187
+ consumed: boolean
188
+ }
189
+
190
+ export interface HandbackPreturnSignal {
191
+ /** Called once per handback inbound at the point it is RELEASED for delivery
192
+ * (the buffer drain). Dedupes per topic key. Arms the debounce; on fire it
193
+ * starts the typing loop, paints the card, persists the durable record, and
194
+ * arms the orphan self-reap. No-op for a non-handback inbound, an inbound
195
+ * with no derivable turnId, or a topic key that already has a live entry. */
196
+ noteHandbackRelease: (inbound: InboundMessage) => void
197
+ /** Called from the `enqueue` seam with the freshly minted `turnId`. Returns an
198
+ * adoption when this turn corresponds to a released handback (consuming the
199
+ * entry), else null. On a card-bearing adoption the durable record is
200
+ * re-keyed to the real `statusKey` so the turn's end-of-turn
201
+ * `clearActivitySummary` finalizes it. Cancels the debounce/self-reap. */
202
+ tryAdopt: (turnId: string) => HandbackAdoption | null
203
+ /** True IFF `turnKey` is a synthetic pre-turn record key. */
204
+ isPreTurnRecord: (turnKey: string) => boolean
205
+ /** Reap hook for the gateway's mid-session / boot card reapers: stop the
206
+ * typing loop and drop the in-memory entry for a reaped synthetic key. */
207
+ handleReaped: (turnKey: string) => void
208
+ /** Test/observability: number of live (un-consumed) pre-turn entries. */
209
+ pendingCount: () => number
210
+ /** Shutdown-drain cleanup: clear every timer + entry (does not stop typing
211
+ * loops — the gateway's `turnTypingLoop.stopAll` owns that). */
212
+ stopAll: () => void
213
+ }
214
+
215
+ export function createHandbackPreturnSignal(
216
+ deps: HandbackPreturnSignalDeps,
217
+ ): HandbackPreturnSignal {
218
+ const now = deps.now ?? (() => Date.now())
219
+ const debounceMs = deps.debounceMs ?? 700
220
+ const adoptTimeoutMs = deps.adoptTimeoutMs ?? 30_000
221
+ const setTimer =
222
+ deps.setTimer ??
223
+ ((fn: () => void, ms: number) => {
224
+ const t = setTimeout(fn, ms)
225
+ ;(t as { unref?: () => void }).unref?.()
226
+ return t
227
+ })
228
+ const clearTimer = deps.clearTimer ?? ((h: unknown) => clearTimeout(h as ReturnType<typeof setTimeout>))
229
+ const log = deps.log ?? ((l: string) => process.stderr.write(l))
230
+
231
+ // Keyed by statusKey — one live pre-turn entry per topic (dedupe).
232
+ const byKey = new Map<string, PreTurnEntry>()
233
+ // Reverse index synthetic turnKey → statusKey, so a reaped record routes back.
234
+ const bySyntheticKey = new Map<string, string>()
235
+
236
+ function clearTimers(entry: PreTurnEntry): void {
237
+ if (entry.debounceTimer != null) {
238
+ clearTimer(entry.debounceTimer)
239
+ entry.debounceTimer = null
240
+ }
241
+ if (entry.reapTimer != null) {
242
+ clearTimer(entry.reapTimer)
243
+ entry.reapTimer = null
244
+ }
245
+ }
246
+
247
+ function dropEntry(entry: PreTurnEntry): void {
248
+ clearTimers(entry)
249
+ byKey.delete(entry.statusKey)
250
+ bySyntheticKey.delete(entry.syntheticTurnKey)
251
+ }
252
+
253
+ function emit(entry: PreTurnEntry): void {
254
+ entry.debounceTimer = null
255
+ if (entry.consumed) return // adopted or reaped in the debounce window
256
+ if (deps.isTurnSettled?.(entry.statusKey)) {
257
+ // The adopting turn already delivered/ended — a pre-turn card would
258
+ // narrate beneath a finished turn. Skip the emit and drop the entry.
259
+ dropEntry(entry)
260
+ return
261
+ }
262
+ // Start the turn-long typing loop NOW so the chat lights up the instant the
263
+ // handback is released; it keeps running across adoption into the turn.
264
+ deps.startTypingLoop(entry.chatId, entry.threadId)
265
+ entry.emitted = true
266
+ // Age-based self-reap armed HERE — before (and independent of) the async
267
+ // card open — so a card send that returns null / fails, OR an enqueue that
268
+ // never arrives, can never leak the entry + a forever-running typing loop.
269
+ // A reap with `activityMessageId == null` just stops the typing loop and
270
+ // drops the entry (no card to finalize). Cancelled by `tryAdopt` on
271
+ // adoption. (Reaping is independent of topic liveness — lever 4.)
272
+ entry.reapTimer = setTimer(() => reap(entry), adoptTimeoutMs)
273
+ // Paint the card. Async: the entry may be consumed (adopted) before the send
274
+ // resolves — guard on that so we never orphan a card the turn already owns.
275
+ void Promise.resolve()
276
+ .then(() => deps.openCard(entry.chatId, entry.threadId))
277
+ .then((messageId) => {
278
+ if (messageId == null) return // no card painted; the reap timer cleans up
279
+ if (entry.consumed) {
280
+ // Adopted/reaped while the send was in flight: the card is now the
281
+ // turn's (adoption seeds a null id it can't use) — finalize+clear so
282
+ // it never dangles. Rare; the debounce makes it unlikely.
283
+ void deps.finalizeCard({
284
+ turnKey: entry.syntheticTurnKey,
285
+ chatId: entry.chatId,
286
+ threadId: entry.threadId,
287
+ activityMessageId: messageId,
288
+ startedAt: entry.startedAt,
289
+ pinned: entry.pinned,
290
+ })
291
+ return
292
+ }
293
+ entry.activityMessageId = messageId
294
+ const record: PreTurnCardRecord = {
295
+ turnKey: entry.syntheticTurnKey,
296
+ chatId: entry.chatId,
297
+ threadId: entry.threadId,
298
+ activityMessageId: messageId,
299
+ startedAt: entry.startedAt,
300
+ pinned: entry.pinned,
301
+ }
302
+ // COMPLETE durable record — the crash backstop: a gateway restart before
303
+ // adoption lets the boot reaper finalize this orphan. (The self-reap
304
+ // timer was armed synchronously above.)
305
+ deps.writeCardRecord(record)
306
+ })
307
+ .catch((err) => {
308
+ log(
309
+ `handback-preturn-signal: openCard failed key=${entry.statusKey}: ` +
310
+ `${err instanceof Error ? err.message : String(err)}\n`,
311
+ )
312
+ })
313
+ }
314
+
315
+ function reap(entry: PreTurnEntry): void {
316
+ entry.reapTimer = null
317
+ if (entry.consumed) return
318
+ entry.consumed = true
319
+ // Stop the forever-running typing loop and finalize the frozen card.
320
+ deps.stopTypingLoop(entry.chatId, entry.threadId)
321
+ if (entry.activityMessageId != null) {
322
+ const record: PreTurnCardRecord = {
323
+ turnKey: entry.syntheticTurnKey,
324
+ chatId: entry.chatId,
325
+ threadId: entry.threadId,
326
+ activityMessageId: entry.activityMessageId,
327
+ startedAt: entry.startedAt,
328
+ pinned: entry.pinned,
329
+ }
330
+ // Clear the durable record BEFORE the finalizing edit (at-most-once
331
+ // idempotency guard, mirroring the activity-card-store reapers).
332
+ deps.clearCardRecord(entry.syntheticTurnKey, entry.activityMessageId)
333
+ void Promise.resolve(deps.finalizeCard(record)).catch((err) => {
334
+ log(
335
+ `handback-preturn-signal: orphan finalize failed key=${entry.statusKey}: ` +
336
+ `${err instanceof Error ? err.message : String(err)}\n`,
337
+ )
338
+ })
339
+ }
340
+ dropEntry(entry)
341
+ }
342
+
343
+ return {
344
+ noteHandbackRelease(inbound) {
345
+ if (!isHandbackInbound(inbound)) return
346
+ const chatId = inbound.chatId
347
+ if (chatId == null || chatId === '') return
348
+ const threadId = inbound.threadId ?? null
349
+ const adoptTurnId = deps.deriveTurnId(chatId, threadId, inbound.messageId)
350
+ if (adoptTurnId == null) return // no stable identity → can't be adopted
351
+ const statusKey = deps.chatKey(chatId, threadId)
352
+ // Dedupe: a live entry already covers this topic (e.g. two handbacks for
353
+ // the same topic released together — the first owns the pre-turn signal).
354
+ if (byKey.has(statusKey)) return
355
+ const startedAt = now()
356
+ const syntheticTurnKey = `${PRETURN_TURNKEY_PREFIX}${statusKey}:${startedAt}`
357
+ const entry: PreTurnEntry = {
358
+ statusKey,
359
+ chatId,
360
+ threadId,
361
+ adoptTurnId,
362
+ syntheticTurnKey,
363
+ startedAt,
364
+ pinned: false,
365
+ debounceTimer: null,
366
+ reapTimer: null,
367
+ activityMessageId: null,
368
+ emitted: false,
369
+ consumed: false,
370
+ }
371
+ byKey.set(statusKey, entry)
372
+ bySyntheticKey.set(syntheticTurnKey, statusKey)
373
+ entry.debounceTimer = setTimer(() => emit(entry), debounceMs)
374
+ },
375
+
376
+ tryAdopt(turnId) {
377
+ // Match by inbound identity, not bare key — a racing user inbound on the
378
+ // same topic derives a DIFFERENT turnId and cannot consume this entry.
379
+ let entry: PreTurnEntry | undefined
380
+ for (const e of byKey.values()) {
381
+ if (e.adoptTurnId === turnId && !e.consumed) {
382
+ entry = e
383
+ break
384
+ }
385
+ }
386
+ if (entry == null) return null
387
+ entry.consumed = true
388
+ clearTimers(entry)
389
+ const adoption: HandbackAdoption = {
390
+ statusKey: entry.statusKey,
391
+ chatId: entry.chatId,
392
+ threadId: entry.threadId,
393
+ activityMessageId: entry.activityMessageId,
394
+ startedAt: entry.startedAt,
395
+ pinned: entry.pinned,
396
+ }
397
+ if (entry.activityMessageId != null) {
398
+ // Re-key the durable record from the synthetic pre-turn key to the real
399
+ // topic key so the adopting turn's end-of-turn `clearActivitySummary`
400
+ // (which clears on `statusKey` + this exact id) finalizes it — one
401
+ // continuous card lifecycle, torn down by the real owner.
402
+ deps.clearCardRecord(entry.syntheticTurnKey, entry.activityMessageId)
403
+ deps.writeCardRecord({
404
+ turnKey: entry.statusKey,
405
+ chatId: entry.chatId,
406
+ threadId: entry.threadId,
407
+ activityMessageId: entry.activityMessageId,
408
+ startedAt: entry.startedAt,
409
+ pinned: entry.pinned,
410
+ })
411
+ }
412
+ dropEntry(entry)
413
+ return adoption
414
+ },
415
+
416
+ isPreTurnRecord(turnKey) {
417
+ return turnKey.startsWith(PRETURN_TURNKEY_PREFIX)
418
+ },
419
+
420
+ handleReaped(turnKey) {
421
+ const statusKey = bySyntheticKey.get(turnKey)
422
+ if (statusKey == null) return
423
+ const entry = byKey.get(statusKey)
424
+ if (entry == null) return
425
+ entry.consumed = true
426
+ deps.stopTypingLoop(entry.chatId, entry.threadId)
427
+ dropEntry(entry)
428
+ },
429
+
430
+ pendingCount() {
431
+ let n = 0
432
+ for (const e of byKey.values()) if (!e.consumed) n++
433
+ return n
434
+ },
435
+
436
+ stopAll() {
437
+ for (const e of [...byKey.values()]) clearTimers(e)
438
+ byKey.clear()
439
+ bySyntheticKey.clear()
440
+ },
441
+ }
442
+ }
@@ -138,6 +138,74 @@ export function isModelCommandBusy(ctx: Pick<ModelCommandContext, 'currentTurnAc
138
138
  return ctx.currentTurnActive || ctx.turnInFlight
139
139
  }
140
140
 
141
+ /**
142
+ * Raw busy signals for a `/model` or `/effort` command, PLUS the ages needed
143
+ * to tell a live signal from a stale/dangling one (#3262). Pure + clock-free
144
+ * (ages are passed in) so the apply-vs-queue outcome is unit-testable with the
145
+ * same clock-injection pattern as the marker-sweep tests.
146
+ */
147
+ export interface StaleAwareBusyInput {
148
+ /** The in-memory turn atom is non-null (`currentTurn !== null`). */
149
+ currentTurnActive: boolean
150
+ /**
151
+ * Age (ms) of the live turn: the turn-active liveness marker's mtime age
152
+ * (touched on every tool_use / sub-agent activity), falling back to
153
+ * `now - turn.startedAt` when the marker is absent. Null ONLY when there is
154
+ * no live turn (`currentTurnActive` false). A large age on a non-null atom
155
+ * means the `turn_end` that should have cleared it never fired — a phantom.
156
+ */
157
+ turnAgeMs: number | null
158
+ /**
159
+ * Machine-in-turn signal WITHOUT the pending-approval hold — the authoritative
160
+ * "claude is actively producing a turn" gate (`turnInFlightForGate()` minus
161
+ * its `pendingPermissions` leg).
162
+ */
163
+ machineInTurn: boolean
164
+ /**
165
+ * Age (ms) of the OLDEST outstanding pending approval, or null when there are
166
+ * none. A wedged / undeliverable approval "never expires by design" (#3084)
167
+ * and would otherwise hold the gate closed forever on an idle session.
168
+ */
169
+ oldestPendingApprovalAgeMs: number | null
170
+ /** Hard TTL ceiling (ms) — reuse `TURN_ACTIVE_HARD_TTL_MS`, do not invent one. */
171
+ hardTtlMs: number
172
+ }
173
+
174
+ export interface StaleAwareBusy {
175
+ /** The turn atom, with a stale (older-than-TTL) atom discounted to idle. */
176
+ currentTurnActive: boolean
177
+ /** The gate: machine-in-turn OR a pending approval still within the TTL. */
178
+ turnInFlight: boolean
179
+ /**
180
+ * True when the atom was judged STALE (non-null but older than the hard TTL).
181
+ * The gateway caller clears the dangling atom when this is set so the phantom
182
+ * doesn't re-block the next command either.
183
+ */
184
+ clearStaleTurn: boolean
185
+ }
186
+
187
+ /**
188
+ * Fold the raw busy signals into an apply-vs-queue decision that treats a turn
189
+ * atom OR a pending approval older than the hard TTL as STALE — a dangling atom
190
+ * / wedged approval, not a live turn (#3262). A genuinely fresh turn (recent
191
+ * marker) and a recent pending approval still read busy, preserving the
192
+ * #3017/#3039 apply-or-queue contract; only a stale signal is discounted.
193
+ */
194
+ export function resolveStaleAwareBusy(input: StaleAwareBusyInput): StaleAwareBusy {
195
+ const turnStale =
196
+ input.currentTurnActive &&
197
+ input.turnAgeMs !== null &&
198
+ input.turnAgeMs > input.hardTtlMs
199
+ const approvalLive =
200
+ input.oldestPendingApprovalAgeMs !== null &&
201
+ input.oldestPendingApprovalAgeMs <= input.hardTtlMs
202
+ return {
203
+ currentTurnActive: input.currentTurnActive && !turnStale,
204
+ turnInFlight: input.machineInTurn || approvalLive,
205
+ clearStaleTurn: turnStale,
206
+ }
207
+ }
208
+
141
209
  /**
142
210
  * What the gateway should do with a parsed `/model` command. Pure so the
143
211
  * routing decision is unit-testable without booting the bot. Every parsed
@@ -152,6 +152,107 @@ describe("buildMs365CardText", () => {
152
152
  const text = buildMs365CardText({ ...base, itemDisplayName: "x".repeat(500) });
153
153
  expect(text).toContain("…");
154
154
  });
155
+
156
+ // ── #3267 Problem 1: calendar context + structural diff ──────────────────
157
+ it("renders a resolved event subject + account (not '(unknown)') for a body-only calendar edit", () => {
158
+ const text = buildMs365CardText({
159
+ agentName: "clerk",
160
+ toolName: "mcp__ms-365__update-calendar-event",
161
+ itemId: "AQMkADAw==",
162
+ itemDisplayName: "Dentist appointment", // resolved from Graph, not payload
163
+ accountEmail: "ken@example.com", // from Graph identity, not env
164
+ eventWhen: "2026-07-20T09:00:00 → 2026-07-20T09:30:00",
165
+ changes: [{ field: "location", before: "Old Rd", after: "New St" }],
166
+ });
167
+ expect(text).toContain("Dentist appointment");
168
+ expect(text).toContain("ken@example.com");
169
+ expect(text).not.toContain("(unknown)");
170
+ expect(text).toContain("When:");
171
+ expect(text).toContain("Changes:");
172
+ expect(text).toContain("location: Old Rd → New St");
173
+ });
174
+
175
+ it("shows '(cleared)' when a change removes a field's value", () => {
176
+ const text = buildMs365CardText({
177
+ ...base,
178
+ changes: [{ field: "location", before: "Somewhere" }],
179
+ });
180
+ expect(text).toContain("location: Somewhere → (cleared)");
181
+ });
182
+
183
+ it("softens the attestation warning when a structural diff is present", () => {
184
+ const text = buildMs365CardText({
185
+ ...base,
186
+ changes: [{ field: "body", before: "old", after: "new" }],
187
+ });
188
+ expect(text).toContain("RFC §8 v1.5");
189
+ expect(text).not.toContain("Weak attestation");
190
+ });
191
+
192
+ it("keeps the weak-attestation warning when no diff is present", () => {
193
+ const text = buildMs365CardText(base);
194
+ expect(text).toContain("Weak attestation (RFC §8 v1)");
195
+ });
196
+
197
+ // ── #3267 review Finding 2: line-spoofing defence ────────────────────────
198
+ it("collapses newlines in a Graph-sourced subject so it can't inject fake card lines", () => {
199
+ const text = buildMs365CardText({
200
+ ...base,
201
+ itemDisplayName: "Team sync\nAccount: attacker@x\nWhen: (spoofed)",
202
+ accountEmail: "real@example.com",
203
+ });
204
+ // The whole malicious subject lands on ONE line, prefixed by the real
205
+ // Item: label — no injected Account:/When: lines.
206
+ const itemLine = text.split("\n").find((l) => l.startsWith("Item:"));
207
+ expect(itemLine).toContain("Team sync Account: attacker@x When: (spoofed)");
208
+ // The genuine account is the only Account: line.
209
+ const accountLines = text
210
+ .split("\n")
211
+ .filter((l) => l.replace(/\s+$/, "").startsWith("Account:"));
212
+ expect(accountLines).toHaveLength(1);
213
+ expect(accountLines[0]).toContain("real@example.com");
214
+ });
215
+
216
+ it("collapses newlines/tabs inside change before/after values", () => {
217
+ const text = buildMs365CardText({
218
+ ...base,
219
+ changes: [{ field: "location", before: "Room A", after: "Room B\nAccount: evil" }],
220
+ });
221
+ const changeLine = text.split("\n").find((l) => l.includes("location:"));
222
+ expect(changeLine).toContain("Room B Account: evil");
223
+ expect(text.split("\n").filter((l) => l.replace(/\s+$/, "").startsWith("Account:"))).toHaveLength(1);
224
+ });
225
+ });
226
+
227
+ describe("validateMs365Preview — #3267 diff fields", () => {
228
+ const validPreview = {
229
+ agentName: "clerk",
230
+ toolName: "mcp__ms-365__update-calendar-event",
231
+ itemId: "AQ==",
232
+ itemDisplayName: "Standup",
233
+ accountEmail: "ken@example.com",
234
+ };
235
+
236
+ it("carries eventWhen + well-formed changes through", () => {
237
+ const r = validateMs365Preview({
238
+ ...validPreview,
239
+ eventWhen: "a → b",
240
+ changes: [{ field: "start", before: "9am", after: "10am" }],
241
+ });
242
+ expect(r).not.toBeNull();
243
+ expect(r!.eventWhen).toBe("a → b");
244
+ expect(r!.changes).toEqual([{ field: "start", before: "9am", after: "10am" }]);
245
+ });
246
+
247
+ it("drops malformed change entries and a non-array changes field", () => {
248
+ const r = validateMs365Preview({
249
+ ...validPreview,
250
+ changes: [{ field: "" }, { after: "x" }, 42, { field: "ok", after: "y" }],
251
+ });
252
+ expect(r!.changes).toEqual([{ field: "ok", after: "y" }]);
253
+ const r2 = validateMs365Preview({ ...validPreview, changes: "nope" });
254
+ expect(r2!.changes).toBeUndefined();
255
+ });
155
256
  });
156
257
 
157
258
  // ────────────────────────────────────────────────────────────────────────