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.
- package/dist/cli/switchroom.js +24 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_shared/delegation-golden-rule.md.hbs +9 -0
- package/profiles/_shared/dev-protocol.md.hbs +2 -0
- package/profiles/_shared/execution-discipline.md.hbs +2 -2
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +268 -61
- package/telegram-plugin/flushed-turn-supersede.ts +230 -0
- package/telegram-plugin/gateway/gateway.ts +88 -1
- package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +17 -0
- package/telegram-plugin/registry/subagents-schema.ts +6 -0
- package/telegram-plugin/subagent-watcher.ts +86 -1
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +206 -0
- package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +20 -0
- package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +30 -0
- package/telegram-plugin/tests/subagent-watcher-first-paint-independence.test.ts +171 -0
- package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +7 -5
- package/telegram-plugin/tests/subagent-watcher.test.ts +13 -12
- package/telegram-plugin/tests/turn-flush-safety.test.ts +71 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +13 -8
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +196 -0
- package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +40 -0
- package/telegram-plugin/turn-flush-safety.ts +74 -1
- 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
|
-
*
|
|
394
|
-
* flood window blocked the edit.
|
|
395
|
-
*
|
|
396
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
725
|
-
|
|
726
|
-
// the
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
-
|
|
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
|
|
748
|
-
//
|
|
749
|
-
if (isTerminal)
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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=${
|
|
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)
|
|
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
|
-
|
|
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)
|
|
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,
|
|
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
|
-
// (
|
|
845
|
-
if (isTerminal)
|
|
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
|
-
|
|
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 (
|
|
951
|
-
|
|
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
|
|
964
|
-
|
|
965
|
-
//
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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
|
-
|
|
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:
|
|
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 = {
|