switchroom 0.19.22 → 0.19.24

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 (51) hide show
  1. package/dist/agent-scheduler/index.js +5 -2
  2. package/dist/auth-broker/index.js +95 -2
  3. package/dist/cli/notion-write-pretool.mjs +5 -2
  4. package/dist/cli/switchroom.js +749 -357
  5. package/dist/host-control/main.js +96 -3
  6. package/dist/vault/approvals/kernel-server.js +98 -5
  7. package/dist/vault/broker/server.js +98 -5
  8. package/package.json +5 -4
  9. package/profiles/_base/start.sh.hbs +101 -0
  10. package/profiles/_shared/agent-self-service.md.hbs +64 -109
  11. package/profiles/_shared/delegation-golden-rule.md.hbs +5 -5
  12. package/profiles/_shared/dev-protocol.md.hbs +12 -42
  13. package/profiles/_shared/execution-discipline.md.hbs +7 -14
  14. package/profiles/coding/CLAUDE.md.hbs +0 -6
  15. package/profiles/default/CLAUDE.md.hbs +21 -50
  16. package/skills/dev-protocol/SKILL.md +97 -107
  17. package/skills/switchroom-release/SKILL.md +2 -1
  18. package/telegram-plugin/bunfig.toml +10 -0
  19. package/telegram-plugin/dist/gateway/gateway.js +267 -52
  20. package/telegram-plugin/gateway/backstop-delivery.ts +97 -16
  21. package/telegram-plugin/gateway/captured-answer-resume.ts +46 -17
  22. package/telegram-plugin/gateway/gateway.ts +43 -42
  23. package/telegram-plugin/gateway/latest-turn-lookup.ts +60 -0
  24. package/telegram-plugin/gateway/outbound-send-path.ts +61 -22
  25. package/telegram-plugin/gateway/stream-render.ts +6 -0
  26. package/telegram-plugin/gateway/subagent-handback-marker.ts +1 -1
  27. package/telegram-plugin/gateway/turn-end.ts +1 -1
  28. package/telegram-plugin/gateway/turn-record-status.ts +19 -0
  29. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +65 -0
  30. package/telegram-plugin/reply-owner-resolve.ts +110 -9
  31. package/telegram-plugin/send-gate-degraded.test.ts +45 -16
  32. package/telegram-plugin/send-gate.ts +185 -24
  33. package/telegram-plugin/tests/activity-card-send-gate.test.ts +9 -9
  34. package/telegram-plugin/tests/agent-state-dir-preload.test.ts +33 -0
  35. package/telegram-plugin/tests/backstop-delivery.test.ts +204 -7
  36. package/telegram-plugin/tests/backstop-readback-probe.test.ts +12 -0
  37. package/telegram-plugin/tests/captured-answer-resume.test.ts +104 -0
  38. package/telegram-plugin/tests/latest-turn-lookup.test.ts +77 -0
  39. package/telegram-plugin/tests/narrative-lane-golden.test.ts +23 -1
  40. package/telegram-plugin/tests/reply-owner-resolve.test.ts +531 -0
  41. package/telegram-plugin/tests/send-reply-golden.test.ts +296 -28
  42. package/telegram-plugin/tests/stream-controller-send-gate.test.ts +134 -28
  43. package/telegram-plugin/tests/stream-render-golden.test.ts +25 -3
  44. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +92 -1
  45. package/vendor/hindsight-memory/scripts/drain_pending.py +113 -11
  46. package/vendor/hindsight-memory/scripts/lib/pending.py +802 -65
  47. package/vendor/hindsight-memory/scripts/lib/retain_split.py +54 -7
  48. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +1445 -11
  49. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +78 -6
  50. package/vendor/hindsight-memory/tests/test_drain_pending.py +17 -2
  51. package/vendor/hindsight-memory/tests/test_pending.py +12 -4
@@ -114,8 +114,10 @@ export class BackstopDeliveryLedger {
114
114
 
115
115
  /**
116
116
  * #3278 — transition a landed-unconfirmed chunk to `landed-confirmed` after a
117
- * read-back probe proved the message exists in the chat. Only a confirmed
118
- * chunk counts toward delivery / `complete`.
117
+ * read-back probe proved the message exists in the chat. Confirmation is the
118
+ * STRONGER of the two delivery states; the weaker `landed-unconfirmed` also
119
+ * counts as delivered (an inconclusive probe is not a failure — see
120
+ * `runBackstopDelivery`). Only a POSITIVE absence (`demoteChunk`) un-delivers.
119
121
  */
120
122
  confirmChunk(turnId: string, index: number): void {
121
123
  let set = this.confirmed.get(turnId)
@@ -164,8 +166,14 @@ export class BackstopDeliveryLedger {
164
166
  return true
165
167
  }
166
168
 
167
- /** Landed message ids of CONFIRMED chunks only, in chunk-index order — the set
168
- * the delivery predicate counts (fresh non-card ids that are proven-present). */
169
+ /** Landed message ids of CONFIRMED chunks only, in chunk-index order — the
170
+ * read-back view of the ledger (ids proven present in the chat). The delivery
171
+ * predicate counts LANDED ids (`sentIds`), not these; this is the stronger
172
+ * proven-present subset. Its production consumer is
173
+ * {@link BackstopDeliveryResult.landedUnconfirmedIds} — `sentIds` minus these
174
+ * is the landed-but-uncorroborated set that reaches `turns.jsonl` as
175
+ * `landed_unconfirmed`, the counter that measures whether this module's
176
+ * optimism about an inconclusive probe is ever wrong. */
169
177
  confirmedIds(turnId: string): number[] {
170
178
  const m = this.chunks.get(turnId)
171
179
  const set = this.confirmed.get(turnId)
@@ -226,9 +234,11 @@ export class BackstopDeliveryLedger {
226
234
  * `landed-confirmed` (counts toward delivery).
227
235
  * - `absent` — Telegram `400 message to edit not found` ⇒ positive absence
228
236
  * ⇒ demote to `unsent` (safe to re-send — it never landed).
229
- * - `ambiguous` — 429 / 5xx / network / gate-shed / anything else ⇒ leave
230
- * `landed-unconfirmed`; NEVER re-send (a re-send would risk a
231
- * duplicate, and duplicate-risk beats missing-risk here).
237
+ * - `ambiguous` — 429 / 5xx / network / gate-shed / gate no-op / anything else
238
+ * ⇒ leave `landed-unconfirmed`; NEVER re-send (a re-send would
239
+ * risk a duplicate, and duplicate-risk beats missing-risk
240
+ * here) and NEVER count it as a delivery failure either — the
241
+ * probe established nothing, so the landed-id evidence stands.
232
242
  *
233
243
  * NOTE (honest limitation, #3278 §1.4): a passing probe proves only that the
234
244
  * message EXISTS at that chat_id. It does NOT prove the human's client rendered
@@ -322,8 +332,36 @@ export interface BackstopDeliveryResult {
322
332
  sentIds: number[]
323
333
  /** Number of input chunks the answer was split into. */
324
334
  chunkCount: number
325
- /** True IFF every chunk landed at least one fresh non-card id. */
335
+ /**
336
+ * True IFF every chunk landed at least one message id AND at least one of
337
+ * them is a fresh non-card chat id. This is the DELIVERY verdict the turn
338
+ * record, the obligation ledger and the status reaction key on.
339
+ *
340
+ * A read-back probe can only ever LOWER it, and only on POSITIVE absence: an
341
+ * `absent` verdict demotes the chunk back to `unsent`, so it stops counting as
342
+ * landed. An `ambiguous` probe carries no information and therefore does not
343
+ * move this flag — see {@link confirmed}.
344
+ */
326
345
  delivered: boolean
346
+ /**
347
+ * True IFF every chunk was read-back CONFIRMED (`exists`). Strictly stronger
348
+ * than {@link delivered} and purely observational — nothing keys a failure on
349
+ * it. `delivered && !confirmed` is the `landed-unconfirmed` state: the Bot API
350
+ * returned fresh ids for every chunk but the probe could not corroborate them.
351
+ */
352
+ confirmed: boolean
353
+ /**
354
+ * The landed message ids NO read-back corroborated — `sentIds` minus the
355
+ * confirmed subset, in chunk-index order. Empty when {@link confirmed}.
356
+ *
357
+ * This is the MEASURABLE form of this module's central bet: a landed id whose
358
+ * probe came back inconclusive is counted as delivered. Its count is stamped
359
+ * onto the turn record as `landed_unconfirmed` (see `buildTurnRecord`) so the
360
+ * fleet can tell how often that bet is being made — and, if a
361
+ * `landed_unconfirmed` turn is ever followed by a "you never answered me",
362
+ * that the bet was wrong. Purely observational: nothing keys a failure on it.
363
+ */
364
+ landedUnconfirmedIds: number[]
327
365
  /** How many attempts ran (1..maxAttempts). */
328
366
  attempts: number
329
367
  /** True when retries were exhausted without full delivery (terminal fail). */
@@ -345,9 +383,12 @@ export interface BackstopDeliveryResult {
345
383
  * is read-back-probed via `deps.readBack` (when provided): `exists` confirms it,
346
384
  * `absent` demotes it to `unsent` so the NEXT attempt re-sends only that chunk,
347
385
  * and `ambiguous` leaves it `landed-unconfirmed` — never re-sent (duplicate-risk
348
- * beats missing-risk). `delivered` now requires every chunk `landed-confirmed`,
349
- * so an API-ack'd-but-silently-dropped send (fresh id, absent on read-back) is
350
- * reported `delivered:false` and the caller leaves the obligation OPEN.
386
+ * beats missing-risk). An API-ack'd-but-silently-dropped send (fresh id, absent
387
+ * on read-back) is therefore reported `delivered:false` and the caller leaves
388
+ * the obligation OPEN a correct mechanism that is INERT UNTIL #3703, because
389
+ * the probe is 100% shed in production and never resolves `absent`. An
390
+ * INCONCLUSIVE probe is not a failure: it leaves `delivered` alone and only
391
+ * clears `confirmed` (see `BackstopDeliveryResult`).
351
392
  *
352
393
  * `recordOutbound` (when provided) fires ONCE at the end with the full landed
353
394
  * set and a `texts` array ALIGNED to the actual sent ids (via `ledger.entries`).
@@ -449,12 +490,52 @@ export async function runBackstopDelivery(
449
490
  }
450
491
 
451
492
  const sentIds = ledger.sentIds(turnId)
452
- // #3278 — delivered IFF every chunk is `landed-confirmed` AND at least one
453
- // confirmed id is a fresh non-card chat id (the receipt gate, guard 7).
493
+ const confirmed = ledger.allConfirmed(turnId, chunkCount)
494
+ // The delivery verdict is EVIDENCE-BASED, not confirmation-gated.
495
+ //
496
+ // #3278 originally required every chunk to be `landed-confirmed`, so an
497
+ // `ambiguous` probe — which by definition establishes nothing — produced
498
+ // `delivered:false`. That inverted the guard it was meant to be: the turn was
499
+ // recorded `send_failed`, the status reaction painted error, and the delivery
500
+ // obligation was left OPEN for a re-present, all for an answer the user had
501
+ // demonstrably received. In production the probe is ambiguous essentially
502
+ // always (it is issued at cosmetic priority in the same millisecond as the
503
+ // send it probes, so the per-chat token bucket sheds it), so this turned a
504
+ // successful backstop delivery into a logged failure ~146 times in two weeks.
505
+ //
506
+ // Absence of evidence is not evidence of absence. The verdict is therefore:
507
+ // every chunk LANDED (guard 6) and at least one landed id is a fresh non-card
508
+ // chat id (the receipt gate, guard 7). A probe can still lower it — an
509
+ // `absent` verdict demotes the chunk to `unsent` above, so it is no longer
510
+ // landed — which keeps #3278's real contribution (a positive absence is
511
+ // caught and re-sent) while an inconclusive probe changes nothing.
512
+ //
513
+ // Honesty about what that contribution is worth TODAY: it is INERT UNTIL
514
+ // #3703. The probe is issued at cosmetic priority in the same millisecond as
515
+ // the send it probes, so the per-chat token bucket sheds it 100% of the time
516
+ // (146 `ambiguous` / 0 `absent` across the live fleet) and the `absent` branch
517
+ // is structurally unreachable in production. The demote-and-re-send path below
518
+ // is a correct mechanism with no live trigger; #3703 is the change that wakes
519
+ // the probe (and must first fix its resplit-overwrite hazard). Do not read
520
+ // this block as "absence is still caught in production" — it is not, yet.
521
+ const allLanded = chunkCount > 0 && ledger.unsentIndices(turnId, chunkCount).length === 0
454
522
  const delivered =
455
- ledger.allConfirmed(turnId, chunkCount) &&
456
- backstopReceiptIds(ledger.confirmedIds(turnId), cardMessageId).length > 0
523
+ allLanded && backstopReceiptIds(sentIds, cardMessageId).length > 0
457
524
  const exhausted = !delivered
525
+ // The landed-but-uncorroborated set (L2/L5): `sentIds` minus the read-back
526
+ // confirmed subset. Surfaced on the result so the caller can stamp
527
+ // `landed_unconfirmed` on the turn record — the only way to measure whether
528
+ // counting an inconclusive probe as delivered is ever wrong.
529
+ const confirmedSet = new Set(ledger.confirmedIds(turnId))
530
+ const landedUnconfirmedIds = sentIds.filter(id => !confirmedSet.has(id))
531
+ if (delivered && !confirmed) {
532
+ stderr(
533
+ `telegram gateway: backstop delivery landed-unconfirmed for turn ${turnId} — ` +
534
+ `every chunk returned a fresh message id but the read-back probe was ` +
535
+ `inconclusive for ${landedUnconfirmedIds.length} of ${sentIds.length} landed ` +
536
+ `id(s); counting it delivered (an ambiguous probe is not a failure)\n`,
537
+ )
538
+ }
458
539
 
459
540
  if (deps.recordOutbound && sentIds.length > 0) {
460
541
  const texts: string[] = []
@@ -468,5 +549,5 @@ export async function runBackstopDelivery(
468
549
  deps.recordOutbound(ids, texts)
469
550
  }
470
551
 
471
- return { sentIds, chunkCount, delivered, attempts, exhausted }
552
+ return { sentIds, chunkCount, delivered, confirmed, landedUnconfirmedIds, attempts, exhausted }
472
553
  }
@@ -14,8 +14,8 @@
14
14
  *
15
15
  * ── The fix (design-of-record §2.2) ─────────────────────────────────────────
16
16
  * Make the re-present a *byte-identical captured-answer resume* of ONLY the
17
- * not-yet-`landed-confirmed` tail — never a regeneration — so a chunk that
18
- * already reached the chat is provably never re-posted:
17
+ * not-yet-LANDED tail — never a regeneration — so a chunk that already reached
18
+ * the chat is provably never re-posted:
19
19
  *
20
20
  * 1. On a partial (`!delivered`) turn-flush, persist a {@link
21
21
  * CapturedDeliverySnapshot} (the split chunks + each landed chunk's ids and
@@ -25,9 +25,9 @@
25
25
  * 2. The obligation sweep's `represent` branch becomes source-aware: when the
26
26
  * obligation carries a captured-delivery snapshot it drives THIS resume
27
27
  * (re-run `runBackstopDelivery` for the SAME `turnId` and the SAME captured
28
- * chunks, resuming at the first non-`landed-confirmed` index) instead of
29
- * pushing a fresh-generation inbound. No snapshot ⇒ the genuine "model wrote
30
- * nothing / never fired a backstop" case falls through to fresh generation
28
+ * chunks, resuming at the first UNSENT index) instead of pushing a
29
+ * fresh-generation inbound. No snapshot ⇒ the genuine "model wrote nothing
30
+ * / never fired a backstop" case falls through to fresh generation
31
31
  * unchanged.
32
32
  * 3. The in-memory ledger is rehydrated from the snapshot AND reconciled against
33
33
  * the durable outbound-text oracle (`hasOutboundWithText`), so a chunk that
@@ -39,9 +39,29 @@
39
39
  * chunks (not a regenerated answer). #3278's per-chunk state machine
40
40
  * (`unsent → pending → landed-unconfirmed → {landed-confirmed | unsent}`) binds
41
41
  * here too: the resume re-PROBES `landed-unconfirmed` chunks (never re-sends
42
- * them) and re-SENDS only `unsent` ones, so a confirmed chunk is never duplicated
42
+ * them) and re-SENDS only `unsent` ones, so a landed chunk is never duplicated
43
43
  * by either the in-fire retry OR the cross-represent resume.
44
44
  *
45
+ * ── The CLOSE condition is LANDED, not confirmed (#3702) ────────────────────
46
+ * `runBackstopDelivery`'s verdict is evidence-based: every chunk landed a
47
+ * message id and at least one is a fresh non-card id. An inconclusive read-back
48
+ * does not lower it (only a POSITIVE `absent`, which demotes the chunk back to
49
+ * `unsent`, does). So a resume over a snapshot whose chunks ALL landed but were
50
+ * never corroborated closes the obligation having sent NOTHING — the re-probe
51
+ * ran, resolved nothing, and the landed-id evidence stood.
52
+ *
53
+ * That is deliberate, not an oversight. The alternative — keep the obligation
54
+ * OPEN because nothing corroborated the ids — has no corrective action
55
+ * available: the resume never re-sends a LANDED chunk, so every subsequent
56
+ * represent would re-run the same shed probe, send zero messages, and burn
57
+ * represent budget until the cap escalates a false "the agent never answered
58
+ * you" nudge to the operator. Representing cannot deliver anything the first
59
+ * delivery did not; the only thing it can produce is noise. When the durable
60
+ * outbound-text oracle CAN corroborate (history enabled + the row present) it
61
+ * confirms the chunk at hydration and the same close happens by the older
62
+ * `hasOutboundWithText` route — this path is the same decision when that oracle
63
+ * is unavailable.
64
+ *
45
65
  * This module is PURE state + orchestration over injected effects — no Telegram,
46
66
  * no SQLite, no gateway module state — so the resume decision is unit-testable
47
67
  * (see tests/captured-answer-resume.test.ts). The gateway owns the real
@@ -55,10 +75,10 @@ import type { CapturedDeliverySnapshot, Obligation } from './obligation-ledger.j
55
75
  /**
56
76
  * Build the durable {@link CapturedDeliverySnapshot} from the live per-chunk
57
77
  * ledger after a partial `runBackstopDelivery`. Captures every LANDED chunk's
58
- * message ids + confirmed flag so the resume can (a) never re-send a confirmed
59
- * chunk and (b) re-PROBE a landed-unconfirmed chunk instead of blindly re-sending
60
- * it. Chunks that never landed (send threw) are simply absent → the resume treats
61
- * them as `unsent` and re-sends them. Pure.
78
+ * message ids + confirmed flag so the resume can (a) never re-send a chunk that
79
+ * already landed and (b) re-PROBE a landed-unconfirmed chunk instead of blindly
80
+ * re-sending it. Chunks that never landed (send threw) are simply absent → the
81
+ * resume treats them as `unsent` and re-sends them. Pure.
62
82
  */
63
83
  export function buildCapturedDeliverySnapshot(
64
84
  ledger: BackstopDeliveryLedger,
@@ -165,9 +185,13 @@ export interface CapturedResumeDispatcher {
165
185
  * The obligation-sweep's captured-answer resume driver. Owns the in-flight guard
166
186
  * + the deliver→close/leave-open orchestration so the gateway sweep stays a
167
187
  * two-line dispatch. On each dispatch it consumes one represent-budget unit
168
- * (bounding the ladder → escalate), re-delivers only the non-confirmed tail, and:
188
+ * (bounding the ladder → escalate), re-delivers only the UNSENT tail (a landed
189
+ * chunk is re-probed, never re-sent), and:
169
190
  * - fully delivered ⇒ record the supersede tail, close the obligation, GC the
170
- * ledger (the represent ladder stops);
191
+ * ledger (the represent ladder stops). Since #3702 "delivered" means every
192
+ * chunk LANDED — so a snapshot that is already fully landed closes here
193
+ * having sent nothing (see the module header for why that is the right
194
+ * call, and why leaving it OPEN would only manufacture a false escalation);
171
195
  * - still partial / error ⇒ leave the obligation OPEN so the next eligible
172
196
  * sweep (after the per-represent grace) retries, until the represent cap
173
197
  * escalates it to the operator nudge — no infinite loop.
@@ -176,7 +200,7 @@ export function createCapturedResumeDispatcher(ports: CapturedResumePorts): Capt
176
200
  const stderr = ports.stderr ?? (() => {})
177
201
  const inFlight = new Set<string>()
178
202
 
179
- /** Re-deliver the non-confirmed tail of `o`'s captured answer, byte-identical,
203
+ /** Re-deliver the UNSENT tail of `o`'s captured answer, byte-identical,
180
204
  * by rehydrating the per-chunk ledger from the snapshot reconciled against the
181
205
  * durable text oracle. Never regenerates. */
182
206
  function deliver(o: Obligation, snapshot: CapturedDeliverySnapshot): Promise<{ delivered: boolean; sentIds: number[] }> {
@@ -221,16 +245,21 @@ export function createCapturedResumeDispatcher(ports: CapturedResumePorts): Capt
221
245
  if (delivered) {
222
246
  ports.obligationLedger.close(o.originTurnId)
223
247
  ports.backstopDeliveryLedger.clear(o.originTurnId)
248
+ // `sentIds` is `ledger.sentIds()` — every LANDED id, which since #3702
249
+ // routinely includes ids no read-back corroborated. Say "landed", not
250
+ // "confirmed": claiming confirmation for an uncorroborated id is exactly
251
+ // the overstatement this PR removed from the delivery verdict.
224
252
  stderr(
225
253
  `telegram gateway: captured-answer resume delivered — origin=${o.originTurnId} ` +
226
- `${sentIds.length} chunk(s) confirmed; obligation closed\n`,
254
+ `${sentIds.length} message id(s) landed; obligation closed\n`,
227
255
  )
228
256
  } else {
229
- // Tail still not confirmed leave the obligation OPEN for the next paced
230
- // sweep / escalation (bounded by the represent cap).
257
+ // Not fully landed (a chunk never got an id, or a positive `absent`
258
+ // demoted one) leave the obligation OPEN for the next paced sweep /
259
+ // escalation (bounded by the represent cap).
231
260
  stderr(
232
261
  `telegram gateway: captured-answer resume partial — origin=${o.originTurnId} ` +
233
- `tail still not confirmed; left OPEN for retry\n`,
262
+ `tail still not landed; left OPEN for retry\n`,
234
263
  )
235
264
  }
236
265
  } catch (err) {
@@ -489,7 +489,7 @@ import {
489
489
  import {
490
490
  resolveReplyOwnerTurnId,
491
491
  resolveReplyOwnerTier,
492
- type ReplyOwnerTier,
492
+ type ReplyOwnerTier, type ReplyOwnerCandidates,
493
493
  type AnswerDeliveredLatch,
494
494
  } from '../reply-owner-resolve.js'
495
495
  import { SubagentHandbackMarker } from './subagent-handback-marker.js'
@@ -717,8 +717,9 @@ import {
717
717
  } from './emission-authority.js'
718
718
  import { CurrentTurnMap } from './current-turn-map.js'
719
719
  import { resolveAnswerThreadId } from './answer-thread-resolve.js'
720
+ import { latestTurnForChat } from './latest-turn-lookup.js'
720
721
  import { decideObligationTurnEnd } from './obligation-turn-end.js'
721
- import { maybeRotate } from './turns-jsonl-rotate.js'
722
+ import { maybeRotate, resolveAgentStateDir, resolveTurnsJsonlPath } from './turns-jsonl-rotate.js'
722
723
  import {
723
724
  buildTurnRecord,
724
725
  finalizeBackstopSendGated,
@@ -1032,7 +1033,7 @@ const blockedApprovalStore = createBlockedApprovalStore(
1032
1033
  // Fallback: the agent's OWN state dir, which the scaffold chowns to the agent
1033
1034
  // uid, so a write there always succeeds. Guarantees the record can never be
1034
1035
  // silently lost when the shared dir isn't writable by this agent's uid.
1035
- process.env.SWITCHROOM_AGENT_STATE_DIR ?? '/state/agent',
1036
+ resolveAgentStateDir(),
1036
1037
  )
1037
1038
 
1038
1039
  /**
@@ -2456,9 +2457,9 @@ async function deliverAnswer(args: {
2456
2457
  * the send alive if the user deleted their message. Null for synthesized
2457
2458
  * turns (cron/handback) — those send bare, as before. */
2458
2459
  replyToMessageId: number | null
2459
- /** #3282 captured-answer RESUME (see captured-answer-resume.ts): re-deliver the SAME byte-identical chunks + pre-hydrate the ledger (non-confirmed tail only). */
2460
+ /** #3282 captured-answer RESUME (see captured-answer-resume.ts): re-deliver the SAME byte-identical chunks + pre-hydrate the ledger (unsent tail only; a landed chunk is re-probed, never re-sent). */
2460
2461
  resume?: { snapshot: CapturedDeliverySnapshot; hydrate: (ledger: BackstopDeliveryLedger, turnId: string) => void }
2461
- }): Promise<{ sentIds: number[]; chunkCount: number; delivered: boolean; exhausted: boolean }> {
2462
+ }): Promise<{ sentIds: number[]; chunkCount: number; delivered: boolean; exhausted: boolean; landedUnconfirmed: number }> {
2462
2463
  const { chatId, turnId } = args
2463
2464
  // Spacers into `\n\n` gaps then split (as executeReply); a resume re-delivers the EXACT captured chunks (byte-stable, no re-split).
2464
2465
  const chunks = args.resume
@@ -2594,6 +2595,7 @@ async function deliverAnswer(args: {
2594
2595
  chunkCount: result.chunkCount,
2595
2596
  delivered: result.delivered,
2596
2597
  exhausted: result.exhausted,
2598
+ landedUnconfirmed: result.landedUnconfirmedIds.length, // #3702 — the caller stamps this on the turn record
2597
2599
  }
2598
2600
  }
2599
2601
 
@@ -3427,6 +3429,7 @@ export type CurrentTurn = {
3427
3429
  // turn-end paths (reply-tool tail, silent-marker, genuine no-reply), where
3428
3430
  // the legacy `finalAnswerDelivered` reading still applies unchanged.
3429
3431
  deliveryOutcome?: DeliveryOutcome
3432
+ landedUnconfirmed?: number // #3702 — landed ids no read-back corroborated; emitted as `landed_unconfirmed` (rationale: turn-record-status.ts)
3430
3433
  // Feed-reopen-after-ack refinement — whether the reply that set
3431
3434
  // `finalAnswerDelivered` was a *substantive* final answer (stream
3432
3435
  // `done`, or ≥200 chars) as opposed to a short pinging interim ACK.
@@ -3482,12 +3485,14 @@ export type CurrentTurn = {
3482
3485
  flushedAnswerText: string | null
3483
3486
  // 2026-07 double-reply-on-DM fix (F2 — recency bound). Wall-clock ms the turn
3484
3487
  // ENDED (stamped once by `endCurrentTurnAtomic`), or null while still live.
3485
- // The `findLatestEndedTurnForChat` supersede tier carries DESTRUCTIVE
3486
- // authority (it drives message deletion), so `resolveReplyOwnerTurn` only
3487
- // honours a latest-ended turn whose `endedAt` is within the supersede TTL
3488
+ // The `latest-ended` supersede tier carries DESTRUCTIVE authority (it drives
3489
+ // message deletion), so `resolveReplyOwnerTurn` only honours a latest-ended
3490
+ // turn whose `endedAt` is non-null (#3725 — the registry is populated at turn
3491
+ // START, so the tail entry may still be RUNNING) AND within the supersede TTL —
3488
3492
  // otherwise a late reply belonging to an OLDER turn could resolve its owner to
3489
- // a NEWER turn sitting at the registry tail and delete that newer turn's legit
3490
- // answer. Unbounded routing use of `findLatestEndedTurnForChat` is unaffected.
3493
+ // a NEWER turn at the registry tail and delete that turn's legit answer. The
3494
+ // unbounded ROUTING use (`endedOnly: false`) is unaffected: it only picks a
3495
+ // topic to deliver into and deletes nothing.
3491
3496
  endedAt: number | null
3492
3497
  // #1675 (over-ping safety net): wall-clock ms of the first reply
3493
3498
  // this turn that landed with `disable_notification: false` (a real
@@ -3997,21 +4002,18 @@ const LATE_REPLY_TOPIC_RECOVERY_ENABLED =
3997
4002
  process.env.SWITCHROOM_LATE_REPLY_TOPIC_RECOVERY !== '0'
3998
4003
 
3999
4004
  /**
4000
- * The most-recently-started turn for a chat from the bounded recently-ended
4001
- * registry — the deterministic fallback for a LATE answer reply when the model
4002
- * echoed no `origin_turn_id` and `currentTurn` has already cleared. Iterates in
4003
- * insertion order so the last match is the most recent turn for that chat.
4004
- * Returns null when the chat has no remembered turn (so the caller keeps the
4005
- * legacy result). NB: this is the chat's own most-recent TURN, not the
4006
- * `chatThreadMap` last-seen-any-message heuristic that caused the wrong-topic
4007
- * bug a late reply almost always belongs to the turn that just ended.
4005
+ * The most recent turn for a chat from the bounded recent-turn registry — the
4006
+ * deterministic fallback for a LATE answer reply when the model echoed no
4007
+ * `origin_turn_id` and `currentTurn` has already cleared. Returns null when the
4008
+ * chat has no matching turn (so the caller keeps the legacy result). NB: this is
4009
+ * the chat's own most-recent TURN, not the `chatThreadMap` last-seen-any-message
4010
+ * heuristic that caused the wrong-topic bug. `endedOnly` selects the consumer's
4011
+ * semantics routing takes the tail entry even when that turn is still RUNNING;
4012
+ * the destructive `latest-ended` owner tier takes only a genuinely ENDED turn,
4013
+ * because the registry is populated at turn START (#3725; see the module).
4008
4014
  */
4009
- function findLatestEndedTurnForChat(chatId: string): CurrentTurn | null {
4010
- let latest: CurrentTurn | null = null
4011
- for (const t of recentTurnsById.values()) {
4012
- if (t.sessionChatId === chatId) latest = t
4013
- }
4014
- return latest
4015
+ function findLatestTurnForChat(chatId: string, opts: { endedOnly: boolean }): CurrentTurn | null {
4016
+ return latestTurnForChat(recentTurnsById.values(), chatId, opts)
4015
4017
  }
4016
4018
 
4017
4019
  /**
@@ -4027,7 +4029,7 @@ function findLatestEndedTurnForChat(chatId: string): CurrentTurn | null {
4027
4029
  * 1. the live `currentTurn` passed in (null once the flush nulled the atom);
4028
4030
  * 2. `findTurnByOriginId(origin_turn_id)` — the model echo;
4029
4031
  * 3. `findTurnByQuotedMessageId(chat_id, reply_to)` — framework-owned quote;
4030
- * 4. `findLatestEndedTurnForChat(chat_id)` — the chat's last-ended turn.
4032
+ * 4. `findLatestTurnForChat(chat_id, {endedOnly:true})` — last ENDED turn.
4031
4033
  * Returns the CurrentTurn for the winning id (so callers can read its
4032
4034
  * `answerDelivered` latch), or null when every lookup missed.
4033
4035
  */
@@ -4035,10 +4037,10 @@ function resolveReplyOwnerTurn(
4035
4037
  liveTurn: CurrentTurn | null,
4036
4038
  chatId: string,
4037
4039
  args: Record<string, unknown>,
4038
- ): { turn: CurrentTurn | null; tier: ReplyOwnerTier } {
4040
+ ): { turn: CurrentTurn | null; tier: ReplyOwnerTier; candidates: ReplyOwnerCandidates } {
4039
4041
  const origin = findTurnByOriginId(args.origin_turn_id as string | undefined)
4040
4042
  const quoted = findTurnByQuotedMessageId(chatId, args.reply_to)
4041
- const latestEnded = findLatestEndedTurnForChat(chatId)
4043
+ const latestEnded = findLatestTurnForChat(chatId, { endedOnly: true })
4042
4044
  const byId = new Map<string, CurrentTurn>()
4043
4045
  // Populate lowest-precedence first so a higher tier's turn wins the id slot
4044
4046
  // when two lookups resolve the same turn (they carry the same turnId anyway).
@@ -4047,12 +4049,12 @@ function resolveReplyOwnerTurn(
4047
4049
  }
4048
4050
  // F2 — bound the DESTRUCTIVE latest-ended tier to the supersede TTL so a stale
4049
4051
  // latest-ended turn can't inherit deletion authority over a newer turn's flush
4050
- // record. `endedAt` is null only for a turn still resolvable but not yet ended
4051
- // (not a supersede risk); leave the age unset then (unbounded) rather than
4052
- // fabricate one.
4052
+ // record. #3725: the lookup above is `endedOnly`, so `endedAt` is non-null here
4053
+ // and the age is ALWAYS a real number a not-yet-ended turn is no longer a
4054
+ // candidate at all, and an explicit null age now fails CLOSED downstream.
4053
4055
  const latestEndedAgeMs =
4054
4056
  latestEnded?.endedAt != null ? Date.now() - latestEnded.endedAt : null
4055
- const candidates = {
4057
+ const candidates: ReplyOwnerCandidates = {
4056
4058
  liveTurnId: liveTurn?.turnId ?? null,
4057
4059
  originTurnId: origin?.turnId ?? null,
4058
4060
  quotedTurnId: quoted?.turnId ?? null,
@@ -4060,15 +4062,14 @@ function resolveReplyOwnerTurn(
4060
4062
  latestEndedAgeMs,
4061
4063
  latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
4062
4064
  }
4063
- // #3429 — the WINNING tier travels with the turn. A positive tier
4064
- // (live/origin/quoted) means the reply is this turn's own answer and the
4065
- // supersede fires regardless of text; the ambiguous `latest-ended` fallback
4066
- // keeps the content gate (it cannot tell a late own-reply from an async
4067
- // sub-agent handback). Both derive from the SAME candidates, so the id and the
4068
- // tier can never disagree.
4065
+ // #3429 — the winning tier AND the candidate set it came from travel with the
4066
+ // turn. Tier alone no longer decides the content-gate bypass: the
4067
+ // model-steerable `origin`/`quoted` tiers must be CORROBORATED against the
4068
+ // framework-derived `latestEndedTurnId` (`decideContentGateBypass`). All three
4069
+ // derive from these SAME candidates, so they can never disagree.
4069
4070
  const tier = resolveReplyOwnerTier(candidates)
4070
4071
  const winnerId = resolveReplyOwnerTurnId(candidates)
4071
- return { turn: winnerId != null ? (byId.get(winnerId) ?? null) : null, tier }
4072
+ return { turn: winnerId != null ? (byId.get(winnerId) ?? null) : null, tier, candidates }
4072
4073
  }
4073
4074
 
4074
4075
  /**
@@ -4113,7 +4114,7 @@ function resolveAnswerThreadWithLog(
4113
4114
  explicitThreadId == null &&
4114
4115
  originTurn == null &&
4115
4116
  liveTurn == null
4116
- ? findLatestEndedTurnForChat(chatId)
4117
+ ? findLatestTurnForChat(chatId, { endedOnly: false })
4117
4118
  : null
4118
4119
  const threadId = resolveAnswerThreadId({
4119
4120
  explicitThreadId,
@@ -5012,7 +5013,7 @@ function snapshotContextOccupancy(): void {
5012
5013
  } catch {
5013
5014
  cap = null; // config unreadable → show occupancy without a ratio
5014
5015
  }
5015
- const stateDir = process.env.SWITCHROOM_AGENT_STATE_DIR ?? "/state/agent";
5016
+ const stateDir = resolveAgentStateDir();
5016
5017
  writeContextOccupancySnapshot(
5017
5018
  stateDir,
5018
5019
  buildContextOccupancy(occupancy, cap, Date.now()),
@@ -5033,12 +5034,12 @@ function emitTurnRecord(turn: CurrentTurn, endedAt: number): void {
5033
5034
  toolCallCount: turn.toolCallCount ?? 0,
5034
5035
  turnId: turn.turnId,
5035
5036
  finalAnswerDelivered: turn.finalAnswerDelivered,
5036
- deliveryOutcome: turn.deliveryOutcome,
5037
+ deliveryOutcome: turn.deliveryOutcome, landedUnconfirmed: turn.landedUnconfirmed,
5037
5038
  },
5038
5039
  endedAt,
5039
5040
  ),
5040
5041
  ) + '\n'
5041
- const turnsPath = '/state/agent/turns.jsonl'
5042
+ const turnsPath = resolveTurnsJsonlPath() // honours SWITCHROOM_AGENT_STATE_DIR
5042
5043
  // Size-cap rotation: keep at most one rotated generation so the file can't
5043
5044
  // grow unbounded on a long-lived agent. Best-effort (never throws).
5044
5045
  maybeRotate(turnsPath, {
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The recently-seen-turn registry scan, extracted from `gateway.ts` as a pure
3
+ * function so its ENDED-ness contract is unit-testable (#3725; gateway.ts is not
4
+ * importable in tests — the repo's `decideTurnFlush` / `resolveReplyOwnerTurnId`
5
+ * pattern).
6
+ *
7
+ * ## Why `endedOnly` exists (#3725)
8
+ *
9
+ * `recentTurnsById` is populated at turn **start** (`rememberRecentTurn` fires
10
+ * from the `enqueue` lifecycle event in `stream-render.ts`), and the atom is
11
+ * built with `endedAt: null`; `endedAt` is stamped later, in `turn-end.ts`. So
12
+ * the registry's tail entry for a chat is the most-recently-STARTED turn, which
13
+ * may still be RUNNING. The registry is also chat-wide and thread-agnostic, so
14
+ * on a forum a turn running in ANOTHER topic sits at the tail.
15
+ *
16
+ * That distinction is load-bearing because the two consumers want different
17
+ * things:
18
+ *
19
+ * - **Routing** (`resolveAnswerThreadWithLog`) wants the chat's most recent
20
+ * turn whether or not it has ended — it only picks a topic to deliver into,
21
+ * and a still-running turn's topic is a perfectly good (indeed better)
22
+ * answer than falling back to General. `endedOnly: false`.
23
+ * - **Owner resolution** (`resolveReplyOwnerTurn` → the `latest-ended`
24
+ * supersede tier) wants a genuinely ENDED turn: that tier carries
25
+ * DESTRUCTIVE authority (it drives message deletion) and is bounded by the
26
+ * supersede TTL measured from `endedAt`. A turn with `endedAt == null` has
27
+ * no age, so it could not be TTL-bounded at all — it was an unbounded
28
+ * anchor for the corroborated content-gate bypass (#3725). A still-running
29
+ * turn must be resolved by the `live` tier, never by this fallback.
30
+ * `endedOnly: true`.
31
+ */
32
+
33
+ /** The registry-atom shape this scan needs (structural — `CurrentTurn` in the
34
+ * gateway satisfies it without importing the gateway's type). */
35
+ export interface LatestTurnLookupAtom {
36
+ /** The chat the turn belongs to. */
37
+ sessionChatId: string
38
+ /** Wall-clock ms the turn ENDED, or null while it is still running. */
39
+ endedAt: number | null
40
+ }
41
+
42
+ /**
43
+ * The last turn for `chatId` in registry insertion order — i.e. the most recent
44
+ * one. With `endedOnly: true` the scan skips turns that have not ended yet, so
45
+ * the result is the most-recently-ended turn (which may NOT be the tail entry).
46
+ * Returns null when the chat has no matching turn.
47
+ */
48
+ export function latestTurnForChat<T extends LatestTurnLookupAtom>(
49
+ turns: Iterable<T>,
50
+ chatId: string,
51
+ opts: { endedOnly: boolean },
52
+ ): T | null {
53
+ let latest: T | null = null
54
+ for (const t of turns) {
55
+ if (t.sessionChatId !== chatId) continue
56
+ if (opts.endedOnly && t.endedAt == null) continue
57
+ latest = t
58
+ }
59
+ return latest
60
+ }