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.
Files changed (84) hide show
  1. package/bin/git-agent-attribution-hook.sh +144 -0
  2. package/dist/agent-scheduler/index.js +61 -2
  3. package/dist/auth-broker/index.js +125 -8
  4. package/dist/cli/notion-write-pretool.mjs +61 -2
  5. package/dist/cli/switchroom.js +2347 -1104
  6. package/dist/host-control/main.js +126 -9
  7. package/dist/vault/approvals/kernel-server.js +124 -8
  8. package/dist/vault/broker/server.js +124 -8
  9. package/package.json +6 -2
  10. package/profiles/_base/cron-session.sh.hbs +14 -0
  11. package/profiles/_base/start.sh.hbs +145 -4
  12. package/telegram-plugin/card-layout.ts +328 -0
  13. package/telegram-plugin/dist/bridge/bridge.js +93 -1
  14. package/telegram-plugin/dist/gateway/gateway.js +2213 -1204
  15. package/telegram-plugin/dist/server.js +96 -1
  16. package/telegram-plugin/edit-flood-fuse.ts +637 -56
  17. package/telegram-plugin/flood-429-ledger.ts +526 -0
  18. package/telegram-plugin/flood-circuit-breaker.ts +18 -0
  19. package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
  20. package/telegram-plugin/gateway/gateway.ts +103 -112
  21. package/telegram-plugin/gateway/narrative-lane.ts +14 -0
  22. package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
  23. package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
  24. package/telegram-plugin/gateway/periodic-sweep-guard.ts +86 -0
  25. package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
  26. package/telegram-plugin/gateway/status-pin-retarget.ts +180 -0
  27. package/telegram-plugin/gateway/status-pin-store.ts +58 -9
  28. package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
  29. package/telegram-plugin/llm-error-present.ts +61 -2
  30. package/telegram-plugin/model-unavailable.ts +8 -0
  31. package/telegram-plugin/operator-events.ts +72 -5
  32. package/telegram-plugin/outbound-class.ts +81 -0
  33. package/telegram-plugin/provider-credit.ts +237 -0
  34. package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
  35. package/telegram-plugin/send-gate.ts +24 -2
  36. package/telegram-plugin/status-no-truncate.ts +11 -0
  37. package/telegram-plugin/status-pin-driver.ts +33 -17
  38. package/telegram-plugin/status-pin.ts +51 -5
  39. package/telegram-plugin/tests/card-golden.test.ts +69 -0
  40. package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
  41. package/telegram-plugin/tests/card-type-distinguishability.test.ts +291 -0
  42. package/telegram-plugin/tests/card-variants.golden.txt +211 -0
  43. package/telegram-plugin/tests/card-variants.ts +366 -0
  44. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +316 -0
  45. package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
  46. package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
  47. package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
  48. package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
  49. package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
  50. package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
  51. package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
  52. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
  53. package/telegram-plugin/tests/periodic-sweep-guard.test.ts +151 -0
  54. package/telegram-plugin/tests/pinned-card-collapse.test.ts +24 -18
  55. package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
  56. package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
  57. package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
  58. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
  59. package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
  60. package/telegram-plugin/tests/status-pin-retarget.test.ts +244 -0
  61. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
  62. package/telegram-plugin/tests/status-pin-shutdown-wiring.test.ts +94 -0
  63. package/telegram-plugin/tests/status-pin-store.test.ts +179 -64
  64. package/telegram-plugin/tests/status-pin.test.ts +184 -7
  65. package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
  66. package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
  67. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +29 -19
  68. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
  69. package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
  70. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  71. package/telegram-plugin/tier-downgrade.ts +3 -2
  72. package/telegram-plugin/tool-activity-summary.ts +239 -322
  73. package/telegram-plugin/uat/assertions.ts +33 -3
  74. package/telegram-plugin/uat/feed-matcher.test.ts +36 -0
  75. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +9 -2
  76. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +9 -2
  77. package/telegram-plugin/worker-activity-feed.ts +109 -30
  78. package/vendor/hindsight-memory/CLAUDE.md +45 -0
  79. package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
  80. package/vendor/hindsight-memory/scripts/recall.py +176 -7
  81. package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
  82. package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
  83. package/vendor/hindsight-memory/scripts/tests/test_recall_request_timeout.py +241 -0
  84. 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 { reconcilePin, type PinBotApi } from '../status-pin-driver.js'
8
- import { decidePinAction, type PinState, type DesiredPin } from '../status-pin.js'
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 status-pin
25
- * driver + a claim map exactly as the gateway does (`wk:group:<feedKey>` key,
26
- * `void reconcileStatusPin`), and assert the PIN-CALL OUTCOMES:
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: a `Map<key,PinState>` claim store
72
- * driven through the real `reconcilePin` driver against a fake pin API that
73
- * records every pin/unpin call. This is the seam that turns a `reconcilePin`
74
- * hook call into an observable Telegram pin/unpin — proving OUTCOMES, not paths.
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 state = new Map<string, PinState>()
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 prev = state.get(key) ?? null
91
- const next = await reconcilePin({ api, chatId, prevState: prev, desired })
92
- if (next == null) state.delete(key)
93
- else state.set(key, next)
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
- state,
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
- state.clear()
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 (`reconcileAndPersistStatusPin`) against a memFs-backed
151
- * status-pins.json — exactly as the gateway wires it: read prev claim → decide
152
- * → map to a persist op → reconcileAndPersistStatusPin(applyPin=reconcilePin).
153
- * This lets a test INSPECT THE FILE across steady-state edits (F1).
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, PinState>()
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 prev = claims.get(key) ?? null
170
- const action = decidePinAction(prev, desired)
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
- op,
180
- applyPin: () => reconcilePin({ api, chatId, prevState: prev, desired }),
181
- log: () => {},
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.state.get(pin.key())?.messageId).toBe(msgId)
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.state.get(pin.key())?.messageId).toBe(msgId)
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.state.get(key)?.messageId).toBe(msgId)
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 (async () => {
314
- const prev = pin.state.get(key) ?? null
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.state.get(key)?.messageId).toBe(msgId)
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.state.get(pin.key())?.messageId).toBe(msgB)
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).toContain('🛠 **Worker**')
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` (the fleet `low` pin, #1978 /
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 LOW.
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