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.
Files changed (45) hide show
  1. package/dist/cli/ms-365-write-pretool.mjs +92 -20
  2. package/dist/cli/switchroom.js +36 -6
  3. package/dist/host-control/main.js +1 -1
  4. package/package.json +1 -1
  5. package/telegram-plugin/answer-ready-flush.ts +187 -0
  6. package/telegram-plugin/dist/gateway/gateway.js +1131 -285
  7. package/telegram-plugin/dist/server.js +6 -0
  8. package/telegram-plugin/format.ts +208 -125
  9. package/telegram-plugin/gateway/cron-session.ts +32 -0
  10. package/telegram-plugin/gateway/gateway.ts +800 -107
  11. package/telegram-plugin/gateway/idle-clear.ts +170 -0
  12. package/telegram-plugin/gateway/inject-handler.ts +11 -0
  13. package/telegram-plugin/gateway/outbound-send-path.ts +5 -3
  14. package/telegram-plugin/gateway/turn-record-status.ts +134 -0
  15. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +23 -0
  16. package/telegram-plugin/hooks/silent-end-scan.mjs +98 -8
  17. package/telegram-plugin/llm-error-present.ts +68 -30
  18. package/telegram-plugin/narrative-flush.ts +181 -0
  19. package/telegram-plugin/pending-work-progress.ts +65 -1
  20. package/telegram-plugin/session-tail.ts +6 -1
  21. package/telegram-plugin/silent-end.ts +182 -0
  22. package/telegram-plugin/subagent-watcher.ts +244 -81
  23. package/telegram-plugin/tests/answer-ready-flush.test.ts +343 -0
  24. package/telegram-plugin/tests/cron-inject-idle-clock.test.ts +54 -0
  25. package/telegram-plugin/tests/emission-authority-facade.test.ts +13 -10
  26. package/telegram-plugin/tests/format-consistency.test.ts +39 -4
  27. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +26 -0
  28. package/telegram-plugin/tests/idle-clear.test.ts +315 -37
  29. package/telegram-plugin/tests/llm-error-present.test.ts +110 -9
  30. package/telegram-plugin/tests/narrative-flush.test.ts +213 -0
  31. package/telegram-plugin/tests/narrative-splice-before-finalize.test.ts +167 -0
  32. package/telegram-plugin/tests/outbound-send-path.test.ts +2 -0
  33. package/telegram-plugin/tests/paragraph-spacer-golden.test.ts +150 -0
  34. package/telegram-plugin/tests/per-topic-current-turn.test.ts +4 -1
  35. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +194 -0
  36. package/telegram-plugin/tests/silent-end.test.ts +296 -0
  37. package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +218 -0
  38. package/telegram-plugin/tests/telegram-format.test.ts +72 -4
  39. package/telegram-plugin/tests/turn-record-status.test.ts +119 -0
  40. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +218 -1
  41. package/telegram-plugin/tests/worker-feed-terminal-cleanup.test.ts +254 -0
  42. package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +125 -0
  43. package/telegram-plugin/tool-activity-summary.ts +78 -16
  44. package/telegram-plugin/turn-flush-safety.ts +2 -1
  45. 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
- export type WorkerActivityState = 'running' | 'done' | 'failed'
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 synchronously so a late `running` cue on the chain can't resurrect.
920
- row.finished = true
921
- row.state = view.state === 'failed' ? 'failed' : 'done'
922
- markFinalized(agentId)
923
-
924
- const group = g
925
- group.chain = group.chain
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