switchroom 0.18.24 → 0.18.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/switchroom.js +59 -11
- package/dist/host-control/main.js +1 -1
- package/package.json +2 -2
- package/telegram-plugin/dist/bridge/bridge.js +26 -0
- package/telegram-plugin/dist/gateway/gateway.js +1827 -831
- package/telegram-plugin/dist/server.js +26 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +7 -0
- package/telegram-plugin/gateway/gateway.ts +314 -3
- package/telegram-plugin/gateway/model-command.ts +188 -56
- package/telegram-plugin/gateway/redelivery-decision.ts +139 -0
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +42 -1
- package/telegram-plugin/history.ts +118 -0
- package/telegram-plugin/registry/turns-schema.ts +89 -1
- package/telegram-plugin/render/code-segments.ts +210 -0
- package/telegram-plugin/render/dollar-math-guard.ts +126 -0
- package/telegram-plugin/render/emphasis-guard.ts +158 -0
- package/telegram-plugin/render/inline-pairs-guard.ts +171 -0
- package/telegram-plugin/render/line-start-guard.ts +167 -0
- package/telegram-plugin/render/rich-render.ts +7 -0
- package/telegram-plugin/rich-send.ts +48 -2
- package/telegram-plugin/session-tail.ts +185 -0
- package/telegram-plugin/subagent-watcher.ts +45 -0
- package/telegram-plugin/tests/crash-redelivery-resume-exclusion.test.ts +133 -0
- package/telegram-plugin/tests/crash-redelivery-wiring.test.ts +72 -0
- package/telegram-plugin/tests/history.test.ts +91 -0
- package/telegram-plugin/tests/model-command.test.ts +189 -12
- package/telegram-plugin/tests/redelivery-decision.test.ts +84 -0
- package/telegram-plugin/tests/registry-turns.test.ts +51 -0
- package/telegram-plugin/tests/render/dollar-math-guard.test.ts +162 -0
- package/telegram-plugin/tests/render/emphasis-guard.test.ts +205 -0
- package/telegram-plugin/tests/render/guard-composition.test.ts +138 -0
- package/telegram-plugin/tests/render/inline-pairs-guard.test.ts +171 -0
- package/telegram-plugin/tests/render/line-start-guard.test.ts +164 -0
- package/telegram-plugin/tests/session-model-source.test.ts +11 -0
- package/telegram-plugin/tests/session-tail.test.ts +145 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +50 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +109 -0
- package/telegram-plugin/tests/trailing-answer-projector.test.ts +124 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +125 -0
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +306 -0
- package/telegram-plugin/tool-activity-summary.ts +54 -3
- package/telegram-plugin/worker-activity-feed.ts +104 -0
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +762 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +13 -1
- package/vendor/hindsight-memory/scripts/lib/client.py +14 -4
- package/vendor/hindsight-memory/scripts/lib/config.py +8 -0
- package/vendor/hindsight-memory/scripts/lib/pacing.py +102 -0
- package/vendor/hindsight-memory/scripts/lib/watermark.py +213 -0
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +344 -0
- package/vendor/hindsight-memory/scripts/retain.py +299 -143
- package/vendor/hindsight-memory/scripts/session_start.py +14 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill.py +362 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +350 -0
- package/vendor/hindsight-memory/tests/test_hooks.py +8 -2
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
createWorkerActivityFeed,
|
|
4
|
+
type WorkerActivityView,
|
|
5
|
+
type BotApiForWorkerFeed,
|
|
6
|
+
} from '../worker-activity-feed.js'
|
|
7
|
+
import { reconcilePin, type PinBotApi } from '../status-pin-driver.js'
|
|
8
|
+
import type { PinState, DesiredPin } from '../status-pin.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Outcome tests for the invisible-worker-cards fix (2026-07-15).
|
|
12
|
+
*
|
|
13
|
+
* Root cause: the worker feed reuses ONE shared group message per chat for the
|
|
14
|
+
* whole time workers overlap, but `syncPin` was called only at lifecycle edges
|
|
15
|
+
* (first-paint / terminal / GC) — NEVER on the steady-state edit path. So once
|
|
16
|
+
* the pin was lost out-of-band (its in-memory claim dropped), the reused message
|
|
17
|
+
* kept being edited live but was never re-pinned → scroll-buried / invisible.
|
|
18
|
+
*
|
|
19
|
+
* These tests wire the feed's `reconcilePin` hook into the REAL status-pin
|
|
20
|
+
* driver + a claim map exactly as the gateway does (`wk:group:<feedKey>` key,
|
|
21
|
+
* `void reconcileStatusPin`), and assert the PIN-CALL OUTCOMES:
|
|
22
|
+
* 1. a steady-state edit re-pins when the claim was lost — and does NOT re-pin
|
|
23
|
+
* (no storm) when the claim is already correct.
|
|
24
|
+
* 2. routing an unpin through the reconciler CLEARS the claim, so a later edit
|
|
25
|
+
* re-pins.
|
|
26
|
+
* 3. the group-message lifetime cap ROTATES to a fresh message id and re-
|
|
27
|
+
* establishes the pin on the new message.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
function view(partial: Partial<WorkerActivityView> = {}): WorkerActivityView {
|
|
31
|
+
return {
|
|
32
|
+
description: 'background task',
|
|
33
|
+
lastTool: { name: 'Bash', sanitisedArg: 'grep -r x' },
|
|
34
|
+
toolCount: 1,
|
|
35
|
+
latestSummary: 'working',
|
|
36
|
+
elapsedMs: 10_000,
|
|
37
|
+
state: 'running',
|
|
38
|
+
...partial,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface FakeBot extends BotApiForWorkerFeed {
|
|
43
|
+
sent: Array<{ chatId: string; messageId: number; text: string }>
|
|
44
|
+
edits: Array<{ messageId: number; text: string }>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function makeFakeBot(): FakeBot {
|
|
48
|
+
let nextId = 1000
|
|
49
|
+
const fb: FakeBot = {
|
|
50
|
+
sent: [],
|
|
51
|
+
edits: [],
|
|
52
|
+
sendMessage: async (chatId, text) => {
|
|
53
|
+
const message_id = nextId++
|
|
54
|
+
fb.sent.push({ chatId, messageId: message_id, text })
|
|
55
|
+
return { message_id }
|
|
56
|
+
},
|
|
57
|
+
editMessageText: async (_chatId, messageId, text) => {
|
|
58
|
+
fb.edits.push({ messageId, text })
|
|
59
|
+
return {}
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
return fb
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Mirror the gateway's status-pin reconcile: a `Map<key,PinState>` claim store
|
|
67
|
+
* driven through the real `reconcilePin` driver against a fake pin API that
|
|
68
|
+
* records every pin/unpin call. This is the seam that turns a `reconcilePin`
|
|
69
|
+
* hook call into an observable Telegram pin/unpin — proving OUTCOMES, not paths.
|
|
70
|
+
*/
|
|
71
|
+
function makePinHarness() {
|
|
72
|
+
const state = new Map<string, PinState>()
|
|
73
|
+
const pinCalls: number[] = []
|
|
74
|
+
const unpinCalls: number[] = []
|
|
75
|
+
let lastKey: string | null = null
|
|
76
|
+
const api: PinBotApi = {
|
|
77
|
+
pinChatMessage: async (_chat, message_id) => {
|
|
78
|
+
pinCalls.push(message_id)
|
|
79
|
+
},
|
|
80
|
+
unpinChatMessage: async (_chat, message_id) => {
|
|
81
|
+
unpinCalls.push(message_id)
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
async function reconcile(key: string, chatId: string, desired: DesiredPin): Promise<void> {
|
|
85
|
+
const prev = state.get(key) ?? null
|
|
86
|
+
const next = await reconcilePin({ api, chatId, prevState: prev, desired })
|
|
87
|
+
if (next == null) state.delete(key)
|
|
88
|
+
else state.set(key, next)
|
|
89
|
+
}
|
|
90
|
+
const reconcilePinFn = (args: {
|
|
91
|
+
feedKey: string
|
|
92
|
+
chatId: string
|
|
93
|
+
threadId?: number
|
|
94
|
+
messageId: number | null
|
|
95
|
+
}): void => {
|
|
96
|
+
const key = `wk:group:${args.feedKey}`
|
|
97
|
+
lastKey = key
|
|
98
|
+
if (args.messageId != null) void reconcile(key, args.chatId, { pinned: true, messageId: args.messageId })
|
|
99
|
+
else void reconcile(key, args.chatId, { pinned: false })
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
state,
|
|
103
|
+
pinCalls,
|
|
104
|
+
unpinCalls,
|
|
105
|
+
reconcilePinFn,
|
|
106
|
+
key: (): string => {
|
|
107
|
+
if (lastKey == null) throw new Error('no reconcile happened yet')
|
|
108
|
+
return lastKey
|
|
109
|
+
},
|
|
110
|
+
/** Directly drop the claim to simulate a pin lost out-of-band. */
|
|
111
|
+
dropClaim(): void {
|
|
112
|
+
state.clear()
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Flush the microtask queue so fire-and-forget `void reconcile(...)` settles. */
|
|
118
|
+
async function flush(): Promise<void> {
|
|
119
|
+
for (let i = 0; i < 8; i++) await new Promise((r) => setImmediate(r))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
describe('worker-feed pin persistence — steady-state re-pin (invisible-worker-cards)', () => {
|
|
123
|
+
it('re-pins on a steady-state edit when the claim was lost, and does NOT re-pin when already correct (no storm)', async () => {
|
|
124
|
+
const bot = makeFakeBot()
|
|
125
|
+
const pin = makePinHarness()
|
|
126
|
+
let clock = 0
|
|
127
|
+
const feed = createWorkerActivityFeed({
|
|
128
|
+
bot,
|
|
129
|
+
now: () => clock,
|
|
130
|
+
firstPaintMinMs: 0,
|
|
131
|
+
minEditIntervalMs: 0,
|
|
132
|
+
reconcilePin: pin.reconcilePinFn,
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// First paint → message posted AND pinned once.
|
|
136
|
+
clock = 1000
|
|
137
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 1000, toolCount: 1 }))
|
|
138
|
+
await flush()
|
|
139
|
+
expect(bot.sent).toHaveLength(1)
|
|
140
|
+
const msgId = bot.sent[0].messageId
|
|
141
|
+
expect(pin.pinCalls).toEqual([msgId])
|
|
142
|
+
expect(pin.state.get(pin.key())?.messageId).toBe(msgId)
|
|
143
|
+
|
|
144
|
+
// Steady-state edit while the pin is already correct → NO new pin (no storm).
|
|
145
|
+
clock = 2000
|
|
146
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 2000, toolCount: 5 }))
|
|
147
|
+
await flush()
|
|
148
|
+
expect(bot.edits.length).toBeGreaterThanOrEqual(1)
|
|
149
|
+
expect(pin.pinCalls).toEqual([msgId]) // still exactly one pin — decidePinAction no-op'd
|
|
150
|
+
|
|
151
|
+
// Pin lost out-of-band (claim dropped) → next steady edit must re-pin.
|
|
152
|
+
pin.dropClaim()
|
|
153
|
+
clock = 3000
|
|
154
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 3000, toolCount: 9 }))
|
|
155
|
+
await flush()
|
|
156
|
+
expect(pin.pinCalls).toEqual([msgId, msgId]) // re-pinned the SAME live message
|
|
157
|
+
expect(pin.state.get(pin.key())?.messageId).toBe(msgId)
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
describe('worker-feed pin persistence — unpin clears the claim', () => {
|
|
162
|
+
it('an unpin routed through the reconciler clears the claim so a later edit re-pins', async () => {
|
|
163
|
+
const bot = makeFakeBot()
|
|
164
|
+
const pin = makePinHarness()
|
|
165
|
+
let clock = 0
|
|
166
|
+
const feed = createWorkerActivityFeed({
|
|
167
|
+
bot,
|
|
168
|
+
now: () => clock,
|
|
169
|
+
firstPaintMinMs: 0,
|
|
170
|
+
minEditIntervalMs: 0,
|
|
171
|
+
reconcilePin: pin.reconcilePinFn,
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
clock = 1000
|
|
175
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 1000, toolCount: 1 }))
|
|
176
|
+
await flush()
|
|
177
|
+
const msgId = bot.sent[0].messageId
|
|
178
|
+
const key = pin.key()
|
|
179
|
+
expect(pin.state.get(key)?.messageId).toBe(msgId)
|
|
180
|
+
|
|
181
|
+
// Route an unpin of the worker message through the reconciler (the sanctioned
|
|
182
|
+
// path): the claim is dropped AND the Telegram unpin is issued.
|
|
183
|
+
await (async () => {
|
|
184
|
+
const prev = pin.state.get(key) ?? null
|
|
185
|
+
const next = await reconcilePin({
|
|
186
|
+
api: {
|
|
187
|
+
pinChatMessage: async () => {},
|
|
188
|
+
unpinChatMessage: async (_c, id) => {
|
|
189
|
+
pin.unpinCalls.push(id)
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
chatId: 'chat',
|
|
193
|
+
prevState: prev,
|
|
194
|
+
desired: { pinned: false },
|
|
195
|
+
})
|
|
196
|
+
if (next == null) pin.state.delete(key)
|
|
197
|
+
else pin.state.set(key, next)
|
|
198
|
+
})()
|
|
199
|
+
expect(pin.state.has(key)).toBe(false) // claim cleared
|
|
200
|
+
expect(pin.unpinCalls).toContain(msgId)
|
|
201
|
+
|
|
202
|
+
// A later steady edit now re-pins (the claim being clear is what lets
|
|
203
|
+
// decidePinAction issue a fresh pin instead of a stale-id no-op).
|
|
204
|
+
clock = 2000
|
|
205
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 2000, toolCount: 7 }))
|
|
206
|
+
await flush()
|
|
207
|
+
expect(pin.pinCalls).toContain(msgId)
|
|
208
|
+
expect(pin.state.get(key)?.messageId).toBe(msgId)
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
describe('worker-feed pin persistence — group-message lifetime cap rotation', () => {
|
|
213
|
+
it('rotates to a fresh message past the cap and re-establishes the pin on the new id', async () => {
|
|
214
|
+
const bot = makeFakeBot()
|
|
215
|
+
const pin = makePinHarness()
|
|
216
|
+
let clock = 0
|
|
217
|
+
const feed = createWorkerActivityFeed({
|
|
218
|
+
bot,
|
|
219
|
+
now: () => clock,
|
|
220
|
+
firstPaintMinMs: 0,
|
|
221
|
+
minEditIntervalMs: 0,
|
|
222
|
+
heartbeatTickMs: 6000,
|
|
223
|
+
groupMessageLifetimeCapMs: 30_000,
|
|
224
|
+
reconcilePin: pin.reconcilePinFn,
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
// First paint → message A posted + pinned.
|
|
228
|
+
clock = 1000
|
|
229
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 1000, toolCount: 1 }))
|
|
230
|
+
await flush()
|
|
231
|
+
const msgA = bot.sent[0].messageId
|
|
232
|
+
expect(pin.pinCalls).toEqual([msgA])
|
|
233
|
+
|
|
234
|
+
// Worker keeps running; advance well past the 30s message cap, then tick.
|
|
235
|
+
clock = 40_000
|
|
236
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 40_000, toolCount: 4 }))
|
|
237
|
+
await flush()
|
|
238
|
+
feed.heartbeatTick()
|
|
239
|
+
await flush()
|
|
240
|
+
|
|
241
|
+
// A SECOND message was posted (rotation) and it is a DIFFERENT id.
|
|
242
|
+
expect(bot.sent).toHaveLength(2)
|
|
243
|
+
const msgB = bot.sent[1].messageId
|
|
244
|
+
expect(msgB).not.toBe(msgA)
|
|
245
|
+
expect(feed.messageIdOf('w1')).toBe(msgB)
|
|
246
|
+
|
|
247
|
+
// The stale message was unpinned and the fresh one pinned → pin surface
|
|
248
|
+
// re-established on the new id (deterministic, no burst: one unpin + one pin).
|
|
249
|
+
expect(pin.unpinCalls).toContain(msgA)
|
|
250
|
+
expect(pin.pinCalls).toContain(msgB)
|
|
251
|
+
expect(pin.state.get(pin.key())?.messageId).toBe(msgB)
|
|
252
|
+
|
|
253
|
+
// FIX 1: the retired message was collapsed to the honest "moved" note with
|
|
254
|
+
// exactly ONE edit (not left frozen showing live rows, not re-edited per tick).
|
|
255
|
+
const supersedeEdits = bot.edits.filter(
|
|
256
|
+
(e) => e.messageId === msgA && e.text.includes('Live progress moved to a fresh card'),
|
|
257
|
+
)
|
|
258
|
+
expect(supersedeEdits).toHaveLength(1)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('rotation preserves ALL live overlapping worker rows on the fresh message (none dropped/double-counted)', async () => {
|
|
262
|
+
const bot = makeFakeBot()
|
|
263
|
+
const pin = makePinHarness()
|
|
264
|
+
let clock = 0
|
|
265
|
+
const feed = createWorkerActivityFeed({
|
|
266
|
+
bot,
|
|
267
|
+
now: () => clock,
|
|
268
|
+
firstPaintMinMs: 0,
|
|
269
|
+
minEditIntervalMs: 0,
|
|
270
|
+
heartbeatTickMs: 6000,
|
|
271
|
+
groupMessageLifetimeCapMs: 30_000,
|
|
272
|
+
reconcilePin: pin.reconcilePinFn,
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
// Two workers overlap in the SAME chat → one combined group message.
|
|
276
|
+
clock = 1000
|
|
277
|
+
await feed.update('w1', 'chat', view({ description: 'alpha task', elapsedMs: 1000 }))
|
|
278
|
+
await flush()
|
|
279
|
+
clock = 1100
|
|
280
|
+
await feed.update('w2', 'chat', view({ description: 'beta task', elapsedMs: 1100 }))
|
|
281
|
+
await flush()
|
|
282
|
+
const msgA = bot.sent[0].messageId
|
|
283
|
+
// Both workers share the one message.
|
|
284
|
+
expect(feed.messageIdOf('w1')).toBe(msgA)
|
|
285
|
+
expect(feed.messageIdOf('w2')).toBe(msgA)
|
|
286
|
+
|
|
287
|
+
// Advance past the cap with BOTH still live, then tick to rotate.
|
|
288
|
+
clock = 40_000
|
|
289
|
+
await feed.update('w1', 'chat', view({ description: 'alpha task', elapsedMs: 40_000, toolCount: 3 }))
|
|
290
|
+
await flush()
|
|
291
|
+
feed.heartbeatTick()
|
|
292
|
+
await flush()
|
|
293
|
+
|
|
294
|
+
// A fresh message was posted and BOTH live workers moved to it — no row
|
|
295
|
+
// dropped, no worker orphaned on the retired card.
|
|
296
|
+
expect(bot.sent).toHaveLength(2)
|
|
297
|
+
const fresh = bot.sent[1]
|
|
298
|
+
expect(fresh.messageId).not.toBe(msgA)
|
|
299
|
+
expect(feed.messageIdOf('w1')).toBe(fresh.messageId)
|
|
300
|
+
expect(feed.messageIdOf('w2')).toBe(fresh.messageId)
|
|
301
|
+
// The fresh combined body renders BOTH workers' rows.
|
|
302
|
+
expect(fresh.text).toContain('alpha task')
|
|
303
|
+
expect(fresh.text).toContain('beta task')
|
|
304
|
+
expect(feed.size).toBe(2)
|
|
305
|
+
})
|
|
306
|
+
})
|
|
@@ -115,6 +115,11 @@ export interface SessionActivityHeader {
|
|
|
115
115
|
* `formatModelLabel` (model-label.ts).
|
|
116
116
|
*/
|
|
117
117
|
model?: string
|
|
118
|
+
/** Running total tokens for THIS turn (the parent agent's OWN per-message
|
|
119
|
+
* usage, summed + deduped upstream) — rendered as `· {N} tok` on the metrics
|
|
120
|
+
* line via `tokenSegment`. Omitted (0/undefined) → no token segment, same
|
|
121
|
+
* clean-omit behavior as the worker feed. */
|
|
122
|
+
totalTokens?: number
|
|
118
123
|
}
|
|
119
124
|
|
|
120
125
|
/**
|
|
@@ -185,21 +190,57 @@ export function renderActivityHeader(
|
|
|
185
190
|
toolCount: number,
|
|
186
191
|
state: 'running' | 'done' | 'failed' | 'incomplete',
|
|
187
192
|
model?: string,
|
|
193
|
+
totalTokens?: number,
|
|
188
194
|
): [string, string] {
|
|
189
195
|
const toolWord = toolCount === 1 ? 'tool' : 'tools'
|
|
190
196
|
const elapsed = formatFeedElapsed(elapsedMs)
|
|
191
197
|
const descPart = description.length > 0 ? ` · _${escapeMarkdown(description)}_` : ''
|
|
192
198
|
const line1 = `${emoji} **${escapeMarkdown(label)}**${descPart}`
|
|
199
|
+
// Running total tokens: joins the dot-separated metrics between the tool
|
|
200
|
+
// count and the model tag. Omitted (empty) when the total is 0/unknown.
|
|
201
|
+
const tokPart = tokenSegment(totalTokens)
|
|
193
202
|
// Subtle live-model tag: joins the existing dot-separated metrics (never a new
|
|
194
203
|
// line). formatModelLabel returns null for absent/sentinel values → no suffix.
|
|
195
204
|
const modelLabel = formatModelLabel(model)
|
|
196
205
|
const modelPart = modelLabel != null ? ` · ${escapeMarkdown(modelLabel)}` : ''
|
|
197
206
|
const line2 = state === 'running'
|
|
198
|
-
? `_${elapsed} · ${toolCount} ${toolWord}${modelPart}_`
|
|
199
|
-
: `_${state} · ${toolCount} ${toolWord} · ${elapsed}${modelPart}_`
|
|
207
|
+
? `_${elapsed} · ${toolCount} ${toolWord}${tokPart}${modelPart}_`
|
|
208
|
+
: `_${state} · ${toolCount} ${toolWord}${tokPart} · ${elapsed}${modelPart}_`
|
|
200
209
|
return [line1, line2]
|
|
201
210
|
}
|
|
202
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Compact token-count formatter for the activity card's metrics line:
|
|
214
|
+
* <1000 → raw ("940")
|
|
215
|
+
* ≥1000, <1e6 → one-decimal k ("12.4k", "1.0k")
|
|
216
|
+
* ≥1e6 → one-decimal M ("1.2M")
|
|
217
|
+
* Negative / non-finite inputs clamp to "0". The caller appends " tok".
|
|
218
|
+
*/
|
|
219
|
+
export function formatTokenCount(n: number): string {
|
|
220
|
+
if (!Number.isFinite(n) || n <= 0) return '0'
|
|
221
|
+
if (n < 1000) return String(Math.floor(n))
|
|
222
|
+
if (n < 1_000_000) {
|
|
223
|
+
// Round to the displayed 1-decimal k FIRST: inputs in [999_950, 999_999]
|
|
224
|
+
// round to "1000.0k", which must promote into the M branch rather than
|
|
225
|
+
// render a nonsense "1000.0k". Fall through when the rounded k reaches 1000.
|
|
226
|
+
const k = Number((n / 1000).toFixed(1))
|
|
227
|
+
if (k < 1000) return `${k.toFixed(1)}k`
|
|
228
|
+
}
|
|
229
|
+
return `${(n / 1_000_000).toFixed(1)}M`
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The ` · {N} tok` metrics segment, or '' when there are no tokens to show.
|
|
234
|
+
* A 0 / undefined total (a worker that emitted no usage — e.g. a non-Claude
|
|
235
|
+
* transcript) OMITS the segment entirely so the line stays clean; the same
|
|
236
|
+
* predicate is used by BOTH render variants (single-worker header + combined
|
|
237
|
+
* row) so they never diverge.
|
|
238
|
+
*/
|
|
239
|
+
function tokenSegment(totalTokens: number | undefined): string {
|
|
240
|
+
if (totalTokens == null || totalTokens <= 0) return ''
|
|
241
|
+
return ` · ${formatTokenCount(totalTokens)} tok`
|
|
242
|
+
}
|
|
243
|
+
|
|
203
244
|
/** Format elapsed milliseconds for display in the activity header (e.g. "12s", "2m05s"). */
|
|
204
245
|
export function formatFeedElapsed(ms: number): string {
|
|
205
246
|
const s = Math.floor(ms / 1000)
|
|
@@ -289,6 +330,9 @@ export interface StatusCardHeader {
|
|
|
289
330
|
/** Live model id (raw, e.g. `claude-opus-4-8`) — rendered as a short friendly
|
|
290
331
|
* tag on the metrics line via `formatModelLabel`. Omitted when unknown. */
|
|
291
332
|
model?: string
|
|
333
|
+
/** Running total tokens for the worker/turn — rendered as `· {N} tok` on the
|
|
334
|
+
* metrics line. Omitted (0/undefined) → no token segment. */
|
|
335
|
+
totalTokens?: number
|
|
292
336
|
}
|
|
293
337
|
|
|
294
338
|
/** Inputs to the unified status-card renderer. */
|
|
@@ -341,6 +385,7 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
|
|
|
341
385
|
// 'failed', so only the worker card is affected.
|
|
342
386
|
header.state,
|
|
343
387
|
header.model,
|
|
388
|
+
header.totalTokens,
|
|
344
389
|
)
|
|
345
390
|
: []
|
|
346
391
|
|
|
@@ -497,6 +542,7 @@ export function renderActivityFeed(
|
|
|
497
542
|
toolCount: header.toolCount,
|
|
498
543
|
state: header.state,
|
|
499
544
|
model: header.model,
|
|
545
|
+
totalTokens: header.totalTokens,
|
|
500
546
|
}
|
|
501
547
|
: undefined,
|
|
502
548
|
steps: lines,
|
|
@@ -549,6 +595,7 @@ export function renderActivityFeedWithNested(
|
|
|
549
595
|
toolCount: header.toolCount,
|
|
550
596
|
state: header.state,
|
|
551
597
|
model: header.model,
|
|
598
|
+
totalTokens: header.totalTokens,
|
|
552
599
|
}
|
|
553
600
|
: undefined,
|
|
554
601
|
steps: lines,
|
|
@@ -596,6 +643,9 @@ export interface CombinedWorkerRow {
|
|
|
596
643
|
historyLines?: string[]
|
|
597
644
|
/** Live model id (raw, e.g. `claude-opus-4-8`); omitted when unknown. */
|
|
598
645
|
model?: string
|
|
646
|
+
/** Running total tokens for this worker — rendered as `· {N} tok` on the row
|
|
647
|
+
* header. Omitted (0/undefined) → no token segment. */
|
|
648
|
+
totalTokens?: number
|
|
599
649
|
}
|
|
600
650
|
|
|
601
651
|
export interface CombinedWorkerFeedOpts {
|
|
@@ -671,9 +721,10 @@ export function renderCombinedWorkerFeed(
|
|
|
671
721
|
truncate(stripMarkdown(r.description).replace(/\s+/g, ' ').trim() || 'background task', COMBINED_ROW_DESC_MAX),
|
|
672
722
|
)
|
|
673
723
|
const toolWord = r.toolCount === 1 ? 'tool' : 'tools'
|
|
724
|
+
const tokPart = tokenSegment(r.totalTokens)
|
|
674
725
|
const modelLabel = formatModelLabel(r.model)
|
|
675
726
|
const modelPart = modelLabel != null ? ` · ${escapeMarkdown(modelLabel)}` : ''
|
|
676
|
-
return `**${desc}** _· ${formatFeedElapsed(r.elapsedMs)} · ${r.toolCount} ${toolWord}${modelPart}_`
|
|
727
|
+
return `**${desc}** _· ${formatFeedElapsed(r.elapsedMs)} · ${r.toolCount} ${toolWord}${tokPart}${modelPart}_`
|
|
677
728
|
}
|
|
678
729
|
|
|
679
730
|
// Raw (unescaped) history for a worker, oldest→newest, empty lines stripped.
|
|
@@ -92,6 +92,14 @@ export interface WorkerActivityView {
|
|
|
92
92
|
lastTool: { name: string; sanitisedArg: string } | null
|
|
93
93
|
/** Number of tool calls observed so far. */
|
|
94
94
|
toolCount: number
|
|
95
|
+
/**
|
|
96
|
+
* Running TOTAL tokens across the worker's assistant messages so far
|
|
97
|
+
* (input + output + cache_creation, deduped by message.id in the watcher;
|
|
98
|
+
* cache_read is excluded — replayed cached context, not new work). Rendered
|
|
99
|
+
* as `· {N} tok` on the card's metrics line. Omitted /
|
|
100
|
+
* 0 → no token segment (a worker that emitted no usage).
|
|
101
|
+
*/
|
|
102
|
+
totalTokens?: number
|
|
95
103
|
/** The worker's latest narrative line, if any (already capped upstream). */
|
|
96
104
|
latestSummary: string
|
|
97
105
|
/**
|
|
@@ -178,6 +186,7 @@ export function renderWorkerActivity(v: WorkerActivityView, liveSuffix = ''): st
|
|
|
178
186
|
toolCount: v.toolCount,
|
|
179
187
|
state: v.state,
|
|
180
188
|
model: v.model,
|
|
189
|
+
totalTokens: v.totalTokens,
|
|
181
190
|
}
|
|
182
191
|
|
|
183
192
|
// Terminal: latestSummary carries the worker's final result text (gateway
|
|
@@ -328,6 +337,29 @@ export interface WorkerActivityFeedOpts {
|
|
|
328
337
|
* Tests inject a small value.
|
|
329
338
|
*/
|
|
330
339
|
absoluteRowLifetimeCapMs?: number
|
|
340
|
+
/**
|
|
341
|
+
* ABSOLUTE reused-group-MESSAGE lifetime cap (ms), measured from the moment
|
|
342
|
+
* the shared message was posted (`messageCreatedAtMs`), NOT from a worker
|
|
343
|
+
* row's age. When workers overlap continuously the group is NEVER reset to a
|
|
344
|
+
* fresh message (the only messageId reset is the group-reuse-after-terminal
|
|
345
|
+
* branch, gated on `!hasLiveWorker`), so ONE message can live for hours. If
|
|
346
|
+
* that message loses its pin out-of-band (a raw unpin somewhere else, or
|
|
347
|
+
* Telegram pin-stacking burying it), the steady-state edit path re-pins via
|
|
348
|
+
* `syncPin`; but a STALE in-memory pin claim (the claim still names this id, so
|
|
349
|
+
* `decidePinAction` no-ops on equal id) can only be cleared by rotating to a
|
|
350
|
+
* NEW message id. This cap force-rotates the shared message past an absolute
|
|
351
|
+
* age WHILE live workers remain, so the pin surface is periodically re-
|
|
352
|
+
* established through the first-paint `syncPin` path — the deterministic
|
|
353
|
+
* defense-in-depth that bounds how long a buried card can stay invisible
|
|
354
|
+
* (invisible-worker-cards incident, 2026-07-15; mirrors the immutable-anchor
|
|
355
|
+
* absolute-cap pattern of `absoluteRowLifetimeCapMs` / #3239's row cap).
|
|
356
|
+
*
|
|
357
|
+
* Conservative by design: set well above a normal edit cadence so a healthy
|
|
358
|
+
* pinned card is not needlessly churned. Rotation costs ONE unpin (old claim)
|
|
359
|
+
* + ONE pin (fresh message) per cap interval — never a burst. Fallback default
|
|
360
|
+
* (this module) 60 min; tests inject a small value.
|
|
361
|
+
*/
|
|
362
|
+
groupMessageLifetimeCapMs?: number
|
|
331
363
|
/**
|
|
332
364
|
* Group-level status-pin reconcile hook (#3207 review). Because workers now
|
|
333
365
|
* COALESCE into one shared message, the pin MUST follow the GROUP lifecycle,
|
|
@@ -425,6 +457,15 @@ interface FeedGroup {
|
|
|
425
457
|
chatId: string
|
|
426
458
|
threadId?: number
|
|
427
459
|
messageId: number | null
|
|
460
|
+
/**
|
|
461
|
+
* Wall-clock ms the CURRENT shared message was posted (first paint / re-paint).
|
|
462
|
+
* IMMUTABLE for the lifetime of a given `messageId`: stamped when a message id
|
|
463
|
+
* is assigned and NEVER re-stamped by later edits, so the group-message
|
|
464
|
+
* lifetime cap (`groupMessageLifetimeCapMs`) measures the reused message's true
|
|
465
|
+
* absolute age and cannot be reset by a continuous edit stream. Reset to 0
|
|
466
|
+
* whenever `messageId` goes null (rotation / stale-message drop).
|
|
467
|
+
*/
|
|
468
|
+
messageCreatedAtMs: number
|
|
428
469
|
lastBody: string | null
|
|
429
470
|
lastEditAt: number
|
|
430
471
|
cooldownUntil: number
|
|
@@ -460,6 +501,17 @@ interface FeedGroup {
|
|
|
460
501
|
|
|
461
502
|
const COOLDOWN_JITTER_MS = 500
|
|
462
503
|
|
|
504
|
+
/**
|
|
505
|
+
* Static body the retired shared message is finalized to when the group-message
|
|
506
|
+
* lifetime cap rotates to a fresh card (invisible-worker-cards review, FIX 1).
|
|
507
|
+
* Without this the abandoned message stays frozen showing live-styled worker
|
|
508
|
+
* rows and reads like a stuck worker. A single best-effort edit collapses it to
|
|
509
|
+
* an honest "moved" note — issued once per rotation (≥ cap interval), never per
|
|
510
|
+
* tick, so it adds no edit churn / pin storm. Plain voice, no em dash.
|
|
511
|
+
*/
|
|
512
|
+
const WORKER_CARD_SUPERSEDED_BODY =
|
|
513
|
+
'🛠 **Worker** · _continued_\n\n_Live progress moved to a fresh card to stay pinned._'
|
|
514
|
+
|
|
463
515
|
function extractRetryAfterSecs(err: unknown): number | null {
|
|
464
516
|
if (err == null || typeof err !== 'object') return null
|
|
465
517
|
const e = err as { error_code?: unknown; parameters?: { retry_after?: unknown } }
|
|
@@ -600,6 +652,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
600
652
|
const maxRows = Math.max(1, Math.floor(opts.maxRows ?? 8))
|
|
601
653
|
const staleWorkerTtlMs = Math.max(1, Math.floor(opts.staleWorkerTtlMs ?? 50 * 60_000))
|
|
602
654
|
const absoluteRowLifetimeCapMs = Math.max(1, Math.floor(opts.absoluteRowLifetimeCapMs ?? 6 * 60 * 60_000))
|
|
655
|
+
const groupMessageLifetimeCapMs = Math.max(1, Math.floor(opts.groupMessageLifetimeCapMs ?? 60 * 60_000))
|
|
603
656
|
const reconcilePinFn = opts.reconcilePin ?? (() => {})
|
|
604
657
|
const setIntervalFn =
|
|
605
658
|
opts.setInterval ??
|
|
@@ -754,6 +807,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
754
807
|
description: v.description,
|
|
755
808
|
elapsedMs: elapsedFor(r),
|
|
756
809
|
toolCount: v.toolCount,
|
|
810
|
+
totalTokens: v.totalTokens,
|
|
757
811
|
currentStep,
|
|
758
812
|
// Full per-worker rolling history (oldest→newest) so the combined feed
|
|
759
813
|
// can paint an adaptive-depth ✓/→ trail, not just the latest line. The
|
|
@@ -903,6 +957,9 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
903
957
|
return
|
|
904
958
|
}
|
|
905
959
|
g.messageId = sent.message_id
|
|
960
|
+
// Stamp the reused-message birth time (immutable for this id) so the
|
|
961
|
+
// group-message lifetime cap measures the message's true absolute age.
|
|
962
|
+
g.messageCreatedAtMs = now
|
|
906
963
|
g.lastBody = body
|
|
907
964
|
g.lastEditAt = now
|
|
908
965
|
// A fresh message is a live running paint, never a terminal recap.
|
|
@@ -951,6 +1008,16 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
951
1008
|
`worker-feed: edit feed=${g.feedKey} chat=${g.chatId} ` +
|
|
952
1009
|
`thread=${g.threadId ?? '-'} msgId=${g.messageId} workers=${g.workers.size} bytes=${body.length}`,
|
|
953
1010
|
)
|
|
1011
|
+
// Re-assert the group pin on the steady-state edit path (invisible-
|
|
1012
|
+
// worker-cards fix). `syncPin` was previously called ONLY at lifecycle
|
|
1013
|
+
// edges (first-paint / terminal / GC), so once a long-lived reused
|
|
1014
|
+
// message lost its pin out-of-band it was edited live forever but never
|
|
1015
|
+
// re-pinned → scroll-buried. This closes that gap deterministically. It
|
|
1016
|
+
// is a NO-OP when the pin is already correct: `decidePinAction` returns
|
|
1017
|
+
// `noop` on equal claim id (status-pin.ts), so this makes ZERO Telegram
|
|
1018
|
+
// calls on the common path and cannot create a pin storm — when the
|
|
1019
|
+
// claim was dropped (variant 1) it re-pins the current message.
|
|
1020
|
+
syncPin(g)
|
|
954
1021
|
}
|
|
955
1022
|
if (isTerminal) clearStaged()
|
|
956
1023
|
} catch (err) {
|
|
@@ -971,6 +1038,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
971
1038
|
// stale message id; a fresh first-paint re-establishes one if workers
|
|
972
1039
|
// are still live. On a terminal finalize, clear the staged repaint.
|
|
973
1040
|
g.messageId = null
|
|
1041
|
+
g.messageCreatedAtMs = 0
|
|
974
1042
|
g.lastBody = null
|
|
975
1043
|
// The pinned message no longer exists → release the group pin claim
|
|
976
1044
|
// (clearStaged already re-syncs on the terminal path).
|
|
@@ -1054,6 +1122,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
1054
1122
|
description: lv?.description ?? 'background task',
|
|
1055
1123
|
lastTool: null,
|
|
1056
1124
|
toolCount: lv?.toolCount ?? 0,
|
|
1125
|
+
totalTokens: lv?.totalTokens,
|
|
1057
1126
|
// No fabricated result paragraph — an authoritative sweep can't know what
|
|
1058
1127
|
// the worker returned; the terminal card shows the header struck-through
|
|
1059
1128
|
// as `incomplete`, and the handback (if it ran) carries the actual result.
|
|
@@ -1152,6 +1221,39 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
1152
1221
|
const running = runningRows(g)
|
|
1153
1222
|
if (running.length === 0) continue
|
|
1154
1223
|
|
|
1224
|
+
// ABSOLUTE group-message lifetime cap (invisible-worker-cards fix, defense
|
|
1225
|
+
// in depth). While workers overlap continuously the shared message is
|
|
1226
|
+
// never reset, so one message can live for hours; if its pin was lost
|
|
1227
|
+
// out-of-band and the in-memory claim went STALE (claim still names this
|
|
1228
|
+
// id → `decidePinAction` no-ops on equal id), `syncPin` alone can never
|
|
1229
|
+
// re-pin it. Force-rotate to a fresh message past an absolute age WHILE
|
|
1230
|
+
// live workers remain: drop the id (unpinning the stale claim via
|
|
1231
|
+
// `syncPin`, which clears it to null), then fall through to the first-
|
|
1232
|
+
// paint path, whose `syncPin` re-pins the NEW message from a null claim.
|
|
1233
|
+
// Costs exactly one unpin + one pin per cap interval — never a burst.
|
|
1234
|
+
if (g.messageId != null && now - g.messageCreatedAtMs >= groupMessageLifetimeCapMs) {
|
|
1235
|
+
const age = Math.floor((now - g.messageCreatedAtMs) / 1000)
|
|
1236
|
+
const retiredId = g.messageId
|
|
1237
|
+
log(
|
|
1238
|
+
`worker-feed: group-message lifetime cap rotate feed=${g.feedKey} ` +
|
|
1239
|
+
`msgId=${retiredId} — age ${age}s (>= ${Math.floor(groupMessageLifetimeCapMs / 1000)}s); ` +
|
|
1240
|
+
`rotating to a fresh message to re-establish the pin surface`,
|
|
1241
|
+
)
|
|
1242
|
+
g.messageId = null
|
|
1243
|
+
g.messageCreatedAtMs = 0
|
|
1244
|
+
g.lastBody = null
|
|
1245
|
+
// Clear the (possibly stale) pin claim for the retired message; the
|
|
1246
|
+
// fresh first-paint below re-pins the new one from a null claim.
|
|
1247
|
+
syncPin(g)
|
|
1248
|
+
// FIX 1: collapse the retired message to an honest "moved" note so it
|
|
1249
|
+
// doesn't sit frozen showing live-styled rows (mistakable for a stuck
|
|
1250
|
+
// worker). ONE best-effort edit per rotation (≥ cap interval), fired
|
|
1251
|
+
// off-chain and swallowing errors — never per-tick, never a burst.
|
|
1252
|
+
void opts.bot
|
|
1253
|
+
.editMessageText(g.chatId, retiredId, WORKER_CARD_SUPERSEDED_BODY, sendOptsFor(g))
|
|
1254
|
+
.catch(() => {})
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1155
1257
|
// First-paint path: no message yet and some worker has now crossed
|
|
1156
1258
|
// firstPaintMin (a prose-silent worker's single early tick was held).
|
|
1157
1259
|
if (g.messageId == null) {
|
|
@@ -1218,6 +1320,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
1218
1320
|
chatId,
|
|
1219
1321
|
threadId,
|
|
1220
1322
|
messageId: null,
|
|
1323
|
+
messageCreatedAtMs: 0,
|
|
1221
1324
|
lastBody: null,
|
|
1222
1325
|
lastEditAt: 0,
|
|
1223
1326
|
cooldownUntil: 0,
|
|
@@ -1237,6 +1340,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
1237
1340
|
// live worker remains AND the group last went terminal.
|
|
1238
1341
|
if (!g.workers.has(agentId) && g.terminalPainted && !hasLiveWorker(g)) {
|
|
1239
1342
|
g.messageId = null
|
|
1343
|
+
g.messageCreatedAtMs = 0
|
|
1240
1344
|
g.lastBody = null
|
|
1241
1345
|
g.pendingFinalize.clear()
|
|
1242
1346
|
g.terminalPainted = false
|