switchroom 0.19.44 → 0.19.46

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.
@@ -85,7 +85,9 @@ import {
85
85
  type TelegraphAccount,
86
86
  } from '../telegraph.js'
87
87
  import { OutboundDedupCache } from '../recent-outbound-dedup.js'
88
- import { FlushedTurnSupersedeRegistry, DEFAULT_SUPERSEDE_TTL_MS } from '../flushed-turn-supersede.js'
88
+ import { FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
89
+ import { resolveReplyOwnerTurnWith, SUPERSEDE_OPEN_CAP_MS, SUPERSEDE_GRACE_MS } from './reply-owner-wiring.js'
90
+ import { subagentReplyAuthority } from './subagent-reply-authority.js'
89
91
  import { createInboundCoalescer, inboundCoalesceKey } from './inbound-coalesce.js'
90
92
  import {
91
93
  splitCoalescedAttachments,
@@ -455,7 +457,7 @@ import {
455
457
  type SendReplyGatewayDeps,
456
458
  type DeliverCapturedProseDeps,
457
459
  } from './outbound-send-path.js'
458
- import { handleSessionEvent as handleSessionEventCore, drainParkedTurnStartsForChat } from './stream-render.js'
460
+ import { handleSessionEvent as handleSessionEventCore, drainParkedTurnStartsForChat, closeFlushCompletionWindows } from './stream-render.js'
459
461
  import { createNarrativeLane } from './narrative-lane.js'
460
462
  import {
461
463
  parseAgentCallback,
@@ -488,8 +490,6 @@ import {
488
490
  FLUSH_SUBSTANTIVE_MIN_CHARS,
489
491
  } from '../turn-flush-safety.js'
490
492
  import {
491
- resolveReplyOwnerTurnId,
492
- resolveReplyOwnerTier,
493
493
  type ReplyOwnerTier, type ReplyOwnerCandidates,
494
494
  type AnswerDeliveredLatch,
495
495
  } from '../reply-owner-resolve.js'
@@ -657,7 +657,7 @@ import { handleRequestDriveApproval } from './drive-write-approval.js'
657
657
  import { handleRequestMs365Approval } from './ms365-write-approval.js'
658
658
  import { buildDiffPreviewCard } from './diff-preview-card.js'
659
659
  import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick } from './pending-inbound-buffer.js'
660
- import { isCronIdentity, isCronInjectFire, deliverInjectWithFallback } from './cron-session.js'
660
+ import { isCronIdentity, isCronInjectFire, deliverInjectWithFallback, replyCallerIsForeignSession } from './cron-session.js'
661
661
  import {
662
662
  ObligationLedger,
663
663
  obligationEscalationText,
@@ -2377,7 +2377,12 @@ const outboundDedup = new OutboundDedupCache()
2377
2377
  // a second message. Keyed on the per-turn `turnId` nonce, NOT on text — so it
2378
2378
  // catches the containment case the exact-text `outboundDedup` misses (a
2379
2379
  // `narration\n\nanswer` flush never equals the clean `answer`-only reply).
2380
- const flushedTurnSupersede = new FlushedTurnSupersedeRegistry()
2380
+ // #4173/#4175 window bounds (env kill-switch + rationale) live in
2381
+ // reply-owner-wiring.ts so this ctor and the owner-tier candidates share them.
2382
+ const flushedTurnSupersede = new FlushedTurnSupersedeRegistry({
2383
+ openCapMs: SUPERSEDE_OPEN_CAP_MS,
2384
+ completedGraceMs: SUPERSEDE_GRACE_MS,
2385
+ })
2381
2386
  // #3276 — the turn-flush backstop's per-turn delivery latch + per-chunk
2382
2387
  // idempotency ledger. The latch (keyed on `turnId`) is the deterministic
2383
2388
  // arbiter of backstop-vs-reply; the chunk ledger lets a retry after a partial
@@ -3457,12 +3462,18 @@ export type CurrentTurn = {
3457
3462
  // The `latest-ended` supersede tier carries DESTRUCTIVE authority (it drives
3458
3463
  // message deletion), so `resolveReplyOwnerTurn` only honours a latest-ended
3459
3464
  // turn whose `endedAt` is non-null (#3725 — the registry is populated at turn
3460
- // START, so the tail entry may still be RUNNING) AND within the supersede TTL —
3461
- // otherwise a late reply belonging to an OLDER turn could resolve its owner to
3462
- // a NEWER turn at the registry tail and delete that turn's legit answer. The
3463
- // unbounded ROUTING use (`endedOnly: false`) is unaffected: it only picks a
3464
- // topic to deliver into and deletes nothing.
3465
+ // START, so the tail entry may still be RUNNING) AND within the
3466
+ // turn-completion window (#4173) — otherwise a late reply belonging to an
3467
+ // OLDER turn could resolve its owner to a NEWER turn at the registry tail and
3468
+ // delete that turn's legit answer. The unbounded ROUTING use
3469
+ // (`endedOnly: false`) is unaffected: it only picks a topic to deliver into
3470
+ // and deletes nothing.
3465
3471
  endedAt: number | null
3472
+ // #4173 — the turn-completion signal: ms the SESSION's real stop was
3473
+ // observed, or null while the window is OPEN (a flush-ended turn still
3474
+ // composing). Stamped in turn-end.ts / closed by stream-render's hooks;
3475
+ // full three-phase model in flushed-turn-supersede.ts.
3476
+ realEndObservedAt: number | null
3466
3477
  // #1675 (over-ping safety net): wall-clock ms of the first reply
3467
3478
  // this turn that landed with `disable_notification: false` (a real
3468
3479
  // device ping). The conversational-pacing contract
@@ -3985,60 +3996,21 @@ function findLatestTurnForChat(chatId: string, opts: { endedOnly: boolean }): Cu
3985
3996
  return latestTurnForChat(recentTurnsById.values(), chatId, opts)
3986
3997
  }
3987
3998
 
3988
- /**
3989
- * 2026-07 double-reply-on-DM fix (Part 1). Resolve the turn that OWNS a landing
3990
- * reply using the SAME full chain the thread-router uses, so the supersede
3991
- * resolver can never again diverge from routing (the exact bug: supersede
3992
- * omitted the quoted-message and latest-ended recoveries, so a DM late reply —
3993
- * no live turn, no `origin_turn_id` — resolved to a null owner and its flush
3994
- * message was never superseded → duplicate).
3995
- *
3996
- * Precedence (first non-null wins), delegated to the pure
3997
- * `resolveReplyOwnerTurnId` so the exact precedence is unit-tested:
3998
- * 1. the live `currentTurn` passed in (null once the flush nulled the atom);
3999
- * 2. `findTurnByOriginId(origin_turn_id)` — the model echo;
4000
- * 3. `findTurnByQuotedMessageId(chat_id, reply_to)` — framework-owned quote;
4001
- * 4. `findLatestTurnForChat(chat_id, {endedOnly:true})` — last ENDED turn.
4002
- * Returns the CurrentTurn for the winning id (so callers can read its
4003
- * `answerDelivered` latch), or null when every lookup missed.
4004
- */
3999
+ /** 2026-07 double-reply-on-DM fix (Part 1) — thin wrapper over the extracted
4000
+ * composition (`reply-owner-wiring.ts`, which also carries the #4173
4001
+ * turn-completion-window bounds). Kept here only to bind the gateway's
4002
+ * stateful lookups. */
4005
4003
  function resolveReplyOwnerTurn(
4006
4004
  liveTurn: CurrentTurn | null,
4007
4005
  chatId: string,
4008
4006
  args: Record<string, unknown>,
4009
4007
  ): { turn: CurrentTurn | null; tier: ReplyOwnerTier; candidates: ReplyOwnerCandidates } {
4010
- const origin = findTurnByOriginId(args.origin_turn_id as string | undefined)
4011
- const quoted = findTurnByQuotedMessageId(chatId, args.reply_to)
4012
- const latestEnded = findLatestTurnForChat(chatId, { endedOnly: true })
4013
- const byId = new Map<string, CurrentTurn>()
4014
- // Populate lowest-precedence first so a higher tier's turn wins the id slot
4015
- // when two lookups resolve the same turn (they carry the same turnId anyway).
4016
- for (const t of [latestEnded, quoted, origin, liveTurn]) {
4017
- if (t != null) byId.set(t.turnId, t)
4018
- }
4019
- // F2 — bound the DESTRUCTIVE latest-ended tier to the supersede TTL so a stale
4020
- // latest-ended turn can't inherit deletion authority over a newer turn's flush
4021
- // record. #3725: the lookup above is `endedOnly`, so `endedAt` is non-null here
4022
- // and the age is ALWAYS a real number — a not-yet-ended turn is no longer a
4023
- // candidate at all, and an explicit null age now fails CLOSED downstream.
4024
- const latestEndedAgeMs =
4025
- latestEnded?.endedAt != null ? Date.now() - latestEnded.endedAt : null
4026
- const candidates: ReplyOwnerCandidates = {
4027
- liveTurnId: liveTurn?.turnId ?? null,
4028
- originTurnId: origin?.turnId ?? null,
4029
- quotedTurnId: quoted?.turnId ?? null,
4030
- latestEndedTurnId: latestEnded?.turnId ?? null,
4031
- latestEndedAgeMs,
4032
- latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
4033
- }
4034
- // #3429 — the winning tier AND the candidate set it came from travel with the
4035
- // turn. Tier alone no longer decides the content-gate bypass: the
4036
- // model-steerable `origin`/`quoted` tiers must be CORROBORATED against the
4037
- // framework-derived `latestEndedTurnId` (`decideContentGateBypass`). All three
4038
- // derive from these SAME candidates, so they can never disagree.
4039
- const tier = resolveReplyOwnerTier(candidates)
4040
- const winnerId = resolveReplyOwnerTurnId(candidates)
4041
- return { turn: winnerId != null ? (byId.get(winnerId) ?? null) : null, tier, candidates }
4008
+ return resolveReplyOwnerTurnWith(
4009
+ { findTurnByOriginId, findTurnByQuotedMessageId, findLatestTurnForChat, now: Date.now },
4010
+ liveTurn,
4011
+ chatId,
4012
+ args,
4013
+ )
4042
4014
  }
4043
4015
 
4044
4016
  /**
@@ -10759,12 +10731,27 @@ if (isGatewayMain) ipcServer = createIpcServer({
10759
10731
  },
10760
10732
  log: (msg) => process.stderr.write(`${msg}\n`),
10761
10733
  })
10734
+ // #4173 — a MAIN-bridge death is a turn-completion proxy: the dead session
10735
+ // can never deliver a flush-ended turn's real turn_end, so close any open
10736
+ // flush windows now (the replay grace still covers a reconnect replay).
10737
+ // Scoped to a registered non-cron agent; rationale in stream-render.ts.
10738
+ if (client.agentName != null && !isCronIdentity(client.agentName)) {
10739
+ subagentReplyAuthority.reset() // #4176 — the sub-agents died with the session
10740
+ const closedWindows = closeFlushCompletionWindows(flushedTurnSupersede, Date.now())
10741
+ if (closedWindows > 0) {
10742
+ process.stderr.write(
10743
+ `telegram gateway: bridge disconnect closed ${closedWindows} open flush-completion window(s) (#4173)\n`,
10744
+ )
10745
+ }
10746
+ }
10762
10747
  },
10763
10748
 
10764
10749
  async onToolCall(client: IpcClient, msg: ToolCallMessage): Promise<ToolCallResult> {
10765
10750
  process.stderr.write(`telegram gateway: ipc: tool_call tool=${msg.tool} agent=${client.agentName ?? '-'} clientId=${client.id ?? '-'} callId=${msg.id}\n`)
10766
10751
  try {
10767
- const result = await executeToolCall(msg.tool, msg.args)
10752
+ // #4172 the calling client's identity gates reply supersede authority
10753
+ // (see replyCallerIsForeignSession, cron-session.ts).
10754
+ const result = await executeToolCall(msg.tool, msg.args, client.agentName)
10768
10755
  return { type: 'tool_call_result', id: msg.id, success: true, result }
10769
10756
  } catch (err) {
10770
10757
  return {
@@ -11972,13 +11959,18 @@ const ALLOWED_TOOLS = new Set([
11972
11959
  'linear_agent_setup',
11973
11960
  ])
11974
11961
 
11975
- async function executeToolCall(tool: string, args: Record<string, unknown>): Promise<unknown> {
11962
+ async function executeToolCall(
11963
+ tool: string,
11964
+ args: Record<string, unknown>,
11965
+ // #4172 — calling client identity; only the reply path consumes it today.
11966
+ callerAgentName: string | null = null,
11967
+ ): Promise<unknown> {
11976
11968
  if (!ALLOWED_TOOLS.has(tool)) {
11977
11969
  throw new Error(`tool not allowed: ${tool}`)
11978
11970
  }
11979
11971
  switch (tool) {
11980
11972
  case 'reply':
11981
- return executeReply(args)
11973
+ return executeReply(args, callerAgentName)
11982
11974
  case 'progress_update':
11983
11975
  return executeProgressUpdate(args)
11984
11976
  case 'react':
@@ -12490,14 +12482,22 @@ async function synthesizeVoiceOut(plan: {
12490
12482
  }
12491
12483
  }
12492
12484
 
12493
- async function executeReply(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
12485
+ async function executeReply(
12486
+ args: Record<string, unknown>,
12487
+ callerAgentName: string | null = null,
12488
+ ): Promise<{ content: Array<{ type: string; text: string }> }> {
12494
12489
  // #2996 P2: the ~1,400-line orchestration body moved VERBATIM to
12495
12490
  // outbound-send-path.ts `sendReply()` — the single tested send primitive.
12496
12491
  // #1664 — the turn is pinned HERE at entry (same read, same position as the
12497
12492
  // old inline `const turn = currentTurn`) and passed in `req`; the façade
12498
12493
  // also gets a live `getCurrentTurn()` accessor for the deliberate live
12499
12494
  // re-reads (Amendment 9: each call site keeps its pin-vs-live choice).
12500
- return sendReply(gatewaySendReplyDeps(), { args, turn: currentTurn })
12495
+ // #4172 a foreign-session caller gets no supersede/bypass/latch authority.
12496
+ return sendReply(gatewaySendReplyDeps(), {
12497
+ args,
12498
+ turn: currentTurn,
12499
+ callerIsForeignSession: replyCallerIsForeignSession(callerAgentName),
12500
+ })
12501
12501
  }
12502
12502
 
12503
12503
  /** Live gateway deps for the extracted send façade (#2996 P2). Built fresh
@@ -12540,6 +12540,7 @@ function gatewaySendReplyDeps(): SendReplyGatewayDeps {
12540
12540
  resolveThreadId,
12541
12541
  getLatestInboundMessageId,
12542
12542
  getLastSubagentHandbackAt,
12543
+ subagentReplyAuthority,
12543
12544
  recordOutbound,
12544
12545
  emissionAuthorityFor,
12545
12546
  clearActivitySummary,
@@ -20737,7 +20738,7 @@ bot.command("auth", async ctx => {
20737
20738
  const { consentUrl, state, port, child } = await startLoopbackFlow(
20738
20739
  parsed.provider,
20739
20740
  parsed.email,
20740
- { replace: parsed.replace, write: parsed.write, orgMode: parsed.orgMode },
20741
+ { replace: parsed.replace, write: parsed.write, calendar: parsed.calendar, orgMode: parsed.orgMode },
20741
20742
  )
20742
20743
  const newFlow = {
20743
20744
  provider: parsed.provider,
@@ -75,9 +75,10 @@ import { decideSilentReplyAnchor } from '../silent-reply-anchor.js'
75
75
  import {
76
76
  decideSupersedeCorrection,
77
77
  flushedAnswerMatchesReply,
78
- DEFAULT_SUPERSEDE_TTL_MS,
79
78
  type FlushedTurnSupersedeRegistry,
80
79
  } from '../flushed-turn-supersede.js'
80
+ import { HANDBACK_RECENCY_WINDOW_MS } from './subagent-handback-marker.js'
81
+ import type { SubagentReplyAuthorityView } from './subagent-reply-authority.js'
81
82
  import {
82
83
  decideAnswerLatchSuppression,
83
84
  decideContentGateBypass,
@@ -762,6 +763,16 @@ export interface SendReplyRequest {
762
763
  * Late writes (finalAnswerDelivered) attribute to THIS turn even if the
763
764
  * module-scope turn rolls over mid-call. */
764
765
  turn: CurrentTurn | null
766
+ /** #4172 — true when the calling IPC client is NOT the main agent bridge
767
+ * (`replyCallerIsForeignSession`, cron-session.ts): a Tier-1 cheap-cron
768
+ * session or an unregistered client. A foreign-session reply can never be
769
+ * the main session's own late answer, so the flushed-turn
770
+ * supersede/bypass/latch block is skipped wholesale — it always sends
771
+ * FRESH (never edits/deletes a flushed answer, never gets latch-
772
+ * suppressed). Omitted/false ⇒ main-bridge caller (the gateway's
773
+ * `executeReply` always passes the computed value; the default only
774
+ * affects test harnesses). */
775
+ callerIsForeignSession?: boolean
765
776
  }
766
777
 
767
778
  /** Gateway dependencies for {@link sendReply}. Function members use method
@@ -834,6 +845,12 @@ export interface SendReplyGatewayDeps {
834
845
  resolveThreadId(chatId: string, explicit?: string | number | null): number | undefined
835
846
  getLatestInboundMessageId(chatId: string, threadId: number | null): number | null | undefined
836
847
  getLastSubagentHandbackAt(chatId: string): number | null
848
+ /** #4176 — gateway-observed sub-agent liveness (`subagentReplyAuthority`,
849
+ * subagent-reply-authority.ts). A background `Task` sub-agent replies over
850
+ * THIS bridge, so neither the #4172 caller-identity gate nor the handback
851
+ * marker can see it; while one is live the content-gate bypass is refused so
852
+ * a sub-agent's reply can never edit over a flushed answer. */
853
+ subagentReplyAuthority: SubagentReplyAuthorityView
837
854
  recordOutbound(rec: {
838
855
  chat_id: string
839
856
  thread_id: number | null
@@ -898,7 +915,7 @@ export async function sendReply(
898
915
  statusKey, streamKey,
899
916
  resolveReplyOwnerTurn, findTurnByOriginId, findTurnByQuotedMessageId,
900
917
  resolveAnswerThreadWithLog, resolveThreadId,
901
- getLatestInboundMessageId, getLastSubagentHandbackAt, recordOutbound,
918
+ getLatestInboundMessageId, getLastSubagentHandbackAt, subagentReplyAuthority, recordOutbound,
902
919
  emissionAuthorityFor, clearActivitySummary,
903
920
  startTypingLoop, stopTypingLoop, logOutbound,
904
921
  closeObligationOnSubstantiveReply, finalizeStatusReaction,
@@ -1025,7 +1042,20 @@ export async function sendReply(
1025
1042
  // when the reply fits one plain-text message, and falls back to the legacy
1026
1043
  // delete+resend otherwise. `supersedeFlushIds` carries the ids forward.
1027
1044
  let supersedeFlushIds: number[] = []
1028
- {
1045
+ // #4172 — caller-identity gate, BEFORE any owner attribution. A reply from a
1046
+ // foreign session (a Tier-1 cheap-cron bridge, or an unregistered client)
1047
+ // can never be the main session's own late/reworded answer, so it gets NO
1048
+ // supersede authority (it must never edit/delete a flushed answer — the
1049
+ // measured #4172 double loss), NO content-gate bypass, and NO exposure to
1050
+ // the answer-delivered latch (which could otherwise silently swallow a cron
1051
+ // digest landing in the flush's pre-record race window). It simply sends
1052
+ // fresh below, leaving any flush record intact for the genuine own-replay.
1053
+ if (req.callerIsForeignSession === true) {
1054
+ process.stderr.write(
1055
+ `telegram gateway: reply: foreign-session caller — supersede/latch skipped (#4172) ` +
1056
+ `chatId=${chat_id}\n`,
1057
+ )
1058
+ } else {
1029
1059
  const replyThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
1030
1060
  // 2026-07 double-reply-on-DM fix (Part 1) — resolve the turn this reply
1031
1061
  // belongs to by IDENTITY, via the SAME full chain the thread-router uses
@@ -1107,11 +1137,16 @@ export async function sendReply(
1107
1137
  // correction only ever touches ITS OWN topic's record.
1108
1138
  const handbackAt = getLastSubagentHandbackAt(chat_id)
1109
1139
  const now = Date.now()
1140
+ // #4174 — the handback gate-hold keeps its OWN ~60 s bound
1141
+ // (`HANDBACK_RECENCY_WINDOW_MS`), deliberately NOT the supersede window's:
1142
+ // this read is CHAT-WIDE, so a longer bound would hold the content gate —
1143
+ // and re-open the reworded-own-answer visible-dup class — for every topic
1144
+ // in a delegation-heavy chat, near-continuously. Rationale on the constant.
1110
1145
  const handbackCouldOwnReply =
1111
1146
  handbackAt != null &&
1112
1147
  ownerEndedAt != null &&
1113
1148
  handbackAt > ownerEndedAt &&
1114
- now - handbackAt <= DEFAULT_SUPERSEDE_TTL_MS
1149
+ now - handbackAt <= HANDBACK_RECENCY_WINDOW_MS
1115
1150
  // MUST-FIX 1 (silent-data-loss, PROVEN by Fable 2026-07-21) + the 2026-07-27
1116
1151
  // corroboration widening — decided by the pure `decideContentGateBypass` so
1117
1152
  // the gateway runs the exact code the unit tests exercise. The rule, in
@@ -1135,11 +1170,27 @@ export async function sendReply(
1135
1170
  // its OWN turn no longer loses the collapse it would have got
1136
1171
  // by omitting the echo entirely (the observed 2026-07-27
1137
1172
  // `via=origin` duplicate).
1173
+ //
1174
+ // #4176 — the SECOND ambiguity, which the marker structurally cannot see. A
1175
+ // background `Task` sub-agent (the `researcher`/`reviewer` types hold the
1176
+ // full tool set) calls `reply` over THIS bridge, mid-run: the #4172
1177
+ // caller-identity gate sees the MAIN agent identity, and a direct tool call
1178
+ // never traverses `pendingInboundBuffer.push`, so no handback marker is
1179
+ // stamped either. Under the #4173 OPEN window that reply resolves the
1180
+ // flush-ended turn at ANY age, so an unguarded bypass supersedes REGARDLESS
1181
+ // of content and silently edits the sub-agent's message over the user's
1182
+ // flushed answer (the review MAJOR on #4167; pre-existing on main at the
1183
+ // 60 s TTL, extended by #4173 to the OPEN cap). Gateway-observed sub-agent
1184
+ // liveness is the deterministic discriminator: while one is live the content
1185
+ // gate is KEPT, so a reply that IS the flushed answer still collapses and a
1186
+ // sub-agent's foreign content ships FRESH (visible, notifying).
1187
+ const subagentCouldOwnReply = subagentReplyAuthority.subagentCouldOwnReply()
1138
1188
  const replyIsOwnAnswer = decideContentGateBypass({
1139
1189
  tier: ownerTier,
1140
1190
  resolvedTurnId,
1141
1191
  candidates: ownerCandidates,
1142
1192
  handbackCouldOwnReply,
1193
+ subagentCouldOwnReply,
1143
1194
  })
1144
1195
  const decision = flushedTurnSupersede.take(
1145
1196
  chat_id,
@@ -1229,7 +1280,7 @@ export async function sendReply(
1229
1280
  // 2026-07-27 duplicate looked like a pure text-matcher failure and
1230
1281
  // took a log-archive dig to attribute to the tier restriction.
1231
1282
  `tier=${ownerTier} latestEnded=${JSON.stringify(ownerCandidates.latestEndedTurnId)} ` +
1232
- `handbackInWindow=${handbackCouldOwnReply}; sending fresh\n`,
1283
+ `handbackInWindow=${handbackCouldOwnReply} subagentLive=${subagentCouldOwnReply}; sending fresh\n`,
1233
1284
  )
1234
1285
  }
1235
1286
  if (suppressByLatch) {
@@ -1248,9 +1299,10 @@ export async function sendReply(
1248
1299
  // content and must deliver. Byte-identical replays of THIS answer are
1249
1300
  // deduped by the content-keyed #546 cache at the top of this function.
1250
1301
  // Honest bound: the dedup TTL (60 s) is anchored at reply RECORD time,
1251
- // while the latest-ended owner tier's 60 s is anchored at `endedAt` —
1252
- // later by the reply→turn_end gap. A byte-identical replay landing >60 s
1253
- // after record but ≤60 s after endedAt is evicted from dedup yet still
1302
+ // while the latest-ended owner tier's bound is the turn-completion
1303
+ // window (#4173), anchored at the turn's OBSERVED real end later by
1304
+ // the reply→turn_end gap. A byte-identical replay landing >60 s after
1305
+ // record but still inside that window is evicted from dedup yet still
1254
1306
  // resolves this ended turn, so it now DELIVERS as a duplicate message.
1255
1307
  // Conscious trade: a rare duplicate beats the silent handback drop the
1256
1308
  // boolean latch caused (#3426).
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Reply owner-turn resolution WIRING (#2996 anti-inflation extraction; #4173).
3
+ *
4
+ * The pure precedence/acceptance rules live in `../reply-owner-resolve.ts`;
5
+ * gateway.ts holds the stateful lookups (recent-turn registry, quoted-message
6
+ * reverse index). This module is the seam between them: it composes the four
7
+ * lookups into the `ReplyOwnerCandidates` set — INCLUDING the turn-completion
8
+ * window bounds (#4173) — exactly once, so the supersede path, the content-gate
9
+ * bypass, and the tier resolution can never disagree on the candidate set.
10
+ *
11
+ * 2026-07 double-reply-on-DM fix (Part 1): this composition uses the SAME full
12
+ * chain the thread-router uses, so the supersede resolver can never again
13
+ * diverge from routing (the exact bug: supersede omitted the quoted-message and
14
+ * latest-ended recoveries, so a DM late reply — no live turn, no
15
+ * `origin_turn_id` — resolved to a null owner and its flush message was never
16
+ * superseded → duplicate).
17
+ *
18
+ * Precedence (first non-null wins), delegated to the pure
19
+ * `resolveReplyOwnerTurnId` so the exact precedence is unit-tested:
20
+ * 1. the live `currentTurn` passed in (null once the flush nulled the atom);
21
+ * 2. `findTurnByOriginId(origin_turn_id)` — the model echo;
22
+ * 3. `findTurnByQuotedMessageId(chat_id, reply_to)` — framework-owned quote;
23
+ * 4. `findLatestTurnForChat(chat_id, {endedOnly:true})` — last ENDED turn.
24
+ * Returns the turn atom for the winning id (so callers can read its
25
+ * `answerDelivered` latch), or null when every lookup missed.
26
+ */
27
+
28
+ import {
29
+ resolveReplyOwnerTier,
30
+ resolveReplyOwnerTurnId,
31
+ type ReplyOwnerCandidates,
32
+ type ReplyOwnerTier,
33
+ } from '../reply-owner-resolve.js'
34
+ import {
35
+ SUPERSEDE_OPEN_WINDOW_CAP_MS,
36
+ SUPERSEDE_COMPLETED_GRACE_MS,
37
+ } from '../flushed-turn-supersede.js'
38
+
39
+ /**
40
+ * #4173/#4175 — the two turn-completion-window bounds, env-overridable as the
41
+ * operator kill-switch for the window mechanism (clamp the open cap / grace to
42
+ * taste; e.g. `SWITCHROOM_SUPERSEDE_OPEN_CAP_MS=60000` restores something
43
+ * close to the pre-#4166 60 s behaviour). Resolved HERE once — the pure
44
+ * modules stay env-free — and consumed by BOTH window surfaces (the gateway's
45
+ * `FlushedTurnSupersedeRegistry` ctor and the latest-ended owner-tier
46
+ * candidates below) so they can never disagree.
47
+ */
48
+ export const SUPERSEDE_OPEN_CAP_MS = (() => {
49
+ const raw = Number(process.env.SWITCHROOM_SUPERSEDE_OPEN_CAP_MS)
50
+ return Number.isFinite(raw) && raw > 0 ? raw : SUPERSEDE_OPEN_WINDOW_CAP_MS
51
+ })()
52
+ export const SUPERSEDE_GRACE_MS = (() => {
53
+ const raw = Number(process.env.SWITCHROOM_SUPERSEDE_GRACE_MS)
54
+ return Number.isFinite(raw) && raw > 0 ? raw : SUPERSEDE_COMPLETED_GRACE_MS
55
+ })()
56
+
57
+ /** The minimal turn-atom shape this wiring reads. Structural on purpose so the
58
+ * module never imports gateway.ts (no cycle); gateway's `CurrentTurn`
59
+ * satisfies it. */
60
+ export interface OwnerTurnLike {
61
+ turnId: string
62
+ endedAt: number | null
63
+ realEndObservedAt: number | null
64
+ }
65
+
66
+ /**
67
+ * Compose the owner-resolution result from the gateway's four lookups.
68
+ * Generic over the concrete turn atom type so gateway.ts gets its own
69
+ * `CurrentTurn` back without a cast.
70
+ */
71
+ export function resolveReplyOwnerTurnWith<T extends OwnerTurnLike>(
72
+ lookups: {
73
+ findTurnByOriginId(originTurnId: string | null | undefined): T | null
74
+ findTurnByQuotedMessageId(chatId: string, replyTo: unknown): T | null
75
+ findLatestTurnForChat(chatId: string, opts: { endedOnly: boolean }): T | null
76
+ now(): number
77
+ },
78
+ liveTurn: T | null,
79
+ chatId: string,
80
+ args: Record<string, unknown>,
81
+ ): { turn: T | null; tier: ReplyOwnerTier; candidates: ReplyOwnerCandidates } {
82
+ const origin = lookups.findTurnByOriginId(args.origin_turn_id as string | undefined)
83
+ const quoted = lookups.findTurnByQuotedMessageId(chatId, args.reply_to)
84
+ const latestEnded = lookups.findLatestTurnForChat(chatId, { endedOnly: true })
85
+ const byId = new Map<string, T>()
86
+ // Populate lowest-precedence first so a higher tier's turn wins the id slot
87
+ // when two lookups resolve the same turn (they carry the same turnId anyway).
88
+ for (const t of [latestEnded, quoted, origin, liveTurn]) {
89
+ if (t != null) byId.set(t.turnId, t)
90
+ }
91
+ // F2 — bound the DESTRUCTIVE latest-ended tier to the turn-completion window
92
+ // (#4173) so a stale latest-ended turn can't inherit deletion authority over
93
+ // a newer turn's flush record. #3725: the lookup above is `endedOnly`, so
94
+ // `endedAt` is non-null here and the age is ALWAYS a real number — a
95
+ // not-yet-ended turn is no longer a candidate at all, and an explicit null
96
+ // age fails CLOSED downstream (as does an omitted one, since #4175).
97
+ const latestEndedAgeMs =
98
+ latestEnded?.endedAt != null ? lookups.now() - latestEnded.endedAt : null
99
+ // #4173 — the completion signal: null while the flush-ended turn's REAL
100
+ // turn_end has not been observed (window OPEN — acceptance falls to the
101
+ // crash-backstop cap in `latestEndedTtlMs`); ms-since-real-end once observed
102
+ // (window COMPLETED — only the replay grace remains). For a normally-ended
103
+ // turn `realEndObservedAt === endedAt`, so the tier keeps its tight bound.
104
+ const latestEndedRealEndAgeMs =
105
+ latestEnded == null
106
+ ? null
107
+ : latestEnded.realEndObservedAt != null
108
+ ? lookups.now() - latestEnded.realEndObservedAt
109
+ : null
110
+ const candidates: ReplyOwnerCandidates = {
111
+ liveTurnId: liveTurn?.turnId ?? null,
112
+ originTurnId: origin?.turnId ?? null,
113
+ quotedTurnId: quoted?.turnId ?? null,
114
+ latestEndedTurnId: latestEnded?.turnId ?? null,
115
+ latestEndedAgeMs,
116
+ latestEndedTtlMs: SUPERSEDE_OPEN_CAP_MS,
117
+ latestEndedRealEndAgeMs,
118
+ latestEndedCompletedGraceMs: SUPERSEDE_GRACE_MS,
119
+ }
120
+ // #3429 — the winning tier AND the candidate set it came from travel with the
121
+ // turn. Tier alone no longer decides the content-gate bypass: the
122
+ // model-steerable `origin`/`quoted` tiers must be CORROBORATED against the
123
+ // framework-derived `latestEndedTurnId` (`decideContentGateBypass`). All three
124
+ // derive from these SAME candidates, so they can never disagree.
125
+ const tier = resolveReplyOwnerTier(candidates)
126
+ const winnerId = resolveReplyOwnerTurnId(candidates)
127
+ return { turn: winnerId != null ? (byId.get(winnerId) ?? null) : null, tier, candidates }
128
+ }
@@ -73,6 +73,8 @@ import { logStreamingEvent } from '../streaming-metrics.js'
73
73
  import { appendActivityLabel } from '../tool-activity-summary.js'
74
74
  import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
75
75
  import { decideTurnFlush } from '../turn-flush-safety.js'
76
+ import { FlushCompletionTracker } from '../flushed-turn-supersede.js'
77
+ import { subagentReplyAuthority } from './subagent-reply-authority.js'
76
78
  import { decideTerminalReason, deriveTurnRole } from '../turn-liveness-floor.js'
77
79
  import { chatKey, chatKeyWithSuffix } from './chat-key.js'
78
80
  import { deriveTurnId } from './derive-turn-id.js'
@@ -222,6 +224,38 @@ function discardParkedTurnStart(rawContent: string): ParkedTurnStart | null {
222
224
  return null
223
225
  }
224
226
 
227
+ /**
228
+ * #4173 — the ONE turn-completion tracker for flush-ended turns (module-scope
229
+ * like `parkedTurnStarts`: one CLI session per gateway process). The
230
+ * quiescence flush ends a turn SYNTHETICALLY while the claude session is still
231
+ * composing; this holds that turn's atom pending until the session's REAL
232
+ * turn_end (or a proxy: a new turn minting, the bridge dying) is observed,
233
+ * then stamps `realEndObservedAt` — the signal that flips the supersede
234
+ * machinery from the unbounded OPEN phase to the ~60 s replay grace. See the
235
+ * `flushed-turn-supersede.ts` module header for the full three-phase model.
236
+ */
237
+ export const flushCompletionTracker = new FlushCompletionTracker()
238
+
239
+ /**
240
+ * #4173 — a turn-completion signal was observed: close every open flush
241
+ * window. Stamps the pending atoms (`flushCompletionTracker`) AND starts the
242
+ * replay grace on the registry's records (`completeAll`) in one call, so the
243
+ * two surfaces (latest-ended owner tier / supersede record) can never observe
244
+ * different phases. Called from the real-turn_end path, the new-turn mint, and
245
+ * the gateway's bridge-death sweep. Returns how many atom windows closed.
246
+ */
247
+ export function closeFlushCompletionWindows(
248
+ // Optional-shaped on purpose: several test harnesses drive `beginTurn` /
249
+ // `handleSessionEvent` with a partial deps object that carries no supersede
250
+ // registry. The production deps builder always injects the real one.
251
+ registry: { completeAll?: (now: number) => void } | null | undefined,
252
+ now: number,
253
+ ): number {
254
+ const closed = flushCompletionTracker.closeAll(now)
255
+ registry?.completeAll?.(now)
256
+ return closed
257
+ }
258
+
225
259
  /** Test seam: the parked store is module-scope (one CLI session, one queue), so
226
260
  * suites that drive `handleSessionEvent` need a deterministic reset. */
227
261
  export function __resetParkedTurnStartsForTest(): void {
@@ -558,6 +592,9 @@ function beginTurn(deps: StreamRenderDeps, ev: TurnStartEnvelope): void {
558
592
  flushedAnswerText: null,
559
593
  // 2026-07 double-reply-on-DM fix (F2) — stamped at turn end.
560
594
  endedAt: null,
595
+ // #4173 — the turn-completion signal, stamped at turn end (or later, for
596
+ // a flush-ended turn, when the real turn_end is observed).
597
+ realEndObservedAt: null,
561
598
  firstPingAt: null,
562
599
  // Notification ownership (R8 / PR-2): no slot claimed yet, so the
563
600
  // "claimer was substantive" flag starts false. Set atomically with
@@ -682,6 +719,12 @@ function beginTurn(deps: StreamRenderDeps, ev: TurnStartEnvelope): void {
682
719
  // already the single stop-owner for ALL turns, and a start is
683
720
  // self-healing anyway, so this cannot leak an interval.
684
721
  startTurnTypingLoop(ev.chatId, enqThreadIdNum ?? null)
722
+ // #4173 — a new turn minting on the serial claude session is a completion
723
+ // PROXY for any still-open flush window: the session has moved on, so a
724
+ // prior flushed turn's own late reply can now only arrive as a bounded
725
+ // replay (the grace). Closing here is the SAFE direction — a window closed
726
+ // too early costs at most a visible duplicate, never an edit-over.
727
+ closeFlushCompletionWindows(deps.flushedTurnSupersede, Date.now())
685
728
  // PR-4e — route the turn-SET through the keyed accessor: flag-OFF assigns
686
729
  // the singleton (byte-identical to `currentTurn = next`); flag-ON sets the
687
730
  // per-topic `byKey[statusKey]` entry AND the most-recent mirror. The key is
@@ -916,6 +959,16 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
916
959
  const durationMs = ev.kind === 'turn_end' ? ev.durationMs : undefined
917
960
  idleTracker.noteEvent(ev.kind, Date.now(), durationMs)
918
961
  }
962
+ // #4176 — sub-agent liveness, fed from the SAME whole-stream position as the
963
+ // idle clock above and for the same reason: `sub_agent_*` events arrive with
964
+ // or without a live gateway turn, and the reply send path must know whether a
965
+ // background sub-agent could be the author of a decoupled reply (a sub-agent
966
+ // calls `reply` over the parent's OWN bridge, so neither the caller-identity
967
+ // gate nor the handback marker can see it). Deliberately BEFORE the switch:
968
+ // the `sub_agent_tool_use` case below early-returns when `getCurrentTurn()` is
969
+ // null, which is exactly the post-flush state this gate exists for. See
970
+ // gateway/subagent-reply-authority.ts.
971
+ subagentReplyAuthority.noteSessionEvent(ev as { kind: string; agentId?: string })
919
972
  switch (ev.kind) {
920
973
  case 'enqueue': {
921
974
  // #3927 FIX A — an `enqueue` is a QUEUE event, not a turn START event
@@ -1724,6 +1777,17 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
1724
1777
  return
1725
1778
  }
1726
1779
  }
1780
+ // #4173 — every turn_end that PROCEEDS (a real turn_end always; a
1781
+ // synthetic one only past the suppression guard above) is a completion
1782
+ // signal for any flush window still open from an EARLIER turn: the
1783
+ // serial session has reached a stop, so that turn's own late reply can
1784
+ // now only arrive as a bounded replay. Close them BEFORE this event's
1785
+ // own processing — the quiescence-flush branch below may OPEN a new
1786
+ // window for THIS turn right after. The common case this serves: the
1787
+ // flush's synthetic turn_end tore the atom down, so when the REAL
1788
+ // turn_end for that same turn lands here it finds no live atom and
1789
+ // otherwise no-ops — this close is how that real turn_end is observed.
1790
+ closeFlushCompletionWindows(flushedTurnSupersede, Date.now())
1727
1791
  // #2094 finding 1 — turn_end gate-wedge backstop. Capture the turn
1728
1792
  // BEFORE the body runs (the body re-reads currentTurn as `turn`, then
1729
1793
  // nulls it via endCurrentTurnAtomic on every clean branch). The guarded
@@ -2244,6 +2308,22 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
2244
2308
  // #1556 wedge-safety reasons. `backstopTurnEndedAt` is null iff the
2245
2309
  // atom was already torn down elsewhere (no record to emit).
2246
2310
  const backstopTurnEndedAt = endCurrentTurnAtomic(turn, { deferRecord: true, deferObligationClose: true })
2311
+ // #4173 — the ANSWER-READY QUIESCENCE variant of this branch ends the
2312
+ // turn SYNTHETICALLY (durationMs === -1): the claude session is still
2313
+ // composing and its REAL turn_end arrives later. Re-open the atom's
2314
+ // completion window (endCurrentTurnAtomic just stamped
2315
+ // `realEndObservedAt` with the default "ended = really ended" rule) and
2316
+ // hold it pending, so the supersede window for this flush stays
2317
+ // claimable until the real turn_end — or a proxy — is observed. This is
2318
+ // what makes a same-session reply landing after a 20-minute /compact
2319
+ // still collapse onto the flushed message (#4166), without holding the
2320
+ // window open for even a second once the session actually stops.
2321
+ // The TURN-END BACKSTOP variant (durationMs >= 0) fires ON the real
2322
+ // turn_end: the session already stopped, so its window is born
2323
+ // completed (grace-bounded) — it must NOT be re-opened.
2324
+ if (ev.durationMs === -1) {
2325
+ flushCompletionTracker.open(turn)
2326
+ }
2247
2327
  // #549 fix — turn-flush takes ownership of the captured-text
2248
2328
  // backup; reset the preamble buffer (its content is already in
2249
2329
  // the captured `capturedText`, which turn-flush is about to send).
@@ -2446,7 +2526,17 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
2446
2526
  flushedTurnSupersede.record(
2447
2527
  backstopChatId,
2448
2528
  backstopThreadId,
2449
- { turnId: turn.turnId, messageIds: sentIds, text: capturedText },
2529
+ // #4173 inherit the atom's completion state: the async send
2530
+ // above can outlast the real turn_end (a close that ran while
2531
+ // we awaited stamped `realEndObservedAt`), and a window closed
2532
+ // before its record existed must be born completed
2533
+ // (grace-bounded), never resurrected as open.
2534
+ {
2535
+ turnId: turn.turnId,
2536
+ messageIds: sentIds,
2537
+ text: capturedText,
2538
+ completedAt: turn.realEndObservedAt,
2539
+ },
2450
2540
  Date.now(),
2451
2541
  )
2452
2542
  }