switchroom 0.14.13 → 0.14.15
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 +2 -2
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +17 -3
- package/telegram-plugin/dist/gateway/gateway.js +438 -158
- package/telegram-plugin/gateway/gateway.ts +123 -2
- package/telegram-plugin/reaction-defer.ts +98 -0
- package/telegram-plugin/status-reactions.ts +31 -1
- package/telegram-plugin/subagent-watcher.ts +13 -0
- package/telegram-plugin/tests/reaction-defer.test.ts +187 -0
- package/telegram-plugin/tests/status-reactions.test.ts +79 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +256 -0
- package/telegram-plugin/worker-activity-feed.ts +314 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
renderWorkerActivity,
|
|
4
|
+
createWorkerActivityFeed,
|
|
5
|
+
type WorkerActivityView,
|
|
6
|
+
type BotApiForWorkerFeed,
|
|
7
|
+
} from '../worker-activity-feed.js'
|
|
8
|
+
|
|
9
|
+
function view(partial: Partial<WorkerActivityView> = {}): WorkerActivityView {
|
|
10
|
+
return {
|
|
11
|
+
description: 'research competitors',
|
|
12
|
+
lastTool: { name: 'Bash', sanitisedArg: 'grep -r pricing' },
|
|
13
|
+
toolCount: 3,
|
|
14
|
+
latestSummary: 'scanning vendor pages',
|
|
15
|
+
elapsedMs: 10_000,
|
|
16
|
+
state: 'running',
|
|
17
|
+
...partial,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface FakeBot extends BotApiForWorkerFeed {
|
|
22
|
+
sent: Array<{ chatId: string; text: string; opts?: Record<string, unknown> }>
|
|
23
|
+
edits: Array<{ messageId: number; text: string }>
|
|
24
|
+
failNextSendWith?: unknown
|
|
25
|
+
failNextEditWith?: unknown
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeFakeBot(): FakeBot {
|
|
29
|
+
let nextId = 1000
|
|
30
|
+
const fb: FakeBot = {
|
|
31
|
+
sent: [],
|
|
32
|
+
edits: [],
|
|
33
|
+
sendMessage: async (chatId, text, opts) => {
|
|
34
|
+
if (fb.failNextSendWith != null) {
|
|
35
|
+
const e = fb.failNextSendWith
|
|
36
|
+
fb.failNextSendWith = undefined
|
|
37
|
+
throw e
|
|
38
|
+
}
|
|
39
|
+
fb.sent.push({ chatId, text, opts })
|
|
40
|
+
return { message_id: nextId++ }
|
|
41
|
+
},
|
|
42
|
+
editMessageText: async (_chatId, messageId, text) => {
|
|
43
|
+
if (fb.failNextEditWith != null) {
|
|
44
|
+
const e = fb.failNextEditWith
|
|
45
|
+
fb.failNextEditWith = undefined
|
|
46
|
+
throw e
|
|
47
|
+
}
|
|
48
|
+
fb.edits.push({ messageId, text })
|
|
49
|
+
return {}
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
return fb
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─── renderWorkerActivity (pure) ─────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
describe('renderWorkerActivity', () => {
|
|
58
|
+
it('renders running header + tool activity line + summary', () => {
|
|
59
|
+
const out = renderWorkerActivity(view())
|
|
60
|
+
expect(out).toContain('🔧 <b>Worker</b> · <i>research competitors</i>')
|
|
61
|
+
expect(out).toContain('⚡ <code>Bash</code> grep -r pricing')
|
|
62
|
+
expect(out).toContain('(3 tools · ')
|
|
63
|
+
expect(out).toContain('↳ <i>scanning vendor pages</i>')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('shows a "starting…" line when no tool has run yet', () => {
|
|
67
|
+
const out = renderWorkerActivity(view({ lastTool: null, latestSummary: '' }))
|
|
68
|
+
expect(out).toContain('🔧 <b>Worker</b>')
|
|
69
|
+
expect(out).toContain('starting…')
|
|
70
|
+
expect(out).not.toContain('⚡')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('omits the summary line when latestSummary is blank', () => {
|
|
74
|
+
const out = renderWorkerActivity(view({ latestSummary: ' ' }))
|
|
75
|
+
expect(out).not.toContain('↳')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('uses singular "tool" for a single tool call', () => {
|
|
79
|
+
const out = renderWorkerActivity(view({ toolCount: 1 }))
|
|
80
|
+
expect(out).toContain('(1 tool · ')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('renders a done terminal recap', () => {
|
|
84
|
+
const out = renderWorkerActivity(view({ state: 'done', toolCount: 5 }))
|
|
85
|
+
expect(out).toContain('✅ <b>Worker done</b> · <i>research competitors</i>')
|
|
86
|
+
expect(out).toContain('5 tools · ')
|
|
87
|
+
expect(out).not.toContain('⚡')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('renders a failed terminal recap', () => {
|
|
91
|
+
const out = renderWorkerActivity(view({ state: 'failed' }))
|
|
92
|
+
expect(out).toContain('⚠️ <b>Worker failed</b>')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('escapes HTML in description, tool, arg, and summary', () => {
|
|
96
|
+
const out = renderWorkerActivity(
|
|
97
|
+
view({
|
|
98
|
+
description: 'a <b>bold</b> task',
|
|
99
|
+
lastTool: { name: 'Ba<sh', sanitisedArg: 'x & y' },
|
|
100
|
+
latestSummary: 'a > b',
|
|
101
|
+
}),
|
|
102
|
+
)
|
|
103
|
+
expect(out).toContain('a <b>bold</b> task')
|
|
104
|
+
expect(out).toContain('Ba<sh')
|
|
105
|
+
expect(out).toContain('x & y')
|
|
106
|
+
expect(out).toContain('a > b')
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// ─── createWorkerActivityFeed (lifecycle) ────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
describe('createWorkerActivityFeed', () => {
|
|
113
|
+
it('holds first paint until the worker has run firstPaintMinMs', async () => {
|
|
114
|
+
const bot = makeFakeBot()
|
|
115
|
+
let clock = 0
|
|
116
|
+
const feed = createWorkerActivityFeed({
|
|
117
|
+
bot,
|
|
118
|
+
now: () => clock,
|
|
119
|
+
firstPaintMinMs: 8000,
|
|
120
|
+
})
|
|
121
|
+
clock = 5000
|
|
122
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 5000 }))
|
|
123
|
+
expect(bot.sent).toHaveLength(0)
|
|
124
|
+
expect(feed.has('w1')).toBe(false)
|
|
125
|
+
|
|
126
|
+
clock = 9000
|
|
127
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 9000 }))
|
|
128
|
+
expect(bot.sent).toHaveLength(1)
|
|
129
|
+
expect(bot.sent[0].chatId).toBe('chat')
|
|
130
|
+
expect(bot.sent[0].opts?.parse_mode).toBe('HTML')
|
|
131
|
+
expect(feed.has('w1')).toBe(true)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('dedups an identical body (no edit)', async () => {
|
|
135
|
+
const bot = makeFakeBot()
|
|
136
|
+
let clock = 10_000
|
|
137
|
+
const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
|
|
138
|
+
await feed.update('w1', 'chat', view())
|
|
139
|
+
expect(bot.sent).toHaveLength(1)
|
|
140
|
+
clock = 20_000
|
|
141
|
+
await feed.update('w1', 'chat', view()) // same body
|
|
142
|
+
expect(bot.edits).toHaveLength(0)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('throttles edits inside minEditIntervalMs but lets them through after', async () => {
|
|
146
|
+
const bot = makeFakeBot()
|
|
147
|
+
let clock = 10_000
|
|
148
|
+
const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 2500 })
|
|
149
|
+
await feed.update('w1', 'chat', view({ toolCount: 1 }))
|
|
150
|
+
expect(bot.sent).toHaveLength(1)
|
|
151
|
+
|
|
152
|
+
clock = 11_000 // +1000 < 2500
|
|
153
|
+
await feed.update('w1', 'chat', view({ toolCount: 2 }))
|
|
154
|
+
expect(bot.edits).toHaveLength(0)
|
|
155
|
+
|
|
156
|
+
clock = 13_000 // +3000 since last edit > 2500
|
|
157
|
+
await feed.update('w1', 'chat', view({ toolCount: 3 }))
|
|
158
|
+
expect(bot.edits).toHaveLength(1)
|
|
159
|
+
expect(bot.edits[0].text).toContain('(3 tools · ')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('forces a terminal edit on finish, skipping the throttle', async () => {
|
|
163
|
+
const bot = makeFakeBot()
|
|
164
|
+
let clock = 10_000
|
|
165
|
+
const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 9_999_999 })
|
|
166
|
+
await feed.update('w1', 'chat', view())
|
|
167
|
+
expect(bot.sent).toHaveLength(1)
|
|
168
|
+
|
|
169
|
+
clock = 10_500 // well within the throttle window
|
|
170
|
+
await feed.finish('w1', view({ state: 'done', toolCount: 5 }))
|
|
171
|
+
expect(bot.edits).toHaveLength(1)
|
|
172
|
+
expect(bot.edits[0].text).toContain('✅ <b>Worker done</b>')
|
|
173
|
+
// finish forgets the worker.
|
|
174
|
+
expect(feed.has('w1')).toBe(false)
|
|
175
|
+
expect(feed.size).toBe(0)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('finish is a no-op when no message was ever posted', async () => {
|
|
179
|
+
const bot = makeFakeBot()
|
|
180
|
+
let clock = 0
|
|
181
|
+
const feed = createWorkerActivityFeed({ bot, now: () => clock, firstPaintMinMs: 8000 })
|
|
182
|
+
clock = 2000
|
|
183
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 2000 })) // too short to paint
|
|
184
|
+
expect(bot.sent).toHaveLength(0)
|
|
185
|
+
await feed.finish('w1', view({ state: 'done' }))
|
|
186
|
+
expect(bot.edits).toHaveLength(0)
|
|
187
|
+
expect(bot.sent).toHaveLength(0)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('drop forgets a worker without editing', async () => {
|
|
191
|
+
const bot = makeFakeBot()
|
|
192
|
+
let clock = 10_000
|
|
193
|
+
const feed = createWorkerActivityFeed({ bot, now: () => clock })
|
|
194
|
+
await feed.update('w1', 'chat', view())
|
|
195
|
+
expect(feed.has('w1')).toBe(true)
|
|
196
|
+
feed.drop('w1')
|
|
197
|
+
expect(feed.has('w1')).toBe(false)
|
|
198
|
+
expect(feed.size).toBe(0)
|
|
199
|
+
await feed.finish('w1', view({ state: 'done' }))
|
|
200
|
+
expect(bot.edits).toHaveLength(0)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('honours a 429 cooldown before retrying the first paint', async () => {
|
|
204
|
+
const bot = makeFakeBot()
|
|
205
|
+
let clock = 10_000
|
|
206
|
+
const feed = createWorkerActivityFeed({ bot, now: () => clock, firstPaintMinMs: 0 })
|
|
207
|
+
bot.failNextSendWith = { error_code: 429, parameters: { retry_after: 2 } }
|
|
208
|
+
await feed.update('w1', 'chat', view())
|
|
209
|
+
expect(bot.sent).toHaveLength(0) // failed send
|
|
210
|
+
|
|
211
|
+
clock = 11_000 // still inside cooldown (10_000 + 2000 + 500 jitter = 12_500)
|
|
212
|
+
await feed.update('w1', 'chat', view())
|
|
213
|
+
expect(bot.sent).toHaveLength(0)
|
|
214
|
+
|
|
215
|
+
clock = 13_000 // past cooldown
|
|
216
|
+
await feed.update('w1', 'chat', view())
|
|
217
|
+
expect(bot.sent).toHaveLength(1)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('re-posts after a stale-message edit failure', async () => {
|
|
221
|
+
const bot = makeFakeBot()
|
|
222
|
+
let clock = 10_000
|
|
223
|
+
const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
|
|
224
|
+
await feed.update('w1', 'chat', view({ toolCount: 1 }))
|
|
225
|
+
expect(bot.sent).toHaveLength(1)
|
|
226
|
+
|
|
227
|
+
clock = 20_000
|
|
228
|
+
bot.failNextEditWith = new Error('Bad Request: message to edit not found')
|
|
229
|
+
await feed.update('w1', 'chat', view({ toolCount: 2 }))
|
|
230
|
+
expect(bot.edits).toHaveLength(0) // edit threw
|
|
231
|
+
expect(feed.has('w1')).toBe(false) // messageId reset
|
|
232
|
+
|
|
233
|
+
clock = 30_000
|
|
234
|
+
await feed.update('w1', 'chat', view({ toolCount: 3 }))
|
|
235
|
+
expect(bot.sent).toHaveLength(2) // re-posted
|
|
236
|
+
expect(feed.has('w1')).toBe(true)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('skips entirely when chatId is empty (owner DM unconfigured)', async () => {
|
|
240
|
+
const bot = makeFakeBot()
|
|
241
|
+
let clock = 10_000
|
|
242
|
+
const feed = createWorkerActivityFeed({ bot, now: () => clock })
|
|
243
|
+
await feed.update('w1', '', view())
|
|
244
|
+
expect(bot.sent).toHaveLength(0)
|
|
245
|
+
expect(feed.has('w1')).toBe(false)
|
|
246
|
+
expect(feed.size).toBe(0)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('forwards threadId as message_thread_id on send', async () => {
|
|
250
|
+
const bot = makeFakeBot()
|
|
251
|
+
let clock = 10_000
|
|
252
|
+
const feed = createWorkerActivityFeed({ bot, now: () => clock })
|
|
253
|
+
await feed.update('w1', 'chat', view(), 42)
|
|
254
|
+
expect(bot.sent[0].opts?.message_thread_id).toBe(42)
|
|
255
|
+
})
|
|
256
|
+
})
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live worker-activity feed — a regular chat message that edits in place
|
|
3
|
+
* while a *background* sub-agent (Agent/Task `run_in_background: true`)
|
|
4
|
+
* runs, then finalizes when the worker completes.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists: the pinned progress card was deleted (#1126) and the
|
|
7
|
+
* "Chat is the artifact" principle bars re-adding card chrome. But a
|
|
8
|
+
* background worker decouples from the parent turn — when the parent's
|
|
9
|
+
* turn ends, nothing surfaces the worker's ongoing jsonl activity, so a
|
|
10
|
+
* long worker reads as silence (the exact gap an operator hit watching a
|
|
11
|
+
* dispatched worker go quiet). This module surfaces that activity the
|
|
12
|
+
* same way the main agent's live answer does: a normal Telegram message
|
|
13
|
+
* that grows/edits as work happens — indistinguishable from "the agent
|
|
14
|
+
* is typing live", not a status widget.
|
|
15
|
+
*
|
|
16
|
+
* Pure render (`renderWorkerActivity`) + an injected bot API
|
|
17
|
+
* (`BotApiForWorkerFeed`), mirroring `issues-card.ts` so the gateway
|
|
18
|
+
* reuses the same wiring. The manager (`createWorkerActivityFeed`) owns
|
|
19
|
+
* one edit-in-place message per worker, keyed by jsonl agent id, with:
|
|
20
|
+
* - a first-paint delay so trivial sub-second workers never post a
|
|
21
|
+
* message (their result still lands via the handback reply),
|
|
22
|
+
* - a proactive min-edit-interval throttle (worker jsonl ticks ~1/s;
|
|
23
|
+
* Telegram rate-limits edits) plus body-dedup,
|
|
24
|
+
* - per-worker serialization so two rapid ticks can't double-send,
|
|
25
|
+
* - 429 cooldown + message_id drift resilience (re-post on stale edit),
|
|
26
|
+
* - a forced terminal edit on `finish` regardless of throttle.
|
|
27
|
+
*
|
|
28
|
+
* The feed is gated to BACKGROUND workers and lives behind the
|
|
29
|
+
* `SWITCHROOM_WORKER_ACTIVITY_FEED` flag — see the gateway wiring. The
|
|
30
|
+
* watcher already drives the cues (it polls the worker jsonl directly,
|
|
31
|
+
* so it keeps firing after the parent turn ends), which is why the feed
|
|
32
|
+
* is fed from watcher callbacks rather than the bridge event stream.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { escapeHtml, formatDuration, truncate } from './card-format.js'
|
|
36
|
+
|
|
37
|
+
export type WorkerActivityState = 'running' | 'done' | 'failed'
|
|
38
|
+
|
|
39
|
+
/** The render-relevant snapshot of a worker at one instant. */
|
|
40
|
+
export interface WorkerActivityView {
|
|
41
|
+
/** Dispatch-time task description (stable across the worker's life). */
|
|
42
|
+
description: string
|
|
43
|
+
/** Most recent tool the worker invoked, with a pre-sanitised arg. */
|
|
44
|
+
lastTool: { name: string; sanitisedArg: string } | null
|
|
45
|
+
/** Number of tool calls observed so far. */
|
|
46
|
+
toolCount: number
|
|
47
|
+
/** The worker's latest narrative line, if any (already capped upstream). */
|
|
48
|
+
latestSummary: string
|
|
49
|
+
/** Wall-clock since dispatch, ms. */
|
|
50
|
+
elapsedMs: number
|
|
51
|
+
state: WorkerActivityState
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface BotApiForWorkerFeed {
|
|
55
|
+
sendMessage(
|
|
56
|
+
chatId: string,
|
|
57
|
+
text: string,
|
|
58
|
+
opts?: Record<string, unknown>,
|
|
59
|
+
): Promise<{ message_id: number }>
|
|
60
|
+
editMessageText(
|
|
61
|
+
chatId: string,
|
|
62
|
+
messageId: number,
|
|
63
|
+
text: string,
|
|
64
|
+
opts?: Record<string, unknown>,
|
|
65
|
+
): Promise<unknown>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const DESC_MAX = 80
|
|
69
|
+
const TOOL_ARG_MAX = 64
|
|
70
|
+
const SUMMARY_MAX = 100
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Render the worker-activity message body as Telegram HTML.
|
|
74
|
+
*
|
|
75
|
+
* Layout (running):
|
|
76
|
+
* 🔧 <b>Worker</b> · <i>{description}</i>
|
|
77
|
+
* ⚡ <code>{tool}</code> {arg} <i>({n} tools · {elapsed})</i>
|
|
78
|
+
* ↳ <i>{latest summary}</i>
|
|
79
|
+
*
|
|
80
|
+
* Terminal collapses the activity line to a tool-count + duration recap:
|
|
81
|
+
* ✅ <b>Worker done</b> · <i>{description}</i>
|
|
82
|
+
* <i>{n} tools · {elapsed}</i>
|
|
83
|
+
*/
|
|
84
|
+
export function renderWorkerActivity(v: WorkerActivityView): string {
|
|
85
|
+
const desc = truncate(v.description.trim() || 'background task', DESC_MAX)
|
|
86
|
+
const elapsed = formatDuration(v.elapsedMs)
|
|
87
|
+
const toolWord = v.toolCount === 1 ? 'tool' : 'tools'
|
|
88
|
+
|
|
89
|
+
if (v.state === 'done' || v.state === 'failed') {
|
|
90
|
+
const head =
|
|
91
|
+
v.state === 'done'
|
|
92
|
+
? `✅ <b>Worker done</b> · <i>${escapeHtml(desc)}</i>`
|
|
93
|
+
: `⚠️ <b>Worker failed</b> · <i>${escapeHtml(desc)}</i>`
|
|
94
|
+
return `${head}\n<i>${v.toolCount} ${toolWord} · ${elapsed}</i>`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const header = `🔧 <b>Worker</b> · <i>${escapeHtml(desc)}</i>`
|
|
98
|
+
|
|
99
|
+
let activity: string
|
|
100
|
+
if (v.lastTool != null) {
|
|
101
|
+
const arg = v.lastTool.sanitisedArg.trim()
|
|
102
|
+
const argPart = arg.length > 0 ? ` ${escapeHtml(truncate(arg, TOOL_ARG_MAX))}` : ''
|
|
103
|
+
activity = `⚡ <code>${escapeHtml(v.lastTool.name)}</code>${argPart} <i>(${v.toolCount} ${toolWord} · ${elapsed})</i>`
|
|
104
|
+
} else {
|
|
105
|
+
activity = `<i>starting… (${elapsed})</i>`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const summary = v.latestSummary.trim()
|
|
109
|
+
const lines = [header, activity]
|
|
110
|
+
if (summary.length > 0) {
|
|
111
|
+
lines.push(` ↳ <i>${escapeHtml(truncate(summary, SUMMARY_MAX))}</i>`)
|
|
112
|
+
}
|
|
113
|
+
return lines.join('\n')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface WorkerActivityFeedOpts {
|
|
117
|
+
bot: BotApiForWorkerFeed
|
|
118
|
+
/** `Date.now` override for tests. */
|
|
119
|
+
now?: () => number
|
|
120
|
+
/**
|
|
121
|
+
* Minimum ms between in-place edits to one worker's message. Worker
|
|
122
|
+
* jsonl ticks roughly once per second; without this we'd burn through
|
|
123
|
+
* Telegram's edit budget. Default 2500ms. First paint and the terminal
|
|
124
|
+
* `finish` edit bypass it.
|
|
125
|
+
*/
|
|
126
|
+
minEditIntervalMs?: number
|
|
127
|
+
/**
|
|
128
|
+
* A worker must have been running at least this long before its first
|
|
129
|
+
* message is posted. Sub-second / trivial workers never surface a live
|
|
130
|
+
* message — their result still reaches the user via the handback
|
|
131
|
+
* reply, so a message would be pure noise. Default 8000ms.
|
|
132
|
+
*/
|
|
133
|
+
firstPaintMinMs?: number
|
|
134
|
+
/** stderr-style log sink. Defaults to noop. */
|
|
135
|
+
log?: (msg: string) => void
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface WorkerHandle {
|
|
139
|
+
chatId: string
|
|
140
|
+
threadId?: number
|
|
141
|
+
messageId: number | null
|
|
142
|
+
lastBody: string | null
|
|
143
|
+
lastEditAt: number
|
|
144
|
+
cooldownUntil: number
|
|
145
|
+
/** Per-worker serialization chain so ticks can't interleave sends. */
|
|
146
|
+
chain: Promise<void>
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const COOLDOWN_JITTER_MS = 500
|
|
150
|
+
|
|
151
|
+
function extractRetryAfterSecs(err: unknown): number | null {
|
|
152
|
+
if (err == null || typeof err !== 'object') return null
|
|
153
|
+
const e = err as { error_code?: unknown; parameters?: { retry_after?: unknown } }
|
|
154
|
+
if (e.error_code !== 429) return null
|
|
155
|
+
const ra = e.parameters?.retry_after
|
|
156
|
+
if (typeof ra === 'number' && Number.isFinite(ra) && ra > 0) return ra
|
|
157
|
+
return null
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Manager owning one live message per background worker. Keyed by jsonl
|
|
162
|
+
* agent id. The gateway calls `update` on each watcher activity cue and
|
|
163
|
+
* `finish` on terminal; `drop` discards a worker's state without a final
|
|
164
|
+
* edit (error / supersession paths).
|
|
165
|
+
*/
|
|
166
|
+
export interface WorkerActivityFeed {
|
|
167
|
+
/** True if a message is currently posted for this worker. */
|
|
168
|
+
has(agentId: string): boolean
|
|
169
|
+
/** Push a running-state cue. Returns the serialized op for tests. */
|
|
170
|
+
update(
|
|
171
|
+
agentId: string,
|
|
172
|
+
chatId: string,
|
|
173
|
+
view: WorkerActivityView,
|
|
174
|
+
threadId?: number,
|
|
175
|
+
): Promise<void>
|
|
176
|
+
/** Force the terminal recap edit. No-op if no message was ever posted. */
|
|
177
|
+
finish(agentId: string, view: WorkerActivityView): Promise<void>
|
|
178
|
+
/** Forget a worker's state without editing (e.g. error path). */
|
|
179
|
+
drop(agentId: string): void
|
|
180
|
+
/** Number of tracked workers (test/inspection hook). */
|
|
181
|
+
readonly size: number
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerActivityFeed {
|
|
185
|
+
const log = opts.log ?? (() => {})
|
|
186
|
+
const nowFn = opts.now ?? Date.now
|
|
187
|
+
const minEditInterval = opts.minEditIntervalMs ?? 2500
|
|
188
|
+
const firstPaintMin = opts.firstPaintMinMs ?? 8000
|
|
189
|
+
const handles = new Map<string, WorkerHandle>()
|
|
190
|
+
|
|
191
|
+
function sendOptsFor(h: WorkerHandle): Record<string, unknown> {
|
|
192
|
+
return {
|
|
193
|
+
parse_mode: 'HTML',
|
|
194
|
+
disable_web_page_preview: true,
|
|
195
|
+
...(h.threadId != null ? { message_thread_id: h.threadId } : {}),
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function noteRateLimited(h: WorkerHandle, err: unknown, label: string): void {
|
|
200
|
+
const retryAfter = extractRetryAfterSecs(err)
|
|
201
|
+
if (retryAfter == null) return
|
|
202
|
+
h.cooldownUntil = nowFn() + retryAfter * 1000 + COOLDOWN_JITTER_MS
|
|
203
|
+
log(`worker-feed: ${label} 429 — backing off ${retryAfter}s`)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function doUpdate(h: WorkerHandle, view: WorkerActivityView): Promise<void> {
|
|
207
|
+
if (nowFn() < h.cooldownUntil) return
|
|
208
|
+
const body = renderWorkerActivity(view)
|
|
209
|
+
|
|
210
|
+
// First paint: hold off until the worker has run long enough to be
|
|
211
|
+
// worth a message; trivial workers stay silent (handback covers them).
|
|
212
|
+
if (h.messageId == null) {
|
|
213
|
+
if (view.elapsedMs < firstPaintMin) return
|
|
214
|
+
try {
|
|
215
|
+
const sent = await opts.bot.sendMessage(h.chatId, body, sendOptsFor(h))
|
|
216
|
+
h.messageId = sent.message_id
|
|
217
|
+
h.lastBody = body
|
|
218
|
+
h.lastEditAt = nowFn()
|
|
219
|
+
} catch (err) {
|
|
220
|
+
noteRateLimited(h, err, 'send')
|
|
221
|
+
log(`worker-feed: send failed: ${(err as Error).message}`)
|
|
222
|
+
}
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Dedup + proactive throttle.
|
|
227
|
+
if (body === h.lastBody) return
|
|
228
|
+
if (nowFn() - h.lastEditAt < minEditInterval) return
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
await opts.bot.editMessageText(h.chatId, h.messageId, body, sendOptsFor(h))
|
|
232
|
+
h.lastBody = body
|
|
233
|
+
h.lastEditAt = nowFn()
|
|
234
|
+
} catch (err) {
|
|
235
|
+
noteRateLimited(h, err, 'edit')
|
|
236
|
+
// Stale message_id (manually deleted / edit window gone). Re-post
|
|
237
|
+
// on the next tick rather than now, so we don't double-down inside
|
|
238
|
+
// a cooldown.
|
|
239
|
+
log(`worker-feed: edit failed, will re-post: ${(err as Error).message}`)
|
|
240
|
+
h.messageId = null
|
|
241
|
+
h.lastBody = null
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function doFinish(h: WorkerHandle, view: WorkerActivityView): Promise<void> {
|
|
246
|
+
// No message ever posted → nothing to finalize. The worker's result
|
|
247
|
+
// reaches the user via the handback reply; a bare "done" recap with
|
|
248
|
+
// no preceding activity would be noise.
|
|
249
|
+
if (h.messageId == null) return
|
|
250
|
+
if (nowFn() < h.cooldownUntil) {
|
|
251
|
+
// Honour the flood-wait; a terminal edit isn't worth a ban. The
|
|
252
|
+
// message is left at its last running render — stale but harmless.
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
const body = renderWorkerActivity(view)
|
|
256
|
+
if (body === h.lastBody) return
|
|
257
|
+
try {
|
|
258
|
+
await opts.bot.editMessageText(h.chatId, h.messageId, body, sendOptsFor(h))
|
|
259
|
+
h.lastBody = body
|
|
260
|
+
h.lastEditAt = nowFn()
|
|
261
|
+
} catch (err) {
|
|
262
|
+
noteRateLimited(h, err, 'finish')
|
|
263
|
+
log(`worker-feed: finish edit failed: ${(err as Error).message}`)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
has(agentId) {
|
|
269
|
+
return handles.get(agentId)?.messageId != null
|
|
270
|
+
},
|
|
271
|
+
get size() {
|
|
272
|
+
return handles.size
|
|
273
|
+
},
|
|
274
|
+
update(agentId, chatId, view, threadId) {
|
|
275
|
+
// No chat to post to (owner DM unconfigured) — don't create a
|
|
276
|
+
// handle that would retry a failing send('') every tick.
|
|
277
|
+
if (chatId.length === 0) return Promise.resolve()
|
|
278
|
+
let h = handles.get(agentId)
|
|
279
|
+
if (h == null) {
|
|
280
|
+
h = {
|
|
281
|
+
chatId,
|
|
282
|
+
threadId,
|
|
283
|
+
messageId: null,
|
|
284
|
+
lastBody: null,
|
|
285
|
+
lastEditAt: 0,
|
|
286
|
+
cooldownUntil: 0,
|
|
287
|
+
chain: Promise.resolve(),
|
|
288
|
+
}
|
|
289
|
+
handles.set(agentId, h)
|
|
290
|
+
}
|
|
291
|
+
const handle = h
|
|
292
|
+
handle.chain = handle.chain.then(() => doUpdate(handle, view)).catch((err) => {
|
|
293
|
+
log(`worker-feed: update chain error ${agentId}: ${(err as Error).message}`)
|
|
294
|
+
})
|
|
295
|
+
return handle.chain
|
|
296
|
+
},
|
|
297
|
+
finish(agentId, view) {
|
|
298
|
+
const h = handles.get(agentId)
|
|
299
|
+
if (h == null) return Promise.resolve()
|
|
300
|
+
h.chain = h.chain
|
|
301
|
+
.then(() => doFinish(h, view))
|
|
302
|
+
.catch((err) => {
|
|
303
|
+
log(`worker-feed: finish chain error ${agentId}: ${(err as Error).message}`)
|
|
304
|
+
})
|
|
305
|
+
.finally(() => {
|
|
306
|
+
handles.delete(agentId)
|
|
307
|
+
})
|
|
308
|
+
return h.chain
|
|
309
|
+
},
|
|
310
|
+
drop(agentId) {
|
|
311
|
+
handles.delete(agentId)
|
|
312
|
+
},
|
|
313
|
+
}
|
|
314
|
+
}
|