switchroom 0.15.9 → 0.15.11

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.
@@ -139,6 +139,7 @@ import {
139
139
  recordReaction, lookupMessageRoleAndText,
140
140
  checkpointWal as checkpointHistoryWal,
141
141
  pruneMessagesOlderThanDays,
142
+ hasOutboundDeliveredSince,
142
143
  } from '../history.js'
143
144
  import {
144
145
  runRegistryReaper,
@@ -299,7 +300,7 @@ import { handleRequestDriveApproval } from './drive-write-approval.js'
299
300
  import { handleRequestMs365Approval } from './ms365-write-approval.js'
300
301
  import { buildDiffPreviewCard } from './diff-preview-card.js'
301
302
  import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick } from './pending-inbound-buffer.js'
302
- import { isCronIdentity, resolveInjectTarget } from './cron-session.js'
303
+ import { isCronIdentity, deliverInjectWithFallback } from './cron-session.js'
303
304
  import {
304
305
  ObligationLedger,
305
306
  buildObligationRepresentInbound,
@@ -1506,6 +1507,20 @@ const OBLIGATION_BACKGROUND_WORK_GRACE_MS = (() => {
1506
1507
  const n = Number(raw)
1507
1508
  return Number.isFinite(n) && n >= 0 ? n : 20 * 60_000
1508
1509
  })()
1510
+ // Per-represent grace window. After a re-present fires, the obligation is
1511
+ // ineligible for the next represent/escalate until at least this many ms have
1512
+ // elapsed since markRepresented. Without this the 5s sweep can fire again
1513
+ // before the re-presented turn even reaches the agent, burning the represent
1514
+ // budget and producing back-to-back re-presents or a premature escalation.
1515
+ // Default 120s — generous enough for a turn to start + deliver an answer;
1516
+ // small enough not to delay genuine unanswered re-presents.
1517
+ // Kill switch: SWITCHROOM_OBLIGATION_REPRESENT_GRACE_MS=0 → no per-represent grace.
1518
+ const OBLIGATION_REPRESENT_GRACE_MS = (() => {
1519
+ const raw = process.env.SWITCHROOM_OBLIGATION_REPRESENT_GRACE_MS
1520
+ if (raw == null || raw === '') return 120_000
1521
+ const n = Number(raw)
1522
+ return Number.isFinite(n) && n >= 0 ? n : 120_000
1523
+ })()
1509
1524
  // Marker-freshness window for the orphaned-foreground signal. The turn-active
1510
1525
  // marker is touched on every foreground tool_use and on foreground sub-agent
1511
1526
  // JSONL growth, so an mtime younger than this means a sub-agent is touching it
@@ -2175,23 +2190,38 @@ function hasDifferentThreadedRecentTurn(
2175
2190
  * PR2 obligation-ledger CLOSE. Called when a SUBSTANTIVE final answer lands
2176
2191
  * (not a bare interim ack — using finalAnswerSubstantive, the #2141 signal): the
2177
2192
  * obligation discharged is the one for the SAME origin the answer routes to
2178
- * (origin_turn_id the model echoed, else the live turn). So 713's reply closes
2179
- * 713's obligation even after currentTurn flipped to 715, and 715 stays open
2180
- * until ITS own substantive answer. An ack does NOT close (so ack-then-ghost is
2181
- * re-presented, not re-dropped). turn.turnId === the obligation's origin id
2182
- * (both deriveTurnId(chat,thread,messageId) of the same inbound). No-op unless
2183
- * the flag is on. NOTE residual: a genuinely SHORT answer (<200 chars, not a
2184
- * stream-done) reads as non-substantive and won't close a bounded re-ask
2185
- * (≤2) then one operator-visible nudge the accepted double-ask tradeoff,
2186
- * measured in the canary.
2193
+ * (origin_turn_id the model echoed, else the routed origin the gateway resolved,
2194
+ * else the live turn). So 713's reply closes 713's obligation even after
2195
+ * currentTurn flipped to 715, and 715 stays open until ITS own substantive
2196
+ * answer. Answers to re-presented obligations (via=quoted, no model echo) close
2197
+ * via the gateway-resolved routedOriginTurn. An ack does NOT close (so
2198
+ * ack-then-ghost is re-presented, not re-dropped). The live-turn fallback fires
2199
+ * only for the live turn's OWN obligation (it was the turn delivering this
2200
+ * reply), preserving the 713/715 invariant. No-op unless the flag is on.
2201
+ *
2202
+ * @param routedOriginTurn — the origin the reply router already resolved
2203
+ * (echoedTurn ?? quotedTurn); pass whenever the TURN_ORIGIN_ROUTING path ran.
2204
+ * Skipped when null/undefined (pre-routing paths, or DM with no quote).
2187
2205
  */
2188
2206
  function closeObligationOnSubstantiveReply(
2189
2207
  args: Record<string, unknown>,
2190
2208
  liveTurn: CurrentTurn | null | undefined,
2209
+ routedOriginTurn?: CurrentTurn | null,
2191
2210
  ): void {
2192
2211
  if (!OBLIGATION_LEDGER_ENABLED) return
2193
2212
  const echoed = findTurnByOriginId(args.origin_turn_id as string | undefined)
2194
- const target = obligationLedger.resolveCloseTarget(echoed?.turnId, liveTurn?.turnId)
2213
+ // routedOriginTurn is the gateway-resolved origin (echoedTurn ?? quotedTurn).
2214
+ // Only pass it as routedOriginId when it DIFFERS from the echoed turn (if
2215
+ // echoed is present, resolveCloseTarget's first branch already handles it),
2216
+ // and only when it is NOT the live turn (live-turn is the fallback, not the
2217
+ // routed origin — passing live turn here would bypass the live-turn fallback
2218
+ // logic and still close correctly, but naming matters for the 713/715 case:
2219
+ // the routed origin on a via=quoted reply IS the origin, not "live fallback").
2220
+ const routedOriginId =
2221
+ routedOriginTurn != null && echoed == null
2222
+ ? routedOriginTurn.turnId
2223
+ : null
2224
+ const target = obligationLedger.resolveCloseTarget(echoed?.turnId, liveTurn?.turnId, routedOriginId)
2195
2225
  if (target != null) obligationLedger.close(target)
2196
2226
  }
2197
2227
 
@@ -5414,13 +5444,16 @@ function obligationSweep(): void {
5414
5444
  OBLIGATION_BACKGROUND_WORK_GRACE_MS > 0 && agentHasInFlightBackgroundWork(now)
5415
5445
  // Grace window: skip an obligation whose handling turn ended < grace ago — its
5416
5446
  // trailing slow/worker answer may still be landing (over-escalation fix).
5447
+ // Per-represent grace: skip an obligation re-presented < grace ago — prevents
5448
+ // the 5s sweep from immediately firing again before the re-present even lands.
5417
5449
  const decision = obligationLedger.decideAtIdle(
5418
- OBLIGATION_ESCALATE_GRACE_MS > 0 || backgroundWorkActive
5450
+ OBLIGATION_ESCALATE_GRACE_MS > 0 || backgroundWorkActive || OBLIGATION_REPRESENT_GRACE_MS > 0
5419
5451
  ? {
5420
5452
  now,
5421
5453
  graceMs: OBLIGATION_ESCALATE_GRACE_MS,
5422
5454
  backgroundWorkActive,
5423
5455
  backgroundGraceMs: OBLIGATION_BACKGROUND_WORK_GRACE_MS,
5456
+ representGraceMs: OBLIGATION_REPRESENT_GRACE_MS,
5424
5457
  }
5425
5458
  : undefined,
5426
5459
  )
@@ -5449,8 +5482,22 @@ function obligationSweep(): void {
5449
5482
  )
5450
5483
  return
5451
5484
  }
5452
- // escalate — re-present ladder exhausted. Send ONE operator-visible nudge and
5453
- // close the obligation ONLY AFTER it actually lands. This inverts the old
5485
+ // escalate — re-present ladder exhausted. Before sending the user-visible
5486
+ // apology, check whether the agent has ALREADY delivered an outbound reply
5487
+ // to this chat since the obligation was opened. If yes, the obligation is
5488
+ // stale (the agent did answer, just without closing the obligation via the
5489
+ // normal close path) — close silently instead of alarming the user with a
5490
+ // false "I may have missed this". This is Fix 4: escalate only on knowledge,
5491
+ // not doubt. Fall back to false (safe: never suppresses) if history unavailable.
5492
+ if (HISTORY_ENABLED && hasOutboundDeliveredSince(o.chatId, o.openedAt, o.threadId)) {
5493
+ process.stderr.write(
5494
+ `telegram gateway: obligation closed silently — outbound delivered since open origin=${o.originTurnId}\n`,
5495
+ )
5496
+ obligationLedger.close(o.originTurnId)
5497
+ return
5498
+ }
5499
+ // Proceed with escalation: send ONE operator-visible nudge and close the
5500
+ // obligation ONLY AFTER it actually lands. This inverts the old
5454
5501
  // close-before-send (which silently dropped the terminal whenever the send
5455
5502
  // failed): the close is now itself an observable terminal. A transient send
5456
5503
  // failure leaves the obligation OPEN → retried next sweep; a PERMANENT one
@@ -6425,19 +6472,34 @@ const ipcServer: IpcServer = createIpcServer({
6425
6472
  // unchanged. Route+buffer share the same target so a fire that lands
6426
6473
  // mid cron-session-spawn buffers under the cron identity and drains to
6427
6474
  // it on register.
6428
- const target = resolveInjectTarget(msg.agentName, msg.inbound.meta)
6429
- const toCron = target !== msg.agentName
6430
- const delivered = ipcServer.sendToAgent(target, msg.inbound)
6431
- // Status-silent (§2.4): a cron fire must NOT set the MAIN agent's
6432
- // currentTurn (progress card / silence-poke). The cron session is
6433
- // fire-and-forget; its reply is its only Telegram surface.
6434
- if (delivered && !toCron) markClaudeBusyForInbound(msg.inbound)
6475
+ // Graceful Tier-1 fallback (cheap-crons JTBD: a cron must NEVER be
6476
+ // dropped because of tier routing). A cron-routed fire whose `<agent>-cron`
6477
+ // bridge isn't connected (boot-forked session not up yet for a hot-added
6478
+ // frequent cron, or a crashed cron session) falls back to the MAIN agent
6479
+ // bridge so the fire lands now; it routes cheap again once the session is
6480
+ // up. See deliverInjectWithFallback.
6481
+ const { target, delivered, fellBackToMain } = deliverInjectWithFallback(
6482
+ msg.agentName,
6483
+ msg.inbound.meta,
6484
+ (t) => ipcServer.sendToAgent(t, msg.inbound),
6485
+ )
6486
+ if (fellBackToMain) {
6487
+ process.stderr.write(
6488
+ `telegram gateway: cron fire fell back to main session (no cron bridge) agent=${msg.agentName} prompt_key=${promptKey}\n`,
6489
+ )
6490
+ }
6491
+ // Status-silent (§2.4): a cron fire delivered to the CRON session must NOT
6492
+ // set the MAIN agent's currentTurn. But a fire that LANDED on the main
6493
+ // bridge (a non-cron fire, or one that fell back) IS a main-session turn —
6494
+ // surface it on the progress card, or the session looks dark.
6495
+ if (delivered && target === msg.agentName) markClaudeBusyForInbound(msg.inbound)
6435
6496
  process.stderr.write(
6436
- `telegram gateway: inject_inbound agent=${msg.agentName} target=${target} source=${source} prompt_key=${promptKey} delivered=${delivered}\n`,
6497
+ `telegram gateway: inject_inbound agent=${msg.agentName} target=${target}${fellBackToMain ? " (fellback)" : ""} source=${source} prompt_key=${promptKey} delivered=${delivered}\n`,
6437
6498
  )
6438
6499
  // #1150: same buffer-on-failure pattern as vault_grant_approved.
6439
- // Cron fires use this path too if a cron-driven wake-up lands
6440
- // mid bridge-reconnect, buffer it for the next register.
6500
+ // Only reached if BOTH the cron bridge and the main bridge are down
6501
+ // (e.g. mid-restart) buffer under the bridge we tried last so it
6502
+ // drains on the next register.
6441
6503
  if (!delivered) {
6442
6504
  pendingInboundBuffer.push(target, msg.inbound)
6443
6505
  }
@@ -6955,6 +7017,10 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
6955
7017
  // heuristic is what mis-routed a late reply to whichever topic most
6956
7018
  // recently received a message. DM: every tier is undefined → unchanged.
6957
7019
  // Kill switch off → exact legacy resolveThreadId precedence.
7020
+ // Hoist the resolved origin turn so the obligation-close path (below) can
7021
+ // pass it into resolveCloseTarget as routedOriginId, closing re-presented
7022
+ // obligations even when the model omitted origin_turn_id (Fix 1/2).
7023
+ let replyRoutedOriginTurn: CurrentTurn | null = null
6958
7024
  let threadId: number | undefined
6959
7025
  if (TURN_ORIGIN_ROUTING_ENABLED) {
6960
7026
  const explicit = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
@@ -6964,6 +7030,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
6964
7030
  const echoedTurn = findTurnByOriginId(args.origin_turn_id as string | undefined)
6965
7031
  const quotedTurn = echoedTurn == null ? findTurnByQuotedMessageId(chat_id, args.reply_to) : null
6966
7032
  const originTurn = echoedTurn ?? quotedTurn
7033
+ replyRoutedOriginTurn = originTurn ?? null
6967
7034
  threadId = resolveAnswerThreadWithLog(
6968
7035
  chat_id,
6969
7036
  Number.isFinite(explicit as number) ? (explicit as number) : undefined,
@@ -7205,7 +7272,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
7205
7272
  text: decision.mergedText,
7206
7273
  disableNotification,
7207
7274
  })
7208
- if (turn.finalAnswerSubstantive) closeObligationOnSubstantiveReply(args, turn)
7275
+ if (turn.finalAnswerSubstantive) closeObligationOnSubstantiveReply(args, turn, replyRoutedOriginTurn)
7209
7276
  }
7210
7277
  outboundDedup.record(
7211
7278
  chat_id,
@@ -7558,7 +7625,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
7558
7625
  finalizeStatusReaction(chat_id, threadId, 'done')
7559
7626
  // PR2: close this origin's obligation on a SUBSTANTIVE final answer
7560
7627
  // (after finalize so the reaction guard test's anchor window is stable).
7561
- if (turn.finalAnswerSubstantive) closeObligationOnSubstantiveReply(args, turn)
7628
+ if (turn.finalAnswerSubstantive) closeObligationOnSubstantiveReply(args, turn, replyRoutedOriginTurn)
7562
7629
  }
7563
7630
  // v0.13.30 follow-up — release the buffer gate on EVERY reply
7564
7631
  // finalize, not just on `isFinalAnswerReply`. The narrow
@@ -7618,20 +7685,36 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
7618
7685
  // topic and a late stream-reply can't be stolen by a successor turn. DM:
7619
7686
  // every tier undefined → unchanged. Kill switch off → legacy live-turn
7620
7687
  // injection only.
7688
+ // Origin resolution is hoisted UNCONDITIONALLY (outside the
7689
+ // message_thread_id==null guard below) so the obligation-close path has
7690
+ // the correct routedOriginTurn even when the model explicitly passes
7691
+ // message_thread_id (forum-topic streams). Without this hoist, Fix 1
7692
+ // is a no-op for forum-topic streams — the origin is never resolved and
7693
+ // closeObligationOnSubstantiveReply falls through to the live-turn
7694
+ // fallback. Matches executeReply's unconditional resolution. Thread
7695
+ // injection still stays scoped to the message_thread_id==null branch —
7696
+ // only the obligation-close input changes.
7697
+ let streamRoutedOriginTurn: CurrentTurn | null = null
7698
+ // Track whether the origin was found via echo (for the routing log below).
7699
+ let streamOriginVia: 'echo' | 'quoted' | null = null
7700
+ if (TURN_ORIGIN_ROUTING_ENABLED) {
7701
+ // Origin precedence: model echo first, then the framework-owned quoted
7702
+ // message_id as a deterministic fallback (mirrors executeReply).
7703
+ const echoedTurn = findTurnByOriginId(args.origin_turn_id as string | undefined)
7704
+ const quotedTurn =
7705
+ echoedTurn == null ? findTurnByQuotedMessageId(String(args.chat_id), args.reply_to) : null
7706
+ const originTurn = echoedTurn ?? quotedTurn
7707
+ streamRoutedOriginTurn = originTurn ?? null
7708
+ streamOriginVia = originTurn == null ? null : echoedTurn != null ? 'echo' : 'quoted'
7709
+ }
7621
7710
  if (args.message_thread_id == null) {
7622
7711
  let injected: number | undefined
7623
7712
  if (TURN_ORIGIN_ROUTING_ENABLED) {
7624
- // Origin precedence: model echo first, then the framework-owned quoted
7625
- // message_id as a deterministic fallback (mirrors executeReply).
7626
- const echoedTurn = findTurnByOriginId(args.origin_turn_id as string | undefined)
7627
- const quotedTurn =
7628
- echoedTurn == null ? findTurnByQuotedMessageId(String(args.chat_id), args.reply_to) : null
7629
- const originTurn = echoedTurn ?? quotedTurn
7630
7713
  injected = resolveAnswerThreadWithLog(
7631
7714
  String(args.chat_id),
7632
7715
  undefined,
7633
- originTurn,
7634
- originTurn == null ? null : echoedTurn != null ? 'echo' : 'quoted',
7716
+ streamRoutedOriginTurn,
7717
+ streamOriginVia,
7635
7718
  turn,
7636
7719
  'stream_reply',
7637
7720
  )
@@ -7910,7 +7993,7 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
7910
7993
  disableNotification: args.disable_notification === true,
7911
7994
  done: args.done === true,
7912
7995
  })
7913
- if (turn.finalAnswerSubstantive) closeObligationOnSubstantiveReply(args, turn)
7996
+ if (turn.finalAnswerSubstantive) closeObligationOnSubstantiveReply(args, turn, streamRoutedOriginTurn)
7914
7997
  // #1744 follow-up — stream_reply edge case. The first-emit gate at
7915
7998
  // L5178 only clears silent-end state on the FIRST emit of a stream.
7916
7999
  // If a stream's first emit was ack-shaped (disable_notification:true,
@@ -55,6 +55,12 @@ export interface Obligation {
55
55
  * that re-stamps this once, and representCount is capped, so the ladder still
56
56
  * terminates. Durable (part of the snapshot) so the grace survives restart. */
57
57
  lastTurnEndedAt?: number
58
+ /** Wall-clock ms this obligation was most recently re-presented. Drives the
59
+ * per-represent grace: a freshly re-presented obligation is skipped until
60
+ * at least `representGraceMs` has elapsed, preventing immediate second
61
+ * re-present/escalate when the sweep fires < 5s later. Durable (part of the
62
+ * snapshot) so the grace window survives a restart. */
63
+ lastRepresentedAt?: number
58
64
  }
59
65
 
60
66
  /** What the gateway should do for the oldest open obligation at an idle boundary. */
@@ -202,20 +208,30 @@ export class ObligationLedger {
202
208
  * pathologically-stuck/leaked worker cannot suppress the escalation forever —
203
209
  * once openedAt+backgroundGraceMs passes, the obligation is acted on regardless
204
210
  * of work state, and the FSM still terminates.
211
+ *
212
+ * PER-REPRESENT GRACE (opts.representGraceMs > 0): an obligation that was just
213
+ * re-presented is ineligible until at least `representGraceMs` ms have elapsed
214
+ * since `lastRepresentedAt`. Without this, the 5s sweep can fire again before
215
+ * the re-presented turn even reaches the agent, burning the represent budget
216
+ * immediately and producing back-to-back escalations on the same message.
205
217
  */
206
218
  decideAtIdle(opts?: {
207
219
  now: number
208
220
  graceMs: number
209
221
  backgroundWorkActive?: boolean
210
222
  backgroundGraceMs?: number
223
+ representGraceMs?: number
211
224
  }): LedgerDecision {
212
- const useEligible = opts != null && (opts.graceMs > 0 || opts.backgroundWorkActive === true)
225
+ const useEligible =
226
+ opts != null &&
227
+ (opts.graceMs > 0 || opts.backgroundWorkActive === true || (opts.representGraceMs ?? 0) > 0)
213
228
  const o = useEligible
214
229
  ? this.oldestEligible(
215
230
  opts!.now,
216
231
  opts!.graceMs,
217
232
  opts!.backgroundWorkActive === true,
218
233
  opts!.backgroundGraceMs ?? 0,
234
+ opts!.representGraceMs ?? 0,
219
235
  )
220
236
  : this.oldest()
221
237
  if (o === undefined) return { action: 'none' }
@@ -224,24 +240,30 @@ export class ObligationLedger {
224
240
  }
225
241
 
226
242
  /** The oldest open obligation that is currently ELIGIBLE to act on — i.e. NOT
227
- * within either grace window:
243
+ * within any grace window:
228
244
  * - trailing-answer grace: its handling turn ended < `graceMs` ago (a queued
229
245
  * obligation with no lastTurnEndedAt can't have a trailing answer, so it is
230
- * always eligible on this axis); AND
246
+ * always eligible on this axis);
231
247
  * - background-work grace: when `backgroundWorkActive`, it was opened <
232
248
  * `backgroundGraceMs` ago (genuine in-flight autonomous work — bounded by
233
- * the ceiling so a stale/leaked worker can't suppress escalation forever). */
249
+ * the ceiling so a stale/leaked worker can't suppress escalation forever);
250
+ * - per-represent grace: it was re-presented < `representGraceMs` ago (prevents
251
+ * a 5s sweep tick from immediately firing again on the same obligation before
252
+ * the re-presented turn even reaches the agent). */
234
253
  private oldestEligible(
235
254
  now: number,
236
255
  graceMs: number,
237
256
  backgroundWorkActive: boolean,
238
257
  backgroundGraceMs: number,
258
+ representGraceMs: number,
239
259
  ): Obligation | undefined {
240
260
  let best: Obligation | undefined
241
261
  for (const o of this.open.values()) {
242
262
  if (o.lastTurnEndedAt != null && now - o.lastTurnEndedAt < graceMs) continue // trailing-answer grace
243
263
  if (backgroundWorkActive && backgroundGraceMs > 0 && now - o.openedAt < backgroundGraceMs)
244
264
  continue // in-flight autonomous work, bounded by the ceiling
265
+ if (representGraceMs > 0 && o.lastRepresentedAt != null && now - o.lastRepresentedAt < representGraceMs)
266
+ continue // per-represent grace: sweep fired before re-presented turn landed
245
267
  if (best === undefined || o.openedAt < best.openedAt) best = o
246
268
  }
247
269
  return best
@@ -259,29 +281,48 @@ export class ObligationLedger {
259
281
  /**
260
282
  * Decide which obligation a substantive reply discharges — DETERMINISTICALLY,
261
283
  * holding for any model behavior:
262
- * - `echoedTurnId` (the model echoed origin_turn_id back) → authoritative;
263
- * close exactly that (a no-op via close() if it isn't actually open).
264
- * - else, close the live turn's obligation ONLY when UNAMBIGUOUS — exactly
265
- * one obligation open. With >1 open and no echo we cannot tell which one
266
- * the reply answered; closing the live turn's would silently drop the other
267
- * (713's un-echoed reply landing while currentTurn=715 must NOT close 715).
268
- * So we close nothing the real target stays open and is re-presented (a
269
- * bounded double-ask), never wrong-closed. Returns the id to close, or null.
284
+ *
285
+ * 1. `echoedTurnId` (model echoed origin_turn_id back) authoritative; close
286
+ * exactly that (a no-op via close() if it isn't actually open).
287
+ * 2. `routedOriginId` (gateway-resolved origin from quote/via=quoted or
288
+ * via=live routing) treat as the definitive target when present; this
289
+ * makes answers to re-presented messages close their obligation even when
290
+ * no model echo was provided and even with >1 open obligation (the routed
291
+ * origin IS the answer's origin this is deterministic, not a guess).
292
+ * The 713/715 invariant still holds: the gateway only passes a routedOriginId
293
+ * that it has positively resolved as the reply's origin (quote resolution,
294
+ * via=quoted); it never passes the LIVE turn id here when a different
295
+ * obligation is the resolved origin.
296
+ * 3. else, close the live turn's own obligation when that turn itself is open —
297
+ * this is unambiguous (the reply happened IN that turn, so the turn's own
298
+ * obligation IS the right target). The 713/715 wrong-close protection is
299
+ * preserved by ordering: routed/echoed origin (steps 1/2) wins first;
300
+ * live-turn fallback (step 3) only fires when no routed origin resolved, AND
301
+ * only for the live turn's OWN obligation (not another open obligation). A
302
+ * reply answering message A landing while currentTurn=B must STILL not close B
303
+ * — only steps 1/2 can close A in that case. With multiple open obligations
304
+ * and no routed origin, the LIVE turn's own obligation is the safe default
305
+ * (relaxed from size==1 which wrongly blocked it when a second message arrived
306
+ * meanwhile). Returns the id to close, or null.
270
307
  */
271
308
  resolveCloseTarget(
272
309
  echoedTurnId: string | null | undefined,
273
310
  liveTurnId: string | null | undefined,
311
+ routedOriginId?: string | null,
274
312
  ): string | null {
275
313
  if (echoedTurnId != null) return echoedTurnId
276
- if (liveTurnId != null && this.open.size === 1 && this.open.has(liveTurnId)) return liveTurnId
314
+ if (routedOriginId != null) return routedOriginId
315
+ if (liveTurnId != null && this.open.has(liveTurnId)) return liveTurnId
277
316
  return null
278
317
  }
279
318
 
280
- /** Record that an obligation was just re-presented (bumps representCount). */
281
- markRepresented(originTurnId: string): number {
319
+ /** Record that an obligation was just re-presented (bumps representCount, stamps
320
+ * lastRepresentedAt for the per-represent grace window). */
321
+ markRepresented(originTurnId: string, now = Date.now()): number {
282
322
  const o = this.open.get(originTurnId)
283
323
  if (o === undefined) return 0
284
324
  o.representCount += 1
325
+ o.lastRepresentedAt = now
285
326
  this.persist()
286
327
  return o.representCount
287
328
  }
@@ -546,6 +546,63 @@ export function getRecentOutboundCount(
546
546
  return row?.cnt ?? 0
547
547
  }
548
548
 
549
+ /**
550
+ * Returns true if at least one SUBSTANTIVE outbound (bot → user, role='assistant')
551
+ * message was delivered to `chatId` (and optionally `threadId`) AFTER `sinceMs`
552
+ * (wall-clock epoch milliseconds). Used by the obligation sweep to suppress a false
553
+ * "I may have missed this" escalation when the agent visibly answered: if a
554
+ * substantive outbound landed since the obligation was opened, the obligation is
555
+ * stale — close it silently rather than alarming the user.
556
+ *
557
+ * SUBSTANTIVE: we never suppress escalation on a bare ack ("on it", "give me a
558
+ * sec") — an agent that acks then ghosts must still escalate. The history schema
559
+ * does not store a done/substantive flag, so we approximate: a row counts only
560
+ * when LENGTH(text) >= 200 (the FINAL_ANSWER_MIN_CHARS constant from
561
+ * final-answer-detect.ts). This is false-negative-safe: a genuine substantive
562
+ * answer that happens to be < 200 chars will still fire an escalation, which is
563
+ * the conservative (safe) outcome. A schema column would be more precise but is
564
+ * disproportionate for this predicate; the reviewer accepted this approach.
565
+ *
566
+ * `threadId` semantics:
567
+ * - undefined → any message in the chat regardless of thread (DMs + supergroups)
568
+ * - explicit number → only that thread (precise for supergroups with topics)
569
+ * - explicit null → only chat-root (non-thread) messages
570
+ *
571
+ * Falls back to false (safe: never suppresses escalation) if history is not yet
572
+ * initialised or the query fails.
573
+ */
574
+ export function hasOutboundDeliveredSince(
575
+ chatId: string,
576
+ sinceMs: number,
577
+ threadId?: number | null,
578
+ ): boolean {
579
+ try {
580
+ const cutoffSec = Math.floor(sinceMs / 1000)
581
+ const params: unknown[] = [chatId, cutoffSec]
582
+ // LENGTH(text) >= 200 scopes to substantive replies only — never suppress
583
+ // escalation on a mere ack. Mirrors FINAL_ANSWER_MIN_CHARS (200) from
584
+ // final-answer-detect.ts; the `done` flag is not stored in the history
585
+ // schema, so length is the closest available proxy.
586
+ let sql =
587
+ "SELECT 1 FROM messages WHERE chat_id = ? AND role = 'assistant' AND ts >= ? AND LENGTH(text) >= 200"
588
+ if (threadId !== undefined) {
589
+ if (threadId === null) {
590
+ sql += ' AND thread_id IS NULL'
591
+ } else {
592
+ sql += ' AND thread_id = ?'
593
+ params.push(threadId)
594
+ }
595
+ }
596
+ sql += ' LIMIT 1'
597
+ const row = requireDb()
598
+ .prepare(sql)
599
+ .get(...(params as [unknown, ...unknown[]])) as Record<string, unknown> | undefined
600
+ return row != null
601
+ } catch {
602
+ return false
603
+ }
604
+ }
605
+
549
606
  export function query(opts: QueryOptions): RecordedMessage[] {
550
607
  const limit = Math.min(MAX_LIMIT, Math.max(1, opts.limit ?? DEFAULT_LIMIT))
551
608
  const params: unknown[] = [opts.chat_id]
@@ -5,6 +5,7 @@ import {
5
5
  cronIdentity,
6
6
  isCronIdentity,
7
7
  resolveInjectTarget,
8
+ deliverInjectWithFallback,
8
9
  } from '../gateway/cron-session.js'
9
10
 
10
11
  describe('cron-session identity helpers', () => {
@@ -30,3 +31,38 @@ describe('cron-session identity helpers', () => {
30
31
  expect(resolveInjectTarget('clerk', { source: 'telegram' })).toBe('clerk')
31
32
  })
32
33
  })
34
+
35
+ describe('deliverInjectWithFallback — a cron fire is never dropped by tier routing', () => {
36
+ it('delivers to the cron bridge when it is connected', () => {
37
+ const sent: string[] = []
38
+ const r = deliverInjectWithFallback('clerk', { session: 'cron' }, (t) => (sent.push(t), true))
39
+ expect(r).toEqual({ target: 'clerk-cron', delivered: true, fellBackToMain: false })
40
+ expect(sent).toEqual(['clerk-cron']) // tried cron only; it delivered
41
+ })
42
+
43
+ it('falls back to the MAIN bridge when the cron bridge is not connected', () => {
44
+ // The exact gap: a hot-added / agent-authored frequent cron whose
45
+ // boot-forked cron session isn't up. Must land on main, not vanish.
46
+ const sent: string[] = []
47
+ const r = deliverInjectWithFallback('clerk', { session: 'cron' }, (t) => {
48
+ sent.push(t)
49
+ return t === 'clerk' // cron bridge down, main up
50
+ })
51
+ expect(r).toEqual({ target: 'clerk', delivered: true, fellBackToMain: true })
52
+ expect(sent).toEqual(['clerk-cron', 'clerk']) // tried cron, then fell back to main
53
+ })
54
+
55
+ it('reports not-delivered only when BOTH cron and main are down (then it buffers)', () => {
56
+ const sent: string[] = []
57
+ const r = deliverInjectWithFallback('clerk', { session: 'cron' }, (t) => (sent.push(t), false))
58
+ expect(r).toEqual({ target: 'clerk-cron', delivered: false, fellBackToMain: false })
59
+ expect(sent).toEqual(['clerk-cron', 'clerk']) // tried both, both down
60
+ })
61
+
62
+ it('a non-cron (main) fire never tries a fallback', () => {
63
+ const sent: string[] = []
64
+ const r = deliverInjectWithFallback('clerk', { session: 'main' }, (t) => (sent.push(t), false))
65
+ expect(r).toEqual({ target: 'clerk', delivered: false, fellBackToMain: false })
66
+ expect(sent).toEqual(['clerk']) // only the main bridge, no cron fallback attempted
67
+ })
68
+ })
@@ -10,6 +10,7 @@ import {
10
10
  query,
11
11
  getRecentOutboundCount,
12
12
  getLatestInboundMessageId,
13
+ hasOutboundDeliveredSince,
13
14
  _resetForTests,
14
15
  } from '../history.js'
15
16
 
@@ -363,6 +364,88 @@ describe('getRecentOutboundCount (backstop dedup helper)', () => {
363
364
  })
364
365
  })
365
366
 
367
+ // A substantive reply: 200+ chars (the FINAL_ANSWER_MIN_CHARS threshold).
368
+ const SUBSTANTIVE = 'A'.repeat(200)
369
+ // A non-substantive ack: short (<200 chars).
370
+ const ACK = 'On it.'
371
+
372
+ describe('hasOutboundDeliveredSince', () => {
373
+ beforeEach(() => initHistory(stateDir, 30))
374
+
375
+ it('returns true when a substantive outbound exists after openedAt', () => {
376
+ const openedAt = 1_000_000 * 1000 // ms
377
+ recordOutbound({
378
+ chat_id: '-100',
379
+ thread_id: null,
380
+ message_ids: [10],
381
+ texts: [SUBSTANTIVE],
382
+ ts: 1_000_001, // sec — 1s after openedAt
383
+ })
384
+ expect(hasOutboundDeliveredSince('-100', openedAt)).toBe(true)
385
+ })
386
+
387
+ it('returns false when the only outbound is BEFORE openedAt', () => {
388
+ const openedAt = 1_000_002 * 1000 // ms — after the message
389
+ recordOutbound({
390
+ chat_id: '-100',
391
+ thread_id: null,
392
+ message_ids: [10],
393
+ texts: [SUBSTANTIVE],
394
+ ts: 1_000_001, // sec — before openedAt
395
+ })
396
+ expect(hasOutboundDeliveredSince('-100', openedAt)).toBe(false)
397
+ })
398
+
399
+ it('returns false for a non-substantive ack after openedAt (blocker regression)', () => {
400
+ // An agent that sends a short ack ("on it") then ghosts must NOT have
401
+ // its escalation suppressed. The predicate must never match a bare ack.
402
+ const openedAt = 1_000_000 * 1000
403
+ recordOutbound({
404
+ chat_id: '-100',
405
+ thread_id: null,
406
+ message_ids: [10],
407
+ texts: [ACK], // < 200 chars — non-substantive
408
+ ts: 1_000_001,
409
+ })
410
+ expect(hasOutboundDeliveredSince('-100', openedAt)).toBe(false)
411
+ })
412
+
413
+ it('thread_id=undefined matches any thread (DM semantics)', () => {
414
+ const openedAt = 1_000_000 * 1000
415
+ recordOutbound({
416
+ chat_id: '-100',
417
+ thread_id: 5,
418
+ message_ids: [10],
419
+ texts: [SUBSTANTIVE],
420
+ ts: 1_000_001,
421
+ })
422
+ // No thread filter → should find it
423
+ expect(hasOutboundDeliveredSince('-100', openedAt, undefined)).toBe(true)
424
+ })
425
+
426
+ it('thread_id=number scopes to that thread only', () => {
427
+ const openedAt = 1_000_000 * 1000
428
+ recordOutbound({ chat_id: '-100', thread_id: 5, message_ids: [10], texts: [SUBSTANTIVE], ts: 1_000_001 })
429
+ expect(hasOutboundDeliveredSince('-100', openedAt, 5)).toBe(true)
430
+ expect(hasOutboundDeliveredSince('-100', openedAt, 6)).toBe(false)
431
+ })
432
+
433
+ it('thread_id=null matches only chat-root (non-thread) messages', () => {
434
+ const openedAt = 1_000_000 * 1000
435
+ recordOutbound({ chat_id: '-100', thread_id: null, message_ids: [10], texts: [SUBSTANTIVE], ts: 1_000_001 })
436
+ expect(hasOutboundDeliveredSince('-100', openedAt, null)).toBe(true)
437
+ // A thread-scoped message should NOT match the root filter
438
+ recordOutbound({ chat_id: '-100', thread_id: 3, message_ids: [11], texts: [SUBSTANTIVE], ts: 1_000_002 })
439
+ expect(hasOutboundDeliveredSince('-100', openedAt, null)).toBe(true) // root still there
440
+ expect(hasOutboundDeliveredSince('-100', openedAt, 3)).toBe(true) // thread 3 also there
441
+ expect(hasOutboundDeliveredSince('-100', openedAt, 9)).toBe(false) // thread 9 not there
442
+ })
443
+
444
+ it('returns false when no history is present for the chat', () => {
445
+ expect(hasOutboundDeliveredSince('-999', 0)).toBe(false)
446
+ })
447
+ })
448
+
366
449
  describe('secret redaction at persistence (both directions)', () => {
367
450
  beforeEach(() => initHistory(stateDir, 30))
368
451