switchroom 0.20.5 → 0.20.7

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.
@@ -49,6 +49,16 @@ export interface PendingInboundBuffer {
49
49
  depth: (agent: string) => number
50
50
  /** Test-only: total depth across all agents. */
51
51
  totalDepth: () => number
52
+ /**
53
+ * Delivery-time gate consulted by `redeliverBufferedInbound` for EVERY message
54
+ * it is about to hand to the bridge. Returns TRUE to proceed, FALSE to RETRACT
55
+ * (drop) the message (the spool entry is acked so it is not boot-replayed, and
56
+ * it is NOT re-buffered). The gateway wires this to the represent delivery
57
+ * re-check (represent-delivery-guard.ts): a represent buffered while its
58
+ * obligation was open must be re-evaluated at drain time, since a reply may have
59
+ * landed between the sweep's decision and this drain. Undefined ⇒ never retract.
60
+ */
61
+ beforeRedeliver?: (msg: InboundMessage) => boolean
52
62
  }
53
63
 
54
64
  export interface PendingInboundBufferOptions {
@@ -94,6 +104,13 @@ export interface PendingInboundBufferOptions {
94
104
  * breaks the push hot path.
95
105
  */
96
106
  onHandbackEnqueue?: (chatId: string, threadId: number | undefined, ts: number) => void
107
+ /**
108
+ * Delivery-time retract gate — see `PendingInboundBuffer.beforeRedeliver`. The
109
+ * gateway supplies the represent delivery re-check here so EVERY drain path
110
+ * (idle-drain, bridge re-register, silence-poke fallback, turn-end) inherits it
111
+ * by construction rather than by per-call-site discipline.
112
+ */
113
+ beforeRedeliver?: (msg: InboundMessage) => boolean
97
114
  }
98
115
 
99
116
  /**
@@ -123,16 +140,38 @@ export function redeliverBufferedInbound(
123
140
  // silently dropped (clerk 2026-06-03). `send` returning true only means
124
141
  // the bytes reached the bridge, NOT that claude consumed them.
125
142
  onDelivered?: (merged: InboundMessage, originals: InboundMessage[]) => void,
126
- ): { drained: number; redelivered: number; rebuffered: number } {
143
+ ): { drained: number; redelivered: number; rebuffered: number; retracted: number } {
127
144
  const pending = buffer.drain(agent)
128
145
  let redelivered = 0
129
146
  let rebuffered = 0
147
+ let retracted = 0
130
148
  // Collapse consecutive same-sender Telegram user messages into one turn
131
149
  // (see planBufferedRedelivery) so a forwarded burst that spanned a turn
132
150
  // boundary doesn't fan out into N sequential replies. System inbounds
133
151
  // (vault grants, approvals, cron, handbacks — anything with meta.source)
134
152
  // are never merged and are delivered individually exactly as before.
135
153
  for (const { merged, originals } of planBufferedRedelivery(pending)) {
154
+ // Delivery-time retract (F1): a buffered `obligation_represent` whose reply
155
+ // has landed since the sweep decided it is stale — drop it rather than hand
156
+ // a duplicate to the CLI queue. Ack the spool so it is not boot-replayed and
157
+ // do NOT re-buffer. The gateway closure closes the ledger + logs the retract.
158
+ // FAIL-OPEN: a throwing predicate must never silence or lose a real message,
159
+ // nor abort the drain loop (which would strand every following message and
160
+ // crash the setInterval tick). On throw we treat it as "deliver".
161
+ let proceed = true
162
+ if (buffer.beforeRedeliver != null) {
163
+ try {
164
+ proceed = buffer.beforeRedeliver(merged)
165
+ } catch (e) {
166
+ proceed = true
167
+ process.stderr.write(`redeliver beforeRedeliver threw — failing open (deliver): ${String(e)}\n`)
168
+ }
169
+ }
170
+ if (!proceed) {
171
+ for (const o of originals) spool?.ack(o)
172
+ retracted += originals.length
173
+ continue
174
+ }
136
175
  let delivered = false
137
176
  try {
138
177
  delivered = send(merged)
@@ -157,7 +196,7 @@ export function redeliverBufferedInbound(
157
196
  rebuffered += originals.length
158
197
  }
159
198
  }
160
- return { drained: pending.length, redelivered, rebuffered }
199
+ return { drained: pending.length, redelivered, rebuffered, retracted }
161
200
  }
162
201
 
163
202
  /** True when `msg` is an ordinary Telegram user message eligible to be
@@ -312,7 +351,7 @@ export function idleDrainTick(
312
351
  // enrols redelivered inbounds in the deliver-until-acked queue (parity with
313
352
  // the bridgeUp drain — clerk lost-message incident, 2026-06-03).
314
353
  onDelivered?: (merged: InboundMessage, originals: InboundMessage[]) => void,
315
- ): { drained: number; redelivered: number; rebuffered: number } | null {
354
+ ): { drained: number; redelivered: number; rebuffered: number; retracted: number } | null {
316
355
  if (!agent) return null
317
356
  if (buffer.depth(agent) === 0) return null
318
357
  if (!isBridgeAlive()) return null
@@ -412,5 +451,6 @@ export function createPendingInboundBuffer(
412
451
  for (const q of queues.values()) n += q.length
413
452
  return n
414
453
  },
454
+ beforeRedeliver: opts.beforeRedeliver,
415
455
  }
416
456
  }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * represent-delivery-guard.ts — the DELIVERY-time half of the duplicate-represent
3
+ * defence (fix/represent-double-send-delivery-recheck).
4
+ *
5
+ * Background — the race this closes (verified from history.db, 2026):
6
+ * 1. A ~5-min silence poke fires the framework fallback, which clears the
7
+ * machine turn (`turnInFlightForGate()` → false) while the CLI session is
8
+ * STILL producing the answer (it lands ~17s later). stream-render's live-turn
9
+ * mirror (`currentTurn`/`parkedTurnStarts`) still knows the session is busy.
10
+ * 2. With the machine turn cleared, `obligationSweep` runs; `shouldSuppressRepresent`
11
+ * correctly returns false (nothing has been sent YET) and the represent is
12
+ * buffered; the idle-drain flushes it into the live CLI queue.
13
+ * 3. ~13s later the real answer is delivered + recorded (ledger closed by the
14
+ * normal path). Then the model consumes the already-queued represent and
15
+ * answers a SECOND time → the duplicate.
16
+ *
17
+ * The represent guard never FAILED — it was consulted BEFORE the reply existed,
18
+ * and there was no re-check between decision → delivery → model consumption. This
19
+ * module adds that missing re-check (F1) plus the bounded busy-defer for the
20
+ * idle-drain gate (F2). Layer F2 (obligation-wiring's sweep + this drain defer)
21
+ * keeps the represent BUFFERED while the session is busy so that, by the time a
22
+ * drain actually hands it to the bridge, the real answer has landed and been
23
+ * recorded — at which point F1 retracts the now-stale represent. `recordOutbound`
24
+ * is a synchronous SQLite write that completes before the reply tool result
25
+ * returns, so a post-reply drain observes the row.
26
+ *
27
+ * Both halves are PURE-ish factories (no Telegram, no SQLite) so the whole
28
+ * decision is executable in a unit test; the gateway injects the ledger accessor,
29
+ * the outbound-history predicate, and the logger.
30
+ */
31
+
32
+ import type { InboundMessage } from './ipc-protocol.js'
33
+ import { shouldSuppressRepresent, type RepresentGuardObligation } from './represent-guard.js'
34
+
35
+ /** The minimal ledger surface the delivery re-check needs. */
36
+ export interface RepresentDeliveryLedger {
37
+ /** The CURRENTLY-open obligation for `originTurnId`, or undefined if none is
38
+ * open (already closed — answered or cancelled — since the represent was
39
+ * buffered). */
40
+ get(originTurnId: string): RepresentGuardObligation | undefined
41
+ /** Close the obligation. Idempotent; returns whether an entry was removed. */
42
+ close(originTurnId: string | null | undefined): boolean
43
+ }
44
+
45
+ export interface RepresentRedeliveryGuardDeps {
46
+ /** OBLIGATION_LEDGER_ENABLED — when off, never retract (nothing is tracked). */
47
+ enabled: boolean
48
+ /** HISTORY_ENABLED — forwarded to the pure guard (no history ⇒ never suppress). */
49
+ historyEnabled: boolean
50
+ ledger: RepresentDeliveryLedger
51
+ /** history.hasOutboundDeliveredSince, already curried with the chat query. */
52
+ hasOutboundDeliveredSince: (
53
+ chatId: string,
54
+ sinceMs: number,
55
+ threadId?: number,
56
+ minChars?: number,
57
+ ) => boolean
58
+ /** The represent guard's OWN low reply-length threshold (a terse-but-real reply
59
+ * must still suppress the duplicate — #2472/#2474). */
60
+ minReplyChars: number
61
+ log: (line: string) => void
62
+ }
63
+
64
+ /**
65
+ * Build the `beforeRedeliver` predicate the pending-inbound buffer consults for
66
+ * EVERY message it is about to hand to the CLI bridge. Returns TRUE to proceed
67
+ * with delivery, FALSE to RETRACT (drop) the message.
68
+ *
69
+ * Only `obligation_represent` inbounds are ever retracted; everything else always
70
+ * proceeds. A represent is retracted when, AT DELIVERY TIME:
71
+ * - the obligation is no longer open (a reply landed and the normal close path
72
+ * fired, or it was cancelled) — re-delivering would duplicate / re-ask a
73
+ * resolved question; OR
74
+ * - it IS still open but `shouldSuppressRepresent` now returns true against the
75
+ * obligation's CURRENT cutoff (a reply landed since decision, but the normal
76
+ * close path missed it because its routing did not resolve back to the origin
77
+ * — the exact #2472 gap, now caught at delivery instead of only at decision).
78
+ *
79
+ * The cutoff is the obligation's own (`openedAt` for the first represent,
80
+ * `lastRepresentedAt` thereafter) — encoded inside `shouldSuppressRepresent`, so
81
+ * an OLD reply to a DIFFERENT earlier question does not suppress a legitimate new
82
+ * represent (#2472 second-represent semantics). The genuine plain-text-no-reply
83
+ * case (#2788) records NO outbound row, so the predicate reports false at BOTH
84
+ * decision and here ⇒ the represent still fires exactly once.
85
+ */
86
+ export function makeRepresentRedeliveryGuard(
87
+ deps: RepresentRedeliveryGuardDeps,
88
+ ): (msg: InboundMessage) => boolean {
89
+ return (msg) => {
90
+ if (!deps.enabled) return true
91
+ if (msg.meta?.source !== 'obligation_represent') return true
92
+ const originTurnId = msg.meta?.origin_turn_id
93
+ if (originTurnId == null) return true
94
+
95
+ const o = deps.ledger.get(originTurnId)
96
+ if (o == null) {
97
+ // Closed since this represent was buffered — a reply landed (normal close)
98
+ // or the turn was cancelled/interrupted. Either way the buffered represent
99
+ // is stale: drop it rather than re-ask an already-resolved obligation.
100
+ deps.log(
101
+ `telegram gateway: represent retracted at delivery — obligation already ` +
102
+ `closed since decision (no re-fire) origin=${originTurnId}\n`,
103
+ )
104
+ return false
105
+ }
106
+
107
+ const suppress = shouldSuppressRepresent(o, {
108
+ historyEnabled: deps.historyEnabled,
109
+ hasOutboundDeliveredSince: (chatId, sinceMs, threadId) =>
110
+ deps.hasOutboundDeliveredSince(chatId, sinceMs, threadId, deps.minReplyChars),
111
+ })
112
+ if (suppress) {
113
+ // A reply landed since this obligation's cutoff but the normal close path
114
+ // missed it. Close the ledger entry ourselves and drop the represent.
115
+ deps.ledger.close(originTurnId)
116
+ deps.log(
117
+ `telegram gateway: represent retracted at delivery — reply landed since ` +
118
+ `decision (no re-fire) origin=${originTurnId}\n`,
119
+ )
120
+ return false
121
+ }
122
+ return true
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Build the BOUNDED busy-defer predicate for the idle-drain gate (F2, drain
128
+ * half). Returns TRUE while the drain should be DEFERRED because the session is
129
+ * busy (stream-render's live-turn mirror), FALSE once it is safe to drain.
130
+ *
131
+ * Why bounded: the drain must not hand a buffered represent to a session that is
132
+ * mid-answer (that re-queues it BEHIND the real answer → the duplicate). But a
133
+ * session that is WEDGED busy forever must not silence a buffered represent
134
+ * forever (red-team #2). So the deferral is capped: once the session has been
135
+ * continuously busy for longer than `boundMs`, the drain proceeds regardless. The
136
+ * clock resets to "not deferring" the moment the session reads idle, so an
137
+ * ordinary busy turn (which ends well within the bound) defers cleanly and never
138
+ * consumes the wedge budget.
139
+ *
140
+ * `boundMs <= 0` disables the busy-defer entirely (kill switch / parity with the
141
+ * background-work grace being disabled).
142
+ */
143
+ export function makeSessionBusyDrainDeferral(
144
+ boundMs: number,
145
+ ): (busy: boolean, now: number) => boolean {
146
+ let deferringSince: number | null = null
147
+ return (busy, now) => {
148
+ if (!busy || boundMs <= 0) {
149
+ deferringSince = null
150
+ return false
151
+ }
152
+ if (deferringSince == null) deferringSince = now
153
+ // Bounded: stop deferring once we have been busy past the ceiling, so a
154
+ // wedged/hung session still eventually drains the buffered represent.
155
+ return now - deferringSince < boundMs
156
+ }
157
+ }
@@ -221,7 +221,44 @@ export function pendingSweepCursors(cursors: readonly SweepCursor[]): SweepCurso
221
221
  return cursors.filter((c) => !c.done && c.attempts < SWEEP_MAX_ATTEMPTS)
222
222
  }
223
223
 
224
- /** Drop discharged and forfeited rows — called once the ledger is reseeded. */
224
+ /**
225
+ * Boot seed pass: drop DISCHARGED (`done: true`) rows ONLY, so a chat that
226
+ * drained in a prior session is re-evaluated LIVE on the next sweep instead of
227
+ * short-circuiting forever on a stale `done` (#3953 regression — the seed pass
228
+ * was never wired, so `done` was effectively permanent).
229
+ *
230
+ * FORFEITED rows (`attempts >= SWEEP_MAX_ATTEMPTS`, still `!done`) are
231
+ * deliberately RETAINED. Their whole purpose is to remember that a chat's
232
+ * attempt budget is spent (bot kicked, pin rights revoked, a chat that
233
+ * flood-waits forever). Pruning one would let the next boot re-seed it from the
234
+ * pin stores and re-burn the full 8-attempt budget on every boot for the rest
235
+ * of time — precisely the Telegram flood the attempt cap exists to prevent. So
236
+ * this filters on `done` ALONE; it must NOT also gate on `attempts`.
237
+ */
225
238
  export function pruneSweepCursors(cursors: readonly SweepCursor[]): SweepCursor[] {
226
- return cursors.filter((c) => !c.done && c.attempts < SWEEP_MAX_ATTEMPTS)
239
+ return cursors.filter((c) => !c.done)
240
+ }
241
+
242
+ /**
243
+ * BOOT-SEED reseed of the durable ledger (#3953): drop discharged (`done:true`)
244
+ * rows so a `(chat, thread)` that drained in a prior session is re-evaluated
245
+ * LIVE on the next sweep instead of short-circuiting forever on a stale `done`.
246
+ * Forfeited rows are retained (see {@link pruneSweepCursors}).
247
+ *
248
+ * Writes ONLY when the prune changed the ledger, so a no-op boot never churns
249
+ * the durable file. Never throws — a reseed failure degrades to the pre-fix
250
+ * behaviour (the stale `done` survives one more boot), it must not break boot.
251
+ *
252
+ * BOOT-SCOPED BY CONTRACT: the caller must invoke this once at boot, never on
253
+ * the per-inbound path (which fires on every message — re-arming a full re-drain
254
+ * per message is a Telegram flood).
255
+ */
256
+ export function reseedSweepLedger(
257
+ path: string,
258
+ fs: SweepStoreFsSeam,
259
+ log: (line: string) => void = (l) => process.stderr.write(l),
260
+ ): void {
261
+ const rows = loadSweepCursors(path, fs)
262
+ const pruned = pruneSweepCursors(rows)
263
+ if (pruned.length !== rows.length) persistSweepCursors(path, fs, pruned, log)
227
264
  }
@@ -32,6 +32,8 @@ import {
32
32
  import {
33
33
  SWEEP_MAX_ATTEMPTS,
34
34
  loadSweepCursors,
35
+ pruneSweepCursors,
36
+ reseedSweepLedger,
35
37
  upsertSweepCursor,
36
38
  type SweepCursor,
37
39
  type SweepStoreFsSeam,
@@ -907,3 +909,163 @@ describe('stale-pin sweep — classification and seeding', () => {
907
909
  expect(isNothingToUnpinError(new Error('something else'))).toBe(false)
908
910
  })
909
911
  })
912
+
913
+ // ─── #3953: the boot-seed prune reseeds a discharged obligation ───────────────
914
+ //
915
+ // Regression #3953 replaced the per-boot DM self-heal with a cursor-gated stack
916
+ // drain, but the boot "seed pass" that clears discharged cursors was never
917
+ // wired — so `done:true` was effectively PERMANENT and every later boot /
918
+ // first-inbound sweep short-circuited on `already-drained`, orphaning any pin
919
+ // that leaked AFTER the first drain. These tests pin the reseed contract:
920
+ // 1. `pruneSweepCursors` drops `done` rows ONLY and RETAINS forfeited
921
+ // (attempts-exhausted) rows — a store-level test that FAILS on the pre-fix
922
+ // filter (which also dropped forfeited rows, re-burning the attempt budget
923
+ // every boot = flood risk).
924
+ // 2. Running that prune between two fresh-process sweeps re-arms the drain, so
925
+ // a pin leaked after a prior discharge is reaped — for a DM AND a
926
+ // supergroup (channel-class) target alike.
927
+
928
+ /** The boot-seed reseed exactly as the gateway wires it (`gateway.ts`
929
+ * `seedPruneSweepCursors` → `reseedSweepLedger`). */
930
+ function bootSeedPrune(fs: SweepStoreFsSeam, path: string): void {
931
+ reseedSweepLedger(path, fs, () => {})
932
+ }
933
+
934
+ /** A sweeper over a SHARED durable store — a distinct instance models a fresh
935
+ * process (reboot), so the only state carried across is the on-disk ledger. */
936
+ function sweeperOver(
937
+ fs: SweepStoreFsSeam,
938
+ path: string,
939
+ fake: ReturnType<typeof fakeChat>,
940
+ opts: { recordedPinIds?: number[] } = {},
941
+ ) {
942
+ let clock = 1_000_000
943
+ const deps: StalePinSweepDeps = {
944
+ getTopPinnedMessageId: fake.getTopPinnedMessageId,
945
+ pinSilent: fake.pinSilent,
946
+ unpin: fake.unpin,
947
+ unpinAllForumTopicMessages: fake.unpinAllForumTopicMessages,
948
+ canPinInChat: fake.canPinInChat,
949
+ protectedMessageIds: () => [],
950
+ recordedPinIds: () => opts.recordedPinIds ?? [],
951
+ eligible: () => true,
952
+ sleep: async (ms) => {
953
+ clock += ms
954
+ },
955
+ now: () => clock,
956
+ store: { path, fs },
957
+ log: () => {},
958
+ }
959
+ return createStalePinSweeper(deps)
960
+ }
961
+
962
+ describe('pruneSweepCursors — boot-seed reseed (#3953)', () => {
963
+ const now = 1_000_000
964
+
965
+ it('drops discharged rows but RETAINS forfeited (attempts-exhausted) rows', () => {
966
+ const discharged: SweepCursor = {
967
+ chatId: DM,
968
+ kind: 'dm',
969
+ popped: 3,
970
+ done: true,
971
+ attempts: 1,
972
+ updatedAt: now,
973
+ }
974
+ // A no-rights group that spent its whole attempt budget: `done` is false,
975
+ // but re-seeding it would re-burn all 8 attempts on the NEXT boot, and every
976
+ // boot after — the exact Telegram flood the attempt cap exists to stop.
977
+ const forfeited: SweepCursor = {
978
+ chatId: GROUP,
979
+ kind: 'supergroup',
980
+ popped: 0,
981
+ done: false,
982
+ attempts: SWEEP_MAX_ATTEMPTS,
983
+ lastStatus: 'skipped-no-rights',
984
+ updatedAt: now,
985
+ }
986
+ const stillOwed: SweepCursor = {
987
+ chatId: '900000002',
988
+ kind: 'dm',
989
+ popped: 0,
990
+ done: false,
991
+ attempts: 2,
992
+ updatedAt: now,
993
+ }
994
+
995
+ const keys = pruneSweepCursors([discharged, forfeited, stillOwed]).map((c) => c.chatId)
996
+
997
+ expect(keys).not.toContain(DM) // discharged → reseeded (dropped)
998
+ expect(keys).toContain(GROUP) // forfeited no-rights → RETAINED (no re-burn)
999
+ expect(keys).toContain('900000002') // still owed → retained untouched
1000
+ })
1001
+
1002
+ it('clears discharged rows for EVERY surface — dm, forum-topic, supergroup', () => {
1003
+ // The prune must be kind-agnostic: a stuck `done` on a topic or a channel is
1004
+ // the same regression as on a DM, so none may be left short-circuiting.
1005
+ const rows: SweepCursor[] = [
1006
+ { chatId: '1', kind: 'dm', popped: 1, done: true, attempts: 1, updatedAt: now },
1007
+ { chatId: '2', kind: 'forum-topic', threadId: 5, popped: 1, done: true, attempts: 1, updatedAt: now },
1008
+ { chatId: '3', kind: 'supergroup', popped: 1, done: true, attempts: 1, updatedAt: now },
1009
+ ]
1010
+ expect(pruneSweepCursors(rows)).toEqual([])
1011
+ })
1012
+ })
1013
+
1014
+ describe('stale-pin sweep — re-drain after the boot-seed prune (#3953)', () => {
1015
+ it('re-drains a DM whose obligation was discharged in a prior session', async () => {
1016
+ const fs = memFs()
1017
+ const path = '/state/stale-pin-sweep.json'
1018
+
1019
+ // Session 1: an orphan bot pin is drained and the obligation discharged.
1020
+ const s1 = await sweeperOver(fs, path, fakeChat({ stack: [11] })).sweepTarget({ chatId: DM })
1021
+ expect(s1.status).toBe('drained')
1022
+ expect(loadSweepCursors(path, fs).find((c) => c.chatId === DM)?.done).toBe(true)
1023
+
1024
+ // A NEW orphan pin leaks in after that drain.
1025
+ // Fresh process, no prune: the sweep short-circuits on the stale `done` —
1026
+ // the #3953 regression, verbatim. The orphan is left pinned.
1027
+ const stuckChat = fakeChat({ stack: [99] })
1028
+ const stuck = await sweeperOver(fs, path, stuckChat).sweepTarget({ chatId: DM })
1029
+ expect(stuck.status).toBe('already-drained')
1030
+ expect(stuck.popped).toBe(0)
1031
+ expect(stuckChat.stack).toEqual([99])
1032
+
1033
+ // Reboot WITH the boot-seed prune: the discharged row is reseeded, so the
1034
+ // next sweep re-evaluates the chat LIVE and reaps the orphan.
1035
+ bootSeedPrune(fs, path)
1036
+ const rebootChat = fakeChat({ stack: [99] })
1037
+ const redrain = await sweeperOver(fs, path, rebootChat).sweepTarget({ chatId: DM })
1038
+ expect(redrain.status).toBe('drained')
1039
+ expect(redrain.popped).toBe(1)
1040
+ expect(rebootChat.stack).toEqual([])
1041
+ })
1042
+
1043
+ it('re-drains a supergroup (channel-class) discharged in a prior session', async () => {
1044
+ const fs = memFs()
1045
+ const path = '/state/stale-pin-sweep.json'
1046
+
1047
+ // Session 1: the one recorded orphan is reaped, obligation discharged.
1048
+ const s1 = await sweeperOver(fs, path, fakeChat({ stack: [77], canPin: true }), {
1049
+ recordedPinIds: [77],
1050
+ }).sweepTarget({ chatId: GROUP })
1051
+ expect(s1.status).toBe('drained')
1052
+ expect(loadSweepCursors(path, fs).find((c) => c.chatId === GROUP)?.done).toBe(true)
1053
+
1054
+ // A fresh recorded orphan leaks in; a fresh process short-circuits on `done`.
1055
+ const stuckChat = fakeChat({ stack: [88], canPin: true })
1056
+ const stuck = await sweeperOver(fs, path, stuckChat, { recordedPinIds: [88] }).sweepTarget({
1057
+ chatId: GROUP,
1058
+ })
1059
+ expect(stuck.status).toBe('already-drained')
1060
+ expect(stuckChat.stack).toEqual([88])
1061
+
1062
+ // Reboot + prune: the supergroup row is reseeded and re-swept.
1063
+ bootSeedPrune(fs, path)
1064
+ const rebootChat = fakeChat({ stack: [88], canPin: true })
1065
+ const redrain = await sweeperOver(fs, path, rebootChat, { recordedPinIds: [88] }).sweepTarget({
1066
+ chatId: GROUP,
1067
+ })
1068
+ expect(redrain.status).toBe('drained')
1069
+ expect(rebootChat.stack).toEqual([])
1070
+ })
1071
+ })
@@ -279,6 +279,19 @@ export function __resetParkedTurnStartsForTest(): void {
279
279
  export function __parkedTurnStartCountForTest(): number {
280
280
  return parkedTurnStarts.length
281
281
  }
282
+ /**
283
+ * Production accessor for the parked-turn-start count — the second half of the
284
+ * stream-render "session busy" signal (the first being a live `currentTurn`
285
+ * whose `endedAt == null`). A parked turn-start means the CLI has an enqueued
286
+ * message it is about to turn on, so the session is NOT idle even when the
287
+ * gateway's machine-turn gate (`turnInFlightForGate`) reads clear — the exact
288
+ * disagreement a ~5-min silence poke opens (it clears the machine turn while the
289
+ * CLI is still producing the answer). The obligation sweep and the idle-drain
290
+ * consult this so they do not treat a poke-cleared-but-busy session as idle.
291
+ */
292
+ export function parkedTurnStartCount(): number {
293
+ return parkedTurnStarts.length
294
+ }
282
295
 
283
296
  /**
284
297
  * Silence-fallback unwedge for the parked store (the two-state desync fix).
@@ -161,6 +161,7 @@ describe('runBootPinSweepSteps (#3664 salvage S2)', () => {
161
161
  order.push('queued-cards')
162
162
  },
163
163
  enableSweep: () => order.push('enable-sweep'),
164
+ seedPruneSweepCursors: () => order.push('seed-prune'),
164
165
  sweepTarget: async (t) => {
165
166
  order.push(`sweep:${t.chatId}:${t.threadId ?? '-'}`)
166
167
  },
@@ -177,6 +178,7 @@ describe('runBootPinSweepSteps (#3664 salvage S2)', () => {
177
178
  'status-pins',
178
179
  'activity-cards',
179
180
  'queued-cards',
181
+ 'seed-prune',
180
182
  'enable-sweep',
181
183
  'sweep:900000001:-',
182
184
  ])
@@ -194,6 +196,7 @@ describe('runBootPinSweepSteps (#3664 salvage S2)', () => {
194
196
  'scan',
195
197
  'status-pins',
196
198
  'queued-cards',
199
+ 'seed-prune',
197
200
  'enable-sweep',
198
201
  'sweep:900000001:-',
199
202
  ])
@@ -210,7 +213,7 @@ describe('runBootPinSweepSteps (#3664 salvage S2)', () => {
210
213
  queuedCardReaper: boom,
211
214
  })
212
215
  await runBootPinSweepSteps(d.deps)
213
- expect(d.order).toEqual(['scan', 'enable-sweep', 'sweep:900000001:-'])
216
+ expect(d.order).toEqual(['scan', 'seed-prune', 'enable-sweep', 'sweep:900000001:-'])
214
217
  })
215
218
 
216
219
  it('a failing store scan still lets the reapers and the DM enable run', async () => {
@@ -221,7 +224,13 @@ describe('runBootPinSweepSteps (#3664 salvage S2)', () => {
221
224
  })
222
225
  await runBootPinSweepSteps(d.deps)
223
226
  // No targets to sweep, but nothing behind the scan is stranded.
224
- expect(d.order).toEqual(['status-pins', 'activity-cards', 'queued-cards', 'enable-sweep'])
227
+ expect(d.order).toEqual([
228
+ 'status-pins',
229
+ 'activity-cards',
230
+ 'queued-cards',
231
+ 'seed-prune',
232
+ 'enable-sweep',
233
+ ])
225
234
  expect(d.logs.join('')).toContain("step 'sweep-target-scan' failed: ENOENT")
226
235
  })
227
236
 
@@ -272,6 +281,7 @@ describe('runBootPinSweepSteps (#3664 salvage S2)', () => {
272
281
  order.push('queued-cards')
273
282
  },
274
283
  enableSweep: () => order.push('enable-sweep'),
284
+ seedPruneSweepCursors: () => {},
275
285
  sweepTarget: async () => {},
276
286
  log: () => {
277
287
  throw new Error('EPIPE')
@@ -296,6 +306,9 @@ describe('runBootPinSweepSteps (#3664 salvage S2)', () => {
296
306
  enableSweep: () => {
297
307
  throw new Error('boom')
298
308
  },
309
+ seedPruneSweepCursors: () => {
310
+ throw new Error('boom')
311
+ },
299
312
  sweepTarget: boom,
300
313
  log: () => {},
301
314
  }),