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.
- package/dist/cli/switchroom.js +3 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +123 -16
- package/telegram-plugin/gateway/gateway.ts +210 -13
- package/telegram-plugin/reply-owner-resolve.ts +160 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +279 -0
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +117 -1
- package/telegram-plugin/worker-activity-feed.ts +118 -10
|
@@ -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 })
|