switchroom 0.18.22 → 0.18.24

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.
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Reply owner-turn resolution + answer-delivered latch decision
3
+ * (2026-07 double-reply-on-DM fix — completes the #3236 turnId-keyed dedup for
4
+ * the late-reply / DM path).
5
+ *
6
+ * ## The bug this closes
7
+ *
8
+ * On a DM agent a turn double-sent: the answer-ready quiescence flush posted the
9
+ * composed terminal answer as message A (no quote), then the model's REAL `reply`
10
+ * tool call landed a moment later and sent message B (quoted). The user should
11
+ * have received exactly ONE message (the quoted reply).
12
+ *
13
+ * #3236 shipped `flushed-turn-supersede.ts` — a turnId-identity-keyed dedup that
14
+ * is text-agnostic BY DESIGN. It missed here not because of the text rewording
15
+ * but because the reply's owner turn resolved to `null` on the late path:
16
+ *
17
+ * - At reply consumption the gateway resolved the owner turn as
18
+ * `currentTurn ?? findTurnByOriginId(origin_turn_id) ?? null`.
19
+ * - `currentTurn` was already nulled by the flush's synthetic turn_end.
20
+ * - `origin_turn_id` is a forum-supergroup field, ABSENT in DMs, so
21
+ * `findTurnByOriginId` returned null.
22
+ * - ⇒ the resolved live turnId was `null`, and `decideSupersede` deliberately
23
+ * never lets a null live turn supersede a turnId-bearing flush record. No
24
+ * supersede fired → message A survived AND the reply sent message B.
25
+ *
26
+ * The DECISIVE divergence: the gateway's *thread-routing* path DID recover the
27
+ * owner turn for the same late reply — via `findTurnByQuotedMessageId` (the
28
+ * framework-owned default quote target) and `findLatestEndedTurnForChat` (the
29
+ * chat's most-recently-ended turn). The supersede resolver chain omitted BOTH
30
+ * recoveries, so the two resolvers disagreed on who owned the reply. This module
31
+ * unifies them onto ONE precedence so they can never diverge again.
32
+ *
33
+ * ## Why a pure module
34
+ *
35
+ * `gateway.ts` is not importable in tests (heavy top-level side effects), so the
36
+ * repo's convention — `decideTurnFlush`, `decideSupersede`,
37
+ * `decideCapturedProseDelivery` — is to extract the decision core into a pure,
38
+ * unit-testable function and have the gateway run the EXACT code the regression
39
+ * tests exercise. The gateway performs the four turn lookups (currentTurn,
40
+ * findTurnByOriginId, findTurnByQuotedMessageId, findLatestEndedTurnForChat) and
41
+ * feeds their resolved turnIds here; the precedence lives in one place.
42
+ */
43
+
44
+ /**
45
+ * The four owner-turn candidate ids, in the gateway's resolution precedence.
46
+ * Each is the `turnId` of the turn a given lookup resolved, or null when that
47
+ * lookup found nothing.
48
+ */
49
+ export interface ReplyOwnerCandidates {
50
+ /** The LIVE `currentTurn` at reply-consumption time (null once the flush's
51
+ * synthetic turn_end has torn the atom down — the late-reply case). */
52
+ liveTurnId: string | null
53
+ /** `findTurnByOriginId(origin_turn_id)` — the turn the model echoed back.
54
+ * Null in DMs (no `origin_turn_id`) and when the model omitted the echo. */
55
+ originTurnId: string | null
56
+ /** `findTurnByQuotedMessageId(chat_id, reply_to)` — the framework-owned
57
+ * quoted message id, resolved with NO model thread assertion. */
58
+ quotedTurnId: string | null
59
+ /** `findLatestEndedTurnForChat(chat_id)` — the chat's most-recently-ended
60
+ * turn. The deterministic late-reply fallback (the DM path's recovery). */
61
+ latestEndedTurnId: string | null
62
+ /** Age (ms) of the latest-ended turn — `now - turn.endedAt`. The latest-ended
63
+ * tier carries DESTRUCTIVE authority (it drives supersede deletion), so it is
64
+ * honoured ONLY when the turn ended within `latestEndedTtlMs` (the supersede
65
+ * TTL). Without the bound, a late reply belonging to an OLDER turn could
66
+ * resolve its owner to a NEWER turn now sitting at the registry tail and
67
+ * delete THAT turn's legit answer. Undefined/null ⇒ unbounded (back-compat:
68
+ * callers that don't supply an age keep the pre-F2 behaviour). */
69
+ latestEndedAgeMs?: number | null
70
+ /** The supersede TTL bound applied to `latestEndedAgeMs`. Undefined ⇒
71
+ * unbounded. */
72
+ latestEndedTtlMs?: number
73
+ }
74
+
75
+ /**
76
+ * Whether the latest-ended candidate is fresh enough to carry supersede
77
+ * (deletion) authority. A missing age or TTL means unbounded (back-compat).
78
+ */
79
+ function latestEndedAccepted(candidates: ReplyOwnerCandidates): boolean {
80
+ if (candidates.latestEndedTurnId == null) return false
81
+ const age = candidates.latestEndedAgeMs
82
+ const ttl = candidates.latestEndedTtlMs
83
+ if (age == null || ttl == null) return true
84
+ return age <= ttl
85
+ }
86
+
87
+ /**
88
+ * Resolve the turnId that OWNS a landing reply, using the SAME full chain the
89
+ * thread-router uses. Precedence, first non-null wins:
90
+ *
91
+ * 1. the live `currentTurn`;
92
+ * 2. the model-echoed `origin_turn_id` turn;
93
+ * 3. the framework-owned quoted-message turn;
94
+ * 4. the chat's most-recently-ended turn.
95
+ *
96
+ * Returns null only when EVERY lookup missed (a genuinely unattributable reply),
97
+ * in which case `decideSupersede` keeps its null-safety semantics and declines
98
+ * to delete any turnId-bearing flush record.
99
+ */
100
+ export function resolveReplyOwnerTurnId(candidates: ReplyOwnerCandidates): string | null {
101
+ return (
102
+ candidates.liveTurnId ??
103
+ candidates.originTurnId ??
104
+ candidates.quotedTurnId ??
105
+ (latestEndedAccepted(candidates) ? candidates.latestEndedTurnId : null) ??
106
+ null
107
+ )
108
+ }
109
+
110
+ /**
111
+ * The answer-delivered latch inputs (Part 2 — the race backstop).
112
+ *
113
+ * The unified resolver (Part 1) closes the common late-reply case where the
114
+ * flush fully COMPLETED (recorded its supersede entry) before the reply landed:
115
+ * supersede then deletes message A and the reply delivers as the single clean
116
+ * message B. But a residual race remains — a reply whose supersede `take()` runs
117
+ * in the window AFTER the flush FIRED but BEFORE it recorded its message ids.
118
+ * There `flushed-turn-supersede` finds no record (nothing to delete yet) and the
119
+ * reply would ship message B as a duplicate of the flush's message A.
120
+ *
121
+ * The latch closes that window: the gateway sets `answerDelivered = true` on the
122
+ * turn atom SYNCHRONOUSLY at flush-fire time — before the ~500 ms async send and
123
+ * before the record — and the flag persists on the ended turn (readable via the
124
+ * unified resolver after `currentTurn` is null). A reply landing in the race
125
+ * window then sees the latch already set and suppresses itself.
126
+ */
127
+ export interface AnswerLatchSuppressInput {
128
+ /** True when Part 1's supersede already fired for THIS reply (message A was
129
+ * deleted and this reply IS the sanctioned replacement). The latch must NOT
130
+ * then also suppress — that would leave the turn with ZERO messages. */
131
+ superseded: boolean
132
+ /** True when the landing reply is a substantive terminal answer (the same
133
+ * ≥200-char `FINAL_ANSWER_MIN_CHARS` floor the flush latch is scoped to).
134
+ * A sub-floor interim ack (short, `disable_notification`) is never a final
135
+ * answer, so it neither sets nor trips the latch. */
136
+ replySubstantive: boolean
137
+ /** True when this is a LATE reply — `currentTurn` was already null at
138
+ * consumption. The flush-duplicate ALWAYS lands late (the flush's synthetic
139
+ * turn_end nulled the atom); scoping suppression to the late path leaves a
140
+ * legitimate second in-turn substantive reply (a genuine multi-message
141
+ * answer, live currentTurn) untouched. */
142
+ isLateReply: boolean
143
+ /** The resolved owner turn's `answerDelivered` latch. */
144
+ ownerAnswerDelivered: boolean
145
+ }
146
+
147
+ /**
148
+ * Decide whether the answer-delivered latch suppresses a landing reply.
149
+ *
150
+ * Suppress IFF: Part 1 did NOT already supersede, the reply is a substantive
151
+ * final answer, it is a late reply (no live turn), AND the owner turn's latch is
152
+ * already set (the flush delivered the same substantive answer as message A in
153
+ * the pre-record race window). Otherwise the reply sends.
154
+ */
155
+ export function decideAnswerLatchSuppression(input: AnswerLatchSuppressInput): boolean {
156
+ if (input.superseded) return false
157
+ if (!input.replySubstantive) return false
158
+ if (!input.isLateReply) return false
159
+ return input.ownerAnswerDelivered
160
+ }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Regression coverage for the 2026-07 double-reply-on-DM fix — completes the
3
+ * #3236 turnId-keyed dedup (`flushed-turn-supersede.ts`) for the late-reply / DM
4
+ * path.
5
+ *
6
+ * ## The incident these tests pin
7
+ *
8
+ * On a DM agent a turn DOUBLE-SENT: the answer-ready quiescence flush posted the
9
+ * composed terminal answer as message A (no quote), then the model's REAL
10
+ * `reply` tool call landed and sent message B (quoted). The user should have
11
+ * received exactly ONE message (the quoted reply).
12
+ *
13
+ * #3236's supersede is turnId-identity-keyed and text-agnostic BY DESIGN, so the
14
+ * 275-vs-249-char rewording is NOT why it missed. It missed because the reply's
15
+ * owner turn resolved to `null` on the late path: `currentTurn` was nulled by the
16
+ * flush's synthetic turn_end, and `origin_turn_id` (a forum-supergroup field) is
17
+ * absent in DMs — so the OLD 2-tier chain `currentTurn ?? findTurnByOriginId`
18
+ * yielded null, and `decideSupersede` never lets a null live turn supersede a
19
+ * turnId-bearing record.
20
+ *
21
+ * The router recovered the SAME reply's owner via `findTurnByQuotedMessageId` and
22
+ * `findLatestEndedTurnForChat`; the supersede chain omitted BOTH. The fix
23
+ * (`resolveReplyOwnerTurnId`) unifies the two onto one precedence.
24
+ *
25
+ * These tests exercise the extracted pure cores — the exact precedence and latch
26
+ * decision the gateway runs (`gateway.ts` is not importable in tests; the repo's
27
+ * `decideTurnFlush` / `decideSupersede` pattern). The core test also asserts the
28
+ * OLD 2-tier chain FAILS to recover the owner (the red-on-main contrast) while
29
+ * the unified chain recovers it and the supersede fires.
30
+ */
31
+
32
+ import { describe, it, expect } from 'vitest'
33
+ import {
34
+ resolveReplyOwnerTurnId,
35
+ decideAnswerLatchSuppression,
36
+ type ReplyOwnerCandidates,
37
+ } from '../reply-owner-resolve.js'
38
+ import { FlushedTurnSupersedeRegistry, DEFAULT_SUPERSEDE_TTL_MS } from '../flushed-turn-supersede.js'
39
+
40
+ const NONE: ReplyOwnerCandidates = {
41
+ liveTurnId: null,
42
+ originTurnId: null,
43
+ quotedTurnId: null,
44
+ latestEndedTurnId: null,
45
+ }
46
+
47
+ /** The OLD (pre-fix) resolver chain the gateway ran on main — the 2-tier
48
+ * `currentTurn ?? findTurnByOriginId` — reproduced here to prove the incident
49
+ * went unrecovered before the fix (red-on-main contrast). */
50
+ function oldTwoTierResolve(c: ReplyOwnerCandidates): string | null {
51
+ return c.liveTurnId ?? c.originTurnId ?? null
52
+ }
53
+
54
+ describe('resolveReplyOwnerTurnId — unified owner-turn precedence (Part 1)', () => {
55
+ it('prefers the live currentTurn when present', () => {
56
+ expect(
57
+ resolveReplyOwnerTurnId({ ...NONE, liveTurnId: 'live', originTurnId: 'origin', latestEndedTurnId: 'ended' }),
58
+ ).toBe('live')
59
+ })
60
+
61
+ it('falls to the model-echoed origin turn when no live turn', () => {
62
+ expect(
63
+ resolveReplyOwnerTurnId({ ...NONE, originTurnId: 'origin', quotedTurnId: 'quoted', latestEndedTurnId: 'ended' }),
64
+ ).toBe('origin')
65
+ })
66
+
67
+ it('falls to the framework-owned quoted-message turn when no live/origin', () => {
68
+ expect(
69
+ resolveReplyOwnerTurnId({ ...NONE, quotedTurnId: 'quoted', latestEndedTurnId: 'ended' }),
70
+ ).toBe('quoted')
71
+ })
72
+
73
+ it('falls to the chat latest-ended turn as the final tier — the DM recovery', () => {
74
+ expect(resolveReplyOwnerTurnId({ ...NONE, latestEndedTurnId: 'ended' })).toBe('ended')
75
+ })
76
+
77
+ it('returns null only when every lookup missed', () => {
78
+ expect(resolveReplyOwnerTurnId(NONE)).toBeNull()
79
+ })
80
+
81
+ it('RED-ON-MAIN CONTRAST: the DM late reply (no live turn, no origin) is ' +
82
+ 'unrecovered by the old 2-tier chain but recovered by the unified chain', () => {
83
+ // The exact incident inputs: flush nulled currentTurn (liveTurnId=null),
84
+ // DM has no origin_turn_id (originTurnId=null); the owner survives only in
85
+ // the latest-ended registry (latestEndedTurnId).
86
+ const incident: ReplyOwnerCandidates = { ...NONE, latestEndedTurnId: 'turn-T' }
87
+ // Old behaviour (what shipped on main): null → no supersede → duplicate.
88
+ expect(oldTwoTierResolve(incident)).toBeNull()
89
+ // Fixed behaviour: recovers the owning turn T.
90
+ expect(resolveReplyOwnerTurnId(incident)).toBe('turn-T')
91
+ })
92
+ })
93
+
94
+ describe('Part 1 end-to-end: unified resolver drives the flush supersede', () => {
95
+ const CHAT = '424242'
96
+
97
+ it('CORE INCIDENT: flush records message A for turn T, then a late DM reply ' +
98
+ '(currentTurn=null, no origin_turn_id) SUPERSEDES via the recovered owner', () => {
99
+ const reg = new FlushedTurnSupersedeRegistry()
100
+ const now = 1_000
101
+ // Flush commits message A for turn T.
102
+ reg.record(CHAT, undefined, { turnId: 'turn-T', messageIds: [5001], text: 'A' }, now)
103
+
104
+ // Late DM reply: no live turn, no origin echo; owner survives in the
105
+ // latest-ended registry as turn-T.
106
+ const incident: ReplyOwnerCandidates = { ...NONE, latestEndedTurnId: 'turn-T' }
107
+
108
+ // Old 2-tier chain → null → supersede DECLINES (the duplicate ships).
109
+ const oldId = oldTwoTierResolve(incident)
110
+ expect(reg.peek(CHAT, undefined, { liveTurnId: oldId, now: now + 10 }).supersede).toBe(false)
111
+
112
+ // Unified chain → turn-T → supersede FIRES and deletes message A, so the
113
+ // reply below delivers as the single clean message.
114
+ const newId = resolveReplyOwnerTurnId(incident)
115
+ const decision = reg.take(CHAT, undefined, { liveTurnId: newId, now: now + 10 })
116
+ expect(decision.supersede).toBe(true)
117
+ expect(decision.deleteMessageIds).toEqual([5001])
118
+ })
119
+
120
+ it('a DIFFERENT newer turn recovered as owner does NOT supersede turn T', () => {
121
+ const reg = new FlushedTurnSupersedeRegistry()
122
+ const now = 2_000
123
+ reg.record(CHAT, undefined, { turnId: 'turn-T', messageIds: [7001], text: 'A' }, now)
124
+ // Owner recovers to a newer turn (its own flush isn't recorded here).
125
+ const id = resolveReplyOwnerTurnId({ ...NONE, latestEndedTurnId: 'turn-NEWER' })
126
+ expect(reg.take(CHAT, undefined, { liveTurnId: id, now: now + 10 }).supersede).toBe(false)
127
+ })
128
+ })
129
+
130
+ describe('decideAnswerLatchSuppression — race backstop (Part 2)', () => {
131
+ it('RACE: a late substantive reply lands in the flush post-fire pre-record ' +
132
+ 'window (no supersede record yet) → SUPPRESSED by the latch', () => {
133
+ expect(
134
+ decideAnswerLatchSuppression({
135
+ superseded: false,
136
+ replySubstantive: true,
137
+ isLateReply: true,
138
+ ownerAnswerDelivered: true,
139
+ }),
140
+ ).toBe(true)
141
+ })
142
+
143
+ it('does NOT double-suppress when Part 1 already superseded (else the turn ' +
144
+ 'ends with ZERO messages)', () => {
145
+ expect(
146
+ decideAnswerLatchSuppression({
147
+ superseded: true,
148
+ replySubstantive: true,
149
+ isLateReply: true,
150
+ ownerAnswerDelivered: true,
151
+ }),
152
+ ).toBe(false)
153
+ })
154
+
155
+ it('NEGATIVE: an interim sub-floor ack (not substantive) is never suppressed ' +
156
+ '— so interim-ack-then-final both send', () => {
157
+ expect(
158
+ decideAnswerLatchSuppression({
159
+ superseded: false,
160
+ replySubstantive: false,
161
+ isLateReply: true,
162
+ ownerAnswerDelivered: true,
163
+ }),
164
+ ).toBe(false)
165
+ })
166
+
167
+ it('NEGATIVE: a normal in-turn reply with a live currentTurn is never ' +
168
+ 'suppressed (a legitimate second substantive reply still sends)', () => {
169
+ expect(
170
+ decideAnswerLatchSuppression({
171
+ superseded: false,
172
+ replySubstantive: true,
173
+ isLateReply: false,
174
+ ownerAnswerDelivered: true,
175
+ }),
176
+ ).toBe(false)
177
+ })
178
+
179
+ it('does not suppress when the owner latch is unset (a normal answer)', () => {
180
+ expect(
181
+ decideAnswerLatchSuppression({
182
+ superseded: false,
183
+ replySubstantive: true,
184
+ isLateReply: true,
185
+ ownerAnswerDelivered: false,
186
+ }),
187
+ ).toBe(false)
188
+ })
189
+ })
190
+
191
+ describe('F1 — flush send failure must NOT suppress the late reply (zero-message guard)', () => {
192
+ // The flush arms `answerDelivered` synchronously at FIRE time, BEFORE the async
193
+ // send. If the send then throws and NOTHING was delivered, the supersede record
194
+ // is never written (gated on sentIds>0), so Part 1 cannot fire. Leaving the
195
+ // latch armed would make a genuine late reply suppress itself → the user gets
196
+ // ZERO messages. The gateway's send-failure catch resets `answerDelivered =
197
+ // false`; these assert the resulting coordination outcome at the pure core.
198
+ const lateSubstantiveReply = (ownerAnswerDelivered: boolean) =>
199
+ decideAnswerLatchSuppression({
200
+ superseded: false,
201
+ replySubstantive: true,
202
+ isLateReply: true,
203
+ ownerAnswerDelivered,
204
+ })
205
+
206
+ it('WITHOUT the catch-reset (latch still armed) the late reply is suppressed ' +
207
+ '— the zero-message bug', () => {
208
+ // Models the buggy state: flush armed the latch, send failed, latch left true.
209
+ expect(lateSubstantiveReply(true)).toBe(true)
210
+ })
211
+
212
+ it('WITH the catch-reset (answerDelivered=false) the late reply DELIVERS', () => {
213
+ // Models the fixed state: catch reset the latch, so the genuine late reply
214
+ // is not suppressed and the user still receives the answer.
215
+ expect(lateSubstantiveReply(false)).toBe(false)
216
+ })
217
+ })
218
+
219
+ describe('F2 — recency-bound the destructive latest-ended supersede tier', () => {
220
+ const CHAT = '515151'
221
+ const base: ReplyOwnerCandidates = {
222
+ liveTurnId: null,
223
+ originTurnId: null,
224
+ quotedTurnId: null,
225
+ latestEndedTurnId: null,
226
+ }
227
+
228
+ it('accepts a latest-ended turn that ended within the supersede TTL', () => {
229
+ expect(
230
+ resolveReplyOwnerTurnId({
231
+ ...base,
232
+ latestEndedTurnId: 'turn-T',
233
+ latestEndedAgeMs: DEFAULT_SUPERSEDE_TTL_MS - 1,
234
+ latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
235
+ }),
236
+ ).toBe('turn-T')
237
+ })
238
+
239
+ it('REJECTS a STALE latest-ended turn (ended past the supersede TTL) so it ' +
240
+ 'cannot inherit deletion authority — resolver returns null', () => {
241
+ expect(
242
+ resolveReplyOwnerTurnId({
243
+ ...base,
244
+ latestEndedTurnId: 'turn-STALE',
245
+ latestEndedAgeMs: DEFAULT_SUPERSEDE_TTL_MS + 5_000,
246
+ latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
247
+ }),
248
+ ).toBeNull()
249
+ })
250
+
251
+ it('end-to-end: a stale latest-ended turn does NOT delete a live flush ' +
252
+ "record it doesn't own", () => {
253
+ const reg = new FlushedTurnSupersedeRegistry()
254
+ const now = 1_000_000
255
+ // A fresh flush record for the newer turn T2 (its owner, well within TTL).
256
+ reg.record(CHAT, undefined, { turnId: 'turn-T2', messageIds: [9001], text: 'A2' }, now)
257
+ // A late reply whose only recoverable owner is a STALE turn (ended long ago).
258
+ // Without the recency bound the resolver would hand back the stale turn id
259
+ // and a take() keyed on it could mis-target; with the bound it resolves null,
260
+ // so no deletion authority is granted and T2's record survives untouched.
261
+ const ownerId = resolveReplyOwnerTurnId({
262
+ ...base,
263
+ latestEndedTurnId: 'turn-STALE',
264
+ latestEndedAgeMs: DEFAULT_SUPERSEDE_TTL_MS + 5_000,
265
+ latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
266
+ })
267
+ expect(ownerId).toBeNull()
268
+ const decision = reg.take(CHAT, undefined, { liveTurnId: ownerId, now: now + 10 })
269
+ expect(decision.supersede).toBe(false)
270
+ // T2's record is intact (a null owner never reaches its turnId-keyed lane).
271
+ expect(reg.peek(CHAT, undefined, { liveTurnId: 'turn-T2', now: now + 10 }).supersede).toBe(true)
272
+ })
273
+
274
+ it('unbounded when no age is supplied (back-compat: pre-F2 precedence intact)', () => {
275
+ expect(
276
+ resolveReplyOwnerTurnId({ ...base, latestEndedTurnId: 'turn-T' }),
277
+ ).toBe('turn-T')
278
+ })
279
+ })
@@ -768,7 +768,7 @@ describe('renderCombinedWorkerFeed (pure)', () => {
768
768
  * authoritative terminal sweep (`terminate`, driven by `onTerminalCleanup`)
769
769
  * PLUS a backstop TTL sweep. These assert the OUTCOMES, not the code paths.
770
770
  */
771
- function ghostHarness(opts: { staleWorkerTtlMs?: number; now: () => number }) {
771
+ function ghostHarness(opts: { staleWorkerTtlMs?: number; absoluteRowLifetimeCapMs?: number; now: () => number }) {
772
772
  const edits: { messageId: number; text: string }[] = []
773
773
  const sends: { text: string }[] = []
774
774
  const pins: { messageId: number | null }[] = []
@@ -792,6 +792,7 @@ function ghostHarness(opts: { staleWorkerTtlMs?: number; now: () => number }) {
792
792
  setInterval: () => 0,
793
793
  clearInterval: () => {},
794
794
  staleWorkerTtlMs: opts.staleWorkerTtlMs,
795
+ absoluteRowLifetimeCapMs: opts.absoluteRowLifetimeCapMs,
795
796
  reconcilePin: ({ messageId }) => pins.push({ messageId }),
796
797
  })
797
798
  return { feed, edits, sends, pins }
@@ -891,6 +892,121 @@ describe('worker-feed ghost-leak — deterministic terminal removal + backstop',
891
892
  expect(feed.size).toBe(1)
892
893
  })
893
894
 
895
+ // ── ABSOLUTE row-lifetime cap (Carrie 5h zombie pin, v0.18.23) ──────────────
896
+ // The silence-keyed `staleWorkerTtlMs` backstop is defeated by a leaked row
897
+ // that never finishes AND keeps receiving `update()` cues every heartbeat:
898
+ // each cue resets `lastUpdateAt`, so the silence sweep can NEVER match and the
899
+ // pinned card lives forever (observed: 5h, re-edited 3000+ times). The
900
+ // absolute cap is anchored to the row's IMMUTABLE creation time, so it reaps
901
+ // the row regardless of how recently it updated.
902
+ it('ABSOLUTE cap reaps an immortal-but-UPDATING row that resets lastUpdateAt every tick, then unpins', async () => {
903
+ let t = 0
904
+ // Silence TTL huge (would never fire); absolute cap small. The row keeps
905
+ // updating just under the silence TTL each step, so ONLY the absolute cap
906
+ // (immune to the lastUpdateAt reset) can catch it.
907
+ const { feed, edits, pins } = ghostHarness({
908
+ staleWorkerTtlMs: 1_000_000,
909
+ absoluteRowLifetimeCapMs: 5000,
910
+ now: () => t,
911
+ })
912
+ await feed.update('z', 'chat', view('zombie task', 's0', 0))
913
+ await drain()
914
+ expect(feed.size).toBe(1)
915
+ expect(pins.at(-1)?.messageId).not.toBeNull()
916
+
917
+ // Drive continuous updates past the absolute cap — each resets lastUpdateAt,
918
+ // so the silence sweep stays defeated (exactly the Carrie failure mode).
919
+ for (let step = 1; step <= 8; step++) {
920
+ t = step * 1000
921
+ await feed.update('z', 'chat', view('zombie task', `s${step}`, t))
922
+ await drain()
923
+ feed.heartbeatTick()
924
+ await drain()
925
+ }
926
+ // The absolute cap (5s) has been crossed while the row kept updating →
927
+ // reaped despite the fresh cues, and the shared card UNPINNED.
928
+ expect(feed.size).toBe(0)
929
+ expect(pins.at(-1)?.messageId).toBeNull()
930
+
931
+ // Immortality closed for good: the finalized gate blocks a late cue from
932
+ // resurrecting the row, and further heartbeats produce no edits.
933
+ await feed.update('z', 'chat', view('zombie task', 's-late', t))
934
+ await drain()
935
+ expect(feed.size).toBe(0)
936
+ const after = edits.length
937
+ t += 10000
938
+ feed.heartbeatTick()
939
+ await drain()
940
+ expect(edits.length).toBe(after)
941
+ })
942
+
943
+ it('ABSOLUTE cap reaps a row past the cap even when the silence TTL would never fire', async () => {
944
+ let t = 0
945
+ const { feed, pins } = ghostHarness({
946
+ staleWorkerTtlMs: 1_000_000,
947
+ absoluteRowLifetimeCapMs: 5000,
948
+ now: () => t,
949
+ })
950
+ await feed.update('f', 'chat', view('task f', 's0', 0))
951
+ await drain()
952
+ expect(pins.at(-1)?.messageId).not.toBeNull()
953
+ // Past the absolute cap with no update at all — the age anchor alone reaps.
954
+ t = 6000
955
+ feed.heartbeatTick()
956
+ await drain()
957
+ expect(feed.size).toBe(0)
958
+ expect(pins.at(-1)?.messageId).toBeNull()
959
+ })
960
+
961
+ // ── Boot/reconnect purge (Ken's key ask) ───────────────────────────────────
962
+ // Every tracked row is a dead child sub-agent after a restart/reconnect. On a
963
+ // bare bridge reconnect the OLD feed's `wk:group:` pins are orphaned — the new
964
+ // empty feed never knew those groups, so it can never unpin them, and no full-
965
+ // boot pin sweep runs. `purgeAllOnBoot()` must reconcile the feed to empty AND
966
+ // release the pin unconditionally.
967
+ it('purgeAllOnBoot empties a restored running row + pinned card and UNPINS', async () => {
968
+ let t = 0
969
+ const { feed, pins } = ghostHarness({ now: () => t })
970
+ // A running worker with a live pinned card (the "restored at startup" state).
971
+ await feed.update('p', 'chat', view('task p', 'working', 0))
972
+ await drain()
973
+ expect(feed.size).toBe(1)
974
+ expect(pins.at(-1)?.messageId).not.toBeNull()
975
+
976
+ feed.purgeAllOnBoot()
977
+
978
+ // Feed reconciled to empty AND the coalesced card unpinned (messageId null).
979
+ expect(feed.size).toBe(0)
980
+ expect(pins.at(-1)?.messageId).toBeNull()
981
+
982
+ // A late cue on the torn-down feed must NOT resurrect a row (finalized gate).
983
+ t = 100
984
+ await feed.update('p', 'chat', view('task p', 'zombie tick', 100))
985
+ await drain()
986
+ expect(feed.size).toBe(0)
987
+ })
988
+
989
+ it('purgeAllOnBoot unpins EVERY group (multiple chats) and is a no-op when already empty', async () => {
990
+ let t = 0
991
+ const { feed, pins } = ghostHarness({ now: () => t })
992
+ await feed.update('g1', 'chatA', view('a', 's', 0))
993
+ await feed.update('g2', 'chatB', view('b', 's', 0))
994
+ await drain()
995
+ expect(feed.size).toBe(2)
996
+
997
+ const pinsBefore = pins.length
998
+ feed.purgeAllOnBoot()
999
+ expect(feed.size).toBe(0)
1000
+ // Both groups emitted a final unpin (messageId null).
1001
+ const unpinsAfter = pins.slice(pinsBefore).filter((p) => p.messageId === null)
1002
+ expect(unpinsAfter.length).toBe(2)
1003
+
1004
+ // Idempotent: a second purge on an empty feed emits no further pin calls.
1005
+ const afterFirst = pins.length
1006
+ feed.purgeAllOnBoot()
1007
+ expect(pins.length).toBe(afterFirst)
1008
+ })
1009
+
894
1010
  it('no-op re-render is skipped (byte-identical body → no redundant edit)', async () => {
895
1011
  let t = 0
896
1012
  const { feed, edits } = ghostHarness({ now: () => t })