switchroom 0.19.25 → 0.19.27
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/bin/git-agent-attribution-hook.sh +144 -0
- package/dist/agent-scheduler/index.js +61 -2
- package/dist/auth-broker/index.js +125 -8
- package/dist/cli/notion-write-pretool.mjs +61 -2
- package/dist/cli/switchroom.js +2347 -1104
- package/dist/host-control/main.js +126 -9
- package/dist/vault/approvals/kernel-server.js +124 -8
- package/dist/vault/broker/server.js +124 -8
- package/package.json +6 -2
- package/profiles/_base/cron-session.sh.hbs +14 -0
- package/profiles/_base/start.sh.hbs +145 -4
- package/telegram-plugin/card-layout.ts +328 -0
- package/telegram-plugin/dist/bridge/bridge.js +93 -1
- package/telegram-plugin/dist/gateway/gateway.js +2213 -1204
- package/telegram-plugin/dist/server.js +96 -1
- package/telegram-plugin/edit-flood-fuse.ts +637 -56
- package/telegram-plugin/flood-429-ledger.ts +526 -0
- package/telegram-plugin/flood-circuit-breaker.ts +18 -0
- package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
- package/telegram-plugin/gateway/gateway.ts +103 -112
- package/telegram-plugin/gateway/narrative-lane.ts +14 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
- package/telegram-plugin/gateway/periodic-sweep-guard.ts +86 -0
- package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
- package/telegram-plugin/gateway/status-pin-retarget.ts +180 -0
- package/telegram-plugin/gateway/status-pin-store.ts +58 -9
- package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
- package/telegram-plugin/llm-error-present.ts +61 -2
- package/telegram-plugin/model-unavailable.ts +8 -0
- package/telegram-plugin/operator-events.ts +72 -5
- package/telegram-plugin/outbound-class.ts +81 -0
- package/telegram-plugin/provider-credit.ts +237 -0
- package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
- package/telegram-plugin/send-gate.ts +24 -2
- package/telegram-plugin/status-no-truncate.ts +11 -0
- package/telegram-plugin/status-pin-driver.ts +33 -17
- package/telegram-plugin/status-pin.ts +51 -5
- package/telegram-plugin/tests/card-golden.test.ts +69 -0
- package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
- package/telegram-plugin/tests/card-type-distinguishability.test.ts +291 -0
- package/telegram-plugin/tests/card-variants.golden.txt +211 -0
- package/telegram-plugin/tests/card-variants.ts +366 -0
- package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +316 -0
- package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
- package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
- package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
- package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
- package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
- package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
- package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
- package/telegram-plugin/tests/periodic-sweep-guard.test.ts +151 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +24 -18
- package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
- package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
- package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
- package/telegram-plugin/tests/status-pin-retarget.test.ts +244 -0
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
- package/telegram-plugin/tests/status-pin-shutdown-wiring.test.ts +94 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +179 -64
- package/telegram-plugin/tests/status-pin.test.ts +184 -7
- package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +29 -19
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
- package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
- package/telegram-plugin/tier-downgrade.ts +3 -2
- package/telegram-plugin/tool-activity-summary.ts +239 -322
- package/telegram-plugin/uat/assertions.ts +33 -3
- package/telegram-plugin/uat/feed-matcher.test.ts +36 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +9 -2
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +9 -2
- package/telegram-plugin/worker-activity-feed.ts +109 -30
- package/vendor/hindsight-memory/CLAUDE.md +45 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
- package/vendor/hindsight-memory/scripts/recall.py +176 -7
- package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_request_timeout.py +241 -0
- package/vendor/hindsight-memory/settings.json +1 -1
|
@@ -4,13 +4,22 @@ import {
|
|
|
4
4
|
type WorkerActivityView,
|
|
5
5
|
type BotApiForWorkerFeed,
|
|
6
6
|
} from '../worker-activity-feed.js'
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { executePinLeg, type PinBotApi } from '../status-pin-driver.js'
|
|
8
|
+
import type { PinState, DesiredPin } from '../status-pin.js'
|
|
9
9
|
import {
|
|
10
|
-
reconcileAndPersistStatusPin,
|
|
11
10
|
loadStatusPins,
|
|
11
|
+
type PersistedStatusPin,
|
|
12
12
|
type StatusPinStoreFsSeam,
|
|
13
13
|
} from '../gateway/status-pin-store.js'
|
|
14
|
+
import {
|
|
15
|
+
runStatusPinReconcile,
|
|
16
|
+
type StatusPinClaim,
|
|
17
|
+
} from '../gateway/status-pin-retarget.js'
|
|
18
|
+
|
|
19
|
+
/** Drop the #3810 claim-age stamp so a row's identity can be compared literally. */
|
|
20
|
+
function idOnly(rows: PersistedStatusPin[]): Omit<PersistedStatusPin, 'pinnedAt'>[] {
|
|
21
|
+
return rows.map(({ pinnedAt: _age, ...rest }) => rest)
|
|
22
|
+
}
|
|
14
23
|
|
|
15
24
|
/**
|
|
16
25
|
* Outcome tests for the invisible-worker-cards fix (2026-07-15).
|
|
@@ -21,9 +30,10 @@ import {
|
|
|
21
30
|
* the pin was lost out-of-band (its in-memory claim dropped), the reused message
|
|
22
31
|
* kept being edited live but was never re-pinned → scroll-buried / invisible.
|
|
23
32
|
*
|
|
24
|
-
* These tests wire the feed's `reconcilePin` hook into the REAL
|
|
25
|
-
*
|
|
26
|
-
* `void reconcileStatusPin`), and
|
|
33
|
+
* These tests wire the feed's `reconcilePin` hook into the REAL unified reconcile
|
|
34
|
+
* path (`runStatusPinReconcile` over `executePinLeg`) + a claim map exactly as
|
|
35
|
+
* the gateway does (`wk:group:<feedKey>` key, `void reconcileStatusPin`), and
|
|
36
|
+
* assert the PIN-CALL OUTCOMES:
|
|
27
37
|
* 1. a steady-state edit re-pins when the claim was lost — and does NOT re-pin
|
|
28
38
|
* (no storm) when the claim is already correct.
|
|
29
39
|
* 2. routing an unpin through the reconciler CLEARS the claim, so a later edit
|
|
@@ -68,13 +78,13 @@ function makeFakeBot(): FakeBot {
|
|
|
68
78
|
}
|
|
69
79
|
|
|
70
80
|
/**
|
|
71
|
-
* Mirror the gateway's status-pin reconcile:
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
81
|
+
* Mirror the gateway's status-pin reconcile: ONE claim registry driven through
|
|
82
|
+
* the real orchestrator + driver against a fake pin API that records every
|
|
83
|
+
* pin/unpin call. This is the seam that turns a `reconcilePin` hook call into an
|
|
84
|
+
* observable Telegram pin/unpin — proving OUTCOMES, not paths.
|
|
75
85
|
*/
|
|
76
86
|
function makePinHarness() {
|
|
77
|
-
const
|
|
87
|
+
const claims = new Map<string, StatusPinClaim>()
|
|
78
88
|
const pinCalls: number[] = []
|
|
79
89
|
const unpinCalls: number[] = []
|
|
80
90
|
let lastKey: string | null = null
|
|
@@ -87,10 +97,16 @@ function makePinHarness() {
|
|
|
87
97
|
},
|
|
88
98
|
}
|
|
89
99
|
async function reconcile(key: string, chatId: string, desired: DesiredPin): Promise<void> {
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
100
|
+
const claim = claims.get(key)
|
|
101
|
+
await runStatusPinReconcile({
|
|
102
|
+
pinKey: key,
|
|
103
|
+
chatId,
|
|
104
|
+
prev: claim == null ? null : { messageId: claim.messageId },
|
|
105
|
+
desired,
|
|
106
|
+
persist: null,
|
|
107
|
+
runPin: (action, from) => executePinLeg({ api, chatId, prevState: from, action }),
|
|
108
|
+
claims,
|
|
109
|
+
})
|
|
94
110
|
}
|
|
95
111
|
const reconcilePinFn = (args: {
|
|
96
112
|
feedKey: string
|
|
@@ -104,7 +120,8 @@ function makePinHarness() {
|
|
|
104
120
|
else void reconcile(key, args.chatId, { pinned: false })
|
|
105
121
|
}
|
|
106
122
|
return {
|
|
107
|
-
|
|
123
|
+
claims,
|
|
124
|
+
reconcile,
|
|
108
125
|
pinCalls,
|
|
109
126
|
unpinCalls,
|
|
110
127
|
reconcilePinFn,
|
|
@@ -114,7 +131,7 @@ function makePinHarness() {
|
|
|
114
131
|
},
|
|
115
132
|
/** Directly drop the claim to simulate a pin lost out-of-band. */
|
|
116
133
|
dropClaim(): void {
|
|
117
|
-
|
|
134
|
+
claims.clear()
|
|
118
135
|
},
|
|
119
136
|
}
|
|
120
137
|
}
|
|
@@ -147,14 +164,15 @@ function memFs(): { fs: StatusPinStoreFsSeam; files: Map<string, string> } {
|
|
|
147
164
|
|
|
148
165
|
/**
|
|
149
166
|
* A pin harness that routes the feed's `reconcilePin` hook through the REAL
|
|
150
|
-
* persistence path
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
* This lets a test INSPECT THE FILE across
|
|
167
|
+
* persistence path against a memFs-backed status-pins.json — exactly as the
|
|
168
|
+
* gateway wires it: read prev claim → runStatusPinReconcile (which decides ONCE,
|
|
169
|
+
* maps each leg to a persist op, and drives executePinLeg through
|
|
170
|
+
* reconcileAndPersistStatusPin). This lets a test INSPECT THE FILE across
|
|
171
|
+
* steady-state edits (F1).
|
|
154
172
|
*/
|
|
155
173
|
function makePersistingPinHarness(path: string) {
|
|
156
174
|
const { fs } = memFs()
|
|
157
|
-
const claims = new Map<string,
|
|
175
|
+
const claims = new Map<string, StatusPinClaim>()
|
|
158
176
|
const pinCalls: number[] = []
|
|
159
177
|
const unpinCalls: number[] = []
|
|
160
178
|
const api: PinBotApi = {
|
|
@@ -166,22 +184,16 @@ function makePersistingPinHarness(path: string) {
|
|
|
166
184
|
},
|
|
167
185
|
}
|
|
168
186
|
async function reconcile(key: string, chatId: string, desired: DesiredPin): Promise<void> {
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
const op = action.kind === 'pin'
|
|
172
|
-
? ({ kind: 'pin', messageId: action.messageId } as const)
|
|
173
|
-
: ({ kind: 'clear' } as const)
|
|
174
|
-
const next = await reconcileAndPersistStatusPin({
|
|
175
|
-
path,
|
|
176
|
-
fs,
|
|
187
|
+
const claim = claims.get(key)
|
|
188
|
+
await runStatusPinReconcile({
|
|
177
189
|
pinKey: key,
|
|
178
190
|
chatId,
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
191
|
+
prev: claim == null ? null : { messageId: claim.messageId },
|
|
192
|
+
desired,
|
|
193
|
+
persist: { path, fs },
|
|
194
|
+
runPin: (action, from) => executePinLeg({ api, chatId, prevState: from, action }),
|
|
195
|
+
claims,
|
|
182
196
|
})
|
|
183
|
-
if (next == null) claims.delete(key)
|
|
184
|
-
else claims.set(key, next)
|
|
185
197
|
}
|
|
186
198
|
const reconcilePinFn = (args: {
|
|
187
199
|
feedKey: string
|
|
@@ -226,7 +238,7 @@ describe('worker-feed pin persistence — durable status-pins.json survives stea
|
|
|
226
238
|
expect(pin.rows()).toHaveLength(1)
|
|
227
239
|
const pinKey = pin.rows()[0].pinKey
|
|
228
240
|
expect(pinKey).toMatch(/^wk:group:/)
|
|
229
|
-
expect(pin.rows()).toEqual([
|
|
241
|
+
expect(idOnly(pin.rows())).toEqual([
|
|
230
242
|
{ pinKey, chatId: 'chat', messageId: msgId },
|
|
231
243
|
])
|
|
232
244
|
|
|
@@ -241,7 +253,7 @@ describe('worker-feed pin persistence — durable status-pins.json survives stea
|
|
|
241
253
|
|
|
242
254
|
// The durable row is STILL on disk (inspect the file), and no extra pin/
|
|
243
255
|
// unpin API call was issued across all those steady-state edits.
|
|
244
|
-
expect(pin.rows()).toEqual([
|
|
256
|
+
expect(idOnly(pin.rows())).toEqual([
|
|
245
257
|
{ pinKey, chatId: 'chat', messageId: msgId },
|
|
246
258
|
])
|
|
247
259
|
expect(pin.pinCalls).toEqual([msgId]) // exactly one pin, ever
|
|
@@ -269,7 +281,7 @@ describe('worker-feed pin persistence — steady-state re-pin (invisible-worker-
|
|
|
269
281
|
expect(bot.sent).toHaveLength(1)
|
|
270
282
|
const msgId = bot.sent[0].messageId
|
|
271
283
|
expect(pin.pinCalls).toEqual([msgId])
|
|
272
|
-
expect(pin.
|
|
284
|
+
expect(pin.claims.get(pin.key())?.messageId).toBe(msgId)
|
|
273
285
|
|
|
274
286
|
// Steady-state edit while the pin is already correct → NO new pin (no storm).
|
|
275
287
|
clock = 2000
|
|
@@ -284,7 +296,7 @@ describe('worker-feed pin persistence — steady-state re-pin (invisible-worker-
|
|
|
284
296
|
await feed.update('w1', 'chat', view({ elapsedMs: 3000, toolCount: 9 }))
|
|
285
297
|
await flush()
|
|
286
298
|
expect(pin.pinCalls).toEqual([msgId, msgId]) // re-pinned the SAME live message
|
|
287
|
-
expect(pin.
|
|
299
|
+
expect(pin.claims.get(pin.key())?.messageId).toBe(msgId)
|
|
288
300
|
})
|
|
289
301
|
})
|
|
290
302
|
|
|
@@ -306,27 +318,12 @@ describe('worker-feed pin persistence — unpin clears the claim', () => {
|
|
|
306
318
|
await flush()
|
|
307
319
|
const msgId = bot.sent[0].messageId
|
|
308
320
|
const key = pin.key()
|
|
309
|
-
expect(pin.
|
|
321
|
+
expect(pin.claims.get(key)?.messageId).toBe(msgId)
|
|
310
322
|
|
|
311
323
|
// Route an unpin of the worker message through the reconciler (the sanctioned
|
|
312
324
|
// path): the claim is dropped AND the Telegram unpin is issued.
|
|
313
|
-
await (
|
|
314
|
-
|
|
315
|
-
const next = await reconcilePin({
|
|
316
|
-
api: {
|
|
317
|
-
pinChatMessage: async () => {},
|
|
318
|
-
unpinChatMessage: async (_c, id) => {
|
|
319
|
-
pin.unpinCalls.push(id)
|
|
320
|
-
},
|
|
321
|
-
},
|
|
322
|
-
chatId: 'chat',
|
|
323
|
-
prevState: prev,
|
|
324
|
-
desired: { pinned: false },
|
|
325
|
-
})
|
|
326
|
-
if (next == null) pin.state.delete(key)
|
|
327
|
-
else pin.state.set(key, next)
|
|
328
|
-
})()
|
|
329
|
-
expect(pin.state.has(key)).toBe(false) // claim cleared
|
|
325
|
+
await pin.reconcile(key, 'chat', { pinned: false })
|
|
326
|
+
expect(pin.claims.has(key)).toBe(false) // claim cleared
|
|
330
327
|
expect(pin.unpinCalls).toContain(msgId)
|
|
331
328
|
|
|
332
329
|
// A later steady edit now re-pins (the claim being clear is what lets
|
|
@@ -335,7 +332,7 @@ describe('worker-feed pin persistence — unpin clears the claim', () => {
|
|
|
335
332
|
await feed.update('w1', 'chat', view({ elapsedMs: 2000, toolCount: 7 }))
|
|
336
333
|
await flush()
|
|
337
334
|
expect(pin.pinCalls).toContain(msgId)
|
|
338
|
-
expect(pin.
|
|
335
|
+
expect(pin.claims.get(key)?.messageId).toBe(msgId)
|
|
339
336
|
})
|
|
340
337
|
})
|
|
341
338
|
|
|
@@ -378,7 +375,7 @@ describe('worker-feed pin persistence — group-message lifetime cap rotation',
|
|
|
378
375
|
// re-established on the new id (deterministic, no burst: one unpin + one pin).
|
|
379
376
|
expect(pin.unpinCalls).toContain(msgA)
|
|
380
377
|
expect(pin.pinCalls).toContain(msgB)
|
|
381
|
-
expect(pin.
|
|
378
|
+
expect(pin.claims.get(pin.key())?.messageId).toBe(msgB)
|
|
382
379
|
|
|
383
380
|
// FIX 1: the retired message was collapsed to the honest "moved" note with
|
|
384
381
|
// exactly ONE edit (not left frozen showing live rows, not re-edited per tick).
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression suite for switchroom#3848 — the terminal worker-card frame must
|
|
3
|
+
* not be shed with the heartbeat repaints.
|
|
4
|
+
*
|
|
5
|
+
* #3847 made the edit-flood fuse class-aware and tagged EVERY worker-feed edit
|
|
6
|
+
* `cosmetic`. That is right for a liveness repaint (dropping one costs
|
|
7
|
+
* nothing — the next render carries full state) and wrong for the TERMINAL
|
|
8
|
+
* frame: it is the last frame the operator ever reads for that card, and
|
|
9
|
+
* nothing newer is coming to repaint it. Classed cosmetic, the single frame
|
|
10
|
+
* carrying the finished state competed in the same starved budget as a
|
|
11
|
+
* heartbeat, and under sustained cosmetic pressure it was the frame dropped —
|
|
12
|
+
* leaving the card frozen mid-run forever.
|
|
13
|
+
*
|
|
14
|
+
* Every test here asserts an OUTCOME (did the call reach the API / what class
|
|
15
|
+
* did it carry), under the exact pressure scenario that sheds it. Each one
|
|
16
|
+
* goes RED if the terminal frame is classed `cosmetic` again — at the feed
|
|
17
|
+
* (`{ terminal: … }` no longer passed), at the mapping
|
|
18
|
+
* (`workerFeedEditPriorityClass`), or at the fuse.
|
|
19
|
+
*/
|
|
20
|
+
import { describe, it, expect } from 'vitest'
|
|
21
|
+
import { readFileSync } from 'node:fs'
|
|
22
|
+
import { createEditFloodFuse, EDIT_FLOOD_FUSE_DEFAULTS } from '../edit-flood-fuse.js'
|
|
23
|
+
import { withOutboundClass } from '../outbound-class.js'
|
|
24
|
+
import type { Clock, PriorityClass } from '../send-gate.js'
|
|
25
|
+
import {
|
|
26
|
+
createWorkerActivityFeed,
|
|
27
|
+
workerFeedEditPriorityClass,
|
|
28
|
+
type BotApiForWorkerFeed,
|
|
29
|
+
type WorkerActivityView,
|
|
30
|
+
type WorkerFeedEditMeta,
|
|
31
|
+
} from '../worker-activity-feed.js'
|
|
32
|
+
|
|
33
|
+
const CHAT = '1001'
|
|
34
|
+
|
|
35
|
+
class FakeClock implements Clock {
|
|
36
|
+
private cur = 0
|
|
37
|
+
private seq = 0
|
|
38
|
+
private timers: { at: number; id: number; resolve: () => void }[] = []
|
|
39
|
+
|
|
40
|
+
now(): number { return this.cur }
|
|
41
|
+
|
|
42
|
+
sleep(ms: number): Promise<void> {
|
|
43
|
+
return new Promise<void>((resolve) => {
|
|
44
|
+
this.timers.push({ at: this.cur + ms, id: this.seq++, resolve })
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async advance(ms: number): Promise<void> {
|
|
49
|
+
const target = this.cur + ms
|
|
50
|
+
for (;;) {
|
|
51
|
+
await flush()
|
|
52
|
+
const due = this.timers.filter((t) => t.at <= target).sort((a, b) => a.at - b.at || a.id - b.id)
|
|
53
|
+
if (due.length === 0) break
|
|
54
|
+
const t = due[0]!
|
|
55
|
+
this.timers = this.timers.filter((x) => x !== t)
|
|
56
|
+
this.cur = t.at
|
|
57
|
+
t.resolve()
|
|
58
|
+
await flush()
|
|
59
|
+
}
|
|
60
|
+
this.cur = target
|
|
61
|
+
await flush()
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function flush(): Promise<void> {
|
|
66
|
+
return new Promise((r) => setImmediate(r))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function drain(): Promise<void> {
|
|
70
|
+
for (let i = 0; i < 12; i++) await flush()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function view(step: string, elapsedMs: number, state: 'running' | 'done' = 'running'): WorkerActivityView {
|
|
74
|
+
return { description: 'ship the fix', lastTool: null, toolCount: 2, latestSummary: step, elapsedMs, state }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The gateway's worker-feed edit adapter, reproduced EXACTLY as
|
|
79
|
+
* `gateway.ts` wires it (see the `createWorkerActivityFeed({ bot: { … } })`
|
|
80
|
+
* block): map the feed's out-of-band `meta` to a priority class with
|
|
81
|
+
* `workerFeedEditPriorityClass`, publish it on the AsyncLocalStorage the fuse
|
|
82
|
+
* reads, then hand the call to the transformer.
|
|
83
|
+
*
|
|
84
|
+
* `withOutboundClass` is what the real send gate does with `priorityClass`;
|
|
85
|
+
* doing it here keeps this test on the production mapping rather than a
|
|
86
|
+
* hand-written class literal.
|
|
87
|
+
*/
|
|
88
|
+
function gatewayEditAdapter(
|
|
89
|
+
fuse: { apply: <R>(m: string, p: unknown, next: () => Promise<R>) => Promise<R> },
|
|
90
|
+
onLand: (messageId: number, cls: PriorityClass) => void,
|
|
91
|
+
) {
|
|
92
|
+
return (chatId: string, messageId: number, text: string, meta?: WorkerFeedEditMeta): Promise<unknown> => {
|
|
93
|
+
const cls: PriorityClass = workerFeedEditPriorityClass(meta)
|
|
94
|
+
return withOutboundClass(cls, () =>
|
|
95
|
+
fuse.apply('editMessageText', { chat_id: chatId, message_id: messageId, text }, async () => {
|
|
96
|
+
onLand(messageId, cls)
|
|
97
|
+
return true
|
|
98
|
+
}),
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const D = EDIT_FLOOD_FUSE_DEFAULTS
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Offset at which the frame under test is offered, relative to the pressure
|
|
107
|
+
* burst. Small and non-zero: the frame must arrive AFTER the chat's cosmetic
|
|
108
|
+
* allowance is spent (so it has to wait) but early enough that its own
|
|
109
|
+
* `maxDeferMs` deadline still falls inside the pressure window — which is
|
|
110
|
+
* precisely the "sustained cosmetic pressure" #3848 describes. Offering it at
|
|
111
|
+
* t=0 would race the burst's own late releases at the same instant.
|
|
112
|
+
*/
|
|
113
|
+
const OFFER_AT_MS = 1_000
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Saturate a chat's cosmetic allowance the way a busy agent does: N distinct
|
|
117
|
+
* worker cards all repainting at once. Both the per-chat cosmetic ceiling
|
|
118
|
+
* (`cosmeticPerChatMaxPerWindow`, 6/60s) and the bounded R1 late-release
|
|
119
|
+
* budget (`lateReleaseMaxPerWindow`, 2/60s) get spent — the latter matters
|
|
120
|
+
* because a LONE cosmetic frame is otherwise released late rather than
|
|
121
|
+
* dropped. That late-release budget is the partial mitigation #3848 records,
|
|
122
|
+
* and running it out is what turns the degradation into a guaranteed drop.
|
|
123
|
+
*
|
|
124
|
+
* Returns the still-pending burst; the caller advances the clock past
|
|
125
|
+
* `maxDeferMs` once, resolving the burst and the frame under test together.
|
|
126
|
+
*/
|
|
127
|
+
function startCosmeticPressure(
|
|
128
|
+
fuse: { apply: <R>(m: string, p: unknown, next: () => Promise<R>) => Promise<R> },
|
|
129
|
+
landed: number[],
|
|
130
|
+
): Promise<unknown>[] {
|
|
131
|
+
const total = D.cosmeticPerChatMaxPerWindow + D.lateReleaseMaxPerWindow
|
|
132
|
+
const pending: Promise<unknown>[] = []
|
|
133
|
+
for (let i = 0; i < total; i++) {
|
|
134
|
+
pending.push(
|
|
135
|
+
withOutboundClass('cosmetic', () =>
|
|
136
|
+
fuse.apply('editMessageText', { chat_id: CHAT, message_id: 9000 + i, text: `card ${i}` }, async () => {
|
|
137
|
+
landed.push(9000 + i)
|
|
138
|
+
return true
|
|
139
|
+
}),
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
return pending
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Offer ONE lone edit to a fresh message id under an already-exhausted
|
|
148
|
+
* cosmetic budget, as `cls`, and report whether it reached the API.
|
|
149
|
+
*/
|
|
150
|
+
async function offerLoneEditUnderPressure(cls: PriorityClass): Promise<boolean> {
|
|
151
|
+
const clock = new FakeClock()
|
|
152
|
+
const fuse = createEditFloodFuse({ clock })
|
|
153
|
+
const landed: number[] = []
|
|
154
|
+
const pressure = startCosmeticPressure(fuse, landed)
|
|
155
|
+
await clock.advance(OFFER_AT_MS)
|
|
156
|
+
|
|
157
|
+
let reachedApi = false
|
|
158
|
+
const call = withOutboundClass(cls, () =>
|
|
159
|
+
fuse.apply(
|
|
160
|
+
'editMessageText',
|
|
161
|
+
{ chat_id: CHAT, message_id: 4242, text: '✅ done · 12 tools' },
|
|
162
|
+
async () => { reachedApi = true; return true },
|
|
163
|
+
),
|
|
164
|
+
)
|
|
165
|
+
await clock.advance(D.maxDeferMs + 1_000)
|
|
166
|
+
await Promise.all([...pressure, call])
|
|
167
|
+
// Sanity: the pressure really did consume the whole cosmetic allowance.
|
|
168
|
+
expect(landed).toHaveLength(D.cosmeticPerChatMaxPerWindow + D.lateReleaseMaxPerWindow)
|
|
169
|
+
return reachedApi
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
describe('#3848 — a terminal worker-card frame survives an exhausted cosmetic budget', () => {
|
|
173
|
+
it('drops the terminal frame when it is classed cosmetic, and lands it when classed useful', async () => {
|
|
174
|
+
// Both halves run the SAME fuse config under the SAME pressure, so the
|
|
175
|
+
// ONLY difference is the class the terminal frame carries. The classes
|
|
176
|
+
// come from the production mapping, so reverting the promotion turns the
|
|
177
|
+
// second assertion into the first.
|
|
178
|
+
const asCosmetic = await offerLoneEditUnderPressure(workerFeedEditPriorityClass({ terminal: false }))
|
|
179
|
+
const asUseful = await offerLoneEditUnderPressure(workerFeedEditPriorityClass({ terminal: true }))
|
|
180
|
+
|
|
181
|
+
// The defect (#3848): with the cosmetic budget spent, the frame carrying
|
|
182
|
+
// the finished state never reaches Telegram — the card freezes mid-run.
|
|
183
|
+
expect(asCosmetic).toBe(false)
|
|
184
|
+
// The fix: the same frame, promoted because it is terminal, gets through.
|
|
185
|
+
expect(asUseful).toBe(true)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('still holds INTERMEDIATE repaints to the cosmetic ceiling after the fix', async () => {
|
|
189
|
+
// Guards the other direction: promoting the terminal frame must not
|
|
190
|
+
// promote the heartbeat stream, or #3847's whole flood defence is undone.
|
|
191
|
+
expect(workerFeedEditPriorityClass({ terminal: false })).toBe('cosmetic')
|
|
192
|
+
expect(await offerLoneEditUnderPressure('cosmetic')).toBe(false)
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
describe('#3848 — the REAL feed tags its terminal frame, through the REAL gateway mapping', () => {
|
|
197
|
+
/**
|
|
198
|
+
* End-to-end over the production pieces: `createWorkerActivityFeed` drives
|
|
199
|
+
* the gateway's own edit adapter into a real `createEditFloodFuse`. Nothing
|
|
200
|
+
* here hand-writes a class — the class comes from whatever the feed passes
|
|
201
|
+
* as `meta`. If the feed stops passing `{ terminal: … }`, or the adapter
|
|
202
|
+
* stops consulting it, the terminal recap is shed and this goes RED.
|
|
203
|
+
*/
|
|
204
|
+
it('lands the finish recap at the API while the chat cosmetic budget is exhausted', async () => {
|
|
205
|
+
const clock = new FakeClock()
|
|
206
|
+
const fuse = createEditFloodFuse({ clock })
|
|
207
|
+
const preLanded: number[] = []
|
|
208
|
+
const pressure = startCosmeticPressure(fuse, preLanded)
|
|
209
|
+
await clock.advance(OFFER_AT_MS)
|
|
210
|
+
|
|
211
|
+
const CARD = 777
|
|
212
|
+
const landedEdits: { messageId: number; cls: PriorityClass }[] = []
|
|
213
|
+
const edit = gatewayEditAdapter(fuse, (messageId, cls) => landedEdits.push({ messageId, cls }))
|
|
214
|
+
|
|
215
|
+
const bot: BotApiForWorkerFeed = {
|
|
216
|
+
sendMessage: async () => ({ message_id: CARD }),
|
|
217
|
+
editMessageText: (chatId, messageId, text, _opts, meta) => edit(chatId, messageId, text, meta),
|
|
218
|
+
}
|
|
219
|
+
const feed = createWorkerActivityFeed({
|
|
220
|
+
bot,
|
|
221
|
+
now: () => clock.now(),
|
|
222
|
+
minEditIntervalMs: 0,
|
|
223
|
+
elapsedRefreshMs: 0,
|
|
224
|
+
firstPaintMinMs: 0,
|
|
225
|
+
setInterval: () => 0,
|
|
226
|
+
clearInterval: () => {},
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
// First paint (a send — never dropped), then the worker finishes.
|
|
230
|
+
await feed.update('w1', CHAT, view('reading gateway.ts', 30_000))
|
|
231
|
+
await drain()
|
|
232
|
+
|
|
233
|
+
const finish = feed.finish('w1', view('opened the PR', 60_000, 'done'))
|
|
234
|
+
await clock.advance(D.maxDeferMs + 1_000)
|
|
235
|
+
await finish
|
|
236
|
+
await Promise.all(pressure)
|
|
237
|
+
await drain()
|
|
238
|
+
|
|
239
|
+
// The pressure really did spend the whole cosmetic allowance…
|
|
240
|
+
expect(preLanded).toHaveLength(D.cosmeticPerChatMaxPerWindow + D.lateReleaseMaxPerWindow)
|
|
241
|
+
// …and the terminal recap still reached Telegram, as `useful`.
|
|
242
|
+
expect(landedEdits.map((e) => e.messageId)).toContain(CARD)
|
|
243
|
+
expect(landedEdits.filter((e) => e.messageId === CARD).every((e) => e.cls === 'useful')).toBe(true)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('classes the running repaints cosmetic and only the finish frame useful', async () => {
|
|
247
|
+
const clock = new FakeClock()
|
|
248
|
+
const CARD = 888
|
|
249
|
+
const seen: { text: string; meta?: WorkerFeedEditMeta }[] = []
|
|
250
|
+
const bot: BotApiForWorkerFeed = {
|
|
251
|
+
sendMessage: async () => ({ message_id: CARD }),
|
|
252
|
+
editMessageText: async (_c, _m, text, _opts, meta) => { seen.push({ text, meta }); return true },
|
|
253
|
+
}
|
|
254
|
+
const feed = createWorkerActivityFeed({
|
|
255
|
+
bot,
|
|
256
|
+
now: () => clock.now(),
|
|
257
|
+
minEditIntervalMs: 0,
|
|
258
|
+
elapsedRefreshMs: 0,
|
|
259
|
+
firstPaintMinMs: 0,
|
|
260
|
+
setInterval: () => 0,
|
|
261
|
+
clearInterval: () => {},
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
await feed.update('w1', CHAT, view('first step', 10_000))
|
|
265
|
+
await drain()
|
|
266
|
+
await feed.update('w1', CHAT, view('second step', 20_000))
|
|
267
|
+
await drain()
|
|
268
|
+
await feed.update('w1', CHAT, view('third step', 30_000))
|
|
269
|
+
await drain()
|
|
270
|
+
const beforeFinish = seen.length
|
|
271
|
+
await feed.finish('w1', view('all done', 40_000, 'done'))
|
|
272
|
+
await drain()
|
|
273
|
+
|
|
274
|
+
// At least one intermediate repaint and exactly the finish frame after it.
|
|
275
|
+
expect(beforeFinish).toBeGreaterThan(0)
|
|
276
|
+
expect(seen.length).toBeGreaterThan(beforeFinish)
|
|
277
|
+
for (const e of seen.slice(0, beforeFinish)) {
|
|
278
|
+
expect(e.meta?.terminal).toBe(false)
|
|
279
|
+
expect(workerFeedEditPriorityClass(e.meta)).toBe('cosmetic')
|
|
280
|
+
}
|
|
281
|
+
for (const e of seen.slice(beforeFinish)) {
|
|
282
|
+
expect(e.meta?.terminal).toBe(true)
|
|
283
|
+
expect(workerFeedEditPriorityClass(e.meta)).toBe('useful')
|
|
284
|
+
}
|
|
285
|
+
})
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
describe('#3848 — gateway.ts actually wires the mapping', () => {
|
|
289
|
+
/**
|
|
290
|
+
* `gateway.ts` is not unit-importable (a single 24k-line module with a live
|
|
291
|
+
* bot at construction), so `gatewayEditAdapter` above can only MIRROR its
|
|
292
|
+
* adapter. That mirror would keep passing if someone hard-coded
|
|
293
|
+
* `priorityClass: 'cosmetic'` back into the real adapter, which is exactly
|
|
294
|
+
* the regression #3848 fixes. This asserts the real wiring at the source,
|
|
295
|
+
* deterministically, instead of trusting the mirror.
|
|
296
|
+
*/
|
|
297
|
+
const GATEWAY = new URL('../gateway/gateway.ts', import.meta.url)
|
|
298
|
+
|
|
299
|
+
it('passes the feed edit meta through workerFeedEditPriorityClass', () => {
|
|
300
|
+
const src = readFileSync(GATEWAY, 'utf8')
|
|
301
|
+
// The adapter must actually RECEIVE the meta argument from the feed.
|
|
302
|
+
const ANCHOR = 'editMessageText: (cid, mid, text, editOpts, editMeta) =>'
|
|
303
|
+
const at = src.indexOf(ANCHOR)
|
|
304
|
+
expect(at, `gateway.ts no longer declares the worker-feed edit adapter as \`${ANCHOR}\``).toBeGreaterThan(-1)
|
|
305
|
+
|
|
306
|
+
// Bound the slice to this one adapter: it ends at the `verb: 'worker-feed'`
|
|
307
|
+
// options object it passes to robustApiCall.
|
|
308
|
+
const verbAt = src.indexOf("verb: 'worker-feed'", at)
|
|
309
|
+
expect(verbAt).toBeGreaterThan(at)
|
|
310
|
+
const feedAdapter = src.slice(at, verbAt + 500)
|
|
311
|
+
|
|
312
|
+
expect(feedAdapter).toContain('priorityClass: workerFeedEditPriorityClass(editMeta)')
|
|
313
|
+
// The hard-coded class #3848 removed must not come back.
|
|
314
|
+
expect(feedAdapter).not.toContain("priorityClass: 'cosmetic'")
|
|
315
|
+
// And the symbol must be imported, or the above is dead source text.
|
|
316
|
+
expect(src).toContain('workerFeedEditPriorityClass')
|
|
317
|
+
})
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
describe('#3848 — the class mapping itself', () => {
|
|
321
|
+
it('maps terminal → useful and everything else → cosmetic', () => {
|
|
322
|
+
expect(workerFeedEditPriorityClass({ terminal: true })).toBe('useful')
|
|
323
|
+
expect(workerFeedEditPriorityClass({ terminal: false })).toBe('cosmetic')
|
|
324
|
+
// An adapter that forgets to forward `meta` must degrade to the SAFE
|
|
325
|
+
// class, not silently promote every repaint.
|
|
326
|
+
expect(workerFeedEditPriorityClass(undefined)).toBe('cosmetic')
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it('returns a value assignable to the send gate PriorityClass union', () => {
|
|
330
|
+
// Compile-time half: this line fails `tsc` if the mapping ever returns a
|
|
331
|
+
// class the gate does not know (#3847's OutboundClass/PriorityClass lock).
|
|
332
|
+
const cls: PriorityClass = workerFeedEditPriorityClass({ terminal: true })
|
|
333
|
+
expect(['critical', 'useful', 'cosmetic']).toContain(cls)
|
|
334
|
+
})
|
|
335
|
+
})
|
|
@@ -253,7 +253,7 @@ describe('prose-silent background worker — end-to-end visibility harness (PR 2
|
|
|
253
253
|
expect(h.bot.sent).toHaveLength(1)
|
|
254
254
|
expect(h.feed.messageIdOf(h.agentId)).not.toBeNull()
|
|
255
255
|
expect(h.bot.sent[0].chatId).toBe('chat-42')
|
|
256
|
-
expect(h.bot.sent[0].text
|
|
256
|
+
expect(h.bot.sent[0].text.startsWith('🛠 **WORKER**')).toBe(true)
|
|
257
257
|
|
|
258
258
|
// (b) KEEPS UPDATING: later heartbeats edit the message with a climbing
|
|
259
259
|
// `· Ns` suffix so the still-alive worker visibly advances.
|
|
@@ -33,9 +33,10 @@
|
|
|
33
33
|
*
|
|
34
34
|
* Effort is NATIVE. A live `/effort` override records in gateway memory only
|
|
35
35
|
* (no carrier), so the self-restart sheds it and the downgraded default boots at
|
|
36
|
-
* the configured `thinking_effort` (
|
|
36
|
+
* the configured `thinking_effort` (#1978 /
|
|
37
37
|
* src/config/thinking-effort-risk.ts). This module writes NO effort carrier —
|
|
38
|
-
* that is the whole point: the downgraded opus must resolve
|
|
38
|
+
* that is the whole point: the downgraded opus must resolve to the configured
|
|
39
|
+
* default, whatever that is.
|
|
39
40
|
*
|
|
40
41
|
* Loop guard — the natural on-default guard is the real bound. After the
|
|
41
42
|
* downgrade boot the session runs the configured default (the override is gone
|