switchroom 0.18.18 → 0.18.20
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/ms-365-write-pretool.mjs +92 -20
- package/dist/cli/switchroom.js +36 -6
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/answer-ready-flush.ts +187 -0
- package/telegram-plugin/dist/gateway/gateway.js +1131 -285
- package/telegram-plugin/dist/server.js +6 -0
- package/telegram-plugin/format.ts +208 -125
- package/telegram-plugin/gateway/cron-session.ts +32 -0
- package/telegram-plugin/gateway/gateway.ts +800 -107
- package/telegram-plugin/gateway/idle-clear.ts +170 -0
- package/telegram-plugin/gateway/inject-handler.ts +11 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +5 -3
- package/telegram-plugin/gateway/turn-record-status.ts +134 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +23 -0
- package/telegram-plugin/hooks/silent-end-scan.mjs +98 -8
- package/telegram-plugin/llm-error-present.ts +68 -30
- package/telegram-plugin/narrative-flush.ts +181 -0
- package/telegram-plugin/pending-work-progress.ts +65 -1
- package/telegram-plugin/session-tail.ts +6 -1
- package/telegram-plugin/silent-end.ts +182 -0
- package/telegram-plugin/subagent-watcher.ts +244 -81
- package/telegram-plugin/tests/answer-ready-flush.test.ts +343 -0
- package/telegram-plugin/tests/cron-inject-idle-clock.test.ts +54 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +13 -10
- package/telegram-plugin/tests/format-consistency.test.ts +39 -4
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +26 -0
- package/telegram-plugin/tests/idle-clear.test.ts +315 -37
- package/telegram-plugin/tests/llm-error-present.test.ts +110 -9
- package/telegram-plugin/tests/narrative-flush.test.ts +213 -0
- package/telegram-plugin/tests/narrative-splice-before-finalize.test.ts +167 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +2 -0
- package/telegram-plugin/tests/paragraph-spacer-golden.test.ts +150 -0
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +4 -1
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +194 -0
- package/telegram-plugin/tests/silent-end.test.ts +296 -0
- package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +218 -0
- package/telegram-plugin/tests/telegram-format.test.ts +72 -4
- package/telegram-plugin/tests/turn-record-status.test.ts +119 -0
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +218 -1
- package/telegram-plugin/tests/worker-feed-terminal-cleanup.test.ts +254 -0
- package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +125 -0
- package/telegram-plugin/tool-activity-summary.ts +78 -16
- package/telegram-plugin/turn-flush-safety.ts +2 -1
- package/telegram-plugin/worker-activity-feed.ts +181 -30
|
@@ -69,7 +69,20 @@ export function isWorkerActivityFeedEnabled(envVal: string | undefined): boolean
|
|
|
69
69
|
return envVal !== '0'
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Terminal states a worker card can render.
|
|
74
|
+
* - 'running' — live.
|
|
75
|
+
* - 'done' — clean finish WITH a result (✅).
|
|
76
|
+
* - 'failed' — clean finish reporting a failure / crash observed in the
|
|
77
|
+
* transcript (⚠️).
|
|
78
|
+
* - 'incomplete' — force-reaped / vanished / crashed WITHOUT any clean finish
|
|
79
|
+
* (the TTL sweep or the watcher's authoritative
|
|
80
|
+
* `onTerminalCleanup` synthesised the terminal because
|
|
81
|
+
* neither `onFinish` nor a turn_end ever delivered a result).
|
|
82
|
+
* Renders `incomplete · …` so a reaped worker NEVER reads as
|
|
83
|
+
* "done" — the truthful "ended without result" terminal.
|
|
84
|
+
*/
|
|
85
|
+
export type WorkerActivityState = 'running' | 'done' | 'failed' | 'incomplete'
|
|
73
86
|
|
|
74
87
|
/** The render-relevant snapshot of a worker at one instant. */
|
|
75
88
|
export interface WorkerActivityView {
|
|
@@ -141,7 +154,7 @@ const DESC_MAX = 80
|
|
|
141
154
|
*/
|
|
142
155
|
export function renderWorkerActivity(v: WorkerActivityView, liveSuffix = ''): string {
|
|
143
156
|
const desc = truncate(stripMarkdown(v.description).trim() || 'background task', DESC_MAX)
|
|
144
|
-
const finished = v.state === 'done' || v.state === 'failed'
|
|
157
|
+
const finished = v.state === 'done' || v.state === 'failed' || v.state === 'incomplete'
|
|
145
158
|
|
|
146
159
|
// Raw narrative steps (unstripped/unescaped) — the unified renderer runs the
|
|
147
160
|
// full per-line pipeline (stripMarkdown → collapse ws → clip → escape).
|
|
@@ -169,8 +182,15 @@ export function renderWorkerActivity(v: WorkerActivityView, liveSuffix = ''): st
|
|
|
169
182
|
|
|
170
183
|
// Terminal: latestSummary carries the worker's final result text (gateway
|
|
171
184
|
// onFinish), distinct from the running narrative steps. Pass it as `result`.
|
|
185
|
+
//
|
|
186
|
+
// Truthful-no-result invariant (deterministic control, not caller-discipline):
|
|
187
|
+
// an `incomplete` worker produced NO result, so it must NEVER render a result
|
|
188
|
+
// block regardless of whatever `latestSummary` happens to carry. The current
|
|
189
|
+
// call site (terminateWorker) always sets latestSummary:'' for incomplete, but
|
|
190
|
+
// enforce the invariant HERE so any future/direct caller can't fabricate a
|
|
191
|
+
// `⚠️`-prefixed result paragraph out of stray summary text.
|
|
172
192
|
let result: { emoji: string; text: string } | undefined
|
|
173
|
-
if (finished) {
|
|
193
|
+
if (finished && v.state !== 'incomplete') {
|
|
174
194
|
const text = cleanWorkerResultParagraph(v.latestSummary)
|
|
175
195
|
if (text.length > 0) result = { emoji: v.state === 'done' ? '✅' : '⚠️', text }
|
|
176
196
|
}
|
|
@@ -257,6 +277,27 @@ export interface WorkerActivityFeedOpts {
|
|
|
257
277
|
* `channels.telegram.worker_feed.max_rows` via the config cascade.
|
|
258
278
|
*/
|
|
259
279
|
maxRows?: number
|
|
280
|
+
/**
|
|
281
|
+
* Backstop TTL (ms): a worker row that has received no `update()` cue for
|
|
282
|
+
* longer than this AND is not already finished is force-terminated by the
|
|
283
|
+
* heartbeat sweep — its row is removed, and when it was the last live worker
|
|
284
|
+
* the shared card collapses to its terminal summary and unpins. This is the
|
|
285
|
+
* durable guard against an immortal card if BOTH terminal signals are missed
|
|
286
|
+
* (the gateway's `onFinish` AND the watcher's `onTerminalCleanup` sweep).
|
|
287
|
+
*
|
|
288
|
+
* The gateway DERIVES this in code from the watcher's effective in-flight
|
|
289
|
+
* terminal cap (`resolveInflightTerminalCapMs()` — the same env/default the
|
|
290
|
+
* watcher resolves) plus a margin, so the invariant "never reap a row the
|
|
291
|
+
* watcher still considers live" holds even if an operator raises the cap via
|
|
292
|
+
* `SWITCHROOM_SUBAGENT_INFLIGHT_TERMINAL_CAP_MS`. A worker mid-very-long tool
|
|
293
|
+
* can go silent up to the cap before the watcher declares it terminal, so any
|
|
294
|
+
* row still present past cap + margin is a definitive leak — the watcher would
|
|
295
|
+
* already have swept a live worker.
|
|
296
|
+
*
|
|
297
|
+
* Fallback default (this module, for direct / non-gateway callers) 50 min.
|
|
298
|
+
* Tests inject a small value.
|
|
299
|
+
*/
|
|
300
|
+
staleWorkerTtlMs?: number
|
|
260
301
|
/**
|
|
261
302
|
* Group-level status-pin reconcile hook (#3207 review). Because workers now
|
|
262
303
|
* COALESCE into one shared message, the pin MUST follow the GROUP lifecycle,
|
|
@@ -309,6 +350,15 @@ interface WorkerRow {
|
|
|
309
350
|
* stable sort order within the combined feed.
|
|
310
351
|
*/
|
|
311
352
|
dispatchAtMs: number | null
|
|
353
|
+
/**
|
|
354
|
+
* Wall-clock ms of the most recent `update()` cue for this worker (its last
|
|
355
|
+
* observed liveness). The heartbeat's backstop TTL sweep force-terminates a
|
|
356
|
+
* row whose `lastUpdateAt` is older than `staleWorkerTtlMs` — the durable
|
|
357
|
+
* guard that no card can go immortal even if every terminal signal (onFinish
|
|
358
|
+
* AND the onTerminalCleanup sweep) is somehow missed. Stamped on row creation
|
|
359
|
+
* and on every `update()`.
|
|
360
|
+
*/
|
|
361
|
+
lastUpdateAt: number
|
|
312
362
|
/**
|
|
313
363
|
* Wall-clock ms the CURRENT step started — stamped whenever a NEW narrative
|
|
314
364
|
* line lands (the `→` line changes). The heartbeat's step suffix shows the
|
|
@@ -432,6 +482,17 @@ export interface WorkerActivityFeed {
|
|
|
432
482
|
* in the group — force the terminal recap edit. No-op if the worker was
|
|
433
483
|
* never tracked. */
|
|
434
484
|
finish(agentId: string, view: WorkerActivityView): Promise<void>
|
|
485
|
+
/**
|
|
486
|
+
* Force a worker terminal from an AUTHORITATIVE watcher sweep
|
|
487
|
+
* (`onTerminalCleanup`) or the backstop TTL, WITHOUT an external result view:
|
|
488
|
+
* the terminal recap is synthesised from the worker's own last-known row
|
|
489
|
+
* state (state → `done`, no fabricated result text — the real result reaches
|
|
490
|
+
* the user via the separate handback, if one ran). When it was the last live
|
|
491
|
+
* worker the shared card collapses to its terminal summary and unpins; with
|
|
492
|
+
* siblings, its row is dropped and the combined body re-renders. Idempotent —
|
|
493
|
+
* a no-op if the worker was already removed (e.g. `onFinish` fired first).
|
|
494
|
+
*/
|
|
495
|
+
terminate(agentId: string): Promise<void>
|
|
435
496
|
/** Forget a worker's state without a recap edit (e.g. error path); re-renders
|
|
436
497
|
* the group so the dropped worker disappears from the combined body. */
|
|
437
498
|
drop(agentId: string): void
|
|
@@ -461,6 +522,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
461
522
|
const firstPaintMin = opts.firstPaintMinMs ?? 8000
|
|
462
523
|
const heartbeatTickMs = opts.heartbeatTickMs ?? 6000
|
|
463
524
|
const maxRows = Math.max(1, Math.floor(opts.maxRows ?? 8))
|
|
525
|
+
const staleWorkerTtlMs = Math.max(1, Math.floor(opts.staleWorkerTtlMs ?? 50 * 60_000))
|
|
464
526
|
const reconcilePinFn = opts.reconcilePin ?? (() => {})
|
|
465
527
|
const setIntervalFn =
|
|
466
528
|
opts.setInterval ??
|
|
@@ -616,6 +678,11 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
616
678
|
elapsedMs: elapsedFor(r),
|
|
617
679
|
toolCount: v.toolCount,
|
|
618
680
|
currentStep,
|
|
681
|
+
// Full per-worker rolling history (oldest→newest) so the combined feed
|
|
682
|
+
// can paint an adaptive-depth ✓/→ trail, not just the latest line. The
|
|
683
|
+
// renderer clamps depth to the shared body-line budget; when a worker
|
|
684
|
+
// has no narrative yet this is empty and it falls back to currentStep.
|
|
685
|
+
historyLines: r.narrative.length > 0 ? [...r.narrative] : undefined,
|
|
619
686
|
model: v.model,
|
|
620
687
|
}
|
|
621
688
|
})
|
|
@@ -785,10 +852,110 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
785
852
|
}
|
|
786
853
|
}
|
|
787
854
|
|
|
855
|
+
/**
|
|
856
|
+
* Shared terminal-finalize path for a worker whose row is still tracked.
|
|
857
|
+
* Latches the row terminal synchronously (so a late `running` cue on the
|
|
858
|
+
* chain can't resurrect it), then on the chain either drops the row + re-
|
|
859
|
+
* renders the surviving siblings, or — when it was the last live worker —
|
|
860
|
+
* finalizes the shared message to its terminal recap. Used by `finish` (with
|
|
861
|
+
* the gateway's onFinish view) AND by `terminate` (authoritative watcher
|
|
862
|
+
* sweep / TTL backstop, view synthesised from the row's own last state).
|
|
863
|
+
*/
|
|
864
|
+
function finalizeWorker(group: FeedGroup, agentId: string, row: WorkerRow, view: WorkerActivityView): Promise<void> {
|
|
865
|
+
row.finished = true
|
|
866
|
+
// Preserve the truthful terminal state through to the row (done / failed /
|
|
867
|
+
// incomplete) — never collapse a reaped 'incomplete' into 'done'. view.state
|
|
868
|
+
// is always terminal on this path (finalize is only reached for a finishing
|
|
869
|
+
// worker), so threading it verbatim is correct.
|
|
870
|
+
row.state = view.state
|
|
871
|
+
markFinalized(agentId)
|
|
872
|
+
group.chain = group.chain
|
|
873
|
+
.then(() => {
|
|
874
|
+
const others = runningRows(group).filter((w) => w.agentId !== agentId)
|
|
875
|
+
if (others.length > 0) {
|
|
876
|
+
// Siblings still live → drop this row from the combined body and re-
|
|
877
|
+
// render the running set. The group pin STAYS (siblings still need
|
|
878
|
+
// the shared message).
|
|
879
|
+
removeWorker(group, agentId)
|
|
880
|
+
syncPin(group)
|
|
881
|
+
return doRender(group, { force: true })
|
|
882
|
+
}
|
|
883
|
+
// Last live worker → finalize the shared message to its terminal recap.
|
|
884
|
+
const recap: WorkerActivityView = { ...view, narrativeLines: [...row.narrative] }
|
|
885
|
+
return doRender(group, { force: true, terminalRecap: recap, finishingAgentId: agentId })
|
|
886
|
+
})
|
|
887
|
+
.catch((err) => {
|
|
888
|
+
log(`worker-feed: finalize chain error ${agentId}: ${(err as Error).message}`)
|
|
889
|
+
})
|
|
890
|
+
return group.chain
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Force a worker terminal without an external result view (authoritative
|
|
895
|
+
* `onTerminalCleanup` sweep or the TTL backstop). Synthesises the recap from
|
|
896
|
+
* the row's own last-known state — state `incomplete` (NOT `done`), NO
|
|
897
|
+
* fabricated result text (the real result, if any, reaches the user via the
|
|
898
|
+
* separate handback). Reaching here means NO clean finish arrived: a clean
|
|
899
|
+
* `onFinish` removes the row FIRST (this call is then a no-op), and an
|
|
900
|
+
* errored worker goes through `onFinish(outcome:'failed')` → `finish` — so a
|
|
901
|
+
* row still present at terminate time genuinely ended WITHOUT a result. It
|
|
902
|
+
* must therefore render `incomplete · …`, never "done" (a crash/vanish read
|
|
903
|
+
* as "done" was the residual this fixes). Idempotent: a no-op once the worker
|
|
904
|
+
* was already removed (onFinish first). Depth-generic: operates on the
|
|
905
|
+
* generic worker row by agentId, identical at every nesting level.
|
|
906
|
+
*/
|
|
907
|
+
function terminateWorker(agentId: string): Promise<void> {
|
|
908
|
+
const g = groupOfAgent(agentId)
|
|
909
|
+
const row = g?.workers.get(agentId)
|
|
910
|
+
if (g == null || row == null) {
|
|
911
|
+
// Already gone (onFinish removed it) — latch finalized so a late cue can't
|
|
912
|
+
// resurrect, and no-op.
|
|
913
|
+
markFinalized(agentId)
|
|
914
|
+
return Promise.resolve()
|
|
915
|
+
}
|
|
916
|
+
if (row.finished) {
|
|
917
|
+
// Terminal already latched; the chain will settle removal. No-op.
|
|
918
|
+
return g.chain
|
|
919
|
+
}
|
|
920
|
+
const lv = row.lastView
|
|
921
|
+
const view: WorkerActivityView = {
|
|
922
|
+
description: lv?.description ?? 'background task',
|
|
923
|
+
lastTool: null,
|
|
924
|
+
toolCount: lv?.toolCount ?? 0,
|
|
925
|
+
// No fabricated result paragraph — an authoritative sweep can't know what
|
|
926
|
+
// the worker returned; the terminal card shows the header struck-through
|
|
927
|
+
// as `incomplete`, and the handback (if it ran) carries the actual result.
|
|
928
|
+
latestSummary: '',
|
|
929
|
+
elapsedMs: liveElapsed(row, nowFn()),
|
|
930
|
+
state: 'incomplete',
|
|
931
|
+
model: lv?.model,
|
|
932
|
+
}
|
|
933
|
+
return finalizeWorker(g, agentId, row, view)
|
|
934
|
+
}
|
|
935
|
+
|
|
788
936
|
// Arm the heartbeat once at construction. The real timer is `.unref()`'d so
|
|
789
937
|
// it never keeps the process alive; tests inject setInterval/clearInterval.
|
|
790
938
|
function heartbeatTick(): void {
|
|
791
939
|
const now = nowFn()
|
|
940
|
+
// Backstop TTL sweep: force-terminate any worker row that has gone silent
|
|
941
|
+
// past `staleWorkerTtlMs` (no `update()` cue AND not already finished). This
|
|
942
|
+
// is the durable guard against an immortal card if BOTH terminal signals
|
|
943
|
+
// are missed — the gateway's `onFinish` AND the watcher's authoritative
|
|
944
|
+
// `onTerminalCleanup` sweep. Collect the stale agent ids first (terminate
|
|
945
|
+
// mutates the group's worker map), then terminate each through its chain so
|
|
946
|
+
// the render/unpin happens under the normal cooldown/flood guards.
|
|
947
|
+
const staleAgentIds: string[] = []
|
|
948
|
+
for (const g of groups.values()) {
|
|
949
|
+
for (const row of g.workers.values()) {
|
|
950
|
+
if (!row.finished && now - row.lastUpdateAt >= staleWorkerTtlMs) {
|
|
951
|
+
staleAgentIds.push(row.agentId)
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
for (const agentId of staleAgentIds) {
|
|
956
|
+
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
|
+
void terminateWorker(agentId)
|
|
958
|
+
}
|
|
792
959
|
for (const g of [...groups.values()]) {
|
|
793
960
|
// Deferred-finalize re-drive: a terminal edit that hit a cooldown/flood
|
|
794
961
|
// window was staged on `pendingFinalize`. Re-drive it once the cooldown
|
|
@@ -888,12 +1055,16 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
888
1055
|
lastView: null,
|
|
889
1056
|
state: 'running',
|
|
890
1057
|
finished: false,
|
|
1058
|
+
lastUpdateAt: nowFn(),
|
|
891
1059
|
dispatchAtMs: null,
|
|
892
1060
|
stepStartedAtMs: null,
|
|
893
1061
|
}
|
|
894
1062
|
g.workers.set(agentId, row)
|
|
895
1063
|
agentIndex.set(agentId, feedKey)
|
|
896
1064
|
}
|
|
1065
|
+
// Stamp liveness for the backstop TTL sweep (this is the worker's most
|
|
1066
|
+
// recent observed activity cue).
|
|
1067
|
+
row.lastUpdateAt = nowFn()
|
|
897
1068
|
// Accumulate before the gate so a throttled tick still grows the
|
|
898
1069
|
// narrative — it surfaces on the next edit that does fire.
|
|
899
1070
|
accumulateNarrative(row, view)
|
|
@@ -916,33 +1087,13 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
916
1087
|
markFinalized(agentId)
|
|
917
1088
|
return Promise.resolve()
|
|
918
1089
|
}
|
|
919
|
-
// Latch
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
.then(() => {
|
|
927
|
-
const others = runningRows(group).filter((w) => w.agentId !== agentId)
|
|
928
|
-
if (others.length > 0) {
|
|
929
|
-
// Siblings still live → drop this row from the combined body and
|
|
930
|
-
// re-render the running set. The result reaches the user via the
|
|
931
|
-
// separate handback, never folded into this cosmetic edit. The
|
|
932
|
-
// group pin STAYS (siblings still need the shared message) — a
|
|
933
|
-
// per-worker unpin here was the #3207 review blocker.
|
|
934
|
-
removeWorker(group, agentId)
|
|
935
|
-
syncPin(group)
|
|
936
|
-
return doRender(group, { force: true })
|
|
937
|
-
}
|
|
938
|
-
// Last live worker → finalize the shared message to its terminal recap.
|
|
939
|
-
const recap: WorkerActivityView = { ...view, narrativeLines: [...row.narrative] }
|
|
940
|
-
return doRender(group, { force: true, terminalRecap: recap, finishingAgentId: agentId })
|
|
941
|
-
})
|
|
942
|
-
.catch((err) => {
|
|
943
|
-
log(`worker-feed: finish chain error ${agentId}: ${(err as Error).message}`)
|
|
944
|
-
})
|
|
945
|
-
return group.chain
|
|
1090
|
+
// Latch + finalize via the shared path (drop-with-siblings / terminal
|
|
1091
|
+
// recap on the last worker). Synchronous latch inside finalizeWorker
|
|
1092
|
+
// stops a late `running` cue on the chain from resurrecting the row.
|
|
1093
|
+
return finalizeWorker(g, agentId, row, view)
|
|
1094
|
+
},
|
|
1095
|
+
terminate(agentId) {
|
|
1096
|
+
return terminateWorker(agentId)
|
|
946
1097
|
},
|
|
947
1098
|
drop(agentId) {
|
|
948
1099
|
// A dropped worker is also done — mark finalized so a late tick can't
|