switchroom 0.18.30 → 0.18.32
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/agent-scheduler/index.js +4 -2
- package/dist/auth-broker/index.js +4 -2
- package/dist/cli/notion-write-pretool.mjs +4 -2
- package/dist/cli/switchroom.js +708 -255
- package/dist/host-control/main.js +5 -3
- package/dist/vault/approvals/kernel-server.js +4 -2
- package/dist/vault/broker/server.js +4 -2
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +142 -7
- package/telegram-plugin/dist/gateway/gateway.js +25870 -25007
- package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
- package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
- package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
- package/telegram-plugin/gateway/gateway.ts +5479 -7069
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
- package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
- package/telegram-plugin/gateway/obligation-store.ts +37 -1
- package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
- package/telegram-plugin/gateway/turn-flush-suppression.ts +82 -0
- package/telegram-plugin/pending-user-notice.ts +59 -13
- package/telegram-plugin/subagent-watcher.ts +111 -28
- package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
- package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +16 -6
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +3 -3
- package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +29 -19
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +18 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +249 -0
- package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +303 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +10 -3
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
- package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +4 -4
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +9 -3
- package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
- package/telegram-plugin/tests/obligation-store.test.ts +43 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +2 -1
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
- package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +3 -2
- package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
- package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +291 -0
- package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
- package/telegram-plugin/tests/turn-flush-safety.test.ts +8 -6
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +112 -0
- package/telegram-plugin/tests/turn-flush-suppression.test.ts +90 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +126 -0
- package/telegram-plugin/tool-activity-summary.ts +27 -3
- package/telegram-plugin/worker-activity-feed.ts +24 -0
- package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
- package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
- package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
- package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
- package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3278 — read-back confirmation probe primitive (outbound-send-path.ts).
|
|
3
|
+
*
|
|
4
|
+
* `classifyReadBackError` maps a raw grammy `editMessageText` FAILURE into the
|
|
5
|
+
* confirmation tri-state, and `createBackstopReadBackProbe` combines a chunk's
|
|
6
|
+
* per-id probes into the chunk verdict `runBackstopDelivery` consumes. These
|
|
7
|
+
* assert OUTCOMES: which error shape means present vs absent vs ambiguous, and
|
|
8
|
+
* that a length-resplit chunk (>1 id) is confirmed only if EVERY id is present.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, test, expect, vi } from 'vitest'
|
|
11
|
+
import { GrammyError } from 'grammy'
|
|
12
|
+
import {
|
|
13
|
+
classifyReadBackError,
|
|
14
|
+
createBackstopReadBackProbe,
|
|
15
|
+
createBackstopReadBack,
|
|
16
|
+
} from '../gateway/outbound-send-path.js'
|
|
17
|
+
import type { ReadBackResult } from '../gateway/backstop-delivery.js'
|
|
18
|
+
|
|
19
|
+
// GrammyError's constructor takes (message, payload, method, parameters).
|
|
20
|
+
function grammy(code: number, description: string): GrammyError {
|
|
21
|
+
return new GrammyError(
|
|
22
|
+
`Call to 'editMessageText' failed! (${code}: ${description})`,
|
|
23
|
+
{ ok: false, error_code: code, description },
|
|
24
|
+
'editMessageText',
|
|
25
|
+
{},
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('#3278 classifyReadBackError — raw edit-failure → confirmation tri-state', () => {
|
|
30
|
+
test('400 "message to edit not found" ⇒ absent (positive drop → safe re-send)', () => {
|
|
31
|
+
expect(classifyReadBackError(grammy(400, 'Bad Request: message to edit not found'))).toBe('absent')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('400 "message is not modified" ⇒ exists (a no-op edit → message present)', () => {
|
|
35
|
+
expect(classifyReadBackError(grammy(400, 'Bad Request: message is not modified'))).toBe('exists')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('429 flood ⇒ ambiguous (never re-send)', () => {
|
|
39
|
+
expect(classifyReadBackError(grammy(429, 'Too Many Requests: retry after 30'))).toBe('ambiguous')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('5xx / non-grammy network error ⇒ ambiguous', () => {
|
|
43
|
+
expect(classifyReadBackError(grammy(500, 'Internal Server Error'))).toBe('ambiguous')
|
|
44
|
+
expect(classifyReadBackError(new Error('fetch failed: ECONNRESET'))).toBe('ambiguous')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('an unrelated 400 (e.g. thread not found) is ambiguous, NOT absent', () => {
|
|
48
|
+
// A wrong-classification here would demote-and-re-send a message that may
|
|
49
|
+
// well exist — the never-demote-on-ambiguous rule guards against duplicates.
|
|
50
|
+
expect(classifyReadBackError(grammy(400, 'Bad Request: message thread not found'))).toBe('ambiguous')
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('#3278 createBackstopReadBackProbe — per-chunk id combine', () => {
|
|
55
|
+
test('single id present ⇒ exists; probes the id with the SAME rich body', async () => {
|
|
56
|
+
const probeId = vi.fn(async (): Promise<ReadBackResult> => 'exists')
|
|
57
|
+
const richMessage = vi.fn((s: string) => ({ rich: s }))
|
|
58
|
+
const readBack = createBackstopReadBackProbe({ probeId, richMessage })
|
|
59
|
+
const verdict = await readBack(0, [111], 'answer')
|
|
60
|
+
expect(verdict).toBe('exists')
|
|
61
|
+
expect(richMessage).toHaveBeenCalledWith('answer')
|
|
62
|
+
expect(probeId).toHaveBeenCalledTimes(1)
|
|
63
|
+
expect(probeId).toHaveBeenCalledWith(111, { rich: 'answer' })
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('resplit chunk (2 ids): one absent ⇒ absent (whole chunk re-sent)', async () => {
|
|
67
|
+
const probeId = vi.fn(async (id: number): Promise<ReadBackResult> => (id === 12 ? 'absent' : 'exists'))
|
|
68
|
+
const readBack = createBackstopReadBackProbe({ probeId, richMessage: (s) => s })
|
|
69
|
+
expect(await readBack(1, [11, 12], 'B')).toBe('absent')
|
|
70
|
+
expect(probeId).toHaveBeenCalledTimes(2) // every id of the chunk is probed
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('resplit chunk (2 ids): all present ⇒ exists', async () => {
|
|
74
|
+
const probeId = vi.fn(async (): Promise<ReadBackResult> => 'exists')
|
|
75
|
+
const readBack = createBackstopReadBackProbe({ probeId, richMessage: (s) => s })
|
|
76
|
+
expect(await readBack(0, [1, 2], 'A')).toBe('exists')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('empty id set ⇒ ambiguous and NEVER probes (no confirm, no demote)', async () => {
|
|
80
|
+
const probeId = vi.fn(async (): Promise<ReadBackResult> => 'exists')
|
|
81
|
+
const readBack = createBackstopReadBackProbe({ probeId, richMessage: (s) => s })
|
|
82
|
+
expect(await readBack(0, [], 'A')).toBe('ambiguous')
|
|
83
|
+
expect(probeId).not.toHaveBeenCalled()
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const SHED = Symbol.for('switchroom.send-gate.shed')
|
|
88
|
+
|
|
89
|
+
describe('#3278 createBackstopReadBack — full gate+edit+classify wiring', () => {
|
|
90
|
+
function wiring(overrides: Partial<Parameters<typeof createBackstopReadBack>[0]> = {}) {
|
|
91
|
+
return {
|
|
92
|
+
editMessageText: vi.fn(async () => true), // edit applied → exists
|
|
93
|
+
gate: vi.fn(async (fn: () => Promise<unknown>) => fn()), // passthrough gate
|
|
94
|
+
isShed: (r: unknown) => r === SHED,
|
|
95
|
+
richMessage: (s: string) => ({ rich: s }),
|
|
96
|
+
chatId: '1001',
|
|
97
|
+
threadId: undefined as number | undefined,
|
|
98
|
+
...overrides,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
test('edit RESOLVES ⇒ exists; edits to the identical rich body (no visible mutation)', async () => {
|
|
103
|
+
const w = wiring()
|
|
104
|
+
const readBack = createBackstopReadBack(w)
|
|
105
|
+
expect(await readBack(0, [55], 'answer')).toBe('exists')
|
|
106
|
+
// Probed the landed id with the SAME rich body the chunk was sent with.
|
|
107
|
+
expect(w.editMessageText).toHaveBeenCalledWith(55, { rich: 'answer' }, expect.anything())
|
|
108
|
+
// Routed through the send gate (pacing) rather than a raw call.
|
|
109
|
+
expect(w.gate).toHaveBeenCalledTimes(1)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('400 message-not-found ⇒ absent (safe re-send)', async () => {
|
|
113
|
+
const editMessageText = vi.fn(async () => {
|
|
114
|
+
throw new GrammyError('boom', { ok: false, error_code: 400, description: 'Bad Request: message to edit not found' }, 'editMessageText', {})
|
|
115
|
+
})
|
|
116
|
+
const readBack = createBackstopReadBack(wiring({ editMessageText }))
|
|
117
|
+
expect(await readBack(0, [55], 'answer')).toBe('absent')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('400 not-modified ⇒ exists (a no-op edit on a present message)', async () => {
|
|
121
|
+
const editMessageText = vi.fn(async () => {
|
|
122
|
+
throw new GrammyError('boom', { ok: false, error_code: 400, description: 'Bad Request: message is not modified' }, 'editMessageText', {})
|
|
123
|
+
})
|
|
124
|
+
const readBack = createBackstopReadBack(wiring({ editMessageText }))
|
|
125
|
+
expect(await readBack(0, [55], 'answer')).toBe('exists')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('429 flood ⇒ ambiguous (never re-send)', async () => {
|
|
129
|
+
const editMessageText = vi.fn(async () => {
|
|
130
|
+
throw new GrammyError('boom', { ok: false, error_code: 429, description: 'Too Many Requests' }, 'editMessageText', {})
|
|
131
|
+
})
|
|
132
|
+
const readBack = createBackstopReadBack(wiring({ editMessageText }))
|
|
133
|
+
expect(await readBack(0, [55], 'answer')).toBe('ambiguous')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('gate SHED (SEND_GATE_SHED sentinel) ⇒ ambiguous, NOT a false exists', async () => {
|
|
137
|
+
// Under a flood window the cosmetic probe sheds — the gate resolves the
|
|
138
|
+
// sentinel, not the edit. Must be ambiguous so the chunk is never demoted.
|
|
139
|
+
const gate = vi.fn(async () => SHED)
|
|
140
|
+
const editMessageText = vi.fn(async () => true)
|
|
141
|
+
const readBack = createBackstopReadBack(wiring({ gate, editMessageText }))
|
|
142
|
+
expect(await readBack(0, [55], 'answer')).toBe('ambiguous')
|
|
143
|
+
})
|
|
144
|
+
})
|
|
@@ -36,6 +36,13 @@ const gatewaySrc = readFileSync(
|
|
|
36
36
|
resolve(__dirname, '..', 'gateway', 'gateway.ts'),
|
|
37
37
|
'utf-8',
|
|
38
38
|
)
|
|
39
|
+
// #2996 P2: executeReply's body moved verbatim to outbound-send-path.ts
|
|
40
|
+
// (`sendReply`); the post-send block assertions read there. The
|
|
41
|
+
// `releaseTurnBufferGate` helper itself stays in gateway.ts.
|
|
42
|
+
const sendPathSrc = readFileSync(
|
|
43
|
+
resolve(__dirname, '..', 'gateway', 'outbound-send-path.ts'),
|
|
44
|
+
'utf-8',
|
|
45
|
+
)
|
|
39
46
|
|
|
40
47
|
describe('buffer-gate release decoupled from final-answer classification', () => {
|
|
41
48
|
// Extract the helper's docstring (everything between the matching
|
|
@@ -102,8 +109,8 @@ describe('buffer-gate release decoupled from final-answer classification', () =>
|
|
|
102
109
|
it('executeReply calls releaseTurnBufferGate OUTSIDE the isFinalAnswerReply branch', () => {
|
|
103
110
|
// Slice the executeReply post-send block (between the anchor
|
|
104
111
|
// comments and the next exported function).
|
|
105
|
-
const post =
|
|
106
|
-
const slice = post.split('\
|
|
112
|
+
const post = sendPathSrc.split("fresh sendMessage from reply tool is a user-visible")[1] ?? ''
|
|
113
|
+
const slice = post.split('\nexport async function ')[0] ?? ''
|
|
107
114
|
// The narrow `isFinalAnswerReply`-gated finalize MUST stay (it
|
|
108
115
|
// emits the 👍 reaction on the final-answer happy path).
|
|
109
116
|
expect(slice).toMatch(/isFinalAnswerReply\(/)
|
|
@@ -131,9 +138,12 @@ describe('buffer-gate release decoupled from final-answer classification', () =>
|
|
|
131
138
|
// helper is narrow on purpose. If future code adds new
|
|
132
139
|
// callsites that aren't turn-terminal, the steer-vs-queue
|
|
133
140
|
// semantics could drift.
|
|
134
|
-
|
|
135
|
-
//
|
|
136
|
-
//
|
|
141
|
+
// #2996 P2: the reply-finalize callsite moved (verbatim) into
|
|
142
|
+
// outbound-send-path.ts `sendReply`; gateway.ts keeps the definition,
|
|
143
|
+
// the halt callsite, and the deps-injection reference.
|
|
144
|
+
const callMatches = (gatewaySrc + sendPathSrc).match(/releaseTurnBufferGate\(/g) ?? []
|
|
145
|
+
// Definition + deps-interface member + 2 callsites = 4:
|
|
146
|
+
// - sendReply's post-send block (reply-finalize, the original).
|
|
137
147
|
// - executeHaltNow (#3020): an interrupt-cancelled turn never reaches
|
|
138
148
|
// reply-finalize (the C-c killed it and no replacement inbound
|
|
139
149
|
// follows), so the halt IS that turn's terminal — releasing there is
|
|
@@ -142,6 +152,6 @@ describe('buffer-gate release decoupled from final-answer classification', () =>
|
|
|
142
152
|
// with executeStreamReply.)
|
|
143
153
|
// If this count grows the test catches it; reviewer must justify
|
|
144
154
|
// any new callsite.
|
|
145
|
-
expect(callMatches.length).toBe(
|
|
155
|
+
expect(callMatches.length).toBe(4)
|
|
146
156
|
})
|
|
147
157
|
})
|
|
@@ -115,9 +115,9 @@ describe("agent button taps use the turn-gate (mid-turn → buffer)", () => {
|
|
|
115
115
|
});
|
|
116
116
|
|
|
117
117
|
it("buffers + spools on bridge-offline (existing behaviour intact)", () => {
|
|
118
|
-
// The idle-branch send missed → the
|
|
119
|
-
//
|
|
120
|
-
|
|
118
|
+
// The idle-branch send missed → the tap is spooled to the
|
|
119
|
+
// pending-inbound buffer for replay. (#2996 P1: the legacy busy-key
|
|
120
|
+
// reservation/release pair was deleted with the claudeBusyKeys set.)
|
|
121
121
|
expect(body).toMatch(/return 'buffered-bridge-offline'/);
|
|
122
122
|
// The bridge-offline buffer push is the LAST push (after the send miss),
|
|
123
123
|
// so it always spools before returning the offline outcome.
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
// The dispatcher's resume closure reaches for the durable outbound-text oracle
|
|
4
|
+
// (history.hasOutboundWithText) to reconcile crash-idempotency. Mock it so these
|
|
5
|
+
// stay pure vitest unit tests (no bun:sqlite / real DB) and we can control the
|
|
6
|
+
// "did this chunk's text land durably?" answer per scenario.
|
|
7
|
+
let mockOracle: (chatId: string, text: string, threadId: number | null, since: number) => boolean = () => false
|
|
8
|
+
vi.mock('../history.js', () => ({
|
|
9
|
+
hasOutboundWithText: (chatId: string, text: string, threadId: number | null, since: number) =>
|
|
10
|
+
mockOracle(chatId, text, threadId, since),
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
BackstopDeliveryLedger,
|
|
15
|
+
runBackstopDelivery,
|
|
16
|
+
type ReadBackResult,
|
|
17
|
+
} from '../gateway/backstop-delivery.js'
|
|
18
|
+
import { ObligationLedger, type Obligation } from '../gateway/obligation-ledger.js'
|
|
19
|
+
import {
|
|
20
|
+
buildCapturedDeliverySnapshot,
|
|
21
|
+
hydrateResumeLedger,
|
|
22
|
+
createCapturedResumeDispatcher,
|
|
23
|
+
type CapturedResumeArgs,
|
|
24
|
+
} from '../gateway/captured-answer-resume.js'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* #3282 — a partial turn-flush backstop that left an obligation OPEN used to be
|
|
28
|
+
* re-presented as a FRESH model generation, re-posting chunks that already
|
|
29
|
+
* landed (the user saw the head of the answer twice). These assert the
|
|
30
|
+
* deterministic OUTCOME of the fix — the represent is now a byte-identical
|
|
31
|
+
* captured-answer RESUME of ONLY the non-`landed-confirmed` tail:
|
|
32
|
+
* (a) 3-chunk answer, tail fails ⇒ resume sends EXACTLY the unlanded tail,
|
|
33
|
+
* chunk 0 is NEVER re-sent, and the chat ends with each chunk once;
|
|
34
|
+
* (b) a restart between landing and confirmation does NOT re-send a chunk the
|
|
35
|
+
* durable oracle proves landed;
|
|
36
|
+
* (c) a fully-landed answer's represent sends NOTHING.
|
|
37
|
+
* A test that would still pass if the resume re-sent a confirmed chunk is not a
|
|
38
|
+
* test — every scenario asserts the exact `sendChunk` index set.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
mockOracle = () => false
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
function obligation(over: Partial<Obligation> = {}): Obligation {
|
|
46
|
+
return {
|
|
47
|
+
originTurnId: '#t1',
|
|
48
|
+
chatId: '900',
|
|
49
|
+
threadId: undefined,
|
|
50
|
+
messageId: 42,
|
|
51
|
+
text: 'the question',
|
|
52
|
+
openedAt: 1000,
|
|
53
|
+
representCount: 0,
|
|
54
|
+
...over,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Simulate the ORIGINAL partial backstop delivery into `ledger`, then snapshot
|
|
59
|
+
* it — the exact input the represent resume consumes. `landPlan(i)` returns the
|
|
60
|
+
* per-chunk behavior: 'ok' (lands + confirms), 'throw' (send fails → unsent),
|
|
61
|
+
* or 'unconfirmed' (lands but read-back ambiguous → landed-unconfirmed). */
|
|
62
|
+
async function seedPartial(
|
|
63
|
+
ledger: BackstopDeliveryLedger,
|
|
64
|
+
turnId: string,
|
|
65
|
+
chunks: string[],
|
|
66
|
+
landPlan: (i: number) => 'ok' | 'throw' | 'unconfirmed',
|
|
67
|
+
): Promise<void> {
|
|
68
|
+
await runBackstopDelivery(
|
|
69
|
+
ledger,
|
|
70
|
+
turnId,
|
|
71
|
+
chunks,
|
|
72
|
+
null,
|
|
73
|
+
{
|
|
74
|
+
sendChunk: async (i: number) => {
|
|
75
|
+
if (landPlan(i) === 'throw') throw new Error('FLOOD_WAIT_ACTIVE')
|
|
76
|
+
return [5000 + i]
|
|
77
|
+
},
|
|
78
|
+
readBack: async (i: number): Promise<ReadBackResult> =>
|
|
79
|
+
landPlan(i) === 'unconfirmed' ? 'ambiguous' : 'exists',
|
|
80
|
+
},
|
|
81
|
+
1, // a single attempt → a genuine partial (no in-fire retry masking it)
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A fake `deliverAnswer` that faithfully replicates the gateway's RESUME path:
|
|
86
|
+
* run the injected `hydrate`, then `runBackstopDelivery` over the captured
|
|
87
|
+
* chunks with a `sendChunk` that records which indices are (re-)sent. */
|
|
88
|
+
function makeResumeDeliverAnswer(ledger: BackstopDeliveryLedger, sentIdx: number[]) {
|
|
89
|
+
return async (a: CapturedResumeArgs): Promise<{ delivered: boolean; sentIds: number[] }> => {
|
|
90
|
+
a.resume.hydrate(ledger, a.turnId)
|
|
91
|
+
const res = await runBackstopDelivery(
|
|
92
|
+
ledger,
|
|
93
|
+
a.turnId,
|
|
94
|
+
a.resume.snapshot.chunks,
|
|
95
|
+
a.cardMessageId,
|
|
96
|
+
{
|
|
97
|
+
sendChunk: async (i: number) => {
|
|
98
|
+
sentIdx.push(i)
|
|
99
|
+
return [7000 + i]
|
|
100
|
+
},
|
|
101
|
+
readBack: async (): Promise<ReadBackResult> => 'exists',
|
|
102
|
+
},
|
|
103
|
+
3,
|
|
104
|
+
)
|
|
105
|
+
return { delivered: res.delivered, sentIds: res.sentIds }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function flush(dispatcher: { inFlight: () => string[] }): Promise<void> {
|
|
110
|
+
for (let i = 0; i < 50 && dispatcher.inFlight().length > 0; i++) {
|
|
111
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
describe('#3282 buildCapturedDeliverySnapshot — captures per-chunk landed state', () => {
|
|
116
|
+
it('captures landed chunks (ids + confirmed flag); an unsent chunk is absent', async () => {
|
|
117
|
+
const ledger = new BackstopDeliveryLedger()
|
|
118
|
+
const chunks = ['c0', 'c1', 'c2']
|
|
119
|
+
// chunk 0 confirmed, chunk 1 landed-unconfirmed, chunk 2 never sent.
|
|
120
|
+
await seedPartial(ledger, '#s', chunks, (i) => (i === 0 ? 'ok' : i === 1 ? 'unconfirmed' : 'throw'))
|
|
121
|
+
const snap = buildCapturedDeliverySnapshot(ledger, '#s', chunks)
|
|
122
|
+
expect(snap.chunks).toEqual(chunks)
|
|
123
|
+
const byIndex = Object.fromEntries(snap.chunkStates.map((s) => [s.index, s]))
|
|
124
|
+
expect(byIndex[0]).toMatchObject({ index: 0, confirmed: true })
|
|
125
|
+
expect(byIndex[0].messageIds.length).toBeGreaterThan(0)
|
|
126
|
+
expect(byIndex[1]).toMatchObject({ index: 1, confirmed: false })
|
|
127
|
+
expect(byIndex[2]).toBeUndefined() // never landed → not in the snapshot
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('#3282 hydrateResumeLedger — rebuilds ledger + oracle reconcile', () => {
|
|
132
|
+
it('confirmed chunk ⇒ excluded from the resume send set; unsent chunk ⇒ included', () => {
|
|
133
|
+
const l = new BackstopDeliveryLedger()
|
|
134
|
+
const snap = {
|
|
135
|
+
chunks: ['c0', 'c1', 'c2'],
|
|
136
|
+
chunkStates: [{ index: 0, messageIds: [10], confirmed: true }],
|
|
137
|
+
}
|
|
138
|
+
hydrateResumeLedger(l, '#h', snap, () => false)
|
|
139
|
+
expect(l.hasConfirmedChunk('#h', 0)).toBe(true)
|
|
140
|
+
// chunk 0 confirmed → not resent; chunks 1,2 unsent → the resume tail.
|
|
141
|
+
expect(l.unsentIndices('#h', 3)).toEqual([1, 2])
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('landed-unconfirmed chunk ⇒ re-PROBED (landedUnconfirmed), not re-sent', () => {
|
|
145
|
+
const l = new BackstopDeliveryLedger()
|
|
146
|
+
const snap = {
|
|
147
|
+
chunks: ['c0', 'c1'],
|
|
148
|
+
chunkStates: [{ index: 0, messageIds: [10], confirmed: false }],
|
|
149
|
+
}
|
|
150
|
+
hydrateResumeLedger(l, '#h', snap, () => false)
|
|
151
|
+
expect(l.landedUnconfirmedIndices('#h', 2)).toEqual([0]) // will be re-probed
|
|
152
|
+
expect(l.unsentIndices('#h', 2)).toEqual([1]) // only chunk 1 is unsent
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('oracle proves a landed-unconfirmed chunk landed ⇒ CONFIRMED (crash-idempotency A5)', () => {
|
|
156
|
+
const l = new BackstopDeliveryLedger()
|
|
157
|
+
const snap = {
|
|
158
|
+
chunks: ['c0', 'c1'],
|
|
159
|
+
chunkStates: [{ index: 0, messageIds: [10], confirmed: false }],
|
|
160
|
+
}
|
|
161
|
+
// The durable outbound row for c0 exists → confirm without re-probe/re-send.
|
|
162
|
+
hydrateResumeLedger(l, '#h', snap, (txt) => txt === 'c0')
|
|
163
|
+
expect(l.hasConfirmedChunk('#h', 0)).toBe(true)
|
|
164
|
+
expect(l.landedUnconfirmedIndices('#h', 2)).toEqual([])
|
|
165
|
+
expect(l.unsentIndices('#h', 2)).toEqual([1])
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('a chunkState with no message ids is left unsent (never fabricates a landing)', () => {
|
|
169
|
+
const l = new BackstopDeliveryLedger()
|
|
170
|
+
const snap = { chunks: ['c0'], chunkStates: [{ index: 0, messageIds: [], confirmed: false }] }
|
|
171
|
+
hydrateResumeLedger(l, '#h', snap, () => false)
|
|
172
|
+
expect(l.unsentIndices('#h', 1)).toEqual([0])
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
describe('#3282 createCapturedResumeDispatcher — the represent RESUME outcome', () => {
|
|
177
|
+
it('(a) partial [0 confirmed, 1+2 unsent] ⇒ resume sends EXACTLY [1,2]; chunk 0 never re-sent; each chunk once', async () => {
|
|
178
|
+
const chunks = ['c0', 'c1', 'c2']
|
|
179
|
+
// Original delivery: chunk 0 lands+confirms, chunks 1,2 fail (unsent).
|
|
180
|
+
const orig = new BackstopDeliveryLedger()
|
|
181
|
+
await seedPartial(orig, '#t1', chunks, (i) => (i === 0 ? 'ok' : 'throw'))
|
|
182
|
+
const snapshot = buildCapturedDeliverySnapshot(orig, '#t1', chunks)
|
|
183
|
+
orig.clear('#t1') // the turn-flush GCs the in-memory ledger (state is durable)
|
|
184
|
+
|
|
185
|
+
// The represent runs on a FRESH ledger (as after a sweep / restart).
|
|
186
|
+
const resumeLedger = new BackstopDeliveryLedger()
|
|
187
|
+
const sentIdx: number[] = []
|
|
188
|
+
const oblLedger = new ObligationLedger(2)
|
|
189
|
+
const supersedes: Array<{ ids: number[] }> = []
|
|
190
|
+
const o = obligation({ capturedDelivery: snapshot })
|
|
191
|
+
oblLedger.openIfAbsent(o)
|
|
192
|
+
oblLedger.noteCapturedDelivery('#t1', snapshot)
|
|
193
|
+
|
|
194
|
+
const dispatcher = createCapturedResumeDispatcher({
|
|
195
|
+
deliverAnswer: makeResumeDeliverAnswer(resumeLedger, sentIdx),
|
|
196
|
+
obligationLedger: oblLedger,
|
|
197
|
+
backstopDeliveryLedger: resumeLedger,
|
|
198
|
+
flushedTurnSupersede: { record: (_c, _t, rec) => supersedes.push({ ids: rec.messageIds }) },
|
|
199
|
+
historyEnabled: false,
|
|
200
|
+
})
|
|
201
|
+
dispatcher.dispatch(oblLedger.list()[0])
|
|
202
|
+
await flush(dispatcher)
|
|
203
|
+
|
|
204
|
+
// The core anti-regression assertion: ONLY the unlanded tail is (re-)sent.
|
|
205
|
+
expect(sentIdx.sort()).toEqual([1, 2])
|
|
206
|
+
expect(sentIdx).not.toContain(0) // chunk 0 (already landed) NEVER re-posted
|
|
207
|
+
expect(oblLedger.isOpen('#t1')).toBe(false) // fully delivered ⇒ closed
|
|
208
|
+
// §4 — the supersede record covers the WHOLE delivered set (original landed
|
|
209
|
+
// chunk 0's id 5000 + the resumed tail's ids), keyed by turn so a late reply
|
|
210
|
+
// corrects the complete answer in place.
|
|
211
|
+
expect(supersedes.at(-1)?.ids).toEqual([5000, 7001, 7002])
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('(b) restart between landing and confirmation ⇒ oracle-confirmed chunk NOT re-sent', async () => {
|
|
215
|
+
const chunks = ['c0', 'c1']
|
|
216
|
+
// Snapshot has chunk 0 landed-unconfirmed (crashed before read-back), chunk 1 unsent.
|
|
217
|
+
const snapshot = {
|
|
218
|
+
chunks,
|
|
219
|
+
chunkStates: [{ index: 0, messageIds: [5000], confirmed: false }],
|
|
220
|
+
}
|
|
221
|
+
const resumeLedger = new BackstopDeliveryLedger()
|
|
222
|
+
const sentIdx: number[] = []
|
|
223
|
+
const oblLedger = new ObligationLedger(2)
|
|
224
|
+
oblLedger.openIfAbsent(obligation({ capturedDelivery: snapshot }))
|
|
225
|
+
oblLedger.noteCapturedDelivery('#t1', snapshot)
|
|
226
|
+
|
|
227
|
+
// The durable oracle proves chunk 0's text landed → confirm, don't re-send.
|
|
228
|
+
mockOracle = (_c, txt) => txt === 'c0'
|
|
229
|
+
|
|
230
|
+
const dispatcher = createCapturedResumeDispatcher({
|
|
231
|
+
deliverAnswer: makeResumeDeliverAnswer(resumeLedger, sentIdx),
|
|
232
|
+
obligationLedger: oblLedger,
|
|
233
|
+
backstopDeliveryLedger: resumeLedger,
|
|
234
|
+
flushedTurnSupersede: { record: () => {} },
|
|
235
|
+
historyEnabled: true,
|
|
236
|
+
})
|
|
237
|
+
dispatcher.dispatch(oblLedger.list()[0])
|
|
238
|
+
await flush(dispatcher)
|
|
239
|
+
|
|
240
|
+
expect(sentIdx).toEqual([1]) // chunk 0 reconciled-confirmed, only chunk 1 sent
|
|
241
|
+
expect(sentIdx).not.toContain(0)
|
|
242
|
+
expect(oblLedger.isOpen('#t1')).toBe(false)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('(c) fully-landed answer ⇒ resume sends NOTHING and closes the obligation', async () => {
|
|
246
|
+
const chunks = ['c0', 'c1']
|
|
247
|
+
const snapshot = {
|
|
248
|
+
chunks,
|
|
249
|
+
chunkStates: [
|
|
250
|
+
{ index: 0, messageIds: [5000], confirmed: true },
|
|
251
|
+
{ index: 1, messageIds: [5001], confirmed: true },
|
|
252
|
+
],
|
|
253
|
+
}
|
|
254
|
+
const resumeLedger = new BackstopDeliveryLedger()
|
|
255
|
+
const sentIdx: number[] = []
|
|
256
|
+
const oblLedger = new ObligationLedger(2)
|
|
257
|
+
oblLedger.openIfAbsent(obligation({ capturedDelivery: snapshot }))
|
|
258
|
+
oblLedger.noteCapturedDelivery('#t1', snapshot)
|
|
259
|
+
|
|
260
|
+
const dispatcher = createCapturedResumeDispatcher({
|
|
261
|
+
deliverAnswer: makeResumeDeliverAnswer(resumeLedger, sentIdx),
|
|
262
|
+
obligationLedger: oblLedger,
|
|
263
|
+
backstopDeliveryLedger: resumeLedger,
|
|
264
|
+
flushedTurnSupersede: { record: () => {} },
|
|
265
|
+
historyEnabled: false,
|
|
266
|
+
})
|
|
267
|
+
dispatcher.dispatch(oblLedger.list()[0])
|
|
268
|
+
await flush(dispatcher)
|
|
269
|
+
|
|
270
|
+
expect(sentIdx).toEqual([]) // nothing re-posted
|
|
271
|
+
expect(oblLedger.isOpen('#t1')).toBe(false)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('a partial RESUME (tail still fails) leaves the obligation OPEN + consumes represent budget', async () => {
|
|
275
|
+
const chunks = ['c0', 'c1']
|
|
276
|
+
const snapshot = {
|
|
277
|
+
chunks,
|
|
278
|
+
chunkStates: [{ index: 0, messageIds: [5000], confirmed: true }],
|
|
279
|
+
}
|
|
280
|
+
const resumeLedger = new BackstopDeliveryLedger()
|
|
281
|
+
const oblLedger = new ObligationLedger(2)
|
|
282
|
+
oblLedger.openIfAbsent(obligation({ capturedDelivery: snapshot }))
|
|
283
|
+
oblLedger.noteCapturedDelivery('#t1', snapshot)
|
|
284
|
+
|
|
285
|
+
// A resume whose tail send keeps throwing ⇒ not delivered ⇒ obligation stays OPEN.
|
|
286
|
+
const dispatcher = createCapturedResumeDispatcher({
|
|
287
|
+
deliverAnswer: async (a) => {
|
|
288
|
+
a.resume.hydrate(resumeLedger, a.turnId)
|
|
289
|
+
const res = await runBackstopDelivery(
|
|
290
|
+
resumeLedger, a.turnId, a.resume.snapshot.chunks, a.cardMessageId,
|
|
291
|
+
{ sendChunk: async () => { throw new Error('FLOOD_WAIT_ACTIVE') }, readBack: async () => 'exists' as ReadBackResult },
|
|
292
|
+
2,
|
|
293
|
+
)
|
|
294
|
+
return { delivered: res.delivered, sentIds: res.sentIds }
|
|
295
|
+
},
|
|
296
|
+
obligationLedger: oblLedger,
|
|
297
|
+
backstopDeliveryLedger: resumeLedger,
|
|
298
|
+
flushedTurnSupersede: { record: () => {} },
|
|
299
|
+
historyEnabled: false,
|
|
300
|
+
})
|
|
301
|
+
dispatcher.dispatch(oblLedger.list()[0])
|
|
302
|
+
await flush(dispatcher)
|
|
303
|
+
|
|
304
|
+
expect(oblLedger.isOpen('#t1')).toBe(true) // left OPEN for the next paced sweep
|
|
305
|
+
expect(oblLedger.list()[0].representCount).toBe(1) // budget consumed → bounded ladder
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it('de-dupes concurrent dispatches for the same origin (only one resume runs)', async () => {
|
|
309
|
+
const chunks = ['c0', 'c1']
|
|
310
|
+
const orig = new BackstopDeliveryLedger()
|
|
311
|
+
await seedPartial(orig, '#t1', chunks, (i) => (i === 0 ? 'ok' : 'throw'))
|
|
312
|
+
const snapshot = buildCapturedDeliverySnapshot(orig, '#t1', chunks)
|
|
313
|
+
orig.clear('#t1')
|
|
314
|
+
|
|
315
|
+
const resumeLedger = new BackstopDeliveryLedger()
|
|
316
|
+
let deliverCalls = 0
|
|
317
|
+
const oblLedger = new ObligationLedger(2)
|
|
318
|
+
oblLedger.openIfAbsent(obligation({ capturedDelivery: snapshot }))
|
|
319
|
+
oblLedger.noteCapturedDelivery('#t1', snapshot)
|
|
320
|
+
|
|
321
|
+
const dispatcher = createCapturedResumeDispatcher({
|
|
322
|
+
deliverAnswer: async (a) => {
|
|
323
|
+
deliverCalls++
|
|
324
|
+
a.resume.hydrate(resumeLedger, a.turnId)
|
|
325
|
+
const res = await runBackstopDelivery(
|
|
326
|
+
resumeLedger, a.turnId, a.resume.snapshot.chunks, a.cardMessageId,
|
|
327
|
+
{ sendChunk: async (i: number) => [7000 + i], readBack: async () => 'exists' as ReadBackResult },
|
|
328
|
+
3,
|
|
329
|
+
)
|
|
330
|
+
return { delivered: res.delivered, sentIds: res.sentIds }
|
|
331
|
+
},
|
|
332
|
+
obligationLedger: oblLedger,
|
|
333
|
+
backstopDeliveryLedger: resumeLedger,
|
|
334
|
+
flushedTurnSupersede: { record: () => {} },
|
|
335
|
+
historyEnabled: false,
|
|
336
|
+
})
|
|
337
|
+
const o = oblLedger.list()[0]
|
|
338
|
+
dispatcher.dispatch(o)
|
|
339
|
+
dispatcher.dispatch(o) // second, concurrent — must be a no-op
|
|
340
|
+
await flush(dispatcher)
|
|
341
|
+
expect(deliverCalls).toBe(1)
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('no captured snapshot ⇒ dispatch is a no-op (caller falls through to fresh generation)', async () => {
|
|
345
|
+
let delivered = false
|
|
346
|
+
const dispatcher = createCapturedResumeDispatcher({
|
|
347
|
+
deliverAnswer: async () => { delivered = true; return { delivered: true, sentIds: [] } },
|
|
348
|
+
obligationLedger: new ObligationLedger(2),
|
|
349
|
+
backstopDeliveryLedger: new BackstopDeliveryLedger(),
|
|
350
|
+
flushedTurnSupersede: { record: () => {} },
|
|
351
|
+
historyEnabled: false,
|
|
352
|
+
})
|
|
353
|
+
dispatcher.dispatch(obligation()) // no capturedDelivery
|
|
354
|
+
dispatcher.dispatch(obligation({ capturedDelivery: { chunks: [], chunkStates: [] } })) // empty
|
|
355
|
+
await flush(dispatcher)
|
|
356
|
+
expect(delivered).toBe(false)
|
|
357
|
+
})
|
|
358
|
+
})
|
|
@@ -48,6 +48,19 @@ const gatewaySrc = readFileSync(
|
|
|
48
48
|
resolve(__dirname, '..', 'gateway', 'gateway.ts'),
|
|
49
49
|
'utf-8',
|
|
50
50
|
)
|
|
51
|
+
// #2996 P2: executeReply's body (the over-ping block + the lever-2 finalize)
|
|
52
|
+
// moved VERBATIM to outbound-send-path.ts `sendReply()`. The façade call-site
|
|
53
|
+
// assertions that used to grep the executeReply window grep the sendReply
|
|
54
|
+
// window there instead — the wiring contract they pin is unchanged.
|
|
55
|
+
const sendPathSrc = readFileSync(
|
|
56
|
+
resolve(__dirname, '..', 'gateway', 'outbound-send-path.ts'),
|
|
57
|
+
'utf-8',
|
|
58
|
+
)
|
|
59
|
+
/** The sendReply window: from the function decl to the next export. */
|
|
60
|
+
function sendReplyWindow(): string {
|
|
61
|
+
const after = sendPathSrc.split('export async function sendReply(')[1] ?? ''
|
|
62
|
+
return after.split('\nexport ')[0] ?? after
|
|
63
|
+
}
|
|
51
64
|
|
|
52
65
|
/** Source of a named gateway function up to the next top-level definition. */
|
|
53
66
|
function fnSrc(name: string): string {
|
|
@@ -243,15 +256,15 @@ describe('claimOrDowngradePing — PR-4c over-ping decision moves into the faça
|
|
|
243
256
|
// The relocation must NOT split the #2562 pair across the façade boundary:
|
|
244
257
|
// the façade decides; the call-site thunk sets firstPingAt AND
|
|
245
258
|
// firstPingWasSubstantive on two adjacent lines, no await between.
|
|
246
|
-
expect(
|
|
259
|
+
expect(sendPathSrc).toMatch(
|
|
247
260
|
/turn\.firstPingAt = now\s*\n\s*turn\.firstPingWasSubstantive = replySubstantive/,
|
|
248
261
|
)
|
|
249
262
|
// No await anywhere in the executeReply over-ping block (the decide→apply→
|
|
250
263
|
// pair-set synchronous chain). Bound to the block, BEFORE the Telegraph
|
|
251
264
|
// block below (which legitimately awaits).
|
|
252
|
-
const blockStart =
|
|
253
|
-
const telegraphIdx =
|
|
254
|
-
const block =
|
|
265
|
+
const blockStart = sendPathSrc.indexOf('const applyOverPingDecision')
|
|
266
|
+
const telegraphIdx = sendPathSrc.indexOf('// Telegraph publish (#579)', blockStart)
|
|
267
|
+
const block = sendPathSrc.slice(blockStart, telegraphIdx)
|
|
255
268
|
const blockCode = block
|
|
256
269
|
.split('\n')
|
|
257
270
|
.filter((l) => {
|
|
@@ -267,17 +280,14 @@ describe('claimOrDowngradePing — PR-4c over-ping decision moves into the faça
|
|
|
267
280
|
// The over-ping net exists ONLY in executeReply. The retired stream_reply
|
|
268
281
|
// tool (executeStreamReply) had no decideOverPing / firstPingAt /
|
|
269
282
|
// wasOverPingSuppressed and never called claimOrDowngradePing.
|
|
270
|
-
|
|
283
|
+
// #2996 P2: the single call now lives in the extracted sendReply body;
|
|
284
|
+
// gateway.ts must have ZERO (the wrapper delegates).
|
|
285
|
+
const gwCalls = [...gatewaySrc.matchAll(/\.claimOrDowngradePing\(/g)]
|
|
286
|
+
expect(gwCalls).toHaveLength(0)
|
|
287
|
+
const calls = [...sendPathSrc.matchAll(/\.claimOrDowngradePing\(/g)]
|
|
271
288
|
expect(calls).toHaveLength(1)
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
// Upper bound: the next top-level function after executeReply.
|
|
275
|
-
const nextFnIdx = gatewaySrc.indexOf('\nasync function ', execReplyIdx + 1)
|
|
276
|
-
expect(execReplyIdx).toBeGreaterThan(-1)
|
|
277
|
-
expect(nextFnIdx).toBeGreaterThan(execReplyIdx)
|
|
278
|
-
// The single call is inside executeReply.
|
|
279
|
-
expect(callIdx).toBeGreaterThan(execReplyIdx)
|
|
280
|
-
expect(callIdx).toBeLessThan(nextFnIdx)
|
|
289
|
+
// The single call is inside the sendReply window.
|
|
290
|
+
expect(sendReplyWindow()).toMatch(/\.claimOrDowngradePing\(/)
|
|
281
291
|
})
|
|
282
292
|
})
|
|
283
293
|
|
|
@@ -299,7 +309,8 @@ describe('façade delegates to the existing emission primitives (call-site liter
|
|
|
299
309
|
it('finalizeCard wraps clearActivitySummary at the call sites (not CALLED inside the façade)', () => {
|
|
300
310
|
expect(facadeSrc).not.toMatch(/import .*clearActivitySummary/)
|
|
301
311
|
expect(facadeCode).not.toMatch(/clearActivitySummary\(/)
|
|
302
|
-
|
|
312
|
+
// #2996 P2: the lever-2 call site moved (verbatim) into sendReply.
|
|
313
|
+
expect(sendPathSrc).toMatch(/finalizeCard\(\(\) => \{\s*\n\s*clearActivitySummary\(/)
|
|
303
314
|
})
|
|
304
315
|
|
|
305
316
|
it('claimOrDowngradePing — the call-site disabled thunk STILL contains a literal decideOverPing( (disabled-path proof)', () => {
|
|
@@ -308,9 +319,9 @@ describe('façade delegates to the existing emission primitives (call-site liter
|
|
|
308
319
|
// its OWN literal `decideOverPing(` call inside the call-site thunk, VERBATIM
|
|
309
320
|
// from PR-4b-base — so the disabled path is provably byte-identical (never
|
|
310
321
|
// depends on the façade for the decision).
|
|
311
|
-
const pingIdx =
|
|
322
|
+
const pingIdx = sendPathSrc.indexOf('claimOrDowngradePing(')
|
|
312
323
|
expect(pingIdx).toBeGreaterThan(-1)
|
|
313
|
-
const window =
|
|
324
|
+
const window = sendPathSrc.slice(pingIdx, pingIdx + 1600)
|
|
314
325
|
expect(window).toMatch(/decideOverPing\(/)
|
|
315
326
|
})
|
|
316
327
|
})
|
|
@@ -385,8 +396,7 @@ describe('the drain sites route through the façade with producers preserved ver
|
|
|
385
396
|
|
|
386
397
|
describe('the lever-2 finalize block routes through the façade', () => {
|
|
387
398
|
it('executeReply finalize routes via markSubstantiveFinalDelivered + finalizeCard (latch + clear preserved)', () => {
|
|
388
|
-
const
|
|
389
|
-
const body = after.split('\nasync function ')[0]?.split('\nfunction ')[0] ?? after
|
|
399
|
+
const body = sendReplyWindow()
|
|
390
400
|
expect(body).toMatch(/markSubstantiveFinalDelivered\(\(\) => \{\s*\n\s*finalizeTurn\.finalAnswerEverDelivered = true/)
|
|
391
401
|
expect(body).toMatch(/finalizeCard\(\(\) => \{\s*\n\s*clearActivitySummary\(finalizeTurn\)/)
|
|
392
402
|
})
|