switchroom 0.19.43 → 0.19.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/agent-scheduler/index.js +4 -1
  2. package/dist/auth-broker/index.js +254 -119
  3. package/dist/cli/notion-write-pretool.mjs +4 -1
  4. package/dist/cli/switchroom.js +3995 -3196
  5. package/dist/host-control/main.js +270 -130
  6. package/dist/vault/approvals/kernel-server.js +201 -66
  7. package/dist/vault/broker/server.js +263 -128
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +11 -4
  10. package/telegram-plugin/dist/gateway/gateway.js +945 -638
  11. package/telegram-plugin/flushed-turn-supersede.ts +190 -16
  12. package/telegram-plugin/gateway/cron-session.ts +31 -0
  13. package/telegram-plugin/gateway/gateway.ts +66 -65
  14. package/telegram-plugin/gateway/outbound-send-path.ts +60 -8
  15. package/telegram-plugin/gateway/reply-owner-wiring.ts +128 -0
  16. package/telegram-plugin/gateway/stream-render.ts +91 -1
  17. package/telegram-plugin/gateway/subagent-handback-marker.ts +49 -9
  18. package/telegram-plugin/gateway/subagent-reply-authority.ts +147 -0
  19. package/telegram-plugin/gateway/turn-end.ts +9 -1
  20. package/telegram-plugin/reply-owner-resolve.ts +102 -26
  21. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +110 -8
  22. package/telegram-plugin/tests/narrative-lane-golden.test.ts +2 -0
  23. package/telegram-plugin/tests/reply-owner-resolve.test.ts +297 -27
  24. package/telegram-plugin/tests/reply-quote-wire.test.ts +2 -0
  25. package/telegram-plugin/tests/send-reply-golden.test.ts +567 -5
  26. package/telegram-plugin/tests/stream-render-golden.test.ts +204 -1
  27. package/vendor/hindsight-memory/scripts/lib/config.py +15 -0
  28. package/vendor/hindsight-memory/scripts/tests/test_retain_provenance_tag.py +135 -0
  29. package/vendor/hindsight-memory/settings.json +1 -1
@@ -437,10 +437,18 @@ function endCurrentTurnAtomic(
437
437
  const turnEndedAt = Date.now()
438
438
  // 2026-07 double-reply-on-DM fix (F2) — stamp the turn's end time so the
439
439
  // `latest-ended` supersede tier can be recency-bounded to the
440
- // supersede TTL (a stale latest-ended turn must not inherit deletion
440
+ // turn-completion window (a stale latest-ended turn must not inherit deletion
441
441
  // authority over a newer turn's flush record). Set once; idempotent on the
442
442
  // deferRecord flush path (which calls this synchronously before its send).
443
443
  turn.endedAt = turnEndedAt
444
+ // #4173 — default completion stamp: for every ordinary turn-end path, the
445
+ // turn ending IS the session's real stop, so the completion window is born
446
+ // closed (the latest-ended tier keeps its tight ~60 s replay grace). The ONE
447
+ // path where that is false — the answer-ready quiescence flush, which ends
448
+ // the turn synthetically while the session is still composing — re-opens the
449
+ // window right after this call (`flushCompletionTracker.open`, stream-render
450
+ // turn-flush branch) and holds it until the REAL turn_end is observed.
451
+ turn.realEndObservedAt = turnEndedAt
444
452
  process.stderr.write(
445
453
  `telegram gateway: ${formatTurnLifecycle('clear', 'turn_end', turn, turnEndedAt)}\n`,
446
454
  )
@@ -41,6 +41,11 @@
41
41
  * feeds their resolved turnIds here; the precedence lives in one place.
42
42
  */
43
43
 
44
+ // The one cross-module import this pure module takes — a sibling PURE module's
45
+ // constant, so the two consumers of the turn-completion window (#4173) can
46
+ // never disagree on the default replay grace.
47
+ import { SUPERSEDE_COMPLETED_GRACE_MS } from './flushed-turn-supersede.js'
48
+
44
49
  /**
45
50
  * The four owner-turn candidate ids, in the gateway's resolution precedence.
46
51
  * Each is the `turnId` of the turn a given lookup resolved, or null when that
@@ -63,38 +68,75 @@ export interface ReplyOwnerCandidates {
63
68
  latestEndedTurnId: string | null
64
69
  /** Age (ms) of the latest-ended turn — `now - turn.endedAt`. The latest-ended
65
70
  * tier carries DESTRUCTIVE authority (it drives supersede deletion), so it is
66
- * honoured ONLY when the turn ended within `latestEndedTtlMs` (the supersede
67
- * TTL). Without the bound, a late reply belonging to an OLDER turn could
68
- * resolve its owner to a NEWER turn now sitting at the registry tail and
69
- * delete THAT turn's legit answer.
71
+ * honoured ONLY within the turn-completion window (#4173 see
72
+ * `latestEndedRealEndAgeMs`). Without a bound, a late reply belonging to an
73
+ * OLDER turn could resolve its owner to a NEWER turn now sitting at the
74
+ * registry tail and delete THAT turn's legit answer.
70
75
  *
71
76
  * Two distinct absences (#3725):
72
- * - `undefined` (property omitted) ⇒ unbounded, the pre-F2 back-compat
73
- * escape for callers that don't supply an age at all;
77
+ * - `undefined` (property omitted) ⇒ no age supplied at all — fails
78
+ * CLOSED since #4175 (the pre-F2 "unbounded" escape granted unbounded
79
+ * destructive authority exactly when the wiring was DROPPED, the
80
+ * dangerous direction);
74
81
  * - explicit `null` ⇒ the caller COMPUTED no age, i.e. its candidate turn
75
- * has no `endedAt` and has NOT ended. That cannot be TTL-bounded, so it
82
+ * has no `endedAt` and has NOT ended. That cannot be bounded, so it
76
83
  * fails CLOSED (not accepted) rather than granting unbounded authority
77
84
  * to a turn that is still running. */
78
85
  latestEndedAgeMs?: number | null
79
- /** The supersede TTL bound applied to `latestEndedAgeMs`. Undefined
80
- * unbounded. */
86
+ /** Bound applied to `latestEndedAgeMs` while the turn's completion window is
87
+ * OPEN (#4173): for a flush-ended turn whose REAL turn_end has not been
88
+ * observed yet, this is the crash-backstop cap
89
+ * (`SUPERSEDE_OPEN_WINDOW_CAP_MS`). Undefined ⇒ fails closed (#4175). */
81
90
  latestEndedTtlMs?: number
91
+ /** #4173 — the turn-completion signal, ms since the candidate turn's REAL
92
+ * turn_end was observed (`turn.realEndObservedAt`):
93
+ * - `null` ⇒ the real turn_end has NOT been observed (a flush ended this
94
+ * turn synthetically and the session is still composing) — the OPEN
95
+ * phase; acceptance falls to `latestEndedAgeMs <= latestEndedTtlMs`
96
+ * (the crash backstop), so a 20-minute compaction still resolves.
97
+ * - a number ⇒ the session stopped that long ago — the COMPLETED phase;
98
+ * accepted only within `latestEndedCompletedGraceMs` (the bounded
99
+ * tool-call-replay window). For a normally-ended turn the real end IS
100
+ * `endedAt`, so this equals `latestEndedAgeMs` and the tier keeps its
101
+ * original tight ~60 s bound.
102
+ * - `undefined` ⇒ legacy caller shape (no completion signal wired):
103
+ * falls back to the plain `age <= ttl` rule. */
104
+ latestEndedRealEndAgeMs?: number | null
105
+ /** COMPLETED-phase bound for `latestEndedRealEndAgeMs`. Undefined ⇒ the
106
+ * module default (`SUPERSEDE_COMPLETED_GRACE_MS`). */
107
+ latestEndedCompletedGraceMs?: number
82
108
  }
83
109
 
84
110
  /**
85
111
  * Whether the latest-ended candidate is fresh enough to carry supersede
86
- * (deletion) authority. An OMITTED age or TTL means unbounded (the pre-F2
87
- * back-compat escape); an EXPLICIT null age fails closed (#3725 — the caller
88
- * computed no age because its candidate turn has not ended, and an un-ended turn
89
- * must be resolved by the `live` tier, never by this destructive fallback).
112
+ * (deletion) authority the turn-completion-window rule (#4173, three phases
113
+ * documented on `latestEndedRealEndAgeMs` above).
114
+ *
115
+ * An EXPLICIT null age fails closed (#3725 the caller computed no age
116
+ * because its candidate turn has not ended, and an un-ended turn must be
117
+ * resolved by the `live` tier, never by this destructive fallback). An OMITTED
118
+ * age or TTL also fails closed (#4175): the old "omitted ⇒ unbounded"
119
+ * back-compat escape meant DROPPING the gateway's bound wiring silently
120
+ * granted unbounded destructive authority — failing toward silent edit-over.
121
+ * Now dropped wiring degrades to "tier never accepted" — a visible duplicate,
122
+ * the safe direction.
90
123
  */
91
124
  function latestEndedAccepted(candidates: ReplyOwnerCandidates): boolean {
92
125
  if (candidates.latestEndedTurnId == null) return false
93
126
  const age = candidates.latestEndedAgeMs
94
127
  const ttl = candidates.latestEndedTtlMs
95
- if (age === null) return false
96
- if (age === undefined || ttl == null) return true
97
- return age <= ttl
128
+ if (age == null || ttl == null) return false
129
+ const realEndAge = candidates.latestEndedRealEndAgeMs
130
+ if (realEndAge === undefined || realEndAge === null) {
131
+ // Legacy caller (no completion signal) or OPEN window (real turn_end not
132
+ // yet observed): the plain age-vs-cap rule. For an open window `ttl` is
133
+ // the generous crash backstop, so a long compaction still resolves.
134
+ return age <= ttl
135
+ }
136
+ // COMPLETED: the session stopped `realEndAge` ago — only the bounded
137
+ // tool-call-replay grace remains.
138
+ const grace = candidates.latestEndedCompletedGraceMs ?? SUPERSEDE_COMPLETED_GRACE_MS
139
+ return realEndAge <= grace
98
140
  }
99
141
 
100
142
  /**
@@ -207,6 +249,21 @@ export function resolveReplyOwnerTurnId(candidates: ReplyOwnerCandidates): strin
207
249
  * `live` keeps its unconditional bypass: `currentTurn` is framework-owned, and
208
250
  * `decideSupersede`'s same-turnId requirement already bars it from reaching a
209
251
  * DIFFERENT ended turn's record. `none` never bypasses (nothing to attribute).
252
+ *
253
+ * ## The second ambiguity: a live sub-agent (#4176)
254
+ *
255
+ * `handbackCouldOwnReply` covers the sub-agent completion that the gateway
256
+ * SYNTHESIZED as an inbound. It does not cover a background sub-agent calling
257
+ * `reply` DIRECTLY, mid-run: that call rides the same main bridge as the parent
258
+ * loop (so the #4172 caller-identity gate cannot see it) and never traverses
259
+ * `pendingInboundBuffer.push` (so no marker is stamped). Marker-absence is only
260
+ * evidence of "the turn's own answer" if nothing else on the session could have
261
+ * emitted this reply — which is exactly what `subagentCouldOwnReply` reports,
262
+ * from gateway-observed `sub_agent_*` liveness. When it is true the content gate
263
+ * is KEPT on every non-`live` tier: a reply that IS the flushed answer still
264
+ * collapses, a sub-agent's foreign content sends fresh. See
265
+ * `gateway/subagent-reply-authority.ts` for the ordering argument and the
266
+ * failure directions.
210
267
  */
211
268
  export function decideContentGateBypass(input: {
212
269
  /** The winning owner tier (`resolveReplyOwnerTier`). */
@@ -216,18 +273,36 @@ export function decideContentGateBypass(input: {
216
273
  /** The SAME candidate set both of the above were derived from — supplies the
217
274
  * framework-derived `latestEndedTurnId` plus its freshness bound, so the
218
275
  * corroboration reuses the EXACT rule `resolveReplyOwnerTier` applies
219
- * (`latestEndedAccepted`) instead of duplicating it: within the TTL, and —
220
- * since #3725 — not a turn that is still running. */
276
+ * (`latestEndedAccepted`) instead of duplicating it: within the
277
+ * turn-completion window (#4173), and — since #3725 — not a turn that is
278
+ * still running. */
221
279
  candidates: ReplyOwnerCandidates
222
280
  /** True when a decoupled-completion inbound (`subagent_handback`) was enqueued
223
- * in this chat AFTER the owner turn ended and within the supersede TTL — the
224
- * ambiguous window where the late reply might BE that handback rather than
225
- * the turn's own answer. Keeps the content gate on every non-`live` tier. */
281
+ * in this chat AFTER the owner turn ended and within the handback recency
282
+ * window (`HANDBACK_RECENCY_WINDOW_MS`, #4174) the ambiguous window where
283
+ * the late reply might BE that handback rather than the turn's own answer.
284
+ * Keeps the content gate on every non-`live` tier. */
226
285
  handbackCouldOwnReply: boolean
286
+ /** #4176 — true when a sub-agent is LIVE on this session
287
+ * (`subagentReplyAuthority`, gateway/subagent-reply-authority.ts). A
288
+ * background `Task` sub-agent calls `reply` over the SAME main bridge as the
289
+ * parent loop, so the #4172 caller-identity gate cannot see it and — because
290
+ * a DIRECT tool call never traverses `pendingInboundBuffer.push` — it stamps
291
+ * no handback marker either. Marker-absence therefore does NOT prove "the
292
+ * turn's own answer" while a sub-agent is live: the reply might be the
293
+ * sub-agent's, and bypassing the content gate would silently EDIT OVER the
294
+ * flushed answer. Typed REQUIRED and evaluated FAIL-CLOSED (anything but an
295
+ * explicit `false` keeps the gate): `telegram-plugin/` is not covered by
296
+ * `tsc --noEmit`, so the type alone cannot stop a caller from dropping the
297
+ * wiring — and the #4175 precedent is that a dropped bound fails closed,
298
+ * never open. */
299
+ subagentCouldOwnReply: boolean
227
300
  }): boolean {
228
301
  if (input.tier === 'live') return true
229
302
  if (input.tier === 'none') return false
230
303
  if (input.handbackCouldOwnReply) return false
304
+ // Fail-CLOSED on an omitted flag (see the field doc): `!== false`, not truthiness.
305
+ if (input.subagentCouldOwnReply !== false) return false
231
306
  // Total over degenerate input (#3726). TypeScript makes `candidates` mandatory
232
307
  // and the one production caller (`resolveReplyOwnerTurn`) always builds it, so
233
308
  // this cannot fire today — but this module is exported precisely so the
@@ -260,16 +335,17 @@ export function decideContentGateBypass(input: {
260
335
  * handback pattern (#3426): the parent turn's interim ack (a substantive
261
336
  * `reply`) armed the latch, the turn ended, and the sub-agent completion
262
337
  * handback — a genuinely NEW answer arriving with no live gateway turn —
263
- * resolved the ended turn as owner (latest-ended tier, inside the 60 s
264
- * supersede TTL), saw the stale latch, and was silently dropped with a false
338
+ * resolved the ended turn as owner (latest-ended tier, inside the — then 60 s
339
+ * supersede window), saw the stale latch, and was silently dropped with a false
265
340
  * "deduped" success. Tagging the source lets the suppression fire ONLY for the
266
341
  * flush races it exists for; byte-identical replays of a reply-delivered
267
342
  * answer remain covered by the content-keyed outbound dedup (#546).
268
343
  *
269
344
  * Honest bound on that dedup cover: the #546 TTL (60 s) is anchored at reply
270
- * RECORD time, while the latest-ended owner tier's 60 s is anchored at the
271
- * turn's `endedAt` — later by the reply→turn_end gap. A byte-identical replay
272
- * landing >60 s after record but 60 s after endedAt is evicted from dedup yet
345
+ * RECORD time, while the latest-ended owner tier's bound is the turn-completion
346
+ * window (#4173) anchored at the turn's OBSERVED real end — later by the
347
+ * reply→turn_end gap. A byte-identical replay landing >60 s after record but
348
+ * still inside the completion window is evicted from dedup yet
273
349
  * still resolves the ended turn, so it DELIVERS as a duplicate message. That
274
350
  * is a conscious trade: this fix also drops the weak "reworded/bridge-replayed
275
351
  * duplicate" suppression the reply-armed boolean latch used to provide —
@@ -23,7 +23,9 @@ import {
23
23
  decideSupersedeCorrection,
24
24
  flushedAnswerMatchesReply,
25
25
  FlushedTurnSupersedeRegistry,
26
- DEFAULT_SUPERSEDE_TTL_MS,
26
+ FlushCompletionTracker,
27
+ SUPERSEDE_OPEN_WINDOW_CAP_MS,
28
+ SUPERSEDE_COMPLETED_GRACE_MS,
27
29
  SUPERSEDE_MATCH_MIN_CONTAINMENT_CHARS,
28
30
  type FlushedTurnRecord,
29
31
  } from '../flushed-turn-supersede.js'
@@ -33,6 +35,8 @@ const rec = (over: Partial<FlushedTurnRecord> = {}): FlushedTurnRecord => ({
33
35
  messageIds: [101, 102],
34
36
  text: 'narration\n\nthe real answer',
35
37
  ts: 1_000_000,
38
+ // #4173 — default fixture is an OPEN window (real turn_end not yet observed).
39
+ completedAt: null,
36
40
  ...over,
37
41
  })
38
42
 
@@ -133,10 +137,60 @@ describe('decideSupersede — the duplicate-reply decision core', () => {
133
137
  expect(d.reason).toBe('different-turn')
134
138
  })
135
139
 
136
- it('does NOT supersede once the record is past its TTL', () => {
140
+ it('2026-08-01 incident gap: an OPEN record is still live 143 s after the flush ' +
141
+ '(Stop-hook nudge + proactive /compact delayed the canonical reply)', () => {
142
+ // klanker msgs 25680/25682: under the old 60 s TTL this returned 'expired'
143
+ // and the reply shipped as a second bubble. With the turn-completion window
144
+ // (#4173) the record stays claimable while the session is still composing
145
+ // (real turn_end not yet observed — completedAt null).
137
146
  const d = decideSupersede(rec(), {
138
147
  liveTurnId: 'turn-A',
139
- now: 1_000_000 + DEFAULT_SUPERSEDE_TTL_MS + 1,
148
+ now: 1_000_000 + 143_000,
149
+ })
150
+ expect(d.supersede).toBe(true)
151
+ expect(d.reason).toBe('supersede')
152
+ })
153
+
154
+ it('#4173 outcome: an OPEN record survives a 20-minute /compact — any fixed ' +
155
+ 'sub-20-min TTL turns this red', () => {
156
+ // The point of the completion signal over a widened constant: a compaction
157
+ // longer than any tuned TTL still collapses to ONE bubble, because the
158
+ // claude session has not stopped (no real turn_end observed).
159
+ const d = decideSupersede(rec(), {
160
+ liveTurnId: 'turn-A',
161
+ now: 1_000_000 + 20 * 60_000,
162
+ })
163
+ expect(d.supersede).toBe(true)
164
+ expect(d.reason).toBe('supersede')
165
+ })
166
+
167
+ it('an OPEN record past the crash-backstop cap does NOT supersede', () => {
168
+ const d = decideSupersede(rec(), {
169
+ liveTurnId: 'turn-A',
170
+ now: 1_000_000 + SUPERSEDE_OPEN_WINDOW_CAP_MS + 1,
171
+ })
172
+ expect(d.supersede).toBe(false)
173
+ expect(d.reason).toBe('expired')
174
+ })
175
+
176
+ it('#4173: a COMPLETED record stays claimable through the replay grace ' +
177
+ '(the bridge-reconnect tool_call replay class) …', () => {
178
+ const d = decideSupersede(rec({ completedAt: 1_010_000 }), {
179
+ liveTurnId: 'turn-A',
180
+ now: 1_010_000 + SUPERSEDE_COMPLETED_GRACE_MS - 1,
181
+ })
182
+ expect(d.supersede).toBe(true)
183
+ })
184
+
185
+ it('… and EXPIRES past the grace — a late claimant (the Task-sub-agent-after-' +
186
+ 'turn-end shape) can no longer reach the record at all', () => {
187
+ // Once the session stopped and the grace lapsed, nothing may edit/delete
188
+ // the flushed answer — a genuinely late duplicate ships fresh (safe).
189
+ const d = decideSupersede(rec({ completedAt: 1_010_000 }), {
190
+ liveTurnId: 'turn-A',
191
+ replyText: 'unrelated worker handback content, definitely not the answer',
192
+ positiveAttribution: true,
193
+ now: 1_010_000 + SUPERSEDE_COMPLETED_GRACE_MS + 1,
140
194
  })
141
195
  expect(d.supersede).toBe(false)
142
196
  expect(d.reason).toBe('expired')
@@ -249,7 +303,7 @@ describe('FlushedTurnSupersedeRegistry — record / peek / take lifecycle', () =
249
303
  })
250
304
 
251
305
  it('size() evicts expired records and prunes emptied lanes', () => {
252
- const reg = new FlushedTurnSupersedeRegistry({ ttlMs: 1000 })
306
+ const reg = new FlushedTurnSupersedeRegistry({ openCapMs: 1000 })
253
307
  reg.record('chat1', undefined, { turnId: 'turn-A', messageIds: [1], text: 'a' }, 1000)
254
308
  reg.record('chat1', undefined, { turnId: 'turn-B', messageIds: [2], text: 'b' }, 1000)
255
309
  expect(reg.size(1500)).toBe(2)
@@ -257,7 +311,7 @@ describe('FlushedTurnSupersedeRegistry — record / peek / take lifecycle', () =
257
311
  })
258
312
 
259
313
  it('record() actively sweeps expired records (LOW-2 GC — no orphan accumulation)', () => {
260
- const reg = new FlushedTurnSupersedeRegistry({ ttlMs: 1000 })
314
+ const reg = new FlushedTurnSupersedeRegistry({ openCapMs: 1000 })
261
315
  reg.record('chat1', undefined, { turnId: 'turn-A', messageIds: [1], text: 'a' }, 1000)
262
316
  // A much later flush on a DIFFERENT lane sweeps the now-expired turn-A record.
263
317
  reg.record('chat2', undefined, { turnId: 'turn-B', messageIds: [2], text: 'b' }, 5000)
@@ -434,12 +488,12 @@ describe('positiveAttribution — tier-gated content check', () => {
434
488
  expect(d.reason).toBe('different-turn')
435
489
  })
436
490
 
437
- it('positiveAttribution=true still respects the TTL (expired record never supersedes)', () => {
438
- const d = decideSupersede(rec({ text: FLUSHED }), {
491
+ it('positiveAttribution=true still respects the window (expired record never supersedes)', () => {
492
+ const d = decideSupersede(rec({ text: FLUSHED, completedAt: 1_000_000 }), {
439
493
  liveTurnId: 'turn-A',
440
494
  replyText: REWORDED,
441
495
  positiveAttribution: true,
442
- now: 1_000_000 + DEFAULT_SUPERSEDE_TTL_MS + 1,
496
+ now: 1_000_000 + SUPERSEDE_COMPLETED_GRACE_MS + 1,
443
497
  })
444
498
  expect(d.supersede).toBe(false)
445
499
  expect(d.reason).toBe('expired')
@@ -470,3 +524,51 @@ describe('positiveAttribution — tier-gated content check', () => {
470
524
  expect(reg.peek('chatT', undefined, { liveTurnId: 'turn-Q', now: now + 7_000 }).reason).toBe('no-record')
471
525
  })
472
526
  })
527
+
528
+ describe('#4173 — turn-completion window plumbing (completeAll / FlushCompletionTracker)', () => {
529
+ it('completeAll(now) flips OPEN records to grace-bounded; already-completed keep their earlier stamp', () => {
530
+ const reg = new FlushedTurnSupersedeRegistry()
531
+ reg.record('chat1', undefined, { turnId: 'turn-A', messageIds: [1], text: 'a' }, 1_000)
532
+ reg.record('chat1', undefined, { turnId: 'turn-B', messageIds: [2], text: 'b', completedAt: 2_000 }, 1_000)
533
+ reg.completeAll(50_000)
534
+ // turn-A: completed at 50s → claimable until 50s+grace, expired after.
535
+ expect(reg.peek('chat1', undefined, { liveTurnId: 'turn-A', now: 50_000 + SUPERSEDE_COMPLETED_GRACE_MS - 1 }).supersede).toBe(true)
536
+ expect(reg.peek('chat1', undefined, { liveTurnId: 'turn-A', now: 50_000 + SUPERSEDE_COMPLETED_GRACE_MS + 1 }).reason).toBe('expired')
537
+ // turn-B kept its ORIGINAL earlier completion (2s) — completeAll never widens.
538
+ expect(reg.peek('chat1', undefined, { liveTurnId: 'turn-B', now: 2_000 + SUPERSEDE_COMPLETED_GRACE_MS + 1 }).reason).toBe('expired')
539
+ })
540
+
541
+ it('a record born completed (flush send finished AFTER the close) is grace-bounded, never open', () => {
542
+ const reg = new FlushedTurnSupersedeRegistry()
543
+ // The close ran at t=5s while the flush send was in flight; record lands at
544
+ // t=6s carrying the atom's stamped completion.
545
+ reg.record('chatX', undefined, { turnId: 'turn-C', messageIds: [9], text: 'c', completedAt: 5_000 }, 6_000)
546
+ expect(reg.peek('chatX', undefined, { liveTurnId: 'turn-C', now: 5_000 + SUPERSEDE_COMPLETED_GRACE_MS + 1 }).reason).toBe('expired')
547
+ })
548
+
549
+ it('FlushCompletionTracker: open() nulls realEndObservedAt; closeAll() stamps every pending atom once', () => {
550
+ const t = new FlushCompletionTracker()
551
+ const turnA = { realEndObservedAt: 111 as number | null }
552
+ const turnB = { realEndObservedAt: 222 as number | null }
553
+ t.open(turnA)
554
+ t.open(turnB)
555
+ expect(turnA.realEndObservedAt).toBeNull()
556
+ expect(turnB.realEndObservedAt).toBeNull()
557
+ expect(t.pendingCount()).toBe(2)
558
+ expect(t.closeAll(9_000)).toBe(2)
559
+ expect(turnA.realEndObservedAt).toBe(9_000)
560
+ expect(turnB.realEndObservedAt).toBe(9_000)
561
+ expect(t.pendingCount()).toBe(0)
562
+ // Idempotent: nothing pending → nothing closed, stamps untouched.
563
+ expect(t.closeAll(10_000)).toBe(0)
564
+ expect(turnA.realEndObservedAt).toBe(9_000)
565
+ })
566
+
567
+ it('FlushCompletionTracker: re-open of the same atom does not double-register', () => {
568
+ const t = new FlushCompletionTracker()
569
+ const turn = { realEndObservedAt: null as number | null }
570
+ t.open(turn)
571
+ t.open(turn)
572
+ expect(t.pendingCount()).toBe(1)
573
+ })
574
+ })
@@ -423,6 +423,8 @@ function makeSendReplyDeps(dedup: OutboundDedupCache) {
423
423
  streamKey: key,
424
424
  resolveReplyOwnerTurn: () => ownerRes(null, 'none'),
425
425
  getLastSubagentHandbackAt: () => null,
426
+ // #4176 — no sub-agent live on this session (see subagent-reply-authority.ts).
427
+ subagentReplyAuthority: { subagentCouldOwnReply: () => false },
426
428
  findTurnByOriginId: () => null,
427
429
  findTurnByQuotedMessageId: () => null,
428
430
  resolveAnswerThreadWithLog: (_c: string, explicit: number | undefined) => explicit,