switchroom 0.14.14 → 0.14.16
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/telegram-plugin/dist/gateway/gateway.js +448 -162
- package/telegram-plugin/gateway/gateway.ts +144 -8
- 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/uat/scenarios/jtbd-worker-activity-feed-dm.test.ts +125 -0
- package/telegram-plugin/worker-activity-feed.ts +314 -0
|
@@ -52,6 +52,8 @@ import {
|
|
|
52
52
|
import { OutboundDedupCache } from '../recent-outbound-dedup.js'
|
|
53
53
|
import { createInboundCoalescer, inboundCoalesceKey } from './inbound-coalesce.js'
|
|
54
54
|
import { StatusReactionController } from '../status-reactions.js'
|
|
55
|
+
import { DeferredDoneReactions } from '../reaction-defer.js'
|
|
56
|
+
import { createWorkerActivityFeed } from '../worker-activity-feed.js'
|
|
55
57
|
import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
|
|
56
58
|
import { appendActivityLabel } from '../tool-activity-summary.js'
|
|
57
59
|
import { toolLabel } from '../tool-labels.js'
|
|
@@ -1096,6 +1098,18 @@ if (!STATIC) setInterval(checkApprovals, 5000).unref()
|
|
|
1096
1098
|
const chatThreadMap = new Map<string, number>()
|
|
1097
1099
|
const activeStatusReactions = new Map<string, StatusReactionController>()
|
|
1098
1100
|
const activeReactionMsgIds = new Map<string, { chatId: string; messageId: number }>()
|
|
1101
|
+
// Reactions whose terminal 👍 is deferred because a background sub-agent
|
|
1102
|
+
// worker was still running when the parent's `turn_end` fired. Painting 👍
|
|
1103
|
+
// then would read as "done / nothing happening" while the worker keeps
|
|
1104
|
+
// going. The controller is `hold()`-frozen on a working glyph; the entry
|
|
1105
|
+
// is promoted to 👍 by `deferredDoneReactions.promote()` (wired to the
|
|
1106
|
+
// watcher's `onFinish`) when the last worker completes. See
|
|
1107
|
+
// `reaction-defer.ts` for the promote/purge interaction the unit tests pin.
|
|
1108
|
+
const deferredDoneReactions = new DeferredDoneReactions<StatusReactionController>({
|
|
1109
|
+
countRunningWorkers: () => countRunningWorkers(),
|
|
1110
|
+
getActive: (key) => activeStatusReactions.get(key),
|
|
1111
|
+
purge: (key) => purgeReactionTracking(key),
|
|
1112
|
+
})
|
|
1099
1113
|
|
|
1100
1114
|
// #546 — outbound content-dedup window. PR #599 introduced the four read
|
|
1101
1115
|
// sites (`outboundDedup.check` / `.record` in executeReply, executeStreamReply,
|
|
@@ -1898,10 +1912,34 @@ function finalizeStatusReaction(
|
|
|
1898
1912
|
const key = statusKey(chatId, threadId)
|
|
1899
1913
|
const ctrl = activeStatusReactions.get(key)
|
|
1900
1914
|
if (!ctrl) return
|
|
1915
|
+
// Don't paint the terminal 👍 while a background sub-agent worker is
|
|
1916
|
+
// still running — it reads as "done / nothing happening" even though
|
|
1917
|
+
// the text said work continues. `tryDefer` holds the working glyph
|
|
1918
|
+
// (✍️/⚡) and registers the controller; the watcher's onFinish promotes
|
|
1919
|
+
// it to 👍 once the last worker completes. Errors are terminal
|
|
1920
|
+
// regardless: a failed/aborted turn shouldn't wait on a worker.
|
|
1921
|
+
if (reason === 'done' && deferredDoneReactions.tryDefer(key, ctrl)) return
|
|
1922
|
+
deferredDoneReactions.drop(key)
|
|
1901
1923
|
ctrl.finalize(reason)
|
|
1902
1924
|
purgeReactionTracking(key)
|
|
1903
1925
|
}
|
|
1904
1926
|
|
|
1927
|
+
/**
|
|
1928
|
+
* Count sub-agent workers currently running (excludes historical
|
|
1929
|
+
* boot-leftover entries and any already-terminal worker). The registry
|
|
1930
|
+
* deletes terminal entries after a short grace, so a worker that has
|
|
1931
|
+
* fired its `done`/`failed` onFinish no longer counts here.
|
|
1932
|
+
*/
|
|
1933
|
+
function countRunningWorkers(): number {
|
|
1934
|
+
const reg = subagentWatcher?.getRegistry()
|
|
1935
|
+
if (reg == null) return 0
|
|
1936
|
+
let n = 0
|
|
1937
|
+
for (const e of reg.values()) {
|
|
1938
|
+
if (e.state === 'running' && !e.historical) n++
|
|
1939
|
+
}
|
|
1940
|
+
return n
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1905
1943
|
/**
|
|
1906
1944
|
* Non-terminal error paint (😱). Distinct from `finalize('error')` —
|
|
1907
1945
|
* recovery to a working state is allowed after this (#1713). Mid-turn
|
|
@@ -17219,6 +17257,45 @@ void (async () => {
|
|
|
17219
17257
|
if (streamMode === 'checklist') {
|
|
17220
17258
|
const watcherAgentDir = resolveAgentDirFromEnv()
|
|
17221
17259
|
if (watcherAgentDir != null) {
|
|
17260
|
+
// #PR2 — live worker-activity feed. A *background* sub-agent
|
|
17261
|
+
// decouples from the parent turn, so when the turn ends nothing
|
|
17262
|
+
// surfaces its ongoing jsonl activity and a long worker reads as
|
|
17263
|
+
// silence. This feed posts ONE regular chat message per worker
|
|
17264
|
+
// and edits it in place as work happens (current tool + elapsed),
|
|
17265
|
+
// finalizing on completion — the same "live, growing message"
|
|
17266
|
+
// shape the main agent's answer uses, NOT card chrome (the pinned
|
|
17267
|
+
// card was deleted in #1126). Flag-gated; when ON it also
|
|
17268
|
+
// supersedes the coarse 5-min bucket relay below to avoid
|
|
17269
|
+
// double-surfacing the same progress beat.
|
|
17270
|
+
const workerFeedEnabled = process.env.SWITCHROOM_WORKER_ACTIVITY_FEED === '1'
|
|
17271
|
+
const workerActivityFeed = createWorkerActivityFeed({
|
|
17272
|
+
bot: {
|
|
17273
|
+
sendMessage: async (cid, text, sendOpts) => {
|
|
17274
|
+
const sent = await robustApiCall(
|
|
17275
|
+
() =>
|
|
17276
|
+
lockedBot.api.sendMessage(
|
|
17277
|
+
cid,
|
|
17278
|
+
text,
|
|
17279
|
+
sendOpts as Parameters<typeof lockedBot.api.sendMessage>[2],
|
|
17280
|
+
),
|
|
17281
|
+
{ chat_id: cid, verb: 'worker-feed' },
|
|
17282
|
+
)
|
|
17283
|
+
return sent as { message_id: number }
|
|
17284
|
+
},
|
|
17285
|
+
editMessageText: (cid, mid, text, editOpts) =>
|
|
17286
|
+
robustApiCall(
|
|
17287
|
+
() =>
|
|
17288
|
+
lockedBot.api.editMessageText(
|
|
17289
|
+
cid,
|
|
17290
|
+
mid,
|
|
17291
|
+
text,
|
|
17292
|
+
editOpts as Parameters<typeof lockedBot.api.editMessageText>[3],
|
|
17293
|
+
),
|
|
17294
|
+
{ chat_id: cid, verb: 'worker-feed' },
|
|
17295
|
+
),
|
|
17296
|
+
},
|
|
17297
|
+
log: (msg) => process.stderr.write(`telegram gateway: ${msg}\n`),
|
|
17298
|
+
})
|
|
17222
17299
|
subagentWatcher = startSubagentWatcher({
|
|
17223
17300
|
agentDir: watcherAgentDir,
|
|
17224
17301
|
// Issue #1116 (Bug A): restrict project-dir enumeration to
|
|
@@ -17311,7 +17388,14 @@ void (async () => {
|
|
|
17311
17388
|
// Gated to background completions: foreground sub-agents
|
|
17312
17389
|
// need nothing here, and 'orphan' is a stale historical-at-
|
|
17313
17390
|
// boot row, not a fresh completion the user is waiting on.
|
|
17314
|
-
onFinish: ({ agentId, outcome, description, resultText }) => {
|
|
17391
|
+
onFinish: ({ agentId, outcome, description, resultText, toolCount, durationMs }) => {
|
|
17392
|
+
// Reaction promotion: if the parent turn already ended
|
|
17393
|
+
// with this (or another) worker still running, its 👍 was
|
|
17394
|
+
// deferred (held on ✍️/⚡). Now that a worker finished,
|
|
17395
|
+
// promote to 👍 iff none remain running. Independent of the
|
|
17396
|
+
// handback gating below, so it must run before any early
|
|
17397
|
+
// return. Cheap no-op when nothing is deferred.
|
|
17398
|
+
deferredDoneReactions.promote()
|
|
17315
17399
|
// IO: resolve the fleet chat id and the background flag.
|
|
17316
17400
|
// The DECISION (gating + inbound build) is delegated to
|
|
17317
17401
|
// the pure `decideSubagentHandback` so it is unit-tested
|
|
@@ -17319,6 +17403,10 @@ void (async () => {
|
|
|
17319
17403
|
// `subagent-handback-decision.test.ts`.
|
|
17320
17404
|
let fleetChatId = ''
|
|
17321
17405
|
let isBackground = false
|
|
17406
|
+
// Dispatch-time task description from the registry row —
|
|
17407
|
+
// see the onProgress note; used for the feed's terminal recap
|
|
17408
|
+
// header so it matches the running header ("· <real task>").
|
|
17409
|
+
let dispatchDesc = ''
|
|
17322
17410
|
try {
|
|
17323
17411
|
const fleets = progressDriver?.peekAllFleets() ?? []
|
|
17324
17412
|
for (const f of fleets) {
|
|
@@ -17334,11 +17422,29 @@ void (async () => {
|
|
|
17334
17422
|
if (turnsDb != null) {
|
|
17335
17423
|
try {
|
|
17336
17424
|
const row = turnsDb
|
|
17337
|
-
.prepare('SELECT background FROM subagents WHERE jsonl_agent_id = ?')
|
|
17338
|
-
.get(agentId) as { background: number } | undefined
|
|
17339
|
-
if (row != null)
|
|
17425
|
+
.prepare('SELECT background, description FROM subagents WHERE jsonl_agent_id = ?')
|
|
17426
|
+
.get(agentId) as { background: number; description: string | null } | undefined
|
|
17427
|
+
if (row != null) {
|
|
17428
|
+
isBackground = row.background === 1
|
|
17429
|
+
dispatchDesc = row.description ?? ''
|
|
17430
|
+
}
|
|
17340
17431
|
} catch { /* best-effort */ }
|
|
17341
17432
|
}
|
|
17433
|
+
// #PR2 live worker-feed: force the terminal recap edit on
|
|
17434
|
+
// the worker's live message. No-op when no message was ever
|
|
17435
|
+
// posted (trivial workers stay silent; handback covers them).
|
|
17436
|
+
// 'orphan' is a stale boot row, not a fresh completion — map
|
|
17437
|
+
// it to 'done' so an already-posted message still finalizes.
|
|
17438
|
+
if (workerFeedEnabled) {
|
|
17439
|
+
void workerActivityFeed.finish(agentId, {
|
|
17440
|
+
description: dispatchDesc || description,
|
|
17441
|
+
lastTool: null,
|
|
17442
|
+
toolCount,
|
|
17443
|
+
latestSummary: resultText,
|
|
17444
|
+
elapsedMs: durationMs,
|
|
17445
|
+
state: outcome === 'failed' ? 'failed' : 'done',
|
|
17446
|
+
})
|
|
17447
|
+
}
|
|
17342
17448
|
|
|
17343
17449
|
const decision = decideSubagentHandback({
|
|
17344
17450
|
handbackEnvValue: process.env.SWITCHROOM_SUBAGENT_HANDBACK,
|
|
@@ -17409,9 +17515,14 @@ void (async () => {
|
|
|
17409
17515
|
// suppresses stale-after-restart delivery (a 4-h-old
|
|
17410
17516
|
// "still working (5m)" would be a lie). Sweep on handback
|
|
17411
17517
|
// lives in the `onFinish` block just above.
|
|
17412
|
-
onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx }) => {
|
|
17518
|
+
onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount }) => {
|
|
17413
17519
|
let fleetChatId = ''
|
|
17414
17520
|
let isBackground = false
|
|
17521
|
+
// The watcher's `description` is its 'sub-agent' default (it
|
|
17522
|
+
// never reassigns it from the worker jsonl). The dispatch-time
|
|
17523
|
+
// task description lives in the registry row — prefer it so the
|
|
17524
|
+
// feed header reads "🔧 Worker · <real task>" not "· sub-agent".
|
|
17525
|
+
let dispatchDesc = ''
|
|
17415
17526
|
try {
|
|
17416
17527
|
const fleets = progressDriver?.peekAllFleets() ?? []
|
|
17417
17528
|
for (const f of fleets) {
|
|
@@ -17424,13 +17535,38 @@ void (async () => {
|
|
|
17424
17535
|
if (turnsDb != null) {
|
|
17425
17536
|
try {
|
|
17426
17537
|
const row = turnsDb
|
|
17427
|
-
.prepare('SELECT background FROM subagents WHERE jsonl_agent_id = ?')
|
|
17428
|
-
.get(agentId) as { background: number } | undefined
|
|
17429
|
-
if (row != null)
|
|
17538
|
+
.prepare('SELECT background, description FROM subagents WHERE jsonl_agent_id = ?')
|
|
17539
|
+
.get(agentId) as { background: number; description: string | null } | undefined
|
|
17540
|
+
if (row != null) {
|
|
17541
|
+
isBackground = row.background === 1
|
|
17542
|
+
dispatchDesc = row.description ?? ''
|
|
17543
|
+
}
|
|
17430
17544
|
} catch { /* best-effort */ }
|
|
17431
17545
|
}
|
|
17432
17546
|
if (!isBackground) return // skip overhead for foreground
|
|
17433
17547
|
|
|
17548
|
+
// #PR2 live worker-feed: when ON, the worker's live chat
|
|
17549
|
+
// message owns the progress beat. Push a running cue and
|
|
17550
|
+
// return BEFORE the legacy bucket relay so the same activity
|
|
17551
|
+
// isn't double-surfaced (in-message edit + injected
|
|
17552
|
+
// "still working" inbound turn). Chat = owner DM, since the
|
|
17553
|
+
// pinned-card fleet is gone and every agent is DM-shaped.
|
|
17554
|
+
if (workerFeedEnabled) {
|
|
17555
|
+
void workerActivityFeed.update(
|
|
17556
|
+
agentId,
|
|
17557
|
+
fleetChatId || (loadAccess().allowFrom[0] ?? ''),
|
|
17558
|
+
{
|
|
17559
|
+
description: dispatchDesc || description,
|
|
17560
|
+
lastTool,
|
|
17561
|
+
toolCount,
|
|
17562
|
+
latestSummary,
|
|
17563
|
+
elapsedMs,
|
|
17564
|
+
state: 'running',
|
|
17565
|
+
},
|
|
17566
|
+
)
|
|
17567
|
+
return
|
|
17568
|
+
}
|
|
17569
|
+
|
|
17434
17570
|
const decision = decideSubagentProgress({
|
|
17435
17571
|
disableEnvValue: process.env.SWITCHROOM_DISABLE_SUBAGENT_PROGRESS,
|
|
17436
17572
|
isBackground,
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deferred terminal-reaction bookkeeping for the "hold 👍 until background
|
|
3
|
+
* sub-agent workers finish" behaviour.
|
|
4
|
+
*
|
|
5
|
+
* The status reaction on a user's inbound message reflects CURRENT TURN
|
|
6
|
+
* ACTIVITY (see `status-reactions.ts`). When a turn dispatches a background
|
|
7
|
+
* worker (Agent/Task) and then ends, the parent's `turn_end` would paint
|
|
8
|
+
* the terminal 👍 immediately — reading as "done / nothing happening" even
|
|
9
|
+
* though the worker keeps running and the model's own text said work is
|
|
10
|
+
* ongoing. This helper holds the working glyph and defers the terminal 👍
|
|
11
|
+
* until the sub-agent watcher reports the last worker complete.
|
|
12
|
+
*
|
|
13
|
+
* Extracted as a pure, dependency-injected unit so the gateway-side
|
|
14
|
+
* interaction that bit us in review — `finalizeStatusReaction` defers, then
|
|
15
|
+
* `endCurrentTurnAtomic` purges the controller out of `activeStatusReactions`
|
|
16
|
+
* in the SAME `turn_end` sequence — is directly testable. The fix: promotion
|
|
17
|
+
* finalizes off the STORED controller reference (its emit closure still binds
|
|
18
|
+
* the right message id), not a re-lookup that the purge has already emptied.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Minimal controller surface this helper drives. */
|
|
22
|
+
export interface HoldableController {
|
|
23
|
+
/** Freeze on a working glyph, suppress stall promotion, stay non-terminal. */
|
|
24
|
+
hold(): void
|
|
25
|
+
/** Terminate to 👍 (done) / 😱 (error). Idempotent once finished. */
|
|
26
|
+
finalize(reason?: 'done' | 'error'): void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DeferredDoneDeps<C extends HoldableController> {
|
|
30
|
+
/** Count of sub-agent workers still running (excludes historical/terminal). */
|
|
31
|
+
countRunningWorkers(): number
|
|
32
|
+
/** Current live controller registered for `key`, if any. */
|
|
33
|
+
getActive(key: string): C | undefined
|
|
34
|
+
/** Canonical turn-end cleanup for `key` (drops reaction/typing/etc state). */
|
|
35
|
+
purge(key: string): void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class DeferredDoneReactions<C extends HoldableController> {
|
|
39
|
+
private readonly map = new Map<string, { ctrl: C }>()
|
|
40
|
+
|
|
41
|
+
constructor(private readonly deps: DeferredDoneDeps<C>) {}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Attempt to defer a terminal 'done' for `key`/`ctrl`. Returns true when
|
|
45
|
+
* deferred (the caller must NOT finalize or purge — the held controller
|
|
46
|
+
* owns the reaction until {@link promote}). Returns false when there is no
|
|
47
|
+
* running worker, in which case the caller finalizes normally.
|
|
48
|
+
*/
|
|
49
|
+
tryDefer(key: string, ctrl: C): boolean {
|
|
50
|
+
if (this.deps.countRunningWorkers() > 0) {
|
|
51
|
+
ctrl.hold()
|
|
52
|
+
this.map.set(key, { ctrl })
|
|
53
|
+
// Race guard: a worker may have transitioned to done between the count
|
|
54
|
+
// above and this registration, having already fired its completion
|
|
55
|
+
// callback before the deferred entry existed. Re-check and promote now
|
|
56
|
+
// so the held reaction can't hang on a 👍 that will never come.
|
|
57
|
+
if (this.deps.countRunningWorkers() === 0) this.promote()
|
|
58
|
+
return true
|
|
59
|
+
}
|
|
60
|
+
this.map.delete(key)
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Drop any deferred entry for `key` without finalizing (e.g. on error). */
|
|
65
|
+
drop(key: string): void {
|
|
66
|
+
this.map.delete(key)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Promote every deferred reaction to the terminal 👍 — but only once no
|
|
71
|
+
* workers remain running. Finalize off the stored controller reference:
|
|
72
|
+
* the canonical `turn_end` path purges the key out of the active map right
|
|
73
|
+
* after deferring, so a key re-lookup would find nothing. `finalize()` is
|
|
74
|
+
* idempotent, and the controller's emit closure still targets the correct
|
|
75
|
+
* message. Only re-purge when the active map STILL points at this exact
|
|
76
|
+
* controller — if a newer turn replaced it, that turn owns the key now and
|
|
77
|
+
* must not be clobbered (and the turn_end path already purged, so we skip
|
|
78
|
+
* a redundant second purge).
|
|
79
|
+
*/
|
|
80
|
+
promote(): void {
|
|
81
|
+
if (this.map.size === 0) return
|
|
82
|
+
if (this.deps.countRunningWorkers() > 0) return
|
|
83
|
+
for (const [key, { ctrl }] of this.map) {
|
|
84
|
+
ctrl.finalize('done')
|
|
85
|
+
if (this.deps.getActive(key) === ctrl) this.deps.purge(key)
|
|
86
|
+
}
|
|
87
|
+
this.map.clear()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Test/inspection hook. */
|
|
91
|
+
has(key: string): boolean {
|
|
92
|
+
return this.map.has(key)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
get size(): number {
|
|
96
|
+
return this.map.size
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -141,6 +141,7 @@ export class StatusReactionController {
|
|
|
141
141
|
private stallSoftTimer: ReturnType<typeof setTimeout> | null = null
|
|
142
142
|
private stallHardTimer: ReturnType<typeof setTimeout> | null = null
|
|
143
143
|
private finished = false
|
|
144
|
+
private held = false
|
|
144
145
|
private readonly debounceMs: number
|
|
145
146
|
private readonly stallSoftMs: number
|
|
146
147
|
private readonly stallHardMs: number
|
|
@@ -219,10 +220,38 @@ export class StatusReactionController {
|
|
|
219
220
|
cancel(): void {
|
|
220
221
|
if (this.finished) return
|
|
221
222
|
this.finished = true
|
|
223
|
+
this.held = false
|
|
222
224
|
this.clearDebounceTimer()
|
|
223
225
|
this.clearStallTimers()
|
|
224
226
|
}
|
|
225
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Freeze the controller in a WORKING state pending out-of-turn
|
|
230
|
+
* background work — sub-agent workers that are still running after the
|
|
231
|
+
* parent's `turn_end` fired. Painting the terminal 👍 here would read
|
|
232
|
+
* as "done / nothing happening" while the worker keeps going; instead
|
|
233
|
+
* we hold a working glyph (✍️/⚡) and let the gateway call `finalize()`
|
|
234
|
+
* once the last worker completes.
|
|
235
|
+
*
|
|
236
|
+
* Non-terminal: the controller stays live, so `finalize()` still works
|
|
237
|
+
* afterward. Suppresses stall promotion (🥱/😨) for the held window —
|
|
238
|
+
* the parent turn isn't stalled, a worker is legitimately busy, and the
|
|
239
|
+
* sub-agent watcher owns its own stall detection. Promotes a non-working
|
|
240
|
+
* current state (👀 read-receipt / 🤔 thinking) to an explicit working
|
|
241
|
+
* glyph so the user can tell work is ongoing.
|
|
242
|
+
*/
|
|
243
|
+
hold(): void {
|
|
244
|
+
if (this.finished) return
|
|
245
|
+
this.held = true
|
|
246
|
+
this.clearStallTimers()
|
|
247
|
+
const working = this.resolveEmoji('tool')
|
|
248
|
+
if (working != null && working !== this.currentEmoji && working !== this.pendingEmoji) {
|
|
249
|
+
this.clearDebounceTimer()
|
|
250
|
+
this.pendingEmoji = working
|
|
251
|
+
this.enqueue(working)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
226
255
|
// ──────────────────────────────────────────────────────────────────────
|
|
227
256
|
|
|
228
257
|
private scheduleState(
|
|
@@ -256,6 +285,7 @@ export class StatusReactionController {
|
|
|
256
285
|
private finishWithState(state: ReactionState): void {
|
|
257
286
|
if (this.finished) return
|
|
258
287
|
this.finished = true
|
|
288
|
+
this.held = false
|
|
259
289
|
this.clearStallTimers()
|
|
260
290
|
// F1 fix (#553): if a non-terminal reaction is sitting in the
|
|
261
291
|
// debounce window when the turn ends, flush it BEFORE the terminal
|
|
@@ -311,7 +341,7 @@ export class StatusReactionController {
|
|
|
311
341
|
|
|
312
342
|
private resetStallTimers(): void {
|
|
313
343
|
this.clearStallTimers()
|
|
314
|
-
if (this.finished) return
|
|
344
|
+
if (this.finished || this.held) return
|
|
315
345
|
this.stallSoftTimer = setTimeout(() => {
|
|
316
346
|
this.stallSoftTimer = null
|
|
317
347
|
// Don't reset the stall timers when the stall transition itself fires —
|
|
@@ -305,6 +305,11 @@ export interface SubagentWatcherConfig {
|
|
|
305
305
|
elapsedMs: number
|
|
306
306
|
prevBucketIdx: number | null
|
|
307
307
|
setBucketIdx: (b: number) => void
|
|
308
|
+
/** Most recent tool the worker invoked, or null if none yet. Feeds
|
|
309
|
+
* the live worker-activity feed (#PR2); the bucket relay ignores it. */
|
|
310
|
+
lastTool: { name: string; sanitisedArg: string } | null
|
|
311
|
+
/** Tool-use count observed so far. */
|
|
312
|
+
toolCount: number
|
|
308
313
|
}) => void
|
|
309
314
|
/** `Date.now` override for tests. */
|
|
310
315
|
now?: () => number
|
|
@@ -522,6 +527,12 @@ export function readSubTail(
|
|
|
522
527
|
elapsedMs: number
|
|
523
528
|
prevBucketIdx: number | null
|
|
524
529
|
setBucketIdx: (b: number) => void
|
|
530
|
+
/** Most recent tool the worker invoked (name + sanitised arg), or
|
|
531
|
+
* null if no tool_use has been observed yet. For the live
|
|
532
|
+
* worker-activity feed (#PR2) — the legacy bucket relay ignores it. */
|
|
533
|
+
lastTool: { name: string; sanitisedArg: string } | null
|
|
534
|
+
/** Tool-use count observed so far. */
|
|
535
|
+
toolCount: number
|
|
525
536
|
}) => void,
|
|
526
537
|
): void {
|
|
527
538
|
try {
|
|
@@ -675,6 +686,8 @@ export function readSubTail(
|
|
|
675
686
|
setBucketIdx: (b: number) => {
|
|
676
687
|
entry.lastProgressBucketIdx = b
|
|
677
688
|
},
|
|
689
|
+
lastTool: entry.lastTool,
|
|
690
|
+
toolCount: entry.toolCount,
|
|
678
691
|
})
|
|
679
692
|
} catch (cbErr) {
|
|
680
693
|
log?.(`subagent-watcher: onProgress callback error ${entry.agentId}: ${(cbErr as Error).message}`)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { DeferredDoneReactions, type HoldableController } from '../reaction-defer.js'
|
|
3
|
+
|
|
4
|
+
/** Fake controller recording hold/finalize calls; finalize is idempotent. */
|
|
5
|
+
function makeCtrl() {
|
|
6
|
+
let finished = false
|
|
7
|
+
const ctrl: HoldableController & { held: number; doneCount: number } = {
|
|
8
|
+
held: 0,
|
|
9
|
+
doneCount: 0,
|
|
10
|
+
hold() {
|
|
11
|
+
this.held++
|
|
12
|
+
},
|
|
13
|
+
finalize(reason: 'done' | 'error' = 'done') {
|
|
14
|
+
if (finished) return
|
|
15
|
+
finished = true
|
|
16
|
+
if (reason === 'done') this.doneCount++
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
return ctrl
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Harness modelling the gateway maps so the promote/purge interaction is
|
|
24
|
+
* exercised end-to-end. `purge` deletes from `active` exactly as
|
|
25
|
+
* `purgeReactionTracking` does, so a turn_end that purges right after a
|
|
26
|
+
* defer is faithfully simulated.
|
|
27
|
+
*/
|
|
28
|
+
function makeHarness(initialRunning = 0) {
|
|
29
|
+
const active = new Map<string, ReturnType<typeof makeCtrl>>()
|
|
30
|
+
let running = initialRunning
|
|
31
|
+
const purged: string[] = []
|
|
32
|
+
const deferred = new DeferredDoneReactions<ReturnType<typeof makeCtrl>>({
|
|
33
|
+
countRunningWorkers: () => running,
|
|
34
|
+
getActive: (key) => active.get(key),
|
|
35
|
+
purge: (key) => {
|
|
36
|
+
purged.push(key)
|
|
37
|
+
active.delete(key)
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
return {
|
|
41
|
+
active,
|
|
42
|
+
deferred,
|
|
43
|
+
purged,
|
|
44
|
+
setRunning: (n: number) => {
|
|
45
|
+
running = n
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('DeferredDoneReactions', () => {
|
|
51
|
+
it('does not defer when no workers are running', () => {
|
|
52
|
+
const h = makeHarness(0)
|
|
53
|
+
const ctrl = makeCtrl()
|
|
54
|
+
h.active.set('k', ctrl)
|
|
55
|
+
expect(h.deferred.tryDefer('k', ctrl)).toBe(false)
|
|
56
|
+
expect(ctrl.held).toBe(0)
|
|
57
|
+
expect(h.deferred.size).toBe(0)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('defers and holds while a worker runs', () => {
|
|
61
|
+
const h = makeHarness(1)
|
|
62
|
+
const ctrl = makeCtrl()
|
|
63
|
+
h.active.set('k', ctrl)
|
|
64
|
+
expect(h.deferred.tryDefer('k', ctrl)).toBe(true)
|
|
65
|
+
expect(ctrl.held).toBe(1)
|
|
66
|
+
expect(ctrl.doneCount).toBe(0)
|
|
67
|
+
expect(h.deferred.has('k')).toBe(true)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('promotes to 👍 only once the last worker finishes', () => {
|
|
71
|
+
const h = makeHarness(2)
|
|
72
|
+
const ctrl = makeCtrl()
|
|
73
|
+
h.active.set('k', ctrl)
|
|
74
|
+
h.deferred.tryDefer('k', ctrl)
|
|
75
|
+
|
|
76
|
+
// First worker done — one still running → no promotion.
|
|
77
|
+
h.setRunning(1)
|
|
78
|
+
h.deferred.promote()
|
|
79
|
+
expect(ctrl.doneCount).toBe(0)
|
|
80
|
+
expect(h.deferred.has('k')).toBe(true)
|
|
81
|
+
|
|
82
|
+
// Last worker done → promote to 👍 and clear.
|
|
83
|
+
h.setRunning(0)
|
|
84
|
+
h.deferred.promote()
|
|
85
|
+
expect(ctrl.doneCount).toBe(1)
|
|
86
|
+
expect(h.deferred.size).toBe(0)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('REGRESSION: promotes off the stored ref even after turn_end purged the active map', () => {
|
|
90
|
+
// This is the bug review #1999 caught: on the canonical turn_end path,
|
|
91
|
+
// finalizeStatusReaction defers, then endCurrentTurnAtomic →
|
|
92
|
+
// purgeReactionTracking deletes the controller from activeStatusReactions
|
|
93
|
+
// in the SAME sequence. A key re-lookup would find nothing and the held
|
|
94
|
+
// reaction would hang forever on the working glyph.
|
|
95
|
+
const h = makeHarness(1)
|
|
96
|
+
const ctrl = makeCtrl()
|
|
97
|
+
h.active.set('k', ctrl)
|
|
98
|
+
h.deferred.tryDefer('k', ctrl)
|
|
99
|
+
|
|
100
|
+
// Simulate endCurrentTurnAtomic purging the key out of the active map.
|
|
101
|
+
h.active.delete('k')
|
|
102
|
+
|
|
103
|
+
// Worker finishes.
|
|
104
|
+
h.setRunning(0)
|
|
105
|
+
h.deferred.promote()
|
|
106
|
+
|
|
107
|
+
// Must still reach 👍 via the stored controller reference.
|
|
108
|
+
expect(ctrl.doneCount).toBe(1)
|
|
109
|
+
// No double-purge: active map no longer holds the controller, so promote
|
|
110
|
+
// skips the redundant purge (turn_end already purged).
|
|
111
|
+
expect(h.purged).toEqual([])
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('purges via the helper when the active map still owns the controller (reply path)', () => {
|
|
115
|
+
// executeReply path keeps currentTurn (and the active map entry) alive,
|
|
116
|
+
// so promote must own the purge.
|
|
117
|
+
const h = makeHarness(1)
|
|
118
|
+
const ctrl = makeCtrl()
|
|
119
|
+
h.active.set('k', ctrl)
|
|
120
|
+
h.deferred.tryDefer('k', ctrl)
|
|
121
|
+
|
|
122
|
+
h.setRunning(0)
|
|
123
|
+
h.deferred.promote()
|
|
124
|
+
|
|
125
|
+
expect(ctrl.doneCount).toBe(1)
|
|
126
|
+
expect(h.purged).toEqual(['k'])
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('instance guard: a newer turn that replaced the key is never finalized or purged', () => {
|
|
130
|
+
const h = makeHarness(1)
|
|
131
|
+
const oldCtrl = makeCtrl()
|
|
132
|
+
h.active.set('k', oldCtrl)
|
|
133
|
+
h.deferred.tryDefer('k', oldCtrl)
|
|
134
|
+
|
|
135
|
+
// A fresh turn arrives on the same key with a brand-new controller.
|
|
136
|
+
const newCtrl = makeCtrl()
|
|
137
|
+
h.active.set('k', newCtrl)
|
|
138
|
+
|
|
139
|
+
h.setRunning(0)
|
|
140
|
+
h.deferred.promote()
|
|
141
|
+
|
|
142
|
+
// Old held controller still reaches its 👍 (its message's workers done)...
|
|
143
|
+
expect(oldCtrl.doneCount).toBe(1)
|
|
144
|
+
// ...but the new turn's controller is untouched and its key not purged.
|
|
145
|
+
expect(newCtrl.doneCount).toBe(0)
|
|
146
|
+
expect(h.purged).toEqual([])
|
|
147
|
+
expect(h.active.get('k')).toBe(newCtrl)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('race guard: promotes immediately if the worker finished during defer', () => {
|
|
151
|
+
// countRunningWorkers returns >0 on the first call (so we defer) then 0
|
|
152
|
+
// on the re-check inside tryDefer — emulating a worker completing in the
|
|
153
|
+
// window between the two reads.
|
|
154
|
+
const active = new Map<string, ReturnType<typeof makeCtrl>>()
|
|
155
|
+
const ctrl = makeCtrl()
|
|
156
|
+
active.set('k', ctrl)
|
|
157
|
+
let calls = 0
|
|
158
|
+
const deferred = new DeferredDoneReactions<ReturnType<typeof makeCtrl>>({
|
|
159
|
+
countRunningWorkers: () => (calls++ === 0 ? 1 : 0),
|
|
160
|
+
getActive: (key) => active.get(key),
|
|
161
|
+
purge: (key) => active.delete(key),
|
|
162
|
+
})
|
|
163
|
+
expect(deferred.tryDefer('k', ctrl)).toBe(true)
|
|
164
|
+
// Race guard fired promote() inside tryDefer → already at 👍, map cleared.
|
|
165
|
+
expect(ctrl.doneCount).toBe(1)
|
|
166
|
+
expect(deferred.size).toBe(0)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('drop() clears a deferred entry without finalizing (error path)', () => {
|
|
170
|
+
const h = makeHarness(1)
|
|
171
|
+
const ctrl = makeCtrl()
|
|
172
|
+
h.active.set('k', ctrl)
|
|
173
|
+
h.deferred.tryDefer('k', ctrl)
|
|
174
|
+
expect(h.deferred.has('k')).toBe(true)
|
|
175
|
+
|
|
176
|
+
h.deferred.drop('k')
|
|
177
|
+
expect(h.deferred.has('k')).toBe(false)
|
|
178
|
+
expect(ctrl.doneCount).toBe(0)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('promote is a no-op when nothing is deferred', () => {
|
|
182
|
+
const h = makeHarness(0)
|
|
183
|
+
h.deferred.promote()
|
|
184
|
+
expect(h.purged).toEqual([])
|
|
185
|
+
expect(h.deferred.size).toBe(0)
|
|
186
|
+
})
|
|
187
|
+
})
|