switchroom 0.19.26 → 0.19.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 (92) hide show
  1. package/bin/git-agent-attribution-hook.sh +144 -0
  2. package/dist/agent-scheduler/index.js +60 -2
  3. package/dist/auth-broker/index.js +244 -13
  4. package/dist/cli/autoaccept-poll.js +225 -17
  5. package/dist/cli/notion-write-pretool.mjs +60 -2
  6. package/dist/cli/switchroom.js +2843 -1220
  7. package/dist/host-control/main.js +245 -14
  8. package/dist/vault/approvals/kernel-server.js +242 -13
  9. package/dist/vault/broker/server.js +242 -13
  10. package/package.json +7 -2
  11. package/profiles/_base/cron-session.sh.hbs +8 -0
  12. package/profiles/_base/start.sh.hbs +175 -15
  13. package/telegram-plugin/card-layout.ts +328 -0
  14. package/telegram-plugin/dist/bridge/bridge.js +94 -1
  15. package/telegram-plugin/dist/gateway/gateway.js +2544 -1182
  16. package/telegram-plugin/dist/server.js +97 -1
  17. package/telegram-plugin/edit-flood-fuse.ts +841 -57
  18. package/telegram-plugin/flood-429-ledger.ts +526 -0
  19. package/telegram-plugin/flood-circuit-breaker.ts +18 -0
  20. package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
  21. package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
  22. package/telegram-plugin/gateway/gateway.ts +67 -70
  23. package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
  24. package/telegram-plugin/gateway/narrative-lane.ts +14 -0
  25. package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
  26. package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
  27. package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
  28. package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
  29. package/telegram-plugin/gateway/status-pin-store.ts +58 -9
  30. package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
  31. package/telegram-plugin/inline-keyboard-callbacks.ts +202 -21
  32. package/telegram-plugin/llm-error-present.ts +61 -2
  33. package/telegram-plugin/mcp-credential-failure.ts +459 -0
  34. package/telegram-plugin/model-unavailable.ts +8 -0
  35. package/telegram-plugin/operator-events.ts +110 -5
  36. package/telegram-plugin/outbound-class.ts +81 -0
  37. package/telegram-plugin/provider-credit.ts +237 -0
  38. package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
  39. package/telegram-plugin/send-gate.ts +24 -2
  40. package/telegram-plugin/status-no-truncate.ts +10 -48
  41. package/telegram-plugin/status-pin-driver.ts +33 -45
  42. package/telegram-plugin/status-pin.ts +18 -1
  43. package/telegram-plugin/tests/card-golden.test.ts +69 -0
  44. package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
  45. package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
  46. package/telegram-plugin/tests/card-variants.golden.txt +211 -0
  47. package/telegram-plugin/tests/card-variants.ts +366 -0
  48. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +373 -0
  49. package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
  50. package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -0
  51. package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
  52. package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
  53. package/telegram-plugin/tests/finalize-callback-flood-policy.test.ts +298 -0
  54. package/telegram-plugin/tests/finalize-callback.test.ts +41 -8
  55. package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
  56. package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
  57. package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
  58. package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
  59. package/telegram-plugin/tests/mcp-credential-failure.test.ts +310 -0
  60. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
  61. package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
  62. package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
  63. package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
  64. package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
  65. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
  66. package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
  67. package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
  68. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
  69. package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
  70. package/telegram-plugin/tests/status-pin.test.ts +56 -5
  71. package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
  72. package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
  73. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
  74. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
  75. package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
  76. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  77. package/telegram-plugin/tool-activity-summary.ts +239 -365
  78. package/telegram-plugin/uat/assertions.ts +22 -11
  79. package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
  80. package/telegram-plugin/worker-activity-feed.ts +105 -47
  81. package/vendor/hindsight-memory/CLAUDE.md +45 -0
  82. package/vendor/hindsight-memory/scripts/drain_pending.py +433 -11
  83. package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
  84. package/vendor/hindsight-memory/scripts/lib/pending.py +193 -28
  85. package/vendor/hindsight-memory/scripts/recall.py +176 -7
  86. package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
  87. package/vendor/hindsight-memory/scripts/tests/test_drain_circuit_breaker.py +401 -0
  88. package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
  89. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
  90. package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
  91. package/vendor/hindsight-memory/settings.json +1 -1
  92. package/vendor/hindsight-memory/tests/test_hooks.py +11 -2
@@ -0,0 +1,602 @@
1
+ /**
2
+ * status-pin lifecycle — OUTCOME tests for the UNIFIED pin/unpin path (#3831).
3
+ *
4
+ * What this file is for
5
+ * ---------------------
6
+ * The status-pin subsystem kept regressing because its tests asserted the shape
7
+ * of individual functions rather than what Telegram ends up holding pinned. This
8
+ * suite drives the REAL modules the gateway composes —
9
+ *
10
+ * runStatusPinReconcile (the single decider + the ONLY retarget expansion)
11
+ * → reconcileAndPersistStatusPin (persist-BEFORE-pin, durable rows)
12
+ * → executePinLeg (the driver: one already-decided leg)
13
+ * → a fake Bot API at the boundary
14
+ *
15
+ * — plus the real boot cleanup, the real worker-pin reap decision, and the real
16
+ * narrative lane, and asserts the OBSERVABLE pin/unpin API calls and the
17
+ * resulting pinned set, across the whole lifecycle:
18
+ *
19
+ * claim → retarget → worker pin → restart with a stale claim → SIGTERM →
20
+ * bridge flap → done.
21
+ *
22
+ * The only fakes are at the boundary (Bot API, filesystem, clock, registry
23
+ * lookups). Nothing here re-implements a decision the gateway makes.
24
+ */
25
+ import { describe, it, expect } from 'vitest'
26
+ import { tmpdir } from 'node:os'
27
+ import { createNarrativeLane } from '../gateway/narrative-lane.js'
28
+ import type { CurrentTurn, NarrativeLaneDeps } from '../gateway/gateway.js'
29
+ import { executePinLeg, type PinBotApi } from '../status-pin-driver.js'
30
+ import type { DesiredPin, PinState } from '../status-pin.js'
31
+ import {
32
+ runStatusPinReconcile,
33
+ type StatusPinClaim,
34
+ } from '../gateway/status-pin-retarget.js'
35
+ import {
36
+ loadStatusPins,
37
+ mutateStatusPinRow,
38
+ runStatusPinBootCleanup,
39
+ type StatusPinStoreFsSeam,
40
+ } from '../gateway/status-pin-store.js'
41
+ import {
42
+ decideWorkerPinReaps,
43
+ groupPinStatus,
44
+ storeOnlyWorkerPinCandidates,
45
+ type WorkerFeedLivenessProbe,
46
+ type WorkerRegistryStatus,
47
+ } from '../gateway/worker-pin-reaper.js'
48
+
49
+ const PATH = '/state/agent/telegram/status-pins.json'
50
+ const CHAT = '-1001'
51
+ const HOUR = 60 * 60_000
52
+
53
+ // ── boundary fakes ────────────────────────────────────────────────────────
54
+
55
+ /** In-memory fs seam with atomic rename, mirroring the real store's usage. */
56
+ function memFs(): StatusPinStoreFsSeam {
57
+ const files = new Map<string, string>()
58
+ return {
59
+ readFileSync: (p) => {
60
+ if (!files.has(p)) throw new Error(`ENOENT ${p}`)
61
+ return files.get(p)!
62
+ },
63
+ writeFileSync: (p, d) => {
64
+ files.set(p, d)
65
+ },
66
+ renameSync: (a, b) => {
67
+ if (!files.has(a)) throw new Error(`ENOENT ${a}`)
68
+ files.set(b, files.get(a)!)
69
+ files.delete(a)
70
+ },
71
+ existsSync: (p) => files.has(p),
72
+ }
73
+ }
74
+
75
+ /**
76
+ * A fake Telegram that maintains the REAL observable: which messages are
77
+ * currently pinned. Every call is also logged in order, so a test can assert
78
+ * ordering (e.g. #3812's unpin-before-delete) and call counts (no pin storms).
79
+ */
80
+ function fakeTelegram() {
81
+ const pinned = new Set<string>() // `${chatId}:${messageId}`
82
+ const calls: Array<{ verb: 'pin' | 'unpin' | 'delete'; chatId: string; messageId: number }> = []
83
+ const api: PinBotApi = {
84
+ pinChatMessage: async (chat_id, message_id) => {
85
+ calls.push({ verb: 'pin', chatId: String(chat_id), messageId: message_id })
86
+ pinned.add(`${chat_id}:${message_id}`)
87
+ },
88
+ unpinChatMessage: async (chat_id, message_id) => {
89
+ calls.push({ verb: 'unpin', chatId: String(chat_id), messageId: message_id })
90
+ pinned.delete(`${chat_id}:${message_id}`)
91
+ },
92
+ }
93
+ return {
94
+ api,
95
+ calls,
96
+ pinned,
97
+ ids: (): number[] => [...pinned].map((s) => Number(s.split(':')[1])).sort((a, b) => a - b),
98
+ verbs: (): string[] => calls.map((c) => `${c.verb}:${c.messageId}`),
99
+ /** Delete a message out from under the pin (what a card teardown does). */
100
+ deleted: (chatId: string, messageId: number) => {
101
+ calls.push({ verb: 'delete', chatId, messageId })
102
+ pinned.delete(`${chatId}:${messageId}`)
103
+ },
104
+ }
105
+ }
106
+
107
+ /**
108
+ * The gateway's status-pin subsystem, wired the way gateway.ts wires it:
109
+ * ONE claim registry, the unified reconcile, the durable store, the boot
110
+ * cleanup, the SIGTERM sweep, and the mid-session `wk:` reaper.
111
+ *
112
+ * A "restart" is `makeSubsystem(fs, tg)` again over the SAME fs + Telegram:
113
+ * fresh claims, same durable rows, same live pins.
114
+ */
115
+ function makeSubsystem(fs: StatusPinStoreFsSeam, tg: ReturnType<typeof fakeTelegram>) {
116
+ const claims = new Map<string, StatusPinClaim>()
117
+
118
+ async function reconcileStatusPin(
119
+ pinKey: string,
120
+ chatId: string,
121
+ desired: DesiredPin,
122
+ now = Date.now(),
123
+ ): Promise<void> {
124
+ const claim = claims.get(pinKey)
125
+ const prev: PinState | null = claim == null ? null : { messageId: claim.messageId }
126
+ await runStatusPinReconcile({
127
+ pinKey,
128
+ chatId,
129
+ prev,
130
+ desired,
131
+ persist: { path: PATH, fs },
132
+ runPin: (action, from) =>
133
+ executePinLeg({ api: tg.api, chatId, prevState: from, action }),
134
+ claims,
135
+ now: () => now,
136
+ })
137
+ }
138
+
139
+ /** gateway.ts `unpinAllStatusPins` — the SIGTERM sweep. */
140
+ async function sigtermSweep(): Promise<void> {
141
+ for (const [key, claim] of [...claims.entries()]) {
142
+ await reconcileStatusPin(key, claim.chatId, { pinned: false })
143
+ }
144
+ }
145
+
146
+ /** gateway.ts boot cleanup over the durable rows. */
147
+ async function bootCleanup() {
148
+ return runStatusPinBootCleanup({
149
+ path: PATH,
150
+ fs,
151
+ unpin: (chatId, messageId) => tg.api.unpinChatMessage(chatId, messageId),
152
+ log: () => {},
153
+ })
154
+ }
155
+
156
+ /**
157
+ * gateway.ts's mid-session `wk:` reaper block, verbatim in structure: build
158
+ * in-memory candidates off the single claim registry, fold in durable
159
+ * store-orphans, decide, then execute each reap through the sanctioned path.
160
+ */
161
+ async function midSessionWorkerReap(opts: {
162
+ now: number
163
+ ttlMs: number
164
+ statusOf?: (agentId: string) => WorkerRegistryStatus
165
+ feed?: WorkerFeedLivenessProbe | null
166
+ }): Promise<void> {
167
+ const inMemoryCandidates = [...claims.entries()]
168
+ .filter(([k]) => k.startsWith('wk:'))
169
+ .map(([pinKey, c]) => ({ pinKey, chatId: c.chatId, pinnedAt: c.pinnedAt }))
170
+ const inMemoryKeys = new Set(inMemoryCandidates.map((c) => c.pinKey))
171
+ const storeOnly = storeOnlyWorkerPinCandidates({
172
+ rows: loadStatusPins(PATH, fs),
173
+ inMemoryPinKeys: inMemoryKeys,
174
+ now: opts.now,
175
+ })
176
+ const storeOnlyKeys = new Set(storeOnly.map((c) => c.pinKey))
177
+ const reaps = decideWorkerPinReaps({
178
+ pins: [...inMemoryCandidates, ...storeOnly],
179
+ statusOf: (agentId) => {
180
+ if (agentId.startsWith('group:')) {
181
+ return groupPinStatus(opts.feed, agentId.slice('group:'.length))
182
+ }
183
+ return opts.statusOf?.(agentId) ?? 'unknown'
184
+ },
185
+ ttlMs: opts.ttlMs,
186
+ now: opts.now,
187
+ })
188
+ for (const reap of reaps) {
189
+ if (storeOnlyKeys.has(reap.pinKey) && reap.messageId != null) {
190
+ await tg.api.unpinChatMessage(reap.chatId, reap.messageId)
191
+ await mutateStatusPinRow(PATH, fs, reap.pinKey, null)
192
+ } else {
193
+ await reconcileStatusPin(reap.pinKey, reap.chatId, { pinned: false })
194
+ }
195
+ }
196
+ }
197
+
198
+ return { claims, reconcileStatusPin, sigtermSweep, bootCleanup, midSessionWorkerReap }
199
+ }
200
+
201
+ function rowIds(fs: StatusPinStoreFsSeam): Array<[string, number]> {
202
+ return loadStatusPins(PATH, fs).map((r) => [r.pinKey, r.messageId])
203
+ }
204
+
205
+ // ── 1. claim → retarget → done ────────────────────────────────────────────
206
+
207
+ describe('lifecycle — claim, retarget, release (the fg: activity card)', () => {
208
+ it('a RETARGET leaves the NEW message pinned and the OLD one unpinned, with a durable row naming the new id', async () => {
209
+ const fs = memFs()
210
+ const tg = fakeTelegram()
211
+ const gw = makeSubsystem(fs, tg)
212
+
213
+ // Turn opens: the card is claimed.
214
+ await gw.reconcileStatusPin('fg:c:1', CHAT, { pinned: true, messageId: 100 }, 1_000)
215
+ expect(tg.ids()).toEqual([100])
216
+
217
+ // Ack-first reopen mid-turn: a FRESH card, ONE reconcile, no re-drive.
218
+ // Pre-#3196 this reported a bare `unpin` and stopped — the live card ran
219
+ // unpinned for the rest of the turn and the durable row was deleted.
220
+ // Pre-#3831 the DRIVER also carried its own copy of this expansion, whose
221
+ // second leg bypassed reconcileAndPersistStatusPin entirely, so whichever
222
+ // copy ran decided whether the durable row survived.
223
+ await gw.reconcileStatusPin('fg:c:1', CHAT, { pinned: true, messageId: 101 }, 2_000)
224
+
225
+ expect(tg.ids()).toEqual([101])
226
+ expect(tg.verbs()).toEqual(['pin:100', 'unpin:100', 'pin:101'])
227
+ expect(rowIds(fs)).toEqual([['fg:c:1', 101]])
228
+ expect(gw.claims.get('fg:c:1')).toEqual({
229
+ messageId: 101,
230
+ chatId: CHAT,
231
+ pinnedAt: 2_000,
232
+ })
233
+ })
234
+
235
+ it('turn end releases the claim: nothing pinned, no durable row left behind', async () => {
236
+ const fs = memFs()
237
+ const tg = fakeTelegram()
238
+ const gw = makeSubsystem(fs, tg)
239
+ await gw.reconcileStatusPin('fg:c:1', CHAT, { pinned: true, messageId: 100 }, 1_000)
240
+ await gw.reconcileStatusPin('fg:c:1', CHAT, { pinned: false }, 2_000)
241
+ expect(tg.ids()).toEqual([])
242
+ expect(rowIds(fs)).toEqual([])
243
+ expect(gw.claims.size).toBe(0)
244
+ })
245
+
246
+ it('steady-state re-pin of the SAME id issues NO extra API call and does not re-age the claim', async () => {
247
+ // The worker feed re-drives the reconcile on EVERY edit (20+/turn). A pin
248
+ // storm here is a rate-limit incident, and re-stamping pinnedAt would make
249
+ // the pin immortal against the reaper's TTL.
250
+ const fs = memFs()
251
+ const tg = fakeTelegram()
252
+ const gw = makeSubsystem(fs, tg)
253
+ await gw.reconcileStatusPin('wk:group:c', CHAT, { pinned: true, messageId: 200 }, 1_000)
254
+ for (let i = 1; i <= 10; i++) {
255
+ await gw.reconcileStatusPin('wk:group:c', CHAT, { pinned: true, messageId: 200 }, 1_000 + i * 1_000)
256
+ }
257
+ expect(tg.verbs()).toEqual(['pin:200'])
258
+ expect(gw.claims.get('wk:group:c')?.pinnedAt).toBe(1_000)
259
+ // …and the durable row keeps the ORIGINAL age too (#3810), so a restart
260
+ // inherits an honest claim age rather than a perpetually-fresh one.
261
+ expect(loadStatusPins(PATH, fs)[0]?.pinnedAt).toBe(1_000)
262
+ })
263
+ })
264
+
265
+ // ── 2. worker pins + the mid-session reaper ───────────────────────────────
266
+
267
+ describe('lifecycle — worker pin reaped mid-session', () => {
268
+ it('a TERMINAL worker (missed onFinish) is unpinned at any age and its row dropped', async () => {
269
+ const fs = memFs()
270
+ const tg = fakeTelegram()
271
+ const gw = makeSubsystem(fs, tg)
272
+ await gw.reconcileStatusPin('wk:agent-a', CHAT, { pinned: true, messageId: 300 }, 1_000)
273
+ expect(tg.ids()).toEqual([300])
274
+
275
+ await gw.midSessionWorkerReap({
276
+ now: 2_000, // seconds old — the TTL is nowhere near
277
+ ttlMs: 6 * HOUR,
278
+ statusOf: () => 'terminal',
279
+ })
280
+
281
+ expect(tg.ids()).toEqual([])
282
+ expect(rowIds(fs)).toEqual([])
283
+ expect(gw.claims.size).toBe(0)
284
+ })
285
+
286
+ it('a registry-confirmed RUNNING worker keeps its pin past the TTL (no mid-run churn)', async () => {
287
+ const fs = memFs()
288
+ const tg = fakeTelegram()
289
+ const gw = makeSubsystem(fs, tg)
290
+ await gw.reconcileStatusPin('wk:agent-a', CHAT, { pinned: true, messageId: 300 }, 0)
291
+ await gw.midSessionWorkerReap({ now: 7 * HOUR, ttlMs: 6 * HOUR, statusOf: () => 'running' })
292
+ expect(tg.ids()).toEqual([300])
293
+ })
294
+
295
+ it('leaves fg:/banner: pins alone — the worker reaper only ever touches wk: keys', async () => {
296
+ const fs = memFs()
297
+ const tg = fakeTelegram()
298
+ const gw = makeSubsystem(fs, tg)
299
+ await gw.reconcileStatusPin('fg:c:1', CHAT, { pinned: true, messageId: 100 }, 0)
300
+ await gw.reconcileStatusPin('banner:owner', CHAT, { pinned: true, messageId: 101 }, 0)
301
+ await gw.midSessionWorkerReap({ now: 99 * HOUR, ttlMs: 1, statusOf: () => 'terminal' })
302
+ expect(tg.ids()).toEqual([100, 101])
303
+ })
304
+ })
305
+
306
+ // ── 3. #3810 — the store orphan ages honestly across a restart ────────────
307
+
308
+ describe('lifecycle — restart with a stale claim (#3810)', () => {
309
+ it('a wk: row orphaned by a crash is TTL-reaped mid-session, using its REAL age', async () => {
310
+ const fs = memFs()
311
+ const tg = fakeTelegram()
312
+
313
+ // Session 1 pins a worker message, then the process dies (no SIGTERM
314
+ // sweep): the claim is gone, the Telegram pin and the durable row survive.
315
+ const gw1 = makeSubsystem(fs, tg)
316
+ await gw1.reconcileStatusPin('wk:agent-a', CHAT, { pinned: true, messageId: 300 }, 0)
317
+
318
+ // Session 2: no in-memory claim. The worker's turnsDb row has been pruned,
319
+ // so the registry can only ever answer 'unknown' — pre-#3810 the store
320
+ // orphan was stamped `pinnedAt = now` on EVERY pass, so the TTL never
321
+ // elapsed for it and the pin survived until the next boot.
322
+ const gw2 = makeSubsystem(fs, tg)
323
+ expect(gw2.claims.size).toBe(0)
324
+
325
+ // Not yet past the TTL: untouched.
326
+ await gw2.midSessionWorkerReap({ now: 5 * HOUR, ttlMs: 6 * HOUR, statusOf: () => 'unknown' })
327
+ expect(tg.ids()).toEqual([300])
328
+
329
+ // Past the TTL, measured from the ORIGINAL claim time: reaped.
330
+ await gw2.midSessionWorkerReap({ now: 7 * HOUR, ttlMs: 6 * HOUR, statusOf: () => 'unknown' })
331
+ expect(tg.ids()).toEqual([])
332
+ expect(rowIds(fs)).toEqual([])
333
+ })
334
+
335
+ it('a pre-v3 row (no recorded age) is NOT TTL-reaped — it stays conservative until the next write', async () => {
336
+ // The compatibility half of the same fix: an upgraded gateway must not
337
+ // guess an age for a row written by an older build. Such a row is only
338
+ // reaped on a TERMINAL verdict, and self-heals on the next write.
339
+ const fs = memFs()
340
+ const tg = fakeTelegram()
341
+ fs.writeFileSync(
342
+ PATH,
343
+ JSON.stringify({
344
+ v: 2,
345
+ pins: [{ pinKey: 'wk:agent-a', chatId: CHAT, messageId: 300 }],
346
+ }),
347
+ )
348
+ tg.pinned.add(`${CHAT}:300`)
349
+ const gw = makeSubsystem(fs, tg)
350
+
351
+ await gw.midSessionWorkerReap({ now: 99 * HOUR, ttlMs: 6 * HOUR, statusOf: () => 'unknown' })
352
+ expect(tg.ids()).toEqual([300]) // TTL cannot fire without an honest age
353
+
354
+ await gw.midSessionWorkerReap({ now: 99 * HOUR, ttlMs: 6 * HOUR, statusOf: () => 'terminal' })
355
+ expect(tg.ids()).toEqual([]) // a positive verdict still reaps it
356
+ })
357
+
358
+ it('a crash-orphaned pin is cleared by the NEXT BOOT sweep even if nothing reaps it first', async () => {
359
+ const fs = memFs()
360
+ const tg = fakeTelegram()
361
+ const gw1 = makeSubsystem(fs, tg)
362
+ await gw1.reconcileStatusPin('fg:c:1', CHAT, { pinned: true, messageId: 100 }, 0)
363
+ // crash — no sweep
364
+ expect(tg.ids()).toEqual([100])
365
+
366
+ const gw2 = makeSubsystem(fs, tg)
367
+ const res = await gw2.bootCleanup()
368
+ expect(res.cleared).toBe(1)
369
+ expect(tg.ids()).toEqual([])
370
+ expect(rowIds(fs)).toEqual([])
371
+ })
372
+ })
373
+
374
+ // ── 4. SIGTERM ────────────────────────────────────────────────────────────
375
+
376
+ describe('lifecycle — SIGTERM sweep', () => {
377
+ it('releases every claimed pin and empties the durable store', async () => {
378
+ const fs = memFs()
379
+ const tg = fakeTelegram()
380
+ const gw = makeSubsystem(fs, tg)
381
+ await gw.reconcileStatusPin('fg:c:1', CHAT, { pinned: true, messageId: 100 }, 0)
382
+ await gw.reconcileStatusPin('wk:agent-a', CHAT, { pinned: true, messageId: 300 }, 0)
383
+ await gw.reconcileStatusPin('wk:group:c', CHAT, { pinned: true, messageId: 400 }, 0)
384
+ expect(tg.ids()).toEqual([100, 300, 400])
385
+
386
+ await gw.sigtermSweep()
387
+
388
+ expect(tg.ids()).toEqual([])
389
+ expect(rowIds(fs)).toEqual([])
390
+ expect(gw.claims.size).toBe(0)
391
+
392
+ // And the next boot has nothing left to do — no phantom unpins.
393
+ const gw2 = makeSubsystem(fs, tg)
394
+ const res = await gw2.bootCleanup()
395
+ expect(res.total).toBe(0)
396
+ })
397
+ })
398
+
399
+ // ── 5. #3811 — the bridge flap must not reap a LIVE group pin ─────────────
400
+
401
+ describe('lifecycle — bridge flap (#3811)', () => {
402
+ const liveFeed = (keys: string[]): WorkerFeedLivenessProbe => ({
403
+ hasRunningInFeed: (k) => keys.includes(k),
404
+ })
405
+
406
+ it('does NOT unpin a live group pin while the feed is being rebuilt (no feed to ask)', async () => {
407
+ const fs = memFs()
408
+ const tg = fakeTelegram()
409
+ const gw = makeSubsystem(fs, tg)
410
+ await gw.reconcileStatusPin('wk:group:c', CHAT, { pinned: true, messageId: 400 }, 0)
411
+
412
+ // The bridge dropped: workerActivityFeed is null for this tick. Pre-fix
413
+ // this read as `'terminal'` — which reaps at ANY age, bypassing the TTL —
414
+ // and yanked the pin out from under still-running workers.
415
+ await gw.midSessionWorkerReap({ now: 60_000, ttlMs: 6 * HOUR, feed: null })
416
+
417
+ expect(tg.ids()).toEqual([400])
418
+ expect(rowIds(fs)).toEqual([['wk:group:c', 400]])
419
+ })
420
+
421
+ it('still reaps a group pin the LIVE feed says is finished (the missed-unpin backstop survives)', async () => {
422
+ const fs = memFs()
423
+ const tg = fakeTelegram()
424
+ const gw = makeSubsystem(fs, tg)
425
+ await gw.reconcileStatusPin('wk:group:c', CHAT, { pinned: true, messageId: 400 }, 0)
426
+ await gw.midSessionWorkerReap({ now: 60_000, ttlMs: 6 * HOUR, feed: liveFeed([]) })
427
+ expect(tg.ids()).toEqual([])
428
+ })
429
+
430
+ it('never reaps a group the live feed still reports as running', async () => {
431
+ const fs = memFs()
432
+ const tg = fakeTelegram()
433
+ const gw = makeSubsystem(fs, tg)
434
+ await gw.reconcileStatusPin('wk:group:c', CHAT, { pinned: true, messageId: 400 }, 0)
435
+ await gw.midSessionWorkerReap({ now: 99 * HOUR, ttlMs: 6 * HOUR, feed: liveFeed(['c']) })
436
+ expect(tg.ids()).toEqual([400])
437
+ })
438
+
439
+ it('a feed-null tick still bounds a genuinely STALE group pin by the TTL', async () => {
440
+ // "Unknown" is not "immortal": the flap window protects live pins without
441
+ // giving up the stale-pin ceiling.
442
+ const fs = memFs()
443
+ const tg = fakeTelegram()
444
+ const gw = makeSubsystem(fs, tg)
445
+ await gw.reconcileStatusPin('wk:group:c', CHAT, { pinned: true, messageId: 400 }, 0)
446
+ await gw.midSessionWorkerReap({ now: 7 * HOUR, ttlMs: 6 * HOUR, feed: null })
447
+ expect(tg.ids()).toEqual([])
448
+ })
449
+ })
450
+
451
+ // ── 6. #3809 — one claim record, so a chat can never go missing ───────────
452
+
453
+ describe('lifecycle — the claim carries its own chat (#3809)', () => {
454
+ it('a wk: claim stays reapable across a retarget (the chat survives the intermediate cleared state)', async () => {
455
+ // The old three-map registry (`state` / `chatIds` / `pinnedAt`) was written
456
+ // and cleared in several places; a `wk:` key that kept its state entry but
457
+ // lost its chatId entry was skipped by the in-memory reaper on EVERY pass
458
+ // (it cannot unpin without a chat) AND excluded from the store-orphan net
459
+ // (which skips keys that DO have an in-memory claim) — permanently
460
+ // unreapable, silently, until the next boot. One record makes that state
461
+ // unrepresentable; this asserts the observable consequence end to end.
462
+ const fs = memFs()
463
+ const tg = fakeTelegram()
464
+ const gw = makeSubsystem(fs, tg)
465
+
466
+ await gw.reconcileStatusPin('wk:agent-a', CHAT, { pinned: true, messageId: 300 }, 0)
467
+ await gw.reconcileStatusPin('wk:agent-a', CHAT, { pinned: true, messageId: 301 }, 1_000)
468
+ expect(tg.ids()).toEqual([301])
469
+
470
+ for (const c of gw.claims.values()) expect(c.chatId).not.toBe('')
471
+
472
+ await gw.midSessionWorkerReap({ now: 2_000, ttlMs: 6 * HOUR, statusOf: () => 'terminal' })
473
+ expect(tg.ids()).toEqual([])
474
+ expect(rowIds(fs)).toEqual([])
475
+ })
476
+ })
477
+
478
+ // ── 7. #3812 — the fg: claim is released BEFORE the card is deleted ───────
479
+
480
+ /** The lane's fake bot: records sends/edits/deletes AND drives the pin subsystem
481
+ * through the same fake Telegram, so ordering across both is observable. */
482
+ function laneBot(tg: ReturnType<typeof fakeTelegram>, chat: string) {
483
+ let nextId = 3000
484
+ return {
485
+ sendRichMessage: async (_c: string, _b: { markdown: string }) => ({ message_id: ++nextId }),
486
+ sendMessage: async (_c: string, _t: string) => ({ message_id: ++nextId }),
487
+ editMessageText: async () => ({}),
488
+ deleteMessage: async (_c: string, m: number) => {
489
+ tg.deleted(chat, m)
490
+ return true
491
+ },
492
+ }
493
+ }
494
+
495
+ function laneTurn(lane: ReturnType<typeof createNarrativeLane>, chat: string): CurrentTurn {
496
+ const turn = {
497
+ turnId: 'turn-pin-1',
498
+ sessionChatId: chat,
499
+ sessionThreadId: undefined,
500
+ sourceMessageId: null,
501
+ registryKey: null,
502
+ startedAt: Date.now() - 3000,
503
+ currentModel: null,
504
+ totalTokens: 0,
505
+ labeledToolCount: 1,
506
+ mirrorLines: [] as string[],
507
+ foregroundSubAgents: new Map<string, string[]>(),
508
+ activityPendingRender: null as string | null,
509
+ activityLastSentRender: null as string | null,
510
+ activityMessageId: null as number | null,
511
+ activityInFlight: null as Promise<void> | null,
512
+ activityEverOpened: false,
513
+ activityDrainFailures: 0,
514
+ finalAnswerEverDelivered: false,
515
+ finalAnswerDelivered: false,
516
+ replyCalled: false,
517
+ capturedText: [] as string[],
518
+ lastReplyText: '',
519
+ answerStream: null,
520
+ liveness: { recentlyStreaming: () => false, onStreamEvent: () => {}, note: () => {} },
521
+ } as unknown as CurrentTurn
522
+ ;(turn as { narrativeGate?: unknown }).narrativeGate = lane.makeNarrativeGate(turn)
523
+ return turn
524
+ }
525
+
526
+ describe('lifecycle — clearActivitySummary releases the pin before deleting the card (#3812)', () => {
527
+ it('unpins the fg: claim and clears its durable row BEFORE the delete, so turn-end has nothing left to fail on', async () => {
528
+ const fs = memFs()
529
+ const tg = fakeTelegram()
530
+ const gw = makeSubsystem(fs, tg)
531
+ const noop = () => {}
532
+ const fakeEA = {
533
+ mayDrain: () => true,
534
+ openOrEditCard: (_p: string, fn: () => void) => fn(),
535
+ finalizeCard: (fn: () => void) => fn(),
536
+ markSubstantiveFinalDelivered: (fn: () => void) => fn(),
537
+ }
538
+ const deps = {
539
+ ACTIVITY_CARD_STORE_PATH: `${tmpdir()}/pin-lifecycle-activity-cards.json`,
540
+ // The path that DELETES the card at turn end — the one where a claim on a
541
+ // dead message id produced a guaranteed 4xx unpin every single turn.
542
+ CLEAR_STATUS_ON_COMPLETION: true,
543
+ FEED_HEARTBEAT_ENABLED: false,
544
+ FEED_HEARTBEAT_MIN_STALE_MS: 6000,
545
+ FEED_LIVENESS_OPEN_ENABLED: false,
546
+ FEED_LIVENESS_OPEN_MS: 5000,
547
+ PIN_STATUS_WHILE_WORKING: true,
548
+ POST_ANSWER_LIVENESS_STALE_MS: 90000,
549
+ STATIC: false,
550
+ activeDraftStreams: new Map(),
551
+ activityCardPersistEnabled: false,
552
+ activityCardStoreFs: {
553
+ readFileSync: () => '',
554
+ writeFileSync: noop,
555
+ mkdirSync: noop,
556
+ renameSync: noop,
557
+ unlinkSync: noop,
558
+ },
559
+ bot: { api: laneBot(tg, CHAT) },
560
+ cardDrainGate: (_t: unknown, _ea: unknown, run: () => void) => run(),
561
+ currentTurnMap: { get: () => null, byKey: new Map() },
562
+ earlyLivenessOpenTimers: new Map(),
563
+ emissionAuthorityFor: () => fakeEA,
564
+ feedOpenGateDeps: () => ({
565
+ hasOutboundDeliveredSince: () => false,
566
+ historyEnabled: false,
567
+ finalAnswerMinChars: 200,
568
+ }),
569
+ getCurrentTurn: () => null,
570
+ // THE seam under test: the lane's pin hook wired to the real subsystem.
571
+ reconcileStatusPin: (key: string, chat: string, desired: DesiredPin) =>
572
+ gw.reconcileStatusPin(key, chat, desired),
573
+ robustApiCall: (fn: () => Promise<unknown>) => fn(),
574
+ statusKey: (c: string, t?: number | null) => `${c}:${t ?? 'main'}`,
575
+ } as unknown as NarrativeLaneDeps
576
+
577
+ const lane = createNarrativeLane(deps)
578
+ const turn = laneTurn(lane, CHAT)
579
+
580
+ lane.showNarrativeStep(turn, 'Doing the work now')
581
+ await turn.activityInFlight
582
+ await new Promise((r) => setTimeout(r, 20))
583
+ const cardId = turn.activityMessageId
584
+ expect(cardId).not.toBeNull()
585
+ expect(tg.ids()).toEqual([cardId])
586
+ expect(rowIds(fs)).toEqual([[`fg:${CHAT}:main`, cardId!]])
587
+
588
+ lane.clearActivitySummary(turn)
589
+ await new Promise((r) => setTimeout(r, 40))
590
+
591
+ // OUTCOME: the unpin landed, and it landed BEFORE the delete — so the
592
+ // claim never named a deleted message.
593
+ expect(tg.verbs()).toEqual([`pin:${cardId}`, `unpin:${cardId}`, `delete:${cardId}`])
594
+ const order = tg.calls.map((c) => c.verb)
595
+ expect(order).toEqual(['pin', 'unpin', 'delete'])
596
+ expect(rowIds(fs)).toEqual([])
597
+
598
+ // …and the turn-end backstop is now a genuine no-op: no second API call.
599
+ await gw.reconcileStatusPin(`fg:${CHAT}:main`, CHAT, { pinned: false })
600
+ expect(tg.calls.filter((c) => c.verb === 'unpin')).toHaveLength(1)
601
+ })
602
+ })