switchroom 0.18.20 → 0.18.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/dist/cli/switchroom.js +24 -1
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +1 -1
  4. package/profiles/_shared/delegation-golden-rule.md.hbs +9 -0
  5. package/profiles/_shared/dev-protocol.md.hbs +2 -0
  6. package/profiles/_shared/execution-discipline.md.hbs +2 -2
  7. package/profiles/coding/CLAUDE.md.hbs +1 -1
  8. package/telegram-plugin/dist/gateway/gateway.js +268 -61
  9. package/telegram-plugin/flushed-turn-supersede.ts +230 -0
  10. package/telegram-plugin/gateway/gateway.ts +88 -1
  11. package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +17 -0
  12. package/telegram-plugin/registry/subagents-schema.ts +6 -0
  13. package/telegram-plugin/subagent-watcher.ts +86 -1
  14. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +206 -0
  15. package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +20 -0
  16. package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +30 -0
  17. package/telegram-plugin/tests/subagent-watcher-first-paint-independence.test.ts +171 -0
  18. package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +7 -5
  19. package/telegram-plugin/tests/subagent-watcher.test.ts +13 -12
  20. package/telegram-plugin/tests/turn-flush-safety.test.ts +71 -0
  21. package/telegram-plugin/tests/worker-activity-feed.test.ts +13 -8
  22. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +196 -0
  23. package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +40 -0
  24. package/telegram-plugin/turn-flush-safety.ts +74 -1
  25. package/telegram-plugin/worker-activity-feed.ts +155 -45
@@ -390,12 +390,29 @@ interface FeedGroup {
390
390
  /** Live workers in this group, keyed by agentId (insertion ≈ dispatch order). */
391
391
  workers: Map<string, WorkerRow>
392
392
  /**
393
- * A terminal render (the last worker's recap) staged because a 429 cooldown /
394
- * flood window blocked the edit. The heartbeat re-drives it once the cooldown
395
- * expires so a finished feed can't get stuck on its last running render.
396
- * Null when no finalize is pending.
393
+ * Terminal renders (per finishing worker's recap) staged because a 429
394
+ * cooldown / flood window blocked the edit. Keyed by agentId so a SECOND
395
+ * worker finishing in the same window can't overwrite the first's staged
396
+ * recap (the single-slot bug: two near-simultaneous last-worker finishes
397
+ * leaked the earlier finished row forever). The heartbeat drains EVERY entry
398
+ * once the cooldown expires so a finished feed can't get stuck on its last
399
+ * running render. Empty when no finalize is pending.
400
+ *
401
+ * Note: the finishing row is dropped from `workers` immediately at finalize
402
+ * time (decoupled from edit success — #3207 leaked-finished-row fix), so a
403
+ * staged entry here is purely the best-effort terminal REPAINT; group
404
+ * emptiness / deletion / unpin never wait on it.
405
+ */
406
+ pendingFinalize: Map<string, WorkerActivityView>
407
+ /**
408
+ * True once this group's shared message last rendered a TERMINAL recap (a
409
+ * finished worker's done/failed/incomplete card), and no live worker has
410
+ * repainted since. A later worker joining the group must NOT edit that
411
+ * terminal message — it forces a fresh first-paint (new message) instead, so
412
+ * a new dispatch never appends to a stale "done" card. Reset to false on any
413
+ * fresh first-paint.
397
414
  */
398
- pendingFinalize: WorkerActivityView | null
415
+ terminalPainted: boolean
399
416
  }
400
417
 
401
418
  const COOLDOWN_JITTER_MS = 500
@@ -693,7 +710,24 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
693
710
  function removeWorker(g: FeedGroup, agentId: string): void {
694
711
  g.workers.delete(agentId)
695
712
  agentIndex.delete(agentId)
696
- if (g.workers.size === 0) groups.delete(g.feedKey)
713
+ maybeDeleteGroup(g)
714
+ }
715
+
716
+ /**
717
+ * Delete an empty group. A group is gone only when it has NO tracked workers
718
+ * AND no staged terminal repaints pending — the latter keeps the group object
719
+ * reachable for the heartbeat re-drive after a flood/429 deferred the recap,
720
+ * without keeping it "alive" for the pin (syncPin / hasRunningInFeed count
721
+ * only live workers, so an emptied-but-pending group is already unpinned).
722
+ */
723
+ function maybeDeleteGroup(g: FeedGroup): void {
724
+ if (g.workers.size === 0 && g.pendingFinalize.size === 0) groups.delete(g.feedKey)
725
+ }
726
+
727
+ /** Live (not-yet-finished) workers still tracked in a group. */
728
+ function hasLiveWorker(g: FeedGroup): boolean {
729
+ for (const w of g.workers.values()) if (!w.finished) return true
730
+ return false
697
731
  }
698
732
 
699
733
  /**
@@ -704,7 +738,10 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
704
738
  * still needs — the survivors keep the pin until the LAST worker is done.
705
739
  */
706
740
  function syncPin(g: FeedGroup): void {
707
- const messageId = g.messageId != null && g.workers.size > 0 ? g.messageId : null
741
+ // Pin follows LIVE membership, not row count: a finished-but-not-yet-swept
742
+ // row (or a staged terminal repaint) must NOT keep the pin. Unpin the
743
+ // instant the last running worker is gone.
744
+ const messageId = g.messageId != null && hasLiveWorker(g) ? g.messageId : null
708
745
  reconcilePinFn({ feedKey: g.feedKey, chatId: g.chatId, threadId: g.threadId, messageId })
709
746
  }
710
747
 
@@ -721,45 +758,78 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
721
758
  ): Promise<void> {
722
759
  const now = nowFn()
723
760
  const isTerminal = opts2.terminalRecap != null
724
- // Terminal edit RESOLVED (landed / not-modified / gone): clear the staged
725
- // re-drive unconditionally so a heartbeat re-drive can never loop, and drop
726
- // the finished row when its id is known (the last-worker finalize path).
727
- const settleTerminal = (): void => {
728
- g.pendingFinalize = null
729
- if (opts2.finishingAgentId != null) removeWorker(g, opts2.finishingAgentId)
730
- // Group-level pin follows membership: unpin once this drops the last
731
- // worker; a NOOP-pin (siblings remain) keeps the shared message pinned.
761
+ const finishingAgentId = opts2.finishingAgentId
762
+
763
+ // Stage the terminal recap for the heartbeat re-drive, keyed by the
764
+ // FINISHING agent so a second worker finishing in the same cooldown/flood
765
+ // window can't overwrite an earlier staged recap (#3207: the single-slot
766
+ // overwrite that orphaned finished rows). Best-effort repaint only — the
767
+ // row is dropped and the pin released independently, below.
768
+ const stageRecap = (): void => {
769
+ if (isTerminal && finishingAgentId != null && opts2.terminalRecap != null) {
770
+ g.pendingFinalize.set(finishingAgentId, opts2.terminalRecap)
771
+ }
772
+ }
773
+ // Terminal repaint no longer pending (landed / not-modified / gone / never
774
+ // had a message): stop re-driving it and reap the group if now fully empty.
775
+ const clearStaged = (): void => {
776
+ if (finishingAgentId != null) g.pendingFinalize.delete(finishingAgentId)
777
+ maybeDeleteGroup(g)
778
+ syncPin(g)
779
+ }
780
+
781
+ // #3207 leaked-finished-row fix — decouple row removal + unpin from the
782
+ // terminal edit's SUCCESS. The instant a worker finalizes we drop its row
783
+ // and release the group pin; a 429 / flood / transport failure on the
784
+ // cosmetic recap edit must NEVER keep the group, its message, or its pin
785
+ // alive. The recap is staged (above) purely as a best-effort repaint.
786
+ if (isTerminal) {
787
+ stageRecap()
788
+ if (finishingAgentId != null && g.workers.has(finishingAgentId)) {
789
+ removeWorker(g, finishingAgentId)
790
+ }
791
+ // Only latch terminalPainted when NO live worker remains at paint time.
792
+ // Race: this finalize was the last live worker when the chain latched, but
793
+ // a fresh worker B may have called update() and joined g.workers before
794
+ // this body runs — in which case renderGroupBody paints B's RUNNING card,
795
+ // not a terminal recap. Setting the flag true unconditionally would
796
+ // mislabel that running paint as terminal (a spurious group-reuse fresh-
797
+ // paint on B's next update). Reflect reality: the group only went terminal
798
+ // if it's actually empty of live workers.
799
+ g.terminalPainted = !hasLiveWorker(g)
732
800
  syncPin(g)
733
801
  }
802
+
734
803
  if (now < g.cooldownUntil) {
735
- if (isTerminal && opts2.terminalRecap != null) g.pendingFinalize = opts2.terminalRecap
804
+ // Row already dropped + unpinned above; the recap stays staged so the
805
+ // heartbeat re-drives the repaint once the cooldown expires.
736
806
  return
737
807
  }
738
808
  // A flood window is open: the gate would SHED every call. Park in cooldown
739
809
  // and make ZERO api calls until it closes; the heartbeat re-drives.
740
810
  if (parkIfFloodWindowOpen(g)) {
741
- if (isTerminal && opts2.terminalRecap != null) g.pendingFinalize = opts2.terminalRecap
742
811
  return
743
812
  }
744
813
 
745
814
  const body = renderGroupBody(g, now, opts2.terminalRecap ?? null, opts2.heartbeat ?? false)
746
815
  if (body == null) {
747
- // Nothing to show. On a terminal finalize with no message ever posted,
748
- // just drop the finished row (the handback carries the result).
749
- if (isTerminal) settleTerminal()
816
+ // Nothing to show. On a terminal finalize the row is already dropped;
817
+ // clear the staged repaint (the handback carries the result).
818
+ if (isTerminal) clearStaged()
750
819
  return
751
820
  }
752
821
 
753
822
  // First paint: hold until some worker in the group has run long enough.
754
823
  if (g.messageId == null) {
755
- const maxElapsed = Math.max(0, ...runningRows(g).map((r) => liveElapsed(r, now)))
756
824
  // A terminal recap for a group that never painted → nothing to finalize;
757
825
  // never first-paint a terminal card (trivial workers stay silent, the
758
826
  // handback carries the result — matches the pre-coalesce doFinish guard).
827
+ // The finished row is already dropped; just clear the staged repaint.
759
828
  if (isTerminal) {
760
- settleTerminal()
829
+ clearStaged()
761
830
  return
762
831
  }
832
+ const maxElapsed = Math.max(0, ...runningRows(g).map((r) => liveElapsed(r, now)))
763
833
  if (maxElapsed < firstPaintMin) return
764
834
  try {
765
835
  const sent = await opts.bot.sendMessage(g.chatId, body, sendOptsFor(g))
@@ -775,6 +845,8 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
775
845
  g.messageId = sent.message_id
776
846
  g.lastBody = body
777
847
  g.lastEditAt = now
848
+ // A fresh message is a live running paint, never a terminal recap.
849
+ g.terminalPainted = false
778
850
  // Group's first (or re-established) message is up → pin it for the group.
779
851
  syncPin(g)
780
852
  log(
@@ -790,7 +862,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
790
862
 
791
863
  // Dedup + proactive throttle (finish/terminal edits force through).
792
864
  if (body === g.lastBody) {
793
- if (isTerminal) settleTerminal()
865
+ if (isTerminal) clearStaged()
794
866
  return
795
867
  }
796
868
  if (!opts2.force && now - g.lastEditAt < minEditInterval) return
@@ -803,7 +875,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
803
875
  // The shed payload is NOT on screen, so do not record it as `lastBody`.
804
876
  if (isSendGateShed(res)) {
805
877
  parkIfFloodWindowOpen(g)
806
- if (isTerminal && opts2.terminalRecap != null) g.pendingFinalize = opts2.terminalRecap
878
+ // Recap stays staged (above); row already dropped + unpinned.
807
879
  return
808
880
  }
809
881
  g.lastBody = body
@@ -811,7 +883,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
811
883
  if (isTerminal) {
812
884
  log(
813
885
  `worker-feed: finish feed=${g.feedKey} chat=${g.chatId} thread=${g.threadId ?? '-'} ` +
814
- `msgId=${g.messageId} agent=${opts2.finishingAgentId ?? '-'} ` +
886
+ `msgId=${g.messageId} agent=${finishingAgentId ?? '-'} ` +
815
887
  `state=${opts2.terminalRecap?.state ?? 'done'} bytes=${body.length}`,
816
888
  )
817
889
  } else {
@@ -820,34 +892,34 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
820
892
  `thread=${g.threadId ?? '-'} msgId=${g.messageId} workers=${g.workers.size} bytes=${body.length}`,
821
893
  )
822
894
  }
823
- if (isTerminal) settleTerminal()
895
+ if (isTerminal) clearStaged()
824
896
  } catch (err) {
825
897
  const outcome = classifyEditError(err)
826
898
  if (outcome === 'rate_limited') {
827
899
  noteRateLimited(g, err, isTerminal ? 'finish' : 'edit')
828
- if (isTerminal && opts2.terminalRecap != null) g.pendingFinalize = opts2.terminalRecap
900
+ // Recap stays staged; the heartbeat re-drives after the cooldown.
829
901
  return
830
902
  }
831
903
  if (outcome === 'not_modified') {
832
904
  g.lastBody = body
833
905
  g.lastEditAt = now
834
- if (isTerminal) settleTerminal()
906
+ if (isTerminal) clearStaged()
835
907
  return
836
908
  }
837
909
  if (outcome === 'gone') {
838
910
  // Message/chat gone or edit window closed — no card to update. Drop the
839
911
  // stale message id; a fresh first-paint re-establishes one if workers
840
- // are still live. On a terminal finalize, also drop the finished row.
912
+ // are still live. On a terminal finalize, clear the staged repaint.
841
913
  g.messageId = null
842
914
  g.lastBody = null
843
915
  // The pinned message no longer exists → release the group pin claim
844
- // (settleTerminal already re-syncs on the terminal path).
845
- if (isTerminal) settleTerminal()
916
+ // (clearStaged already re-syncs on the terminal path).
917
+ if (isTerminal) clearStaged()
846
918
  else syncPin(g)
847
919
  return
848
920
  }
849
921
  // 'transient' — leave the message intact; the heartbeat re-attempts.
850
- if (isTerminal && opts2.terminalRecap != null) g.pendingFinalize = opts2.terminalRecap
922
+ // Recap stays staged for the terminal path.
851
923
  log(`worker-feed: edit transient error feed=${g.feedKey}: ${(err as Error).message}`)
852
924
  }
853
925
  }
@@ -945,13 +1017,27 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
945
1017
  // mutates the group's worker map), then terminate each through its chain so
946
1018
  // the render/unpin happens under the normal cooldown/flood guards.
947
1019
  const staleAgentIds: string[] = []
1020
+ const staleFinished: Array<{ g: FeedGroup; agentId: string }> = []
948
1021
  for (const g of groups.values()) {
949
1022
  for (const row of g.workers.values()) {
950
- if (!row.finished && now - row.lastUpdateAt >= staleWorkerTtlMs) {
951
- staleAgentIds.push(row.agentId)
1023
+ if (now - row.lastUpdateAt >= staleWorkerTtlMs) {
1024
+ if (row.finished) staleFinished.push({ g, agentId: row.agentId })
1025
+ else staleAgentIds.push(row.agentId)
952
1026
  }
953
1027
  }
954
1028
  }
1029
+ // GC any FINISHED row still lingering past the TTL (#3207: finished rows
1030
+ // were exempt from the sweep — `if (!row.finished …)` — so a stuck finished
1031
+ // row could keep a group, its message, and its pin alive forever). The
1032
+ // normal finalize path drops the row immediately now, but reap directly
1033
+ // here as a durable backstop: `terminate()` no-ops on a finished row, so
1034
+ // remove it and release the pin without routing through it.
1035
+ for (const { g, agentId } of staleFinished) {
1036
+ log(`worker-feed: TTL GC finished row agent=${agentId} feed=${g.feedKey} — reaping leaked finished row`)
1037
+ g.pendingFinalize.delete(agentId)
1038
+ removeWorker(g, agentId)
1039
+ syncPin(g)
1040
+ }
955
1041
  for (const agentId of staleAgentIds) {
956
1042
  log(`worker-feed: TTL reap agent=${agentId} — no update in ${Math.floor((now - (groupOfAgent(agentId)?.workers.get(agentId)?.lastUpdateAt ?? now)) / 1000)}s (>= ${Math.floor(staleWorkerTtlMs / 1000)}s); force-terminating leaked row`)
957
1043
  void terminateWorker(agentId)
@@ -960,15 +1046,19 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
960
1046
  // Deferred-finalize re-drive: a terminal edit that hit a cooldown/flood
961
1047
  // window was staged on `pendingFinalize`. Re-drive it once the cooldown
962
1048
  // expires so a finished feed can't get stuck on its last running render.
963
- if (g.pendingFinalize != null && now >= g.cooldownUntil) {
964
- const recap = g.pendingFinalize
965
- // The finishing agent is whatever finished row remains (state terminal).
966
- const finishingAgentId = [...g.workers.values()].find((w) => w.finished)?.agentId
967
- g.chain = g.chain
968
- .then(() => doRender(g, { force: true, terminalRecap: recap, finishingAgentId }))
969
- .catch((err) => {
970
- log(`worker-feed: heartbeat finalize re-drive error feed=${g.feedKey}: ${(err as Error).message}`)
971
- })
1049
+ if (g.pendingFinalize.size > 0 && now >= g.cooldownUntil) {
1050
+ // Drain EVERY staged terminal recap, keyed by its finishing agent
1051
+ // (#3207: the single `pendingFinalize` slot dropped all but one when
1052
+ // multiple workers finished inside the same cooldown/flood window, so
1053
+ // the earlier finished rows leaked). Each re-drive removes its own
1054
+ // staged entry on success and reaps the group once fully empty.
1055
+ for (const [agentId, recap] of [...g.pendingFinalize]) {
1056
+ g.chain = g.chain
1057
+ .then(() => doRender(g, { force: true, terminalRecap: recap, finishingAgentId: agentId }))
1058
+ .catch((err) => {
1059
+ log(`worker-feed: heartbeat finalize re-drive error feed=${g.feedKey}: ${(err as Error).message}`)
1060
+ })
1061
+ }
972
1062
  continue
973
1063
  }
974
1064
 
@@ -1013,7 +1103,11 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
1013
1103
  },
1014
1104
  hasRunningInFeed(feedKey) {
1015
1105
  const g = groups.get(feedKey)
1016
- return g != null && g.workers.size > 0
1106
+ // Count only RUNNING (not-yet-finished) rows (#3207): a finished row
1107
+ // lingering pre-sweep — or a group kept alive solely for a staged
1108
+ // terminal repaint — is NOT running, so the gateway's `wk:group:` pin
1109
+ // reaper must be free to unpin it.
1110
+ return g != null && hasLiveWorker(g)
1017
1111
  },
1018
1112
  get size() {
1019
1113
  let n = 0
@@ -1043,10 +1137,26 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
1043
1137
  cooldownUntil: 0,
1044
1138
  chain: Promise.resolve(),
1045
1139
  workers: new Map(),
1046
- pendingFinalize: null,
1140
+ pendingFinalize: new Map(),
1141
+ terminalPainted: false,
1047
1142
  }
1048
1143
  groups.set(feedKey, g)
1049
1144
  }
1145
+ // Group-reuse-after-terminal (#3207): a later worker landing in a group
1146
+ // whose shared message last rendered a TERMINAL recap (its prior workers
1147
+ // all finished, but the group lingered — e.g. a staged repaint, or a
1148
+ // finished row not yet swept) must NOT inherit that message id and edit
1149
+ // the "done" card. Force a fresh first-paint (new message) so the new
1150
+ // dispatch never appends to a stale terminal card. Only triggers when no
1151
+ // live worker remains AND the group last went terminal.
1152
+ if (!g.workers.has(agentId) && g.terminalPainted && !hasLiveWorker(g)) {
1153
+ g.messageId = null
1154
+ g.lastBody = null
1155
+ g.pendingFinalize.clear()
1156
+ g.terminalPainted = false
1157
+ // The stale terminal message is no longer this group's pinned surface.
1158
+ syncPin(g)
1159
+ }
1050
1160
  let row = g.workers.get(agentId)
1051
1161
  if (row == null) {
1052
1162
  row = {