switchroom 0.20.2 → 0.20.4

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 (27) hide show
  1. package/bin/handoff-briefing.sh +41 -1
  2. package/dist/cli/switchroom.js +10259 -1232
  3. package/dist/host-control/main.js +1 -1
  4. package/package.json +3 -3
  5. package/profiles/default/CLAUDE.md.hbs +13 -11
  6. package/telegram-plugin/dist/gateway/gateway.js +429 -98
  7. package/telegram-plugin/edit-flood-fuse.ts +332 -14
  8. package/telegram-plugin/gateway/feed-open-gate.ts +28 -8
  9. package/telegram-plugin/gateway/feed-reopen-gate.ts +88 -6
  10. package/telegram-plugin/gateway/gateway.ts +128 -104
  11. package/telegram-plugin/gateway/narrative-lane.ts +4 -0
  12. package/telegram-plugin/gateway/progress-fallback-cap.ts +195 -0
  13. package/telegram-plugin/gateway/stream-render.ts +67 -12
  14. package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +13 -0
  15. package/telegram-plugin/gateway/subagent-origin-surface.ts +181 -0
  16. package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +7 -0
  17. package/telegram-plugin/registry/subagents-schema.ts +61 -0
  18. package/telegram-plugin/registry/turns-schema.ts +26 -0
  19. package/telegram-plugin/tests/edit-flood-fuse-cosmetic-fairness.test.ts +229 -0
  20. package/telegram-plugin/tests/feed-open-gate.test.ts +42 -0
  21. package/telegram-plugin/tests/feed-reopen-gate.test.ts +114 -0
  22. package/telegram-plugin/tests/progress-cap.test.ts +182 -0
  23. package/telegram-plugin/tests/progress-fallback-cap.test.ts +91 -0
  24. package/telegram-plugin/tests/progress-update.test.ts +108 -12
  25. package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +46 -0
  26. package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +26 -0
  27. package/telegram-plugin/tests/worker-origin-gap-dispatch.test.ts +304 -0
@@ -754,6 +754,7 @@ import { shadowEmit, isMachineInTurn } from './inbound-delivery-machine-shadow.j
754
754
  // #2996 P8 PR-B — the extracted turn-end funnel (state stays here; logic moved).
755
755
  import { createTurnEndFunnel, type TurnEndReason } from './turn-end.js'
756
756
  import { createTurnStartSurfaces } from './turn-start-surfaces.js'
757
+ import { sendWithProgressCap } from './progress-fallback-cap.js'
757
758
  // #2996 P8 PR-C1 — the extracted delivery-confirm sweep wiring.
758
759
  import { createDeliveryConfirmWiring } from './delivery-confirm-wiring.js'
759
760
  // #2996 P8 PR-C2 — the extracted obligation wiring.
@@ -972,7 +973,8 @@ import {
972
973
  DEFAULT_BRIDGE_DEAD_GRACE_MS,
973
974
  } from './bridge-dead-watchdog.js'
974
975
  import { findAgentProcessInContainer } from './boot-probes.js'
975
- import { applySubagentsSchema, getSubagentByJsonlId, resolveSubagentOriginTurnKey, listNonTerminalSubagentsForTurn } from '../registry/subagents-schema.js'
976
+ import { applySubagentsSchema, getSubagentByJsonlId, listNonTerminalSubagentsForTurn } from '../registry/subagents-schema.js'
977
+ import { resolveSubagentOriginChatDb, resolveRecentTurnFallbackChat, resolveWorkerSurfaceChat, resolveWorkerSurfaceForDecider, noteWorkerRecentTurnFloor } from './subagent-origin-surface.js'
976
978
  import type { InterruptedSubagent } from './resume-inbound-builder.js'
977
979
  import { resolveWorkerFeedDispatch, decideWorkerFeedDestination, handleWorkerResume, type WorkerFeedDispatch } from './worker-feed-dispatch.js'
978
980
  import {
@@ -1960,28 +1962,23 @@ function resolveSubagentOriginChat(
1960
1962
  agentId: string,
1961
1963
  ): { chatId: string; threadId?: number } | null {
1962
1964
  if (turnsDb == null) return null
1963
- try {
1964
- // Transitive walk: a NESTED (depth-2+) worker's own parent_turn_key is
1965
- // NULL by construction (its dispatching context is another sub-agent,
1966
- // not a gateway turn) resolveSubagentOriginTurnKey follows the
1967
- // parent_agent_id chain to the ancestor row that WAS stamped at
1968
- // main-turn dispatch time, so nested cards + handbacks route to the
1969
- // originating chat/topic instead of falling back to the owner DM.
1970
- const originKey = resolveSubagentOriginTurnKey(turnsDb, agentId)
1971
- if (originKey == null) return null
1972
- const turn = getTurnByKey(turnsDb, originKey)
1973
- if (turn == null || turn.chat_id.length === 0) return null
1974
- const threadNum =
1975
- turn.thread_id != null && turn.thread_id.length > 0
1976
- ? Number(turn.thread_id)
1977
- : NaN
1978
- return {
1979
- chatId: turn.chat_id,
1980
- threadId: Number.isFinite(threadNum) ? threadNum : undefined,
1981
- }
1982
- } catch {
1983
- return null
1984
- }
1965
+ // Transitive walk (jsonl stem → parent_turn_key chain → turns row) —
1966
+ // extracted to subagent-origin-surface.ts so the resolution precedence is
1967
+ // unit-testable against a real registry DB. Null on any miss; the callers
1968
+ // keep their fallback ladders (now with the recent-turn floor).
1969
+ return resolveSubagentOriginChatDb(turnsDb, agentId)
1970
+ }
1971
+
1972
+ /**
1973
+ * Recent-turn routing floor (msg-6897 misroute fix): the newest turn AT OR
1974
+ * BEFORE the worker's dispatch time — where the operator dispatched it from.
1975
+ * Used ONLY after origin resolution has missed; bounded by the dispatch time
1976
+ * so a later turn in an unrelated (possibly shared) chat can never capture
1977
+ * the worker's surface. Null-safe (no pre-dispatch turn owner-DM fallback).
1978
+ */
1979
+ function recentTurnFallbackChat(agentId: string): { chatId: string; threadId?: number } | null {
1980
+ if (turnsDb == null) return null
1981
+ return resolveRecentTurnFallbackChat(turnsDb, agentId)
1985
1982
  }
1986
1983
 
1987
1984
  /**
@@ -2062,41 +2059,26 @@ const workerFeedOriginDeferrals = new Map<string, number>()
2062
2059
  const WORKER_FEED_ORIGIN_DEFER_MAX = 10
2063
2060
 
2064
2061
  /**
2065
- * Resolve a worker-feed destination chat with a guaranteed last resort.
2066
- *
2067
- * The universal-liveness contract is "any active work has a card, at any
2068
- * nesting depth, with or without a parent turn open." Origin resolution
2069
- * (`resolveSubagentOriginChat`) only succeeds when the ancestor turn row is
2070
- * in `turnsDb` and history is enabled; a depth-2+ worker whose chain can't
2071
- * be walked (history disabled, row reaped, ancestor never stamped) would
2072
- * otherwise fall through `fleetChatId || allowFrom[0]` and, if those are
2073
- * empty too, hit the `chatId.length === 0` skip in `workerActivityFeed.update`
2074
- * — painting NOTHING, silently. That is the one path most likely to violate
2075
- * "any and all active work has a card."
2076
- *
2077
- * This never returns `''`: the precedence is origin chat → fleet chat →
2078
- * first allowed chat (the owner DM). The owner DM is the durable floor —
2079
- * "wrong chat" beats "no card." A one-line routing-decision log flags the
2080
- * fallback so an operator can see the misroute without it reading as an
2081
- * error (it isn't one — the card surfaced).
2062
+ * Resolve a worker-feed destination chat with a guaranteed last resort
2063
+ * (universal-liveness: "any active work has a card" — the owner DM is the
2064
+ * durable floor, "wrong chat" beats "no card"). The ladder itself
2065
+ * (origin fleet recent-turn owner DM) lives in
2066
+ * `resolveWorkerSurfaceChat` (subagent-origin-surface.ts) the ONE tested
2067
+ * implementation, shared with the handback/progress deciders below so the
2068
+ * precedence cannot diverge per call site again; this wrapper adds only the
2069
+ * gateway's audit logs.
2082
2070
  */
2083
2071
  function resolveWorkerFeedChat(
2084
2072
  agentId: string,
2085
2073
  fleetChatId: string,
2086
- // Origin sources threadId; when origin is null the caller can pass the
2087
- // fallback chat's sibling forum-topic id so a misrouted card still lands
2088
- // in the origin topic instead of General (#3458 exhausted-defer paint).
2089
- fallbackThreadId?: number,
2090
2074
  ): { chatId: string; threadId?: number } {
2091
- const origin = resolveSubagentOriginChat(agentId)
2092
- if (origin != null && origin.chatId.length > 0) return origin
2093
- if (fleetChatId.length > 0) return { chatId: fleetChatId, threadId: fallbackThreadId }
2094
- const ownerDm = loadAccess().allowFrom[0] ?? ''
2095
- if (origin == null && fleetChatId.length === 0 && ownerDm.length > 0) {
2096
- // Origin unresolved and no fleet chat — the card lands in the owner DM.
2097
- noteWorkerFeedOwnerDmFallback(agentId)
2098
- }
2099
- return { chatId: ownerDm, threadId: origin?.threadId ?? fallbackThreadId }
2075
+ const dest = resolveWorkerSurfaceChat(turnsDb, agentId, {
2076
+ fleetChatId,
2077
+ ownerDm: loadAccess().allowFrom[0] ?? '',
2078
+ })
2079
+ if (dest.via === 'recent-turn') noteWorkerRecentTurnFloor(agentId, dest)
2080
+ if (dest.via === 'owner-dm') noteWorkerFeedOwnerDmFallback(agentId)
2081
+ return { chatId: dest.chatId, ...(dest.threadId != null ? { threadId: dest.threadId } : {}) }
2100
2082
  }
2101
2083
 
2102
2084
  // ─── Periodic history reaper (#1073) ──────────────────────────────────────
@@ -2798,6 +2780,13 @@ const MIDFLIGHT_BUSY_ACK_ENABLED =
2798
2780
  // the finalAnswerDelivered-consumer interactions.
2799
2781
  const FEED_REOPEN_AFTER_ACK_ENABLED =
2800
2782
  process.env.SWITCHROOM_FEED_REOPEN_AFTER_ACK !== '0'
2783
+ // Feed-reopen-after-SUBSTANTIVE. Distinct from the ack kill switch above: a
2784
+ // turn that delivered a real answer EARLY and then kept doing tool work had
2785
+ // its feed go dark for the rest of the turn. Lets the feed RE-OPEN after >=2
2786
+ // post-answer labels WITHOUT clearing finalAnswerDelivered. Off (=0) → legacy.
2787
+ // See `feed-reopen-gate.ts` for the full rationale.
2788
+ const FEED_REOPEN_AFTER_SUBSTANTIVE_ENABLED =
2789
+ process.env.SWITCHROOM_FEED_REOPEN_AFTER_SUBSTANTIVE !== '0'
2801
2790
 
2802
2791
  // Activity-feed heartbeat (PR1). The feed is pull-only — it only re-renders on
2803
2792
  // a tool_label event, so a long single step that emits no new label leaves the
@@ -3291,6 +3280,18 @@ export type CurrentTurn = {
3291
3280
  // Reset to false on every fresh-turn enqueue alongside
3292
3281
  // `finalAnswerDelivered`.
3293
3282
  finalAnswerSubstantive: boolean
3283
+ // Post-SUBSTANTIVE feed-reopen counter — incremented on each tool label that
3284
+ // arrives while `finalAnswerDelivered && finalAnswerSubstantive` (a real
3285
+ // answer landed early, model still working). At `SUBSTANTIVE_REOPEN_MIN_LABELS`
3286
+ // (>=2) the tool_label handler reopens the feed (see `feed-reopen-gate.ts`).
3287
+ // Reset to 0 on every fresh-turn enqueue.
3288
+ postSubstantiveToolLabelCount: number
3289
+ // Post-SUBSTANTIVE feed-reopen lever-1 lift latch. Set true by the tool_label
3290
+ // handler when the long-turn reopen fires (>=2 post-answer labels). The drain
3291
+ // reads it as `mayOpenActivityCard`'s `postAnswerMainActivity` so the fresh
3292
+ // card may OPEN below the already-delivered reply despite the sticky lever-1
3293
+ // latch. Sticky for the rest of the turn; reset to false on fresh-turn enqueue.
3294
+ postAnswerMainActivity: boolean
3294
3295
  // Sticky "a substantive final answer has been delivered this turn" latch
3295
3296
  // (design `docs/message-emission-determinism.md` §9 preamble / R0). Distinct
3296
3297
  // from the MUTABLE `finalAnswerDelivered`, which the ack-reopen path clears
@@ -12491,22 +12492,15 @@ async function executeProgressUpdate(args: Record<string, unknown>): Promise<unk
12491
12492
  }
12492
12493
  }
12493
12494
 
12494
- // Turn cap: max 5 calls per turn
12495
+ // Attention cap: max 5 DELIVERIES per turn when a turn atom exists, else a
12496
+ // rolling 15-min/chat fallback when the inbound minted none (handback /
12497
+ // progress-inbound turns) so a worker can't send at the 20s floor forever
12498
+ // and defeat the documented per-turn cap. Both count deliveries only. The
12499
+ // slot is RESERVED before the send (via sendWithProgressCap below) so two
12500
+ // truly-concurrent same-key calls can't both pass the check and overshoot
12501
+ // the cap, and it is RELEASED if the send throws — so a thrown send never
12502
+ // burns a slot even under concurrency (#4328 Fix 2 preserved).
12495
12503
  const turnStart = activeTurnStartedAt.get(key)
12496
- if (turnStart != null) {
12497
- const currentCount = progressUpdateTurnCount.get(key) ?? 0
12498
- if (currentCount >= 5) {
12499
- return {
12500
- content: [
12501
- {
12502
- type: 'text',
12503
- text: JSON.stringify({ ok: false, reason: 'turn_limit' }),
12504
- },
12505
- ],
12506
- }
12507
- }
12508
- progressUpdateTurnCount.set(key, currentCount + 1)
12509
- }
12510
12504
 
12511
12505
  // Issue #305 Option A — the card-injection path (route a sub-agent's
12512
12506
  // `progress_update` narrative into its row on the parent's pinned card)
@@ -12527,14 +12521,33 @@ async function executeProgressUpdate(args: Record<string, unknown>): Promise<unk
12527
12521
  ...(threadId != null ? { message_thread_id: threadId } : {}),
12528
12522
  }
12529
12523
 
12530
- const sent = await robustApiCall(
12524
+ // Reserve the attention-cap slot, send, and release the slot on a throw. A
12525
+ // successful send KEEPS the reservation (it IS the delivery record), so no
12526
+ // extra counting runs after the send. `capped` short-circuits when the
12527
+ // reservation is refused.
12528
+ const capped = await sendWithProgressCap(
12529
+ { key, now, turnStart, turnCount: progressUpdateTurnCount },
12531
12530
  (): Promise<{ message_id: number }> =>
12532
- literalText
12533
- // allow-raw-bot-api: literal progress-update send routed through robustApiCall
12534
- ? lockedBot.api.sendMessage(chat_id, text, sendOpts as never)
12535
- : lockedBot.api.sendRichMessage(chat_id, richMessage(text), sendOpts as never),
12536
- { verb: 'sendMessage', chat_id, threadId },
12531
+ robustApiCall(
12532
+ (): Promise<{ message_id: number }> =>
12533
+ literalText
12534
+ // allow-raw-bot-api: literal progress-update send routed through robustApiCall
12535
+ ? lockedBot.api.sendMessage(chat_id, text, sendOpts as never)
12536
+ : lockedBot.api.sendRichMessage(chat_id, richMessage(text), sendOpts as never),
12537
+ { verb: 'sendMessage', chat_id, threadId },
12538
+ ),
12537
12539
  )
12540
+ if (capped.capped) {
12541
+ return {
12542
+ content: [
12543
+ {
12544
+ type: 'text',
12545
+ text: JSON.stringify({ ok: false, reason: 'turn_limit' }),
12546
+ },
12547
+ ],
12548
+ }
12549
+ }
12550
+ const sent = capped.result
12538
12551
 
12539
12552
  // Record in sent-message history. RecordOutboundArgs uses `texts`
12540
12553
  // (parallel array to message_ids), not `text` — the singular-name
@@ -12551,6 +12564,10 @@ async function executeProgressUpdate(args: Record<string, unknown>): Promise<unk
12551
12564
 
12552
12565
  progressUpdateLastSent.set(key, now)
12553
12566
 
12567
+ // The delivery slot was already reserved before the send (sendWithProgressCap
12568
+ // above), so there is nothing to count here — a successful send keeps its
12569
+ // reservation and a thrown send released it.
12570
+
12554
12571
  // Issue #203: progress_update is a user-visible signal — tick the
12555
12572
  // silent-gap tracker so it doesn't count as silent time.
12556
12573
  try {
@@ -13921,6 +13938,7 @@ function gatewayStreamRenderDeps() {
13921
13938
  CONTEXT_EXHAUSTION_COOLDOWN_MS,
13922
13939
  DELIVERY_CONFIRM_ENABLED,
13923
13940
  FEED_REOPEN_AFTER_ACK_ENABLED,
13941
+ FEED_REOPEN_AFTER_SUBSTANTIVE_ENABLED,
13924
13942
  HANDBACK_PRETURN_ENABLED,
13925
13943
  HISTORY_ENABLED,
13926
13944
  LIVENESS_TERMINAL_HONESTY,
@@ -24335,28 +24353,19 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
24335
24353
  // when this group's LAST worker finishes; no per-worker unpin.
24336
24354
  }
24337
24355
 
24338
- const handbackOrigin = resolveSubagentOriginChat(agentId)
24356
+ // ONE ladder — origin → fleet → recent-turn floor → owner DM
24357
+ // (resolveWorkerSurfaceForDecider: the tested precedence,
24358
+ // shaped into the decider's fleetChatId/originThreadId slots,
24359
+ // floor audited — msg-6897 misroute fix).
24360
+ const hbOwnerDm = loadAccess().allowFrom[0] ?? ''
24339
24361
  const decision = decideSubagentHandback({
24340
24362
  handbackEnvValue: process.env.SWITCHROOM_SUBAGENT_HANDBACK,
24341
24363
  outcome,
24342
24364
  isBackground,
24343
- // Route the handback (the worker's result → a synthesized
24344
- // turn) back to the conversation the Task was dispatched
24345
- // from, so the result lands where the user asked — not the
24346
- // agent's DM. Falls back to fleetChatId/ownerChatId.
24347
- fleetChatId: handbackOrigin?.chatId || fleetChatId,
24348
- // Supergroup topic the Task was dispatched from. Plumbed
24349
- // through so the handback turn (and the model's in-voice
24350
- // "here's what the worker found" reply) land in the
24351
- // originating topic — not the chat's last-seen topic.
24352
- // Applied only when the origin chat resolved (DM fallback
24353
- // is topic-less).
24354
- ...(handbackOrigin?.threadId != null
24355
- ? { originThreadId: handbackOrigin.threadId }
24356
- : {}),
24365
+ ...resolveWorkerSurfaceForDecider(turnsDb, agentId, { fleetChatId, ownerDm: hbOwnerDm }),
24357
24366
  // Owner-chat fallback: if the parent-turn chat can't be
24358
24367
  // resolved, route to the owner chat.
24359
- ownerChatId: loadAccess().allowFrom[0] ?? '',
24368
+ ownerChatId: hbOwnerDm,
24360
24369
  taskDescription: description,
24361
24370
  resultText,
24362
24371
  // Plumb the JSONL agent id so the spool can mint a
@@ -24650,14 +24659,26 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
24650
24659
  // chat/thread resolution `resolveWorkerFeedChat` did) is now one
24651
24660
  // pure, unit-tested function; the gateway keeps only the defer-map
24652
24661
  // bookkeeping, the two audit logs, and the feed.update.
24662
+ const wfOrigin = resolveSubagentOriginChat(agentId)
24663
+ // msg-6897 misroute fix: with origin unresolved AND no live
24664
+ // turn to stamp from, the exhausted-defer paint previously
24665
+ // fell to the owner DM. Feed the most-recent turn's
24666
+ // chat/topic through the SAME stamp-fallback slot (#3458)
24667
+ // so the paint lands near the work instead.
24668
+ const wfStamp =
24669
+ stampTurn != null
24670
+ ? { chatId: stampTurn.sessionChatId, threadId: stampTurn.sessionThreadId }
24671
+ : wfOrigin == null
24672
+ ? recentTurnFallbackChat(agentId)
24673
+ : null
24653
24674
  const dest = decideWorkerFeedDestination({
24654
- origin: resolveSubagentOriginChat(agentId),
24675
+ origin: wfOrigin,
24655
24676
  cardExists: workerActivityFeed?.has(agentId) === true,
24656
24677
  priorDeferrals: workerFeedOriginDeferrals.get(agentId) ?? 0,
24657
24678
  maxDeferrals: WORKER_FEED_ORIGIN_DEFER_MAX,
24658
24679
  fleetChatId,
24659
- stampChatId: stampTurn?.sessionChatId,
24660
- stampThreadId: stampTurn?.sessionThreadId,
24680
+ stampChatId: wfStamp?.chatId,
24681
+ stampThreadId: wfStamp?.threadId,
24661
24682
  ownerDm: loadAccess().allowFrom[0] ?? '',
24662
24683
  })
24663
24684
  if (dest.action === 'defer') {
@@ -24678,6 +24699,10 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
24678
24699
  }
24679
24700
  // Card fell to the owner DM — log the misroute once per agent.
24680
24701
  if (dest.ownerDmFallback) noteWorkerFeedOwnerDmFallback(agentId)
24702
+ // Recent-turn floor fed the stamp slot and won — audit once (msg-6897).
24703
+ if (stampTurn == null && wfOrigin == null && wfStamp != null && dest.chatId === wfStamp.chatId) {
24704
+ noteWorkerRecentTurnFloor(agentId, wfStamp)
24705
+ }
24681
24706
  void workerActivityFeed?.update(
24682
24707
  agentId,
24683
24708
  dest.chatId,
@@ -24710,20 +24735,19 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
24710
24735
  // the pure decision (gate 1b → 'skeleton-liveness'), which drops
24711
24736
  // it deterministically (controls-in-code, unit-tested) rather
24712
24737
  // than an opaque inline return here.
24713
- const progressOrigin = resolveSubagentOriginChat(agentId)
24738
+ // Same single ladder as the handback above
24739
+ // (resolveWorkerSurfaceForDecider — msg-6897 misroute fix).
24740
+ const pgOwnerDm = loadAccess().allowFrom[0] ?? ''
24741
+ const progressSurface = resolveWorkerSurfaceForDecider(turnsDb, agentId, {
24742
+ fleetChatId,
24743
+ ownerDm: pgOwnerDm,
24744
+ })
24714
24745
  const decision = decideSubagentProgress({
24715
24746
  skeleton: skeleton === true,
24716
24747
  disableEnvValue: process.env.SWITCHROOM_DISABLE_SUBAGENT_PROGRESS,
24717
24748
  isBackground,
24718
- // Prefer the conversation the Task was dispatched from over
24719
- // the owner DM (see resolveSubagentOriginChat).
24720
- fleetChatId: progressOrigin?.chatId || fleetChatId,
24721
- // Carry the dispatching topic so the progress wake lands in
24722
- // it (applied only when the origin chat resolved).
24723
- ...(progressOrigin?.threadId != null
24724
- ? { originThreadId: progressOrigin.threadId }
24725
- : {}),
24726
- ownerChatId: loadAccess().allowFrom[0] ?? '',
24749
+ ...progressSurface,
24750
+ ownerChatId: pgOwnerDm,
24727
24751
  subagentJsonlId: agentId,
24728
24752
  taskDescription: description,
24729
24753
  latestSummary,
@@ -24744,7 +24768,7 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
24744
24768
  // in (origin thread) so the right lane is yielded in a
24745
24769
  // supergroup; chat-level for DM-shaped agents.
24746
24770
  pendingProgress.clearPending(
24747
- statusKey(decision.chatId, progressOrigin?.threadId),
24771
+ statusKey(decision.chatId, progressSurface.originThreadId),
24748
24772
  'progress',
24749
24773
  )
24750
24774
  process.stderr.write(
@@ -374,6 +374,10 @@ export function createNarrativeLane(deps: NarrativeLaneDeps) {
374
374
  labeledToolCount: turn.labeledToolCount,
375
375
  crossTurnAnswerDelivered,
376
376
  postAnswerSubagentActivity: openFlags?.postAnswerSubagentActivity,
377
+ // Post-SUBSTANTIVE reopen: the tool_label handler latches this sticky
378
+ // on `turn` when the long-turn reopen fires, so the drain lifts lever 1
379
+ // and the fresh card opens BELOW the already-delivered substantive reply.
380
+ postAnswerMainActivity: turn.postAnswerMainActivity,
377
381
  })
378
382
  ) {
379
383
  break
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Fallback attention cap for `progress_update` when the inbound minted no turn
3
+ * atom.
4
+ *
5
+ * The documented ceiling for `progress_update` is "at most 5 per turn" — but
6
+ * that cap is enforced only when a turn atom exists (`activeTurnStartedAt` set
7
+ * by `turn-start-surfaces.ts`). Some inbounds mint no turn atom at all —
8
+ * handback turns and the synthesized progress-inbound turns — so on those a
9
+ * worker could call `progress_update` at the unconditional 20s floor forever
10
+ * (~3/min, ~180/hr into one chat), which defeats the documented cap and pings
11
+ * the operator's phone indefinitely.
12
+ *
13
+ * This module restores the attention cap on that path with a rolling window:
14
+ * at most {@link PROGRESS_FALLBACK_MAX} DELIVERIES per
15
+ * {@link PROGRESS_FALLBACK_WINDOW_MS} per chat/topic key. It is not a ban
16
+ * defence — the 20s floor and the edit-flood fuse still pace sends; this only
17
+ * bounds how many phone-pinging progress messages a turn-less path can emit.
18
+ *
19
+ * State is a per-key list of delivery timestamps, pruned in place on every
20
+ * access; a key whose window empties is dropped from the map so it cannot grow
21
+ * unbounded across chats that have gone quiet. Because each window holds at
22
+ * most {@link PROGRESS_FALLBACK_MAX} entries, the per-key array is O(1).
23
+ */
24
+
25
+ const recentSends = new Map<string, number[]>();
26
+
27
+ /** Rolling window length. */
28
+ export const PROGRESS_FALLBACK_WINDOW_MS = 15 * 60_000;
29
+ /** Max deliveries per window per key — mirrors the 5-per-turn cap. */
30
+ export const PROGRESS_FALLBACK_MAX = 5;
31
+
32
+ /**
33
+ * Prune timestamps older than the window for `key`, persisting the result.
34
+ * Drops the key entirely when its window is empty so the map stays bounded.
35
+ */
36
+ function prune(key: string, now: number): number[] {
37
+ const cutoff = now - PROGRESS_FALLBACK_WINDOW_MS;
38
+ const recent = (recentSends.get(key) ?? []).filter((ts) => ts > cutoff);
39
+ if (recent.length === 0) recentSends.delete(key);
40
+ else recentSends.set(key, recent);
41
+ return recent;
42
+ }
43
+
44
+ /**
45
+ * True when the rolling window for `key` is already at capacity — the caller
46
+ * should refuse the send with `turn_limit`. Read-only w.r.t. the delivery
47
+ * count (it only prunes aged-out entries); record a delivery separately via
48
+ * {@link recordProgressFallbackSend} AFTER the send lands.
49
+ */
50
+ export function progressFallbackAtCap(key: string, now: number): boolean {
51
+ return prune(key, now).length >= PROGRESS_FALLBACK_MAX;
52
+ }
53
+
54
+ /**
55
+ * Record one successful fallback-path delivery. Call ONLY after the send has
56
+ * actually landed, so a thrown send never consumes a window slot.
57
+ *
58
+ * NOTE: this is the check-then-record shape (`progressFallbackAtCap` then
59
+ * `recordProgressFallbackSend`) which is NOT safe against two truly-concurrent
60
+ * same-key calls — both can pass the cap check before either records. The
61
+ * production path uses {@link reserveProgressSlot} / {@link sendWithProgressCap}
62
+ * instead, which reserve the slot BEFORE the await so the count is visible to a
63
+ * concurrent caller. These two remain for the direct unit tests of the window.
64
+ */
65
+ export function recordProgressFallbackSend(key: string, now: number): void {
66
+ const recent = prune(key, now);
67
+ recent.push(now);
68
+ recentSends.set(key, recent);
69
+ }
70
+
71
+ /** A reserved cap slot; call {@link release} to hand it back (idempotent). */
72
+ export interface ProgressSlotReservation {
73
+ release: () => void;
74
+ }
75
+
76
+ /**
77
+ * Reserve one fallback-window slot for `key` at `now`, or refuse when the
78
+ * window is already at capacity.
79
+ *
80
+ * Unlike {@link progressFallbackAtCap} + {@link recordProgressFallbackSend},
81
+ * this records the timestamp AT CHECK TIME (before the caller's `await`), so a
82
+ * second concurrent same-key call sees the reservation and cannot overshoot the
83
+ * cap. On send failure the caller must {@link ProgressSlotReservation.release |
84
+ * release} it, which removes exactly the one timestamp this reservation added —
85
+ * so a throw still burns no slot (#4328 Fix 2 preserved under concurrency).
86
+ *
87
+ * Returns `null` when the window is full (caller refuses with `turn_limit`).
88
+ */
89
+ export function reserveProgressFallbackSlot(
90
+ key: string,
91
+ now: number,
92
+ ): ProgressSlotReservation | null {
93
+ const recent = prune(key, now);
94
+ if (recent.length >= PROGRESS_FALLBACK_MAX) return null;
95
+ recent.push(now);
96
+ recentSends.set(key, recent);
97
+
98
+ let released = false;
99
+ return {
100
+ release: () => {
101
+ if (released) return;
102
+ released = true;
103
+ const arr = recentSends.get(key);
104
+ if (!arr) return;
105
+ // Remove exactly ONE occurrence of the reserved timestamp — concurrent
106
+ // reservations may share the same `now`, and they are interchangeable.
107
+ const idx = arr.indexOf(now);
108
+ if (idx >= 0) arr.splice(idx, 1);
109
+ if (arr.length === 0) recentSends.delete(key);
110
+ else recentSends.set(key, arr);
111
+ },
112
+ };
113
+ }
114
+
115
+ /** Max `progress_update` deliveries per turn atom — mirrors the fallback cap. */
116
+ export const PROGRESS_TURN_MAX = 5;
117
+
118
+ /** Dependencies the unified reservation needs, threaded from the gateway. */
119
+ export interface ProgressCapDeps {
120
+ /** The chat/topic status key. */
121
+ key: string;
122
+ /** `Date.now()` captured once by the caller. */
123
+ now: number;
124
+ /**
125
+ * `activeTurnStartedAt.get(key)` — presence selects the turn-scoped counter
126
+ * path; absence selects the rolling fallback window.
127
+ */
128
+ turnStart: number | undefined;
129
+ /** The live per-turn counter (`progressUpdateTurnCount`). */
130
+ turnCount: Map<string, number>;
131
+ }
132
+
133
+ /**
134
+ * Reserve one attention-cap slot BEFORE the send, race-safely, on whichever
135
+ * path applies:
136
+ *
137
+ * - turn atom present → increment `progressUpdateTurnCount` now (bounded at
138
+ * {@link PROGRESS_TURN_MAX}); release decrements it.
139
+ * - turn atom absent → reserve a rolling-window slot via
140
+ * {@link reserveProgressFallbackSlot}.
141
+ *
142
+ * Because the reservation mutates the shared count synchronously (no `await`
143
+ * between the read and the write), a second concurrent same-key call sees it
144
+ * and cannot push the count past the cap. Returns `null` when already at cap.
145
+ */
146
+ export function reserveProgressSlot(
147
+ deps: ProgressCapDeps,
148
+ ): ProgressSlotReservation | null {
149
+ const { key, now, turnStart, turnCount } = deps;
150
+ if (turnStart != null) {
151
+ const current = turnCount.get(key) ?? 0;
152
+ if (current >= PROGRESS_TURN_MAX) return null;
153
+ turnCount.set(key, current + 1);
154
+ let released = false;
155
+ return {
156
+ release: () => {
157
+ if (released) return;
158
+ released = true;
159
+ turnCount.set(key, Math.max(0, (turnCount.get(key) ?? 0) - 1));
160
+ },
161
+ };
162
+ }
163
+ return reserveProgressFallbackSlot(key, now);
164
+ }
165
+
166
+ /**
167
+ * The reserve-then-confirm wrapper the gateway's `executeProgressUpdate` uses
168
+ * around its send. Reserves a cap slot (race-safe, before the await), runs
169
+ * `send`, and RELEASES the slot if `send` throws — so a failed delivery never
170
+ * consumes a slot even under concurrency.
171
+ *
172
+ * Returns `{ capped: true }` when already at cap (no send attempted), else
173
+ * `{ capped: false, result }` with the successful send's result. A successful
174
+ * send KEEPS the reservation (it is the delivery record); the caller records
175
+ * nothing extra.
176
+ */
177
+ export async function sendWithProgressCap<T>(
178
+ deps: ProgressCapDeps,
179
+ send: () => Promise<T>,
180
+ ): Promise<{ capped: true } | { capped: false; result: T }> {
181
+ const reservation = reserveProgressSlot(deps);
182
+ if (reservation === null) return { capped: true };
183
+ try {
184
+ const result = await send();
185
+ return { capped: false, result };
186
+ } catch (err) {
187
+ reservation.release();
188
+ throw err;
189
+ }
190
+ }
191
+
192
+ /** Test-only: clear all fallback-window state. */
193
+ export function _resetProgressFallbackCap(): void {
194
+ recentSends.clear();
195
+ }