switchroom 0.14.14 → 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.
@@ -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,29 @@ 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()
17399
+ // #PR2 live worker-feed: force the terminal recap edit on
17400
+ // the worker's live message. No-op when no message was ever
17401
+ // posted (trivial workers stay silent; handback covers them).
17402
+ // 'orphan' is a stale boot row, not a fresh completion — map
17403
+ // it to 'done' so an already-posted message still finalizes.
17404
+ if (workerFeedEnabled) {
17405
+ void workerActivityFeed.finish(agentId, {
17406
+ description,
17407
+ lastTool: null,
17408
+ toolCount,
17409
+ latestSummary: resultText,
17410
+ elapsedMs: durationMs,
17411
+ state: outcome === 'failed' ? 'failed' : 'done',
17412
+ })
17413
+ }
17315
17414
  // IO: resolve the fleet chat id and the background flag.
17316
17415
  // The DECISION (gating + inbound build) is delegated to
17317
17416
  // the pure `decideSubagentHandback` so it is unit-tested
@@ -17409,7 +17508,7 @@ void (async () => {
17409
17508
  // suppresses stale-after-restart delivery (a 4-h-old
17410
17509
  // "still working (5m)" would be a lie). Sweep on handback
17411
17510
  // lives in the `onFinish` block just above.
17412
- onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx }) => {
17511
+ onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount }) => {
17413
17512
  let fleetChatId = ''
17414
17513
  let isBackground = false
17415
17514
  try {
@@ -17431,6 +17530,28 @@ void (async () => {
17431
17530
  }
17432
17531
  if (!isBackground) return // skip overhead for foreground
17433
17532
 
17533
+ // #PR2 live worker-feed: when ON, the worker's live chat
17534
+ // message owns the progress beat. Push a running cue and
17535
+ // return BEFORE the legacy bucket relay so the same activity
17536
+ // isn't double-surfaced (in-message edit + injected
17537
+ // "still working" inbound turn). Chat = owner DM, since the
17538
+ // pinned-card fleet is gone and every agent is DM-shaped.
17539
+ if (workerFeedEnabled) {
17540
+ void workerActivityFeed.update(
17541
+ agentId,
17542
+ fleetChatId || (loadAccess().allowFrom[0] ?? ''),
17543
+ {
17544
+ description,
17545
+ lastTool,
17546
+ toolCount,
17547
+ latestSummary,
17548
+ elapsedMs,
17549
+ state: 'running',
17550
+ },
17551
+ )
17552
+ return
17553
+ }
17554
+
17434
17555
  const decision = decideSubagentProgress({
17435
17556
  disableEnvValue: process.env.SWITCHROOM_DISABLE_SUBAGENT_PROGRESS,
17436
17557
  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
+ })
@@ -340,4 +340,83 @@ describe('StatusReactionController', () => {
340
340
  await flush()
341
341
  expect(calls).toEqual(['👀'])
342
342
  })
343
+
344
+ // hold(): freeze on a WORKING glyph while background sub-agent workers
345
+ // outlive the parent turn, deferring the terminal 👍 (worker-reaction fix).
346
+ describe('hold() — defer 👍 while a background worker runs', () => {
347
+ it('suppresses stall promotion (no 🥱/😨) while held', async () => {
348
+ const { emit, calls } = makeEmitter()
349
+ const ctrl = new StatusReactionController(emit)
350
+ ctrl.setQueued()
351
+ ctrl.setTool('Bash') // working: 👨‍💻
352
+ vi.advanceTimersByTime(3500)
353
+ await flush()
354
+
355
+ ctrl.hold()
356
+ await flush()
357
+ // Well past both stall thresholds — held must not yawn or panic.
358
+ vi.advanceTimersByTime(120000)
359
+ await flush()
360
+ expect(calls).not.toContain('🥱')
361
+ expect(calls).not.toContain('😨')
362
+ })
363
+
364
+ it('promotes a read/thinking glyph to a working glyph on hold', async () => {
365
+ const { emit, calls } = makeEmitter()
366
+ const ctrl = new StatusReactionController(emit)
367
+ ctrl.setQueued() // 👀 (read-receipt)
368
+ await flush()
369
+ expect(calls).toEqual(['👀'])
370
+
371
+ ctrl.hold() // should paint an explicit WORKING glyph (✍️)
372
+ await flush()
373
+ expect(calls[calls.length - 1]).toBe('✍')
374
+ })
375
+
376
+ it('finalize() still terminates to 👍 after hold (deferred terminal)', async () => {
377
+ const { emit, calls } = makeEmitter()
378
+ const ctrl = new StatusReactionController(emit)
379
+ ctrl.setQueued()
380
+ ctrl.setTool() // ✍
381
+ vi.advanceTimersByTime(3500)
382
+ await flush()
383
+
384
+ ctrl.hold()
385
+ await flush()
386
+ // Worker runs for a while, then completes → gateway finalizes.
387
+ vi.advanceTimersByTime(60000)
388
+ await flush()
389
+ ctrl.finalize('done')
390
+ await flush()
391
+ expect(calls[calls.length - 1]).toBe('👍')
392
+ })
393
+
394
+ it('does not double-paint when already on a working glyph', async () => {
395
+ const { emit, calls } = makeEmitter()
396
+ const ctrl = new StatusReactionController(emit)
397
+ ctrl.setQueued()
398
+ ctrl.setTool() // ✍
399
+ vi.advanceTimersByTime(3500)
400
+ await flush()
401
+ const before = calls.length
402
+
403
+ ctrl.hold() // already on ✍ → no new emit
404
+ await flush()
405
+ expect(calls.length).toBe(before)
406
+ })
407
+
408
+ it('hold() after finalize is a no-op (cannot resurrect a finished controller)', async () => {
409
+ const { emit, calls } = makeEmitter()
410
+ const ctrl = new StatusReactionController(emit)
411
+ ctrl.setQueued()
412
+ ctrl.finalize('done')
413
+ await flush()
414
+ const snapshot = [...calls]
415
+
416
+ ctrl.hold()
417
+ vi.advanceTimersByTime(120000)
418
+ await flush()
419
+ expect(calls).toEqual(snapshot)
420
+ })
421
+ })
343
422
  })