switchroom 0.18.30 → 0.18.32

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 (67) hide show
  1. package/dist/agent-scheduler/index.js +4 -2
  2. package/dist/auth-broker/index.js +4 -2
  3. package/dist/cli/notion-write-pretool.mjs +4 -2
  4. package/dist/cli/switchroom.js +708 -255
  5. package/dist/host-control/main.js +5 -3
  6. package/dist/vault/approvals/kernel-server.js +4 -2
  7. package/dist/vault/broker/server.js +4 -2
  8. package/package.json +3 -2
  9. package/profiles/_base/start.sh.hbs +142 -7
  10. package/telegram-plugin/dist/gateway/gateway.js +25870 -25007
  11. package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
  12. package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
  13. package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
  14. package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
  15. package/telegram-plugin/gateway/gateway.ts +5479 -7069
  16. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
  17. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
  18. package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
  19. package/telegram-plugin/gateway/obligation-store.ts +37 -1
  20. package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
  21. package/telegram-plugin/gateway/turn-flush-suppression.ts +82 -0
  22. package/telegram-plugin/pending-user-notice.ts +59 -13
  23. package/telegram-plugin/subagent-watcher.ts +111 -28
  24. package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
  25. package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
  26. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +16 -6
  27. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +3 -3
  28. package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
  29. package/telegram-plugin/tests/emission-authority-facade.test.ts +29 -19
  30. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
  31. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +18 -7
  32. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +249 -0
  33. package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
  34. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
  35. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +303 -0
  36. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +10 -3
  37. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
  38. package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
  39. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
  40. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
  41. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +4 -4
  42. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
  43. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +9 -3
  44. package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
  45. package/telegram-plugin/tests/obligation-store.test.ts +43 -0
  46. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +2 -1
  47. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
  48. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
  49. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
  50. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +3 -2
  51. package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
  52. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +291 -0
  53. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
  54. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
  55. package/telegram-plugin/tests/turn-flush-safety.test.ts +8 -6
  56. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +112 -0
  57. package/telegram-plugin/tests/turn-flush-suppression.test.ts +90 -0
  58. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
  59. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +126 -0
  60. package/telegram-plugin/tool-activity-summary.ts +27 -3
  61. package/telegram-plugin/worker-activity-feed.ts +24 -0
  62. package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
  63. package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
  64. package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
  65. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
  66. package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
  67. package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Turn-flush pre-delivery suppression decision (S1 fix, fable red-team
3
+ * 2026-07-17 — `~klanker/work/fable-redteam-delivery-20260717/REDTEAM.md`).
4
+ *
5
+ * Before the turn-flush backstop posts the captured terminal answer, it asks:
6
+ * "did a reply already land for this turn in the last ~2s?" (a race guard for
7
+ * a reply whose IPC signal hadn't registered when `decideTurnFlush` ran, and
8
+ * for answer-stream materializations that already delivered the answer text).
9
+ *
10
+ * The OLD predicate — `getRecentOutboundCount(chatId, 2) > 0` — counted ANY
11
+ * assistant row in the WHOLE chat: a background worker's `progress_update`, a
12
+ * command ack / restart notice, or a reply in a DIFFERENT forum topic all
13
+ * suppressed the flush, and the branch then CLOSED the delivery obligation —
14
+ * the user's real answer was dropped with no re-present. This module scopes
15
+ * the predicate to a SUBSTANTIVE (≥ `FINAL_ANSWER_MIN_CHARS`) outbound in the
16
+ * SAME thread, via the injected `hasSubstantiveOutbound` (the gateway wires
17
+ * `hasOutboundDeliveredSince`, whose thread/length semantics are pinned by
18
+ * `history.test.ts`).
19
+ *
20
+ * Residual (documented, accepted): a ≥200-char same-thread non-answer outbound
21
+ * (e.g. an unusually long progress update) inside the 2s window still
22
+ * suppresses — the history schema has no message-kind column to discriminate
23
+ * further. That residual is why the CALLER must NOT close the obligation on
24
+ * suppression: a genuine reply closes its own obligation idempotently, while a
25
+ * false-positive suppression leaves it open for the liveness floor / sweep.
26
+ */
27
+
28
+ import { FINAL_ANSWER_MIN_CHARS } from '../final-answer-detect.js'
29
+
30
+ /** How far back a just-landed reply can be and still suppress the flush. */
31
+ export const FLUSH_SUPPRESSION_WINDOW_MS = 2000
32
+
33
+ export interface FlushSuppressionDeps {
34
+ /** Durable substantive-outbound oracle — the gateway passes
35
+ * `hasOutboundDeliveredSince` (thread-scoped, length-floored). */
36
+ hasSubstantiveOutbound(
37
+ chatId: string,
38
+ sinceMs: number,
39
+ threadId: number | null,
40
+ minChars: number,
41
+ ): boolean
42
+ }
43
+
44
+ export interface FlushSuppressionArgs {
45
+ chatId: string
46
+ /** The turn's origin thread. `null` = chat root / DM (matches the
47
+ * `hasOutboundDeliveredSince` explicit-null semantics — never pass
48
+ * `undefined`, which would match ANY thread and re-open the
49
+ * cross-topic false positive this module exists to close). */
50
+ threadId: number | null
51
+ /** Length of the captured answer the flush is about to deliver. The
52
+ * length floor is `min(FINAL_ANSWER_MIN_CHARS, answerLength)`: a row
53
+ * can only suppress the flush if it is at least as long as the answer
54
+ * itself (up to the 200-char substantive cap). A TERSE captured answer
55
+ * ("Yes — done.") is therefore still suppressible by its own short
56
+ * answer-stream materialization (avoiding a duplicate bubble), while a
57
+ * short progress ping can never suppress a LONG composed answer. */
58
+ answerLength: number
59
+ nowMs: number
60
+ }
61
+
62
+ /**
63
+ * True iff the flush should be suppressed: a substantive same-thread outbound
64
+ * landed within the suppression window. Fails open (no suppression) on oracle
65
+ * errors — delivering a possible duplicate beats dropping the only copy.
66
+ */
67
+ export function shouldSuppressTurnFlush(
68
+ deps: FlushSuppressionDeps,
69
+ args: FlushSuppressionArgs,
70
+ ): boolean {
71
+ const minChars = Math.max(1, Math.min(FINAL_ANSWER_MIN_CHARS, args.answerLength))
72
+ try {
73
+ return deps.hasSubstantiveOutbound(
74
+ args.chatId,
75
+ args.nowMs - FLUSH_SUPPRESSION_WINDOW_MS,
76
+ args.threadId,
77
+ minChars,
78
+ )
79
+ } catch {
80
+ return false
81
+ }
82
+ }
@@ -36,6 +36,25 @@ export interface PendingUserNotice {
36
36
  kind: string
37
37
  /** When the notice was scheduled (ms epoch). */
38
38
  atMs: number
39
+ /**
40
+ * TOPIC KEY (#3294) — `statusKey(chatId, threadId)` of the turn that was live
41
+ * when the error surfaced, or `undefined` when no turn was attributable (the
42
+ * error arrived between turns, or after a silence poke nulled the live turn).
43
+ *
44
+ * Why it exists: the api_error operator event is agent-level (its wire
45
+ * `chatId` is always empty), so the gate USED to collapse per-agent and let
46
+ * ANY turn end resolve the notice. Under the PR-4e keyed-liveness flag
47
+ * (concurrent per-topic turns) that is a bug: turn B's reply-less end would
48
+ * flush turn A's pending notice while A may still recover — a FALSE "couldn't
49
+ * complete" claim. Keying the notice by the live turn's topic means only a
50
+ * turn end on the SAME topic resolves it (see {@link
51
+ * PendingUserNoticeGate.resolveTurnEnd}).
52
+ *
53
+ * A notice with an UNDEFINED key keeps the legacy agent-wide resolution (any
54
+ * turn end resolves it), so single-turn gateways — the current norm, where
55
+ * there is only ever one topic — are byte-identical to the pre-#3294 gate.
56
+ */
57
+ key?: string
39
58
  }
40
59
 
41
60
  /** How long a scheduled notice may wait for a resolving turn end. */
@@ -45,28 +64,55 @@ export class PendingUserNoticeGate {
45
64
  private pending: PendingUserNotice[] = []
46
65
 
47
66
  /**
48
- * Schedule a notice for turn-end resolution. Collapses per agent a burst
49
- * of error lines within one turn holds ONE pending notice, not a stack.
67
+ * Schedule a notice for turn-end resolution. Collapses per (agent, topic key)
68
+ * — a burst of error lines within one turn holds ONE pending notice for that
69
+ * topic, not a stack. Distinct concurrent topics (keyed liveness) each keep
70
+ * their own pending notice, so one topic's burst never overwrites another's.
71
+ * (For a single-turn gateway every notice shares the one topic — or an
72
+ * undefined key — so the collapse is identical to the pre-#3294 per-agent one.)
50
73
  */
51
74
  schedule(notice: PendingUserNotice): void {
52
75
  this.prune(notice.atMs)
53
- this.pending = this.pending.filter((p) => p.agent !== notice.agent)
76
+ this.pending = this.pending.filter(
77
+ (p) => !(p.agent === notice.agent && p.key === notice.key),
78
+ )
54
79
  this.pending.push(notice)
55
80
  }
56
81
 
57
82
  /**
58
- * Resolve at turn end. `turnDeliveredReply` is the turn's outcome signal
59
- * (the gateway passes `finalAnswerDelivered || replyCalled`):
60
- * - true → the turn recovered; every pending notice is dropped, [] returned.
61
- * - false → the turn died without a reply; the un-expired pending notices
62
- * are returned EXACTLY ONCE for the caller to send.
63
- * Either way the ledger is cleared (a notice never survives its turn end).
83
+ * Resolve at the end of the turn whose topic is `turnKey`. `turnDeliveredReply`
84
+ * is the turn's outcome signal (the gateway passes `finalAnswerDelivered ||
85
+ * replyCalled`).
86
+ *
87
+ * A pending notice is RESOLVED by this turn end iff it belongs to the ending
88
+ * turn's topic (`p.key === turnKey`) OR it is unattributed (`p.key ===
89
+ * undefined` — legacy agent-wide resolution, kept so single-turn gateways are
90
+ * unchanged). Notices scheduled for a DIFFERENT live topic stay pending until
91
+ * their own topic's turn ends (or the TTL expires) — this is the #3294 fix:
92
+ * turn B's end no longer flushes turn A's pending notice.
93
+ *
94
+ * Of the resolved notices:
95
+ * - `turnDeliveredReply === true` → the turn recovered; the resolved
96
+ * notices are dropped and `[]` is returned.
97
+ * - `turnDeliveredReply === false` → the turn died reply-less; the resolved,
98
+ * un-expired notices are returned EXACTLY ONCE for the caller to send.
99
+ * Either way the resolved notices leave the ledger (a notice never survives
100
+ * the end of the turn it is keyed to).
64
101
  */
65
- resolveTurnEnd(turnDeliveredReply: boolean, now: number = Date.now()): PendingUserNotice[] {
102
+ resolveTurnEnd(
103
+ turnKey: string | undefined,
104
+ turnDeliveredReply: boolean,
105
+ now: number = Date.now(),
106
+ ): PendingUserNotice[] {
66
107
  this.prune(now)
67
- const out = turnDeliveredReply ? [] : [...this.pending]
68
- this.pending = []
69
- return out
108
+ const resolved: PendingUserNotice[] = []
109
+ const remaining: PendingUserNotice[] = []
110
+ for (const p of this.pending) {
111
+ if (p.key === turnKey || p.key === undefined) resolved.push(p)
112
+ else remaining.push(p)
113
+ }
114
+ this.pending = remaining
115
+ return turnDeliveredReply ? [] : resolved
70
116
  }
71
117
 
72
118
  /** True when at least one un-expired notice is pending (does not mutate). */
@@ -855,13 +855,14 @@ const BACKFILL_RETRY_INTERVAL_MS = 3000
855
855
 
856
856
  /**
857
857
  * Fix #9a: cap on `terminatedAgentIds` (the re-discovery dedup guard in
858
- * `cleanupTerminalAgent` / `scanSubagentsDir`). Pre-fix the Set only grew
858
+ * `cleanupTerminalAgent` / `scanSubagentsDir`). Pre-fix the map only grew
859
859
  * (added on every terminal cleanup, only ever cleared wholesale in
860
860
  * `stop()`), so a long-lived gateway with sustained sub-agent throughput
861
- * accumulated ids without bound. A `Set` preserves insertion order, so once
861
+ * accumulated ids without bound. A `Map` preserves insertion order, so once
862
862
  * the cap is hit the OLDEST id is evicted on each new insert (simple ring
863
863
  * buffer semantics) — recently-terminated ids (the ones actually at risk of
864
- * a re-discovery race) always stay covered.
864
+ * a re-discovery race, or of a SendMessage resume — issue #3315) always stay
865
+ * covered.
865
866
  */
866
867
  const TERMINATED_AGENT_IDS_CAP = 5000
867
868
 
@@ -1861,18 +1862,26 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1861
1862
  const historicalFiles = new Set<string>()
1862
1863
  /**
1863
1864
  * AgentIds that have transitioned to a terminal state and been swept
1864
- * out of `registry` by `cleanupTerminalAgent`. Issue #1116 (Bug B):
1865
- * the JSONL file outlives the registry entry Claude Code leaves
1866
- * the file on disk after the sub-agent finishes. Without this guard,
1867
- * the next `rescanSubagentDirs` poll re-discovered the file, called
1868
- * `registerAgent`, the fresh entry read the terminal `turn_duration`
1869
- * line, and `maybySendStateTransition` fired a duplicate "Worker done"
1870
- * notification — looping forever every grace-window.
1865
+ * out of `registry` by `cleanupTerminalAgent`, mapped to the JSONL byte
1866
+ * SIZE at cleanup time. Issue #1116 (Bug B): the JSONL file outlives the
1867
+ * registry entry — Claude Code leaves the file on disk after the sub-agent
1868
+ * finishes. Without this guard, the next `rescanSubagentDirs` poll
1869
+ * re-discovered the file, called `registerAgent`, the fresh entry read the
1870
+ * terminal `turn_duration` line, and `maybySendStateTransition` fired a
1871
+ * duplicate "Worker done" notification — looping forever every grace-window.
1871
1872
  *
1872
- * `scanSubagentsDir` consults this set and treats re-discovered
1873
- * terminal JSONLs as a no-op.
1873
+ * Issue #3315 (SendMessage resume): a resumed sub-agent reuses the SAME
1874
+ * `agent-<id>.jsonl`, appending a NEW turn to it — so the file's size grows
1875
+ * PAST the size it had at terminal cleanup. `scanSubagentsDir` compares the
1876
+ * live file size against this recorded terminal size: at/below it the JSONL
1877
+ * is a static leftover and stays suppressed (#1116 Bug B); above it the
1878
+ * worker resumed and is re-registered live (#3315) so its progress card
1879
+ * updates again. Storing the size (not a bare membership flag) is what makes
1880
+ * that distinction deterministic and lifecycle-driven rather than a
1881
+ * heuristic. Map iteration order is insertion order, so the eviction ring
1882
+ * buffer (Fix #9a) still evicts the oldest id.
1874
1883
  */
1875
- const terminatedAgentIds = new Set<string>()
1884
+ const terminatedAgentIds = new Map<string, number>()
1876
1885
  /**
1877
1886
  * Issue #3023 (card resurrection). Per-worker record of a FALSE terminal
1878
1887
  * finish — a terminal state produced by silent-stall synthesis (NOT a real
@@ -1916,7 +1925,17 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1916
1925
 
1917
1926
  // ─── Per-agent registration ─────────────────────────────────────────────
1918
1927
 
1919
- function registerAgent(filePath: string, agentId: string): void {
1928
+ /**
1929
+ * @param resumeFromCursor Issue #3315: when re-registering a worker whose
1930
+ * JSONL resumed growing after a terminal cleanup (a SendMessage resume),
1931
+ * start the tail cursor at the terminal byte boundary instead of 0. The
1932
+ * already-handed-back completed turn (its `sub_agent_turn_end` and all
1933
+ * prior events) is therefore never re-read — so the fresh entry stays
1934
+ * `running` on the NEW turn's content rather than instantly re-reading the
1935
+ * old terminal line and re-finalising. Omitted (⇒ 0) for every normal
1936
+ * registration, which reads from the start.
1937
+ */
1938
+ function registerAgent(filePath: string, agentId: string, resumeFromCursor?: number): void {
1920
1939
  if (registry.has(agentId)) return
1921
1940
  const n = nowFn()
1922
1941
  const isHistorical = historicalFiles.has(filePath)
@@ -1976,7 +1995,12 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1976
1995
  }
1977
1996
 
1978
1997
  const tail: SubTail = {
1979
- cursor: 0, // read from start to capture description
1998
+ // Normally read from the start (to replay the full transcript); on a
1999
+ // resume re-registration (#3315) start at the terminal boundary so only
2000
+ // the new turn is read. A resumed cursor sits on a JSONL newline boundary
2001
+ // (the prior turn ended with a complete `turn_end` line), so no partial
2002
+ // line is straddled.
2003
+ cursor: resumeFromCursor ?? 0,
1980
2004
  pendingPartial: '',
1981
2005
  hasEmittedStart: false,
1982
2006
  watcher: null,
@@ -2274,24 +2298,42 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
2274
2298
  try { tail.watcher.close() } catch { /* ignore */ }
2275
2299
  tail.watcher = null
2276
2300
  }
2277
- tails.delete(agentId)
2278
2301
  const entry = registry.get(agentId)
2302
+ // Issue #3315: snapshot the JSONL byte size at cleanup so a later
2303
+ // SendMessage-resume — which appends a NEW turn to the SAME
2304
+ // agent-<id>.jsonl — is detected by `scanSubagentsDir` as growth PAST this
2305
+ // size and re-registered. The on-disk stat is the source of truth (it
2306
+ // captures any bytes that landed during the terminal-cleanup grace window,
2307
+ // so only genuinely post-cleanup writes trip a resume); fall back to the
2308
+ // tail cursor, then 0, when the file is unreadable.
2309
+ let terminalSize = tail?.cursor ?? 0
2310
+ if (entry?.filePath) {
2311
+ try {
2312
+ terminalSize = fs.statSync(entry.filePath).size
2313
+ } catch { /* keep the cursor-based fallback */ }
2314
+ }
2315
+ tails.delete(agentId)
2279
2316
  if (entry?.filePath) {
2280
2317
  knownFiles.delete(entry.filePath)
2281
2318
  }
2282
2319
  registry.delete(agentId)
2283
2320
  // Issue #1116 (Bug B): record that this agent has been fully
2284
- // processed so a rescan that rediscovers the still-present JSONL
2285
- // doesn't re-register and re-notify.
2321
+ // processed so a rescan that rediscovers the still-present (unchanged)
2322
+ // JSONL doesn't re-register and re-notify. The recorded value is the
2323
+ // terminal byte size (issue #3315): `scanSubagentsDir` keeps suppressing
2324
+ // while the file stays at/below it, and re-registers once it grows past it
2325
+ // (a resume).
2286
2326
  //
2287
- // Fix #9a: bound the Set so it can't grow unboundedly over a long-lived
2288
- // gateway — evict the oldest id once the cap is hit (Set iteration
2289
- // order is insertion order, so `.values().next().value` is the oldest).
2327
+ // Fix #9a: bound the Map so it can't grow unboundedly over a long-lived
2328
+ // gateway — evict the oldest id once the cap is hit (Map iteration
2329
+ // order is insertion order, so the first key is the oldest). A re-set of
2330
+ // an existing key updates its value without moving it, so a re-completed
2331
+ // (resumed-then-finished-again) agent just refreshes its terminal size.
2290
2332
  if (!terminatedAgentIds.has(agentId) && terminatedAgentIds.size >= terminatedAgentIdsCap) {
2291
- const oldest = terminatedAgentIds.values().next().value
2333
+ const oldest = terminatedAgentIds.keys().next().value
2292
2334
  if (oldest != null) terminatedAgentIds.delete(oldest)
2293
2335
  }
2294
- terminatedAgentIds.add(agentId)
2336
+ terminatedAgentIds.set(agentId, terminalSize)
2295
2337
  log?.(`subagent-watcher: cleaned up terminal agent ${agentId}`)
2296
2338
  // Authoritative terminal sweep → notify the worker-activity feed so its row
2297
2339
  // for this agent is removed even on the paths that never fire `onFinish`
@@ -2865,11 +2907,52 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
2865
2907
  const filePath = join(subagentsPath, e)
2866
2908
  if (knownFiles.has(filePath)) continue
2867
2909
  const agentId = e.slice('agent-'.length, -'.jsonl'.length)
2868
- // Issue #1116 (Bug B): skip JSONLs whose agent already completed
2869
- // and was swept by cleanupTerminalAgent. Re-adding to knownFiles
2870
- // here would let a subsequent rescan re-register, fire a duplicate
2871
- // "Worker done", and loop forever every grace-window.
2872
- if (terminatedAgentIds.has(agentId)) continue
2910
+ // Issue #1116 (Bug B) + issue #3315 (SendMessage resume). A rediscovered
2911
+ // JSONL whose agent already went terminal + was swept is normally a
2912
+ // static leftover Claude Code left on disk — re-registering it would
2913
+ // re-read the terminal turn and re-fire a duplicate handback, looping
2914
+ // every grace-window (#1116). BUT a SendMessage resume appends a NEW turn
2915
+ // to the SAME agent-<id>.jsonl, growing the file PAST the size it had at
2916
+ // terminal cleanup. That growth is the deterministic, lifecycle-driven
2917
+ // signal (not a heuristic) that the worker resumed and must be tracked
2918
+ // again so its progress card resumes updating. Distinguish the two by the
2919
+ // recorded terminal size.
2920
+ const terminalSize = terminatedAgentIds.get(agentId)
2921
+ if (terminalSize !== undefined) {
2922
+ // A worker whose terminal state was a FALSE finish (silent-stall
2923
+ // synthesis) is owned by the issue-#3023 resurrection path
2924
+ // (`checkResurrections`), which carries the bounded-chain +
2925
+ // `onResurrect`/`onWorkerLost` semantics and re-reads from cursor 0 to
2926
+ // rebuild the still-in-progress turn. Skipping it here avoids
2927
+ // double-handling the same growth in one poll; this branch's concern is
2928
+ // GENUINE terminal completions (real `turn_end`, no false-finish record)
2929
+ // that were resumed via SendMessage (issue #3315). A false finish that
2930
+ // has since been evicted from `falseFinishTracker` (512-cap) is no
2931
+ // longer double-handled, so falling through to re-register it live here
2932
+ // is correct.
2933
+ if (falseFinishTracker.has(agentId)) continue
2934
+ let currentSize: number
2935
+ try {
2936
+ currentSize = fs.statSync(filePath).size
2937
+ } catch {
2938
+ continue // unreadable — nothing to resume; keep suppressing
2939
+ }
2940
+ // At/below the terminal snapshot ⇒ static leftover file (#1116 Bug B):
2941
+ // stay suppressed, do NOT re-register.
2942
+ if (currentSize <= terminalSize) continue
2943
+ // Grew past it ⇒ the worker resumed (#3315). Re-register as a fresh
2944
+ // LIVE worker, reading only from the terminal boundary so the
2945
+ // already-completed turn is not replayed. At-most-once per resume:
2946
+ // dropping the id from terminatedAgentIds means the next completion
2947
+ // re-adds it with the new (larger) terminal size, so one resume yields
2948
+ // exactly one re-registration.
2949
+ log?.(`subagent-watcher: agent ${agentId} resumed after terminal cleanup — JSONL grew ${terminalSize} → ${currentSize} bytes (SendMessage resume); re-registering as live so its progress card resumes (issue #3315)`)
2950
+ terminatedAgentIds.delete(agentId)
2951
+ historicalFiles.delete(filePath)
2952
+ knownFiles.add(filePath)
2953
+ registerAgent(filePath, agentId, terminalSize)
2954
+ continue
2955
+ }
2873
2956
  knownFiles.add(filePath)
2874
2957
  // During the initial boot scan, mark every discovered file as
2875
2958
  // historical so stall-detection and completion notifications are
@@ -5,6 +5,8 @@ import {
5
5
  backstopReceiptIds,
6
6
  backstopDelivered,
7
7
  runBackstopDelivery,
8
+ combineReadBackResults,
9
+ type ReadBackResult,
8
10
  } from '../gateway/backstop-delivery.js'
9
11
  import {
10
12
  backstopSendOutcomeGated,
@@ -248,3 +250,168 @@ describe('runBackstopDelivery — integration oracle over the delivery wiring',
248
250
  expect(recorded[0].ids).toHaveLength(recorded[0].texts.length)
249
251
  })
250
252
  })
253
+
254
+ /**
255
+ * #3278 — read-back confirmation of an accepted-but-dropped send.
256
+ *
257
+ * A returned message_id proves Telegram ACCEPTED the send, not that the message
258
+ * is VISIBLE (a flood/anti-spam silent discard returns a fresh id yet the user
259
+ * sees nothing). These assert the deterministic OUTCOMES of the per-chunk state
260
+ * machine `unsent → pending → landed-unconfirmed → {landed-confirmed | unsent}`
261
+ * driven by a no-op editMessageText read-back — NOT code-path execution.
262
+ */
263
+ describe('#3278 read-back — the ledger confirmation state machine', () => {
264
+ it('confirmChunk transitions landed-unconfirmed → landed-confirmed', () => {
265
+ const l = new BackstopDeliveryLedger()
266
+ l.recordChunk('#t', 0, [900]) // landed-unconfirmed
267
+ expect(l.hasChunk('#t', 0)).toBe(true)
268
+ expect(l.hasConfirmedChunk('#t', 0)).toBe(false)
269
+ expect(l.landedUnconfirmedIndices('#t', 1)).toEqual([0])
270
+ l.confirmChunk('#t', 0)
271
+ expect(l.hasConfirmedChunk('#t', 0)).toBe(true)
272
+ expect(l.landedUnconfirmedIndices('#t', 1)).toEqual([])
273
+ expect(l.allConfirmed('#t', 1)).toBe(true)
274
+ expect(l.confirmedIds('#t')).toEqual([900])
275
+ })
276
+
277
+ it('demoteChunk resets a landed(-unconfirmed) chunk back to unsent (safe re-send)', () => {
278
+ const l = new BackstopDeliveryLedger()
279
+ l.recordChunk('#t', 0, [901])
280
+ l.confirmChunk('#t', 0)
281
+ l.demoteChunk('#t', 0) // positive-absence read-back
282
+ expect(l.hasChunk('#t', 0)).toBe(false)
283
+ expect(l.hasConfirmedChunk('#t', 0)).toBe(false)
284
+ expect(l.unsentIndices('#t', 1)).toEqual([0]) // resume set includes it again
285
+ expect(l.confirmedIds('#t')).toEqual([])
286
+ })
287
+
288
+ it('allConfirmed is false while any chunk is only landed-unconfirmed', () => {
289
+ const l = new BackstopDeliveryLedger()
290
+ l.recordChunk('#t', 0, [1]); l.confirmChunk('#t', 0)
291
+ l.recordChunk('#t', 1, [2]) // landed-unconfirmed
292
+ expect(l.allConfirmed('#t', 2)).toBe(false)
293
+ expect(l.landedUnconfirmedIndices('#t', 2)).toEqual([1])
294
+ })
295
+
296
+ it('confirmedIds returns ONLY confirmed chunk ids, in index order', () => {
297
+ const l = new BackstopDeliveryLedger()
298
+ l.recordChunk('#t', 0, [10]); l.confirmChunk('#t', 0)
299
+ l.recordChunk('#t', 1, [11]) // unconfirmed
300
+ l.recordChunk('#t', 2, [12, 13]); l.confirmChunk('#t', 2)
301
+ expect(l.confirmedIds('#t')).toEqual([10, 12, 13])
302
+ })
303
+ })
304
+
305
+ describe('#3278 combineReadBackResults — per-chunk id combine (guard A7)', () => {
306
+ it('ANY absent id ⇒ absent (re-send the whole chunk)', () => {
307
+ expect(combineReadBackResults(['exists', 'absent'])).toBe('absent')
308
+ expect(combineReadBackResults(['absent', 'ambiguous'])).toBe('absent')
309
+ })
310
+ it('ALL exist ⇒ exists', () => {
311
+ expect(combineReadBackResults(['exists', 'exists'])).toBe('exists')
312
+ })
313
+ it('some ambiguous, none absent ⇒ ambiguous (never re-send)', () => {
314
+ expect(combineReadBackResults(['exists', 'ambiguous'])).toBe('ambiguous')
315
+ })
316
+ it('empty id set ⇒ ambiguous (fabricate neither confirm nor demote)', () => {
317
+ expect(combineReadBackResults([])).toBe('ambiguous')
318
+ })
319
+ })
320
+
321
+ describe('#3278 runBackstopDelivery — read-back drives the delivery outcome', () => {
322
+ it('exists probe ⇒ landed-confirmed, delivered=true, ONE probe, no re-send', async () => {
323
+ const ledger = new BackstopDeliveryLedger()
324
+ const sendChunk = vi.fn(async () => [960])
325
+ const readBack = vi.fn(async (): Promise<ReadBackResult> => 'exists')
326
+ const res = await runBackstopDelivery(ledger, '#ok', ['the answer'], null, { sendChunk, readBack }, 3)
327
+ expect(res.delivered).toBe(true)
328
+ expect(res.exhausted).toBe(false)
329
+ expect(sendChunk).toHaveBeenCalledTimes(1) // never re-sent
330
+ expect(readBack).toHaveBeenCalledTimes(1) // exactly one probe per chunk
331
+ expect(ledger.hasConfirmedChunk('#ok', 0)).toBe(true)
332
+ })
333
+
334
+ it('400/absent probe ⇒ demote → RE-SENT EXACTLY ONCE, then confirmed', async () => {
335
+ const ledger = new BackstopDeliveryLedger()
336
+ let sends = 0
337
+ const sendChunk = vi.fn(async () => { sends++; return [900 + sends] })
338
+ let probes = 0
339
+ // First probe: the send was silently dropped (absent). After the re-send the
340
+ // message is present (exists).
341
+ const readBack = vi.fn(async (): Promise<ReadBackResult> => {
342
+ probes++
343
+ return probes === 1 ? 'absent' : 'exists'
344
+ })
345
+ const res = await runBackstopDelivery(ledger, '#drop', ['answer'], null, { sendChunk, readBack }, 3)
346
+ expect(sendChunk).toHaveBeenCalledTimes(2) // initial + exactly one re-send
347
+ expect(res.delivered).toBe(true)
348
+ expect(ledger.hasConfirmedChunk('#drop', 0)).toBe(true)
349
+ })
350
+
351
+ it('429/ambiguous probe ⇒ NOT re-sent, stays landed-unconfirmed, delivered=false', async () => {
352
+ const ledger = new BackstopDeliveryLedger()
353
+ const sendChunk = vi.fn(async () => [950])
354
+ const readBack = vi.fn(async (): Promise<ReadBackResult> => 'ambiguous')
355
+ const res = await runBackstopDelivery(ledger, '#amb', ['answer'], null, { sendChunk, readBack }, 3)
356
+ expect(sendChunk).toHaveBeenCalledTimes(1) // never re-sent on ambiguous
357
+ expect(res.delivered).toBe(false)
358
+ expect(res.exhausted).toBe(true)
359
+ expect(ledger.hasConfirmedChunk('#amb', 0)).toBe(false)
360
+ expect(ledger.landedUnconfirmedIndices('#amb', 1)).toEqual([0]) // still landed-unconfirmed
361
+ })
362
+
363
+ it('a read-back adapter THAT THROWS is treated as ambiguous — never re-sent', async () => {
364
+ const ledger = new BackstopDeliveryLedger()
365
+ const sendChunk = vi.fn(async () => [951])
366
+ const readBack = vi.fn(async () => { throw new Error('boom') })
367
+ const res = await runBackstopDelivery(ledger, '#throw', ['answer'], null, { sendChunk, readBack }, 3)
368
+ expect(sendChunk).toHaveBeenCalledTimes(1)
369
+ expect(res.delivered).toBe(false)
370
+ })
371
+
372
+ it('#3278 CORE: API-ack fresh id but read-back ABSENT ⇒ send_failed, NOT complete', async () => {
373
+ const ledger = new BackstopDeliveryLedger()
374
+ const cardId = 500
375
+ // The Bot API returns a fresh non-card id (an accept) every time...
376
+ const sendChunk = vi.fn(async () => [970])
377
+ // ...but the read-back proves the message is not actually in the chat
378
+ // (flood/anti-spam silent discard). It never confirms.
379
+ const readBack = vi.fn(async (): Promise<ReadBackResult> => 'absent')
380
+ const res = await runBackstopDelivery(ledger, '#ghost', ['answer'], cardId, { sendChunk, readBack }, 3)
381
+ expect(res.delivered).toBe(false) // pre-#3278 this was true → false `complete`
382
+ expect(res.exhausted).toBe(true)
383
+ // The exact input the gateway feeds the turn record: delivered=false ⇒ the
384
+ // status is send_failed and the obligation is left OPEN — the answer is NOT
385
+ // silently marked complete.
386
+ const turn: { finalAnswerDelivered: boolean; deliveryOutcome?: 'delivered' | 'failed' | 'suppressed' } = {
387
+ finalAnswerDelivered: true,
388
+ }
389
+ finalizeBackstopSendGated(turn, {
390
+ threw: !res.delivered, sentIds: res.sentIds, chunkCount: res.chunkCount, cardMessageId: cardId,
391
+ })
392
+ expect(computeTurnStatus(turn)).toBe('send_failed')
393
+ })
394
+
395
+ it('scope guard: NO readBack dep ⇒ ZERO probes, legacy confirm-on-landing preserved', async () => {
396
+ const ledger = new BackstopDeliveryLedger()
397
+ const readBack = vi.fn()
398
+ const sendChunk = vi.fn(async () => [980])
399
+ // deliberately omit `readBack` — the hot path / opt-out shape.
400
+ const res = await runBackstopDelivery(ledger, '#noprobe', ['answer'], null, { sendChunk })
401
+ expect(readBack).not.toHaveBeenCalled()
402
+ expect(res.delivered).toBe(true) // confirmed on landing (receipt-gated)
403
+ expect(ledger.hasConfirmedChunk('#noprobe', 0)).toBe(true)
404
+ })
405
+
406
+ it('partial: chunk 0 confirmed, chunk 1 ambiguous ⇒ delivered=false, chunk 0 not re-sent', async () => {
407
+ const ledger = new BackstopDeliveryLedger()
408
+ const calls: number[] = []
409
+ const sendChunk = vi.fn(async (i: number) => { calls.push(i); return [700 + i] })
410
+ const readBack = vi.fn(async (i: number): Promise<ReadBackResult> => (i === 0 ? 'exists' : 'ambiguous'))
411
+ const res = await runBackstopDelivery(ledger, '#part', ['c0', 'c1'], null, { sendChunk, readBack }, 3)
412
+ expect(res.delivered).toBe(false)
413
+ expect(calls.filter(i => i === 0)).toHaveLength(1) // confirmed chunk never re-sent
414
+ expect(ledger.hasConfirmedChunk('#part', 0)).toBe(true)
415
+ expect(ledger.hasConfirmedChunk('#part', 1)).toBe(false)
416
+ })
417
+ })