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.
- package/README.md +6 -2
- package/dist/cli/ms-365-write-pretool.mjs +4953 -14
- package/dist/cli/switchroom.js +1 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +16 -0
- package/telegram-plugin/dist/gateway/gateway.js +571 -43
- package/telegram-plugin/flushed-turn-supersede.ts +58 -0
- package/telegram-plugin/gateway/derive-turn-id.ts +32 -0
- package/telegram-plugin/gateway/gateway.ts +358 -53
- package/telegram-plugin/gateway/handback-preturn-signal.ts +442 -0
- package/telegram-plugin/gateway/model-command.ts +68 -0
- package/telegram-plugin/gateway/ms365-write-approval.test.ts +101 -0
- package/telegram-plugin/gateway/ms365-write-approval.ts +65 -3
- package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +12 -0
- package/telegram-plugin/gateway/turn-active-marker.ts +35 -0
- package/telegram-plugin/gateway/worker-pin-reaper.ts +54 -0
- package/telegram-plugin/send-gate.test.ts +138 -0
- package/telegram-plugin/send-gate.ts +104 -1
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +14 -4
- package/telegram-plugin/tests/effort-command.test.ts +47 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +60 -0
- package/telegram-plugin/tests/handback-preturn-adoption-roundtrip.test.ts +211 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +346 -0
- package/telegram-plugin/tests/model-command.test.ts +112 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +14 -2
- package/telegram-plugin/tests/outbound-send-chunks.test.ts +57 -0
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +18 -11
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +90 -0
- package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +5 -0
- package/telegram-plugin/tests/turn-active-marker.test.ts +29 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +121 -0
- package/telegram-plugin/tests/worker-feed-migration-eviction.test.ts +140 -0
- package/telegram-plugin/tests/worker-pin-reaper.test.ts +78 -0
- 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
|
// ────────────────────────────────────────────────────────────────────────
|