switchroom 0.14.58 → 0.14.60

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.
@@ -278,6 +278,11 @@ import { handleRequestDriveApproval } from './drive-write-approval.js'
278
278
  import { handleRequestMs365Approval } from './ms365-write-approval.js'
279
279
  import { buildDiffPreviewCard } from './diff-preview-card.js'
280
280
  import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick } from './pending-inbound-buffer.js'
281
+ import {
282
+ ObligationLedger,
283
+ buildObligationRepresentInbound,
284
+ obligationEscalationText,
285
+ } from './obligation-ledger.js'
281
286
  import { createInboundSpool } from './inbound-spool.js'
282
287
  import { purgeStaleTurnsForChat } from './turn-state-purge.js'
283
288
  import { decideInboundDelivery } from './inbound-delivery-gate.js'
@@ -1380,6 +1385,20 @@ const DELIVERY_CONFIRM_TIMEOUT_MS =
1380
1385
  const DELIVERY_CONFIRM_SWEEP_MS = 5_000
1381
1386
  const deliveryQueue = createDeliveryQueue<InboundMessage>()
1382
1387
 
1388
+ // ─── Deterministic delivery-obligation ledger (systems-analysis PR2) ──────────
1389
+ // An inbound is an OBLIGATION (keyed origin_turn_id) that is OPEN at receipt and
1390
+ // CLOSED only by an observable substantive reply resolving to that origin —
1391
+ // never the model's words. An open obligation that survives a turn boundary is
1392
+ // re-presented (bounded) until it closes, so a message the model read but never
1393
+ // answered (the marko 715 drop) cannot be silently lost. ADDITIVE + flagged: it
1394
+ // runs ALONGSIDE the existing acks/spool/buffer (PR3 retires the redundant
1395
+ // pieces). Default OFF — the canary turns it on (713/715 interleave UAT) before
1396
+ // any fleet activation. When off, every hook below is a no-op → zero change.
1397
+ const OBLIGATION_LEDGER_ENABLED = process.env.SWITCHROOM_OBLIGATION_LEDGER === '1'
1398
+ const OBLIGATION_REPRESENT_MAX = 2
1399
+ const OBLIGATION_SWEEP_MS = 5_000
1400
+ const obligationLedger = new ObligationLedger(OBLIGATION_REPRESENT_MAX)
1401
+
1383
1402
  // ─── Serialize-until-replied (multitopic reply-routing) ───────────────────
1384
1403
  // Component 1 (deliver-before-drain gate). A buffered cross-topic inbound
1385
1404
  // drains ONLY after the just-ended turn delivered its reply to its own
@@ -1431,6 +1450,25 @@ const QUEUED_STATUS_UX_ENABLED =
1431
1450
  const FEED_REOPEN_AFTER_ACK_ENABLED =
1432
1451
  process.env.SWITCHROOM_FEED_REOPEN_AFTER_ACK !== '0'
1433
1452
 
1453
+ // Activity-feed heartbeat (PR1). The feed is pull-only — it only re-renders on
1454
+ // a tool_label event, so a long single step that emits no new label leaves the
1455
+ // feed frozen on "→ doing X" for tens of seconds (the marko ~26s freeze). The
1456
+ // heartbeat re-renders the live feed every FEED_HEARTBEAT_TICK_MS with a
1457
+ // climbing " · Ns" elapsed on the in-progress line, but only once the current
1458
+ // step has run >= FEED_HEARTBEAT_MIN_STALE_MS (so a normally-advancing feed is
1459
+ // untouched). Kill switch: SWITCHROOM_FEED_HEARTBEAT=0. Default on.
1460
+ const FEED_HEARTBEAT_ENABLED = process.env.SWITCHROOM_FEED_HEARTBEAT !== '0'
1461
+ const FEED_HEARTBEAT_TICK_MS = 6_000
1462
+ const FEED_HEARTBEAT_MIN_STALE_MS = 6_000
1463
+
1464
+ /** Compact mm/ss-ish elapsed for the live feed suffix: "18s", "1m05s". */
1465
+ function formatFeedElapsed(ms: number): string {
1466
+ const s = Math.floor(ms / 1000)
1467
+ if (s < 60) return `${s}s`
1468
+ const m = Math.floor(s / 60)
1469
+ return `${m}m${(s % 60).toString().padStart(2, '0')}s`
1470
+ }
1471
+
1434
1472
  /**
1435
1473
  * Authoritative "is a turn in flight?" for every gate that previously
1436
1474
  * read `claudeBusyKeys.size`. PR 3b cutover (extends PR 3a's bridgeUp
@@ -1653,6 +1691,12 @@ type CurrentTurn = {
1653
1691
  activityInFlight: Promise<void> | null
1654
1692
  activityPendingRender: string | null
1655
1693
  activityLastSentRender: string | null
1694
+ // Wall-clock anchor for the newest in-progress feed step — set each time a
1695
+ // tool_label re-renders the feed. The heartbeat (`feedHeartbeatTick`) reads
1696
+ // it to show a climbing " · Ns" elapsed on the live line so a long single
1697
+ // step that emits no new label doesn't read as frozen (the feed is otherwise
1698
+ // pull-only). undefined until the first label of the turn renders.
1699
+ lastToolLabelAt?: number
1656
1700
  // Accumulating friendly-action feed for this turn. Each non-surface
1657
1701
  // tool_label appends a line via `appendActivityLabel`; the feed renders
1658
1702
  // (via `renderActivityFeed`) as a capped chronological list into the
@@ -1727,6 +1771,69 @@ function findTurnByOriginId(originTurnId: string | null | undefined): CurrentTur
1727
1771
  return recentTurnsById.get(originTurnId) ?? null
1728
1772
  }
1729
1773
 
1774
+ /**
1775
+ * PR2 obligation-ledger CLOSE. Called when a SUBSTANTIVE final answer lands
1776
+ * (not a bare interim ack — using finalAnswerSubstantive, the #2141 signal): the
1777
+ * obligation discharged is the one for the SAME origin the answer routes to
1778
+ * (origin_turn_id the model echoed, else the live turn). So 713's reply closes
1779
+ * 713's obligation even after currentTurn flipped to 715, and 715 stays open
1780
+ * until ITS own substantive answer. An ack does NOT close (so ack-then-ghost is
1781
+ * re-presented, not re-dropped). turn.turnId === the obligation's origin id
1782
+ * (both deriveTurnId(chat,thread,messageId) of the same inbound). No-op unless
1783
+ * the flag is on. NOTE residual: a genuinely SHORT answer (<200 chars, not a
1784
+ * stream-done) reads as non-substantive and won't close → a bounded re-ask
1785
+ * (≤2) then one operator-visible nudge — the accepted double-ask tradeoff,
1786
+ * measured in the canary.
1787
+ */
1788
+ function closeObligationOnSubstantiveReply(
1789
+ args: Record<string, unknown>,
1790
+ liveTurn: CurrentTurn | null | undefined,
1791
+ ): void {
1792
+ if (!OBLIGATION_LEDGER_ENABLED) return
1793
+ const echoed = findTurnByOriginId(args.origin_turn_id as string | undefined)
1794
+ const target = obligationLedger.resolveCloseTarget(echoed?.turnId, liveTurn?.turnId)
1795
+ if (target != null) obligationLedger.close(target)
1796
+ }
1797
+
1798
+ /**
1799
+ * PR2 obligation-ledger OPEN. Track a fresh user inbound as an unanswered
1800
+ * obligation the moment it is received — called BEFORE the buffer-until-idle /
1801
+ * deliver split so a mid-turn cross-topic inbound (the 715 case: buffered while
1802
+ * another turn runs) is tracked too. Drain paths re-deliver buffered inbounds
1803
+ * via sendToAgent (NOT through handleInbound), so handleInbound is the ONLY
1804
+ * point that sees every fresh inbound — opening here is mandatory for both
1805
+ * branches. Same gate as delivery-tracking (real user turns only; synthetic /
1806
+ * steering / `!` interrupt / empty excluded — they have no origin id / need no
1807
+ * answer; deriveTurnId null-guards them). Idempotent → opening at buffer-time
1808
+ * and any later delivery is safe. No-op unless the flag is on.
1809
+ */
1810
+ function openObligationFromInbound(
1811
+ inboundMsg: InboundMessage,
1812
+ gate: { isSteering: boolean; isInterrupt: boolean; effectiveText: string },
1813
+ ): void {
1814
+ if (!OBLIGATION_LEDGER_ENABLED) return
1815
+ if (
1816
+ !shouldTrackDelivery({
1817
+ isSteering: gate.isSteering,
1818
+ isInterrupt: gate.isInterrupt,
1819
+ hasSource: inboundMsg.meta?.source != null,
1820
+ effectiveText: gate.effectiveText,
1821
+ })
1822
+ ) {
1823
+ return
1824
+ }
1825
+ const oid = deriveTurnId(inboundMsg.chatId, inboundMsg.threadId, inboundMsg.messageId)
1826
+ if (oid == null) return
1827
+ obligationLedger.openIfAbsent({
1828
+ originTurnId: oid,
1829
+ chatId: inboundMsg.chatId,
1830
+ threadId: inboundMsg.threadId,
1831
+ messageId: inboundMsg.messageId,
1832
+ text: inboundMsg.text ?? '',
1833
+ openedAt: Date.now(),
1834
+ })
1835
+ }
1836
+
1730
1837
  /**
1731
1838
  * Component 5 — post a "Queued — replying in another topic first" status
1732
1839
  * into a cross-topic buffered message's OWN topic. Fire-and-forget through
@@ -2247,6 +2354,21 @@ function releaseTurnBufferGate(key: string, endingTurn?: CurrentTurn): void {
2247
2354
  function endCurrentTurnAtomic(turn: CurrentTurn): void {
2248
2355
  if (currentTurn !== turn) return
2249
2356
  currentTurn = null
2357
+ // PR2 obligation-ledger CLOSE-at-turn-end. Close the ended turn's obligation
2358
+ // when it delivered a final answer. finalAnswerDelivered is the right signal
2359
+ // HERE (not isSubstantiveFinalReply at reply-time): a SHORT genuine answer
2360
+ // ("4") is final-but-not-substantive, so the reply-time substantive-close
2361
+ // missed it → it looked unanswered → the idle sweep double-asked every short
2362
+ // turn (canary, v0.14.59). At turn_end the #2141 logic has already demoted a
2363
+ // bare interim ack to non-final, so finalAnswerDelivered===true means GENUINELY
2364
+ // answered. This runs before the next idle sweep, so a short answer closes
2365
+ // cleanly (no double-ask); an ack-then-ghost / no-reply turn ends with
2366
+ // finalAnswerDelivered===false → stays open → re-presented (the intended
2367
+ // catch). close() is a no-op for synthetic turns (turnId not in the ledger).
2368
+ // No-op when the flag is off.
2369
+ if (OBLIGATION_LEDGER_ENABLED && turn.finalAnswerDelivered) {
2370
+ obligationLedger.close(turn.turnId)
2371
+ }
2250
2372
  // Component 2 — clear any prior no-reply drain timer for this turn; a
2251
2373
  // fresh end re-evaluates below. (Idempotent — null when never armed.)
2252
2374
  if (turn.noReplyDrainTimer != null) {
@@ -4700,6 +4822,51 @@ const inboundSpool = STATIC
4700
4822
  },
4701
4823
  })
4702
4824
  const pendingInboundBuffer = createPendingInboundBuffer({ spool: inboundSpool })
4825
+
4826
+ // PR2 obligation-ledger idle sweep. Re-present an OPEN obligation only at a
4827
+ // CLEAN idle: no turn in flight AND the inbound buffer is empty — so the
4828
+ // existing buffer-drain has already had its turn and anything still OPEN is
4829
+ // "delivered but never answered" (the 715 gap). Re-present by pushing a
4830
+ // synthetic must-answer inbound through the SAME buffer→drain path (idempotent
4831
+ // OPEN via meta.source; reuses tested delivery). Bounded: after maxRepresents,
4832
+ // escalate to ONE operator-visible "did I miss this?" and close — no loop.
4833
+ // No-op unless the flag is on; gated on the same idle predicate as the drains.
4834
+ function obligationSweep(): void {
4835
+ if (!OBLIGATION_LEDGER_ENABLED) return
4836
+ if (!obligationLedger.hasOpen()) return
4837
+ if (turnInFlightForGate()) return // a turn is running — let it finish/answer
4838
+ const agent = process.env.SWITCHROOM_AGENT_NAME ?? ''
4839
+ if (pendingInboundBuffer.depth(agent) > 0) return // existing drain runs first; avoids double-present
4840
+ const decision = obligationLedger.decideAtIdle()
4841
+ const o = decision.obligation
4842
+ if (decision.action === 'none' || o == null) return
4843
+ if (decision.action === 'represent') {
4844
+ pendingInboundBuffer.push(agent, buildObligationRepresentInbound(o, Date.now()))
4845
+ const attempt = obligationLedger.markRepresented(o.originTurnId)
4846
+ process.stderr.write(
4847
+ `telegram gateway: obligation re-presented origin=${o.originTurnId} attempt=${attempt}/${OBLIGATION_REPRESENT_MAX}\n`,
4848
+ )
4849
+ return
4850
+ }
4851
+ // escalate — close FIRST so the loop ends even if the send fails.
4852
+ obligationLedger.close(o.originTurnId)
4853
+ process.stderr.write(
4854
+ `telegram gateway: obligation escalated (exhausted ${OBLIGATION_REPRESENT_MAX} re-presents) origin=${o.originTurnId}\n`,
4855
+ )
4856
+ void robustApiCall(
4857
+ () =>
4858
+ bot.api.sendMessage(o.chatId, obligationEscalationText(o), {
4859
+ ...(o.threadId != null ? { message_thread_id: o.threadId } : {}),
4860
+ }),
4861
+ { chat_id: o.chatId, ...(o.threadId != null ? { threadId: o.threadId } : {}), verb: 'obligation.escalate' },
4862
+ ).catch((err) => {
4863
+ process.stderr.write(`telegram gateway: obligation escalation send failed: ${err}\n`)
4864
+ })
4865
+ }
4866
+ if (!STATIC && OBLIGATION_LEDGER_ENABLED) {
4867
+ setInterval(obligationSweep, OBLIGATION_SWEEP_MS).unref()
4868
+ }
4869
+
4703
4870
  // Honest-restart-resume: inject the boot resume/report inbound built by the
4704
4871
  // registry classifier above. When the spool exists we only PUT it (the
4705
4872
  // boot-replay loop below pulls it into the in-memory buffer exactly once via
@@ -6336,6 +6503,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
6336
6503
  text: decision.mergedText,
6337
6504
  disableNotification,
6338
6505
  })
6506
+ if (turn.finalAnswerSubstantive) closeObligationOnSubstantiveReply(args, turn)
6339
6507
  }
6340
6508
  outboundDedup.record(
6341
6509
  chat_id,
@@ -6686,6 +6854,9 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
6686
6854
  // the `turn_end` handler when it lands; this only fires the
6687
6855
  // observable side effects that #1718 deferred unconditionally.
6688
6856
  finalizeStatusReaction(chat_id, threadId, 'done')
6857
+ // PR2: close this origin's obligation on a SUBSTANTIVE final answer
6858
+ // (after finalize so the reaction guard test's anchor window is stable).
6859
+ if (turn.finalAnswerSubstantive) closeObligationOnSubstantiveReply(args, turn)
6689
6860
  }
6690
6861
  // v0.13.30 follow-up — release the buffer gate on EVERY reply
6691
6862
  // finalize, not just on `isFinalAnswerReply`. The narrow
@@ -7030,6 +7201,7 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
7030
7201
  disableNotification: args.disable_notification === true,
7031
7202
  done: args.done === true,
7032
7203
  })
7204
+ if (turn.finalAnswerSubstantive) closeObligationOnSubstantiveReply(args, turn)
7033
7205
  // #1744 follow-up — stream_reply edge case. The first-emit gate at
7034
7206
  // L5178 only clears silent-end state on the FIRST emit of a stream.
7035
7207
  // If a stream's first emit was ack-shaped (disable_notification:true,
@@ -8417,12 +8589,12 @@ const FOREGROUND_SUBAGENT_ACCUM_MAX = 12
8417
8589
  * order; the single-sub-agent common case nests precisely under its
8418
8590
  * Delegating line.
8419
8591
  */
8420
- function composeTurnActivity(turn: CurrentTurn, final = false): string | null {
8592
+ function composeTurnActivity(turn: CurrentTurn, final = false, liveSuffix = ''): string | null {
8421
8593
  const childLines: string[] = []
8422
8594
  for (const narrative of turn.foregroundSubAgents.values()) {
8423
8595
  childLines.push(...narrative)
8424
8596
  }
8425
- return renderActivityFeedWithNested(turn.mirrorLines, childLines, final)
8597
+ return renderActivityFeedWithNested(turn.mirrorLines, childLines, final, liveSuffix)
8426
8598
  }
8427
8599
 
8428
8600
  /**
@@ -8495,6 +8667,35 @@ async function drainActivitySummary(turn: CurrentTurn): Promise<void> {
8495
8667
  }
8496
8668
  }
8497
8669
 
8670
+ /**
8671
+ * Heartbeat tick (PR1): keep the live activity feed visibly advancing during a
8672
+ * long single step that emits no new tool_label. Re-renders the feed with a
8673
+ * climbing " · Ns" elapsed on the in-progress line through the SAME single-flight
8674
+ * drain path the tool_label handler uses (no separate transport, no race). Pure
8675
+ * no-op unless there is a live in-flight feed whose newest step has gone stale.
8676
+ * Skips once the final answer landed (the feed is handing off) and after the
8677
+ * turn ends (activityMessageId nulled by clearActivitySummary). Deterministic +
8678
+ * framework-owned — never depends on the model.
8679
+ */
8680
+ function feedHeartbeatTick(): void {
8681
+ const turn = currentTurn
8682
+ if (turn == null) return
8683
+ if (turn.activityMessageId == null) return // no live feed yet / already cleared
8684
+ if (turn.finalAnswerDelivered) return // feed handed off to the answer
8685
+ if (turn.lastToolLabelAt == null) return // feed not driven by a labelled step
8686
+ const elapsed = Date.now() - turn.lastToolLabelAt
8687
+ if (elapsed < FEED_HEARTBEAT_MIN_STALE_MS) return // step is fresh; feed advancing normally
8688
+ const rendered = composeTurnActivity(turn, false, ` · ${formatFeedElapsed(elapsed)}`)
8689
+ if (rendered == null) return
8690
+ turn.activityPendingRender = rendered
8691
+ if (turn.activityInFlight == null) {
8692
+ turn.activityInFlight = drainActivitySummary(turn)
8693
+ }
8694
+ }
8695
+ if (!STATIC && FEED_HEARTBEAT_ENABLED) {
8696
+ setInterval(feedHeartbeatTick, FEED_HEARTBEAT_TICK_MS).unref()
8697
+ }
8698
+
8498
8699
  /**
8499
8700
  * Reconcile the activity summary when the model's reply tool takes over as the
8500
8701
  * authoritative surface. Awaits any in-flight render so we don't race a stale
@@ -8887,6 +9088,10 @@ function handleSessionEvent(ev: SessionEvent): void {
8887
9088
  }
8888
9089
  const rendered = appendActivityLabel(turn.mirrorLines, ev.label)
8889
9090
  if (rendered != null) {
9091
+ // A new tool label = a new live step → re-anchor the heartbeat clock so
9092
+ // the " · Ns" elapsed restarts from this step (and the feed itself just
9093
+ // advanced, so it isn't stale).
9094
+ turn.lastToolLabelAt = Date.now()
8890
9095
  // Recompose so any active foreground sub-agent's nested block (Model A)
8891
9096
  // is preserved when the parent appends its own step. composeTurnActivity
8892
9097
  // == the flat render when no foreground sub-agent is active.
@@ -11437,6 +11642,15 @@ async function handleInbound(
11437
11642
  return
11438
11643
  }
11439
11644
 
11645
+ // PR2 obligation-ledger OPEN — BEFORE the buffer-until-idle / deliver split so
11646
+ // a mid-turn cross-topic inbound (the 715 case) is tracked whether it is
11647
+ // buffered or delivered now. Idempotent + gated; no-op when the flag is off.
11648
+ openObligationFromInbound(inboundMsg, {
11649
+ isSteering,
11650
+ isInterrupt: interrupt.isInterrupt,
11651
+ effectiveText,
11652
+ })
11653
+
11440
11654
  if (
11441
11655
  decideInboundDelivery({
11442
11656
  turnInFlight: turnInFlightAtReceipt,
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Deterministic delivery-obligation ledger (systems-analysis PR2).
3
+ *
4
+ * The framework today tracks whether an inbound was DELIVERED (read by claude),
5
+ * never whether it was ANSWERED — so a message claude reads but never replies to
6
+ * (the marko msg-715 verbal-deferral drop) is silently lost. This ledger adds
7
+ * the one missing invariant, and it is model-INDEPENDENT by construction:
8
+ *
9
+ * An inbound is an OBLIGATION keyed by its origin_turn_id. It is OPEN the
10
+ * moment the message is received, and CLOSED only by an observable framework
11
+ * event — a reply-tool call whose resolved target equals that origin_turn_id
12
+ * AND that carries a substantive answer (not a bare interim ack). The engine
13
+ * may go idle only when no obligation is OPEN; an OPEN obligation that
14
+ * survives a turn boundary is re-presented as a fresh must-answer turn until
15
+ * it closes, bounded so a mis-close degrades to ONE operator-visible nudge
16
+ * rather than an infinite re-ask loop.
17
+ *
18
+ * This file is PURE state + decisions — no Telegram, no claude, no timers. The
19
+ * gateway owns OPEN/CLOSE/re-present I/O and calls in here. Pure ⇒ unit-testable
20
+ * (see tests/obligation-ledger.test.ts), the seam the analysis demanded.
21
+ *
22
+ * The close event (substantive reply resolving to origin) is observed by the
23
+ * framework, never the model's narration/promise — that is the whole point: the
24
+ * 715 "I'll handle thread 3 as its own turn" does NOT close the obligation.
25
+ */
26
+
27
+ import type { InboundMessage } from './ipc-protocol.js'
28
+
29
+ export interface Obligation {
30
+ /** deriveTurnId(chat, thread, messageId) — the stable identity. */
31
+ readonly originTurnId: string
32
+ readonly chatId: string
33
+ readonly threadId?: number
34
+ readonly messageId: number
35
+ /** Original inbound text (may be truncated by the caller for re-presentation). */
36
+ readonly text: string
37
+ /** Wall-clock ms the obligation was first opened. */
38
+ readonly openedAt: number
39
+ /** How many times it has been re-presented (0 on first open). */
40
+ representCount: number
41
+ }
42
+
43
+ /** What the gateway should do for the oldest open obligation at an idle boundary. */
44
+ export type LedgerAction = 'none' | 'represent' | 'escalate'
45
+
46
+ export interface LedgerDecision {
47
+ action: LedgerAction
48
+ obligation?: Obligation
49
+ }
50
+
51
+ export interface ObligationInput {
52
+ originTurnId: string
53
+ chatId: string
54
+ threadId?: number
55
+ messageId: number
56
+ text: string
57
+ openedAt: number
58
+ }
59
+
60
+ export class ObligationLedger {
61
+ private readonly open = new Map<string, Obligation>()
62
+
63
+ /**
64
+ * @param maxRepresents max re-presentations before escalating to an
65
+ * operator-visible nudge instead of re-asking again. Default 2.
66
+ */
67
+ constructor(private readonly maxRepresents = 2) {}
68
+
69
+ /**
70
+ * Open an obligation if not already tracked. Idempotent on originTurnId — a
71
+ * message that is buffered AND later enqueued opens once, keeping the first
72
+ * (earliest openedAt + accumulated representCount). Returns true if newly
73
+ * opened.
74
+ */
75
+ openIfAbsent(input: ObligationInput): boolean {
76
+ if (this.open.has(input.originTurnId)) return false
77
+ this.open.set(input.originTurnId, { ...input, representCount: 0 })
78
+ return true
79
+ }
80
+
81
+ /** Close by origin id. Returns true if an obligation was open and is now closed. */
82
+ close(originTurnId: string | null | undefined): boolean {
83
+ if (originTurnId == null) return false
84
+ return this.open.delete(originTurnId)
85
+ }
86
+
87
+ isOpen(originTurnId: string): boolean {
88
+ return this.open.has(originTurnId)
89
+ }
90
+
91
+ hasOpen(): boolean {
92
+ return this.open.size > 0
93
+ }
94
+
95
+ size(): number {
96
+ return this.open.size
97
+ }
98
+
99
+ /** Snapshot of open obligations, oldest first. For introspection/tests. */
100
+ list(): Obligation[] {
101
+ return [...this.open.values()].sort((a, b) => a.openedAt - b.openedAt)
102
+ }
103
+
104
+ /** The oldest open obligation, or undefined. */
105
+ private oldest(): Obligation | undefined {
106
+ let best: Obligation | undefined
107
+ for (const o of this.open.values()) {
108
+ if (best === undefined || o.openedAt < best.openedAt) best = o
109
+ }
110
+ return best
111
+ }
112
+
113
+ /**
114
+ * Decide what to do at an idle boundary (caller guarantees: no turn in flight
115
+ * AND the inbound buffer is empty — so the existing buffer-drain has already
116
+ * had its turn and anything still OPEN is "delivered but unanswered"). PURE —
117
+ * does not mutate. The caller performs the side effect then calls
118
+ * markRepresented / close accordingly.
119
+ *
120
+ * - 'none' → no open obligation; the agent may idle.
121
+ * - 'represent' → re-present `obligation` as a fresh must-answer turn.
122
+ * - 'escalate' → it has already been re-presented maxRepresents times; send
123
+ * ONE operator-visible "did I miss this?" and close it
124
+ * (caller calls close) rather than loop forever.
125
+ */
126
+ decideAtIdle(): LedgerDecision {
127
+ const o = this.oldest()
128
+ if (o === undefined) return { action: 'none' }
129
+ if (o.representCount >= this.maxRepresents) return { action: 'escalate', obligation: o }
130
+ return { action: 'represent', obligation: o }
131
+ }
132
+
133
+ /**
134
+ * Decide which obligation a substantive reply discharges — DETERMINISTICALLY,
135
+ * holding for any model behavior:
136
+ * - `echoedTurnId` (the model echoed origin_turn_id back) → authoritative;
137
+ * close exactly that (a no-op via close() if it isn't actually open).
138
+ * - else, close the live turn's obligation ONLY when UNAMBIGUOUS — exactly
139
+ * one obligation open. With >1 open and no echo we cannot tell which one
140
+ * the reply answered; closing the live turn's would silently drop the other
141
+ * (713's un-echoed reply landing while currentTurn=715 must NOT close 715).
142
+ * So we close nothing → the real target stays open and is re-presented (a
143
+ * bounded double-ask), never wrong-closed. Returns the id to close, or null.
144
+ */
145
+ resolveCloseTarget(
146
+ echoedTurnId: string | null | undefined,
147
+ liveTurnId: string | null | undefined,
148
+ ): string | null {
149
+ if (echoedTurnId != null) return echoedTurnId
150
+ if (liveTurnId != null && this.open.size === 1 && this.open.has(liveTurnId)) return liveTurnId
151
+ return null
152
+ }
153
+
154
+ /** Record that an obligation was just re-presented (bumps representCount). */
155
+ markRepresented(originTurnId: string): number {
156
+ const o = this.open.get(originTurnId)
157
+ if (o === undefined) return 0
158
+ o.representCount += 1
159
+ return o.representCount
160
+ }
161
+ }
162
+
163
+ /** Original message preview length for re-presentation (mirrors resume builder). */
164
+ const REPRESENT_PREVIEW_MAX = 200
165
+
166
+ /**
167
+ * Build the synthetic inbound that RE-PRESENTS an open obligation as a fresh
168
+ * must-answer turn. Carries the obligation's original message_id (so the
169
+ * reply-quote and origin routing land in the right place) and origin_turn_id in
170
+ * meta (so the model's reply resolves back to THIS obligation → the close event
171
+ * matches). `source: obligation_represent` marks it synthetic, so it is NOT
172
+ * delivery-tracked and does NOT open a fresh obligation (the original stays
173
+ * open until a substantive reply closes it). Pure — the gateway injects it via
174
+ * the existing buffer→drain path. Context restoration (inject vs pointer) is a
175
+ * separate layer; here we point at get_recent_messages and quote the original.
176
+ */
177
+ export function buildObligationRepresentInbound(o: Obligation, now: number): InboundMessage {
178
+ const preview =
179
+ o.text.length > REPRESENT_PREVIEW_MAX ? o.text.slice(0, REPRESENT_PREVIEW_MAX - 1) + '…' : o.text
180
+ const topicClause = o.threadId != null ? ' in this topic' : ''
181
+ return {
182
+ type: 'inbound',
183
+ chatId: o.chatId,
184
+ ...(o.threadId != null ? { threadId: o.threadId } : {}),
185
+ messageId: o.messageId,
186
+ user: 'switchroom',
187
+ userId: 0,
188
+ ts: now,
189
+ text:
190
+ `You have an earlier message${topicClause} that you started but never actually ` +
191
+ `answered (you may have set it aside mid-work): "${preview}". Answer it now via the ` +
192
+ `reply tool — deliver the real answer, don't just acknowledge it. If you've lost the ` +
193
+ `surrounding context, call get_recent_messages for this chat${topicClause} first. ` +
194
+ `That quoted text may be only the first ~200 characters of the original.`,
195
+ meta: {
196
+ source: 'obligation_represent',
197
+ origin_turn_id: o.originTurnId,
198
+ represent_count: String(o.representCount + 1),
199
+ },
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Build the operator-visible escalation message text, used when an obligation
205
+ * has been re-presented maxRepresents times without closing — rather than loop
206
+ * forever (the new failure mode this trades silent-drop for), surface ONE
207
+ * honest "did I miss this?" and close it.
208
+ */
209
+ export function obligationEscalationText(o: Obligation): string {
210
+ const preview =
211
+ o.text.length > REPRESENT_PREVIEW_MAX ? o.text.slice(0, REPRESENT_PREVIEW_MAX - 1) + '…' : o.text
212
+ return (
213
+ `⚠️ I may have missed an earlier message and I'm not sure I answered it: ` +
214
+ `"${preview}". If you still need it, please re-send.`
215
+ )
216
+ }