switchroom 0.18.31 → 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 +693 -253
- 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 +43 -5
- package/telegram-plugin/dist/gateway/gateway.js +25868 -25038
- 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 +7195 -8837
- 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/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/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
|
@@ -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
|
})
|
|
@@ -354,8 +354,11 @@ describe('claimOrDowngradePing — no await in the synchronous decide→apply→
|
|
|
354
354
|
resolve(__dirname, '..', 'gateway', 'emission-authority.ts'),
|
|
355
355
|
'utf-8',
|
|
356
356
|
)
|
|
357
|
+
// #2996 P2: the over-ping call site moved VERBATIM (with executeReply's
|
|
358
|
+
// body) into outbound-send-path.ts `sendReply()`; the await-free window
|
|
359
|
+
// assertion reads there. Contract unchanged.
|
|
357
360
|
const gatewaySrc = readFileSync(
|
|
358
|
-
resolve(__dirname, '..', 'gateway', '
|
|
361
|
+
resolve(__dirname, '..', 'gateway', 'outbound-send-path.ts'),
|
|
359
362
|
'utf-8',
|
|
360
363
|
)
|
|
361
364
|
|
|
@@ -17,6 +17,13 @@ const gatewaySrc = readFileSync(
|
|
|
17
17
|
resolve(__dirname, '..', 'gateway', 'gateway.ts'),
|
|
18
18
|
'utf-8',
|
|
19
19
|
)
|
|
20
|
+
// #2996 P2: executeReply's body (incl. the lever-2 finalize + the latch sets)
|
|
21
|
+
// moved VERBATIM to outbound-send-path.ts `sendReply()`; the assertions that
|
|
22
|
+
// pinned that body now read it there. The wiring contract is unchanged.
|
|
23
|
+
const sendPathSrc = readFileSync(
|
|
24
|
+
resolve(__dirname, '..', 'gateway', 'outbound-send-path.ts'),
|
|
25
|
+
'utf-8',
|
|
26
|
+
)
|
|
20
27
|
|
|
21
28
|
/** Source of `drainActivitySummary` up to the next top-level function. */
|
|
22
29
|
function drainSrc(): string {
|
|
@@ -51,20 +58,23 @@ describe('sticky finalAnswerEverDelivered latch (lever 1 precondition / R0)', ()
|
|
|
51
58
|
it('every site that sets the sticky latch true gates on finalAnswerSubstantive', () => {
|
|
52
59
|
// The latch is set true only at the points that set finalAnswerDelivered=true,
|
|
53
60
|
// and only when the reply was substantive — so an ack never latches it.
|
|
54
|
-
|
|
55
|
-
|
|
61
|
+
// #2996 P2: all three latch-set sites live in the extracted sendReply body.
|
|
62
|
+
const setTrue = [...sendPathSrc.matchAll(/finalAnswerEverDelivered\s*=\s*true/g)]
|
|
63
|
+
// sendReply post-send, silent-anchor merge, + the lever-2 finalize block
|
|
56
64
|
// (which is itself substantive-gated).
|
|
57
65
|
expect(setTrue.length).toBeGreaterThanOrEqual(3)
|
|
66
|
+
// And gateway.ts has none left (the wrapper delegates).
|
|
67
|
+
expect(gatewaySrc).not.toMatch(/finalAnswerEverDelivered\s*=\s*true/)
|
|
58
68
|
// Each `finalAnswerEverDelivered = true` must sit in a substantive context:
|
|
59
69
|
// either guarded by `if (turn.finalAnswerSubstantive)` or inside an
|
|
60
70
|
// `isSubstantiveFinalReply(...)` branch. Assert the substantive-gating
|
|
61
71
|
// token co-occurs (no bare unconditional latch set).
|
|
62
72
|
const bareUnconditional = [
|
|
63
|
-
...
|
|
73
|
+
...sendPathSrc.matchAll(/\n\s*(?:turn|finalizeTurn)\.finalAnswerEverDelivered\s*=\s*true/g),
|
|
64
74
|
]
|
|
65
75
|
for (const m of bareUnconditional) {
|
|
66
76
|
const idx = m.index ?? 0
|
|
67
|
-
const window =
|
|
77
|
+
const window = sendPathSrc.slice(Math.max(0, idx - 600), idx)
|
|
68
78
|
expect(
|
|
69
79
|
/finalAnswerSubstantive/.test(window) || /isSubstantiveFinalReply/.test(window),
|
|
70
80
|
).toBe(true)
|
|
@@ -125,10 +135,11 @@ describe('drain producers — narrative may not OPEN, liveness + tool may', () =
|
|
|
125
135
|
})
|
|
126
136
|
|
|
127
137
|
describe('lever 2 — finalize the card BEFORE a substantive reply send', () => {
|
|
128
|
-
/**
|
|
138
|
+
/** The extracted reply-orchestration body (#2996 P2): the sendReply window
|
|
139
|
+
* in outbound-send-path.ts, up to the next export. */
|
|
129
140
|
function executeReplySrc(): string {
|
|
130
|
-
const after =
|
|
131
|
-
return after.split('\
|
|
141
|
+
const after = sendPathSrc.split('export async function sendReply(')[1] ?? ''
|
|
142
|
+
return after.split('\nexport ')[0] ?? after
|
|
132
143
|
}
|
|
133
144
|
|
|
134
145
|
// #2996 step 1: the chunk send loop was relocated verbatim from executeReply
|