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
@@ -478,6 +478,16 @@ interface MessageEditState {
478
478
  * edit budget. Bounded by the budget cap; empty when the backstop is disabled.
479
479
  */
480
480
  editWindowTs: number[]
481
+ /**
482
+ * Set by the driver while it waits out a NON-critical admission, and fired by
483
+ * `handleEdit` when a `critical` edit is queued for this message meanwhile
484
+ * (#3716). Since cosmetic edits stopped shedding they now occupy the driver,
485
+ * so without this a critical edit arriving one tick after the driver dequeued
486
+ * a cosmetic one would inherit its unbounded wait — re-introducing the
487
+ * multi-hour reply wedge the critical fail-fast path exists to prevent.
488
+ * Null whenever no interruptible wait is in flight.
489
+ */
490
+ criticalWake: (() => void) | null
481
491
  }
482
492
 
483
493
  function hashPayload(payload: unknown): string {
@@ -669,6 +679,7 @@ export function createSendGate(config: SendGateConfig): SendGate {
669
679
  running: false,
670
680
  suppressedUntilMs: 0,
671
681
  editWindowTs: [],
682
+ criticalWake: null,
672
683
  }
673
684
  perMessage.set(key, state)
674
685
  }
@@ -826,9 +837,17 @@ export function createSendGate(config: SendGateConfig): SendGate {
826
837
  * without consuming if admission cannot happen before it — the caller drops
827
838
  * the call as stale (part3-design §2).
828
839
  */
829
- async function admitLoop(buckets: TokenBucket[], deadline?: number): Promise<boolean> {
840
+ async function admitLoop(
841
+ buckets: TokenBucket[],
842
+ deadline?: number,
843
+ interrupt?: Interrupt,
844
+ ): Promise<boolean> {
830
845
  let counted = false
831
846
  for (;;) {
847
+ // Checked BEFORE the consume below so an interrupted admission never
848
+ // spends a token nobody uses (a leaked token would permanently shrink the
849
+ // bucket's effective rate).
850
+ if (interrupt?.fired) return false
832
851
  const now = clock.now()
833
852
  let wait = 0
834
853
  for (const b of buckets) wait = Math.max(wait, b.msUntilAvailable(now))
@@ -841,13 +860,17 @@ export function createSendGate(config: SendGateConfig): SendGate {
841
860
  counters.queued++
842
861
  counted = true
843
862
  }
844
- await clock.sleep(deadline !== undefined ? Math.min(wait, deadline - now) : wait)
863
+ const nap = clock.sleep(deadline !== undefined ? Math.min(wait, deadline - now) : wait)
864
+ // An open flood window makes `wait` as long as the whole ban, so the nap
865
+ // must be raced rather than awaited outright — otherwise the interrupt is
866
+ // not observed until the ban has already elapsed.
867
+ await (interrupt ? Promise.race([nap, interrupt.promise]) : nap)
845
868
  }
846
869
  }
847
870
 
848
871
  /** Back-compat unbounded admission (used by the per-message edit driver). */
849
- function admit(buckets: TokenBucket[]): Promise<boolean> {
850
- return admitLoop(buckets)
872
+ function admit(buckets: TokenBucket[], interrupt?: Interrupt): Promise<boolean> {
873
+ return admitLoop(buckets, undefined, interrupt)
851
874
  }
852
875
 
853
876
  // Serializes `critical` sends that must probe the API during an open (short)
@@ -870,6 +893,36 @@ export function createSendGate(config: SendGateConfig): SendGate {
870
893
  })()
871
894
  }
872
895
 
896
+ /**
897
+ * One-shot abort handle for an in-flight admission wait (#3716). `fired` is
898
+ * the synchronous check (so the loop can bail before consuming a token) and
899
+ * `promise` is the async wake (so a nap covering a whole flood ban is cut
900
+ * short rather than run to completion).
901
+ */
902
+ interface Interrupt {
903
+ fired: boolean
904
+ promise: Promise<void>
905
+ }
906
+
907
+ /** Create an `Interrupt` plus the trigger that fires it exactly once. */
908
+ function makeInterrupt(): { interrupt: Interrupt; fire: () => void } {
909
+ let resolve!: () => void
910
+ const interrupt: Interrupt = {
911
+ fired: false,
912
+ promise: new Promise<void>((r) => {
913
+ resolve = r
914
+ }),
915
+ }
916
+ return {
917
+ interrupt,
918
+ fire: () => {
919
+ if (interrupt.fired) return
920
+ interrupt.fired = true
921
+ resolve()
922
+ },
923
+ }
924
+ }
925
+
873
926
  type AdmitOutcome =
874
927
  | { result: 'ok' }
875
928
  | { result: 'shed' }
@@ -1038,7 +1091,24 @@ export function createSendGate(config: SendGateConfig): SendGate {
1038
1091
  }
1039
1092
  // outcome.result === 'ok' → admitPriority already consumed the buckets.
1040
1093
  } else {
1041
- await admit(bucketsFor(opts))
1094
+ // #3716: this wait is UNBOUNDED, and since cosmetic edits stopped
1095
+ // shedding it is now reachable for them too — so a `critical` edit
1096
+ // arriving while we sit here must be able to jump the queue instead
1097
+ // of inheriting a whole flood ban's worth of waiting. Yield to it and
1098
+ // re-loop; `p` is superseded rather than dropped, so no state is lost
1099
+ // and its callers ride the send that actually goes out.
1100
+ const { interrupt, fire } = makeInterrupt()
1101
+ state.criticalWake = fire
1102
+ let admitted: boolean
1103
+ try {
1104
+ admitted = await admit(bucketsFor(opts), interrupt)
1105
+ } finally {
1106
+ state.criticalWake = null
1107
+ }
1108
+ if (!admitted) {
1109
+ supersede(state, p)
1110
+ continue
1111
+ }
1042
1112
  }
1043
1113
  // Reserve the send-start time BEFORE awaiting the network so the floor
1044
1114
  // is measured from send start (matches the per-message serialization).
@@ -1084,6 +1154,70 @@ export function createSendGate(config: SendGateConfig): SendGate {
1084
1154
  }
1085
1155
  }
1086
1156
 
1157
+ /**
1158
+ * Hand a dequeued-but-unsent edit back to the queue after the driver yielded
1159
+ * to a higher-priority one (#3716). `p` is by definition OLDER than whatever
1160
+ * is queued now, so last-write-wins says its payload loses — but its callers
1161
+ * must still settle, so they ride the newer edit's result exactly as if they
1162
+ * had coalesced onto it in the first place. With nothing queued (the wake
1163
+ * raced an empty slot) `p` simply goes back.
1164
+ */
1165
+ function supersede(state: MessageEditState, p: PendingEdit): void {
1166
+ const next = state.pending
1167
+ if (!next) {
1168
+ state.pending = p
1169
+ return
1170
+ }
1171
+ next.priorityClass = maxPriority(next.priorityClass, p.priorityClass)
1172
+ next.promise.then(p.resolve, p.reject)
1173
+ }
1174
+
1175
+ /**
1176
+ * The condition that used to trigger the cosmetic-edit shed: no token in some
1177
+ * bucket, or a window covering this message. Kept as the trigger — what
1178
+ * changed in #3716 is the CONSEQUENCE (see `settleForCaller`).
1179
+ */
1180
+ function editUnderPressure(state: MessageEditState, opts: SendGateOpts, now: number): boolean {
1181
+ if (state.suppressedUntilMs > now) return true
1182
+ for (const b of bucketsFor(opts)) {
1183
+ if (b.msUntilAvailable(now) > 0) return true
1184
+ }
1185
+ return false
1186
+ }
1187
+
1188
+ /**
1189
+ * What a queued edit returns to the caller that submitted it (#3716).
1190
+ *
1191
+ * Normally: the shared pending promise, settling with the coalesced send's
1192
+ * outcome. Callers rely on that to pace their own painting, so this is the
1193
+ * path whenever the gate is keeping up.
1194
+ *
1195
+ * Under pressure, a `cosmetic` edit instead settles its caller as soon as it
1196
+ * is QUEUED. Callers serialize their own flushes, so a best-effort paint
1197
+ * parked behind a flood ban would hold a subsequent critical edit hostage
1198
+ * UPSTREAM of the gate — where the fail-fast path can never see it — wedging
1199
+ * the reply path for the length of the ban. Releasing the caller keeps that
1200
+ * path free while the edit itself stays queued and still lands carrying the
1201
+ * newest payload. This is the same trigger the old shed used; the difference
1202
+ * is that the state survives instead of being discarded.
1203
+ */
1204
+ function settleForCaller<T>(
1205
+ state: MessageEditState,
1206
+ pending: PendingEdit,
1207
+ opts: SendGateOpts,
1208
+ priority: PriorityClass,
1209
+ now: number,
1210
+ ): Promise<T> {
1211
+ if (priority !== 'cosmetic' || !editUnderPressure(state, opts, now)) {
1212
+ return pending.promise as Promise<T>
1213
+ }
1214
+ // Nobody may be left awaiting the shared promise, so absorb a rejection here
1215
+ // to keep a failed background paint from surfacing as an unhandled rejection.
1216
+ // (A later `critical`/`useful` coalesce still attaches its own handlers.)
1217
+ void pending.promise.catch(() => {})
1218
+ return Promise.resolve(undefined as unknown as T)
1219
+ }
1220
+
1087
1221
  function handleEdit<T>(fn: () => Promise<T>, opts: SendGateOpts): Promise<T> {
1088
1222
  const messageId = opts.messageId as number
1089
1223
  const key = messageKey(opts.chat_id, messageId)
@@ -1093,22 +1227,39 @@ export function createSendGate(config: SendGateConfig): SendGate {
1093
1227
  maybeEvict(now, existing === undefined)
1094
1228
  const state = existing ?? messageState(key)
1095
1229
 
1096
- // Cosmetic edits (part3-design §2) shed under pressure a flood window
1097
- // covering the scope is open, or a bucket has no token free right now. A
1098
- // dropped edit costs nothing: the next edit carries the full state. Only an
1099
- // EXPLICITLY-cosmetic edit sheds; untagged edits keep PR 1's coalescing
1100
- // behaviour (default = useful), so this never changes an untagged caller.
1101
- if ((opts.priorityClass ?? 'useful') === 'cosmetic') {
1102
- let wait = 0
1103
- for (const b of bucketsFor(opts)) wait = Math.max(wait, b.msUntilAvailable(now))
1104
- const msgWait = state.suppressedUntilMs > now ? state.suppressedUntilMs - now : 0
1105
- if (wait > 0 || msgWait > 0) {
1106
- counters.shed++
1107
- // Distinguishable from the no-op drop below (undefined): a shed did
1108
- // NOT land, and edit-driving callers must be able to tell (F1).
1109
- return Promise.resolve(SEND_GATE_SHED as unknown as T)
1110
- }
1111
- }
1230
+ // Cosmetic edits COALESCE under pressure; they are never shed (#3716).
1231
+ //
1232
+ // This previously dropped a cosmetic edit outright whenever a bucket had no
1233
+ // free token or a flood window covered the scope, on the reasoning that "a
1234
+ // dropped edit costs nothing: the next edit carries the full state". That
1235
+ // holds for every edit in a burst EXCEPT the last one — and the last one is
1236
+ // the only edit whose state is still on screen when the burst ends. Dropping
1237
+ // it strands the card on a stale body indefinitely (a progress card frozen
1238
+ // mid-run, a finished task still rendered as running). Pressure is exactly
1239
+ // when bursts end, so the drop was biased toward stranding precisely the
1240
+ // edits that mattered.
1241
+ //
1242
+ // Falling through to the last-write-wins coalescing below loses nothing and
1243
+ // costs no extra flood budget: N queued edits collapse into ONE send that
1244
+ // carries the newest payload, and the driver already paces that send against
1245
+ // the token buckets, the per-message edit floor, and the rolling per-window
1246
+ // cosmetic budget — which DEFERS an over-budget edit rather than dropping it.
1247
+ // Flood safety comes from that pacing, never from discarding state.
1248
+ //
1249
+ // Cost of the change: under a long flood window a cosmetic edit now waits in
1250
+ // the driver instead of resolving immediately as SEND_GATE_SHED. The pending
1251
+ // slot is one-per-message and LRU-bounded by `maxMessageStates`, and the edit
1252
+ // that eventually lands carries the newest state rather than a stale one.
1253
+ // `SEND_GATE_SHED` remains the contract for NON-edit cosmetic sends (typing,
1254
+ // reactions), which carry no state worth preserving.
1255
+ //
1256
+ // What that cost must NOT become is a wait the CALLER inherits. Callers
1257
+ // serialize their own flushes (draft-stream awaits the in-flight paint before
1258
+ // starting the next), so a cosmetic edit that blocked its caller for a whole
1259
+ // flood ban would hold the critical finalize behind it — and the finalize
1260
+ // would never reach the gate to fail fast, wedging the reply path for hours.
1261
+ // That is precisely the failure the gate exists to prevent, so a cosmetic
1262
+ // edit settles its caller as soon as it is QUEUED (see `settleForCaller`).
1112
1263
 
1113
1264
  // N1: the coalesce (last-write-wins) check runs BEFORE the no-op skip. When
1114
1265
  // a distinct edit is already queued, the newest payload always replaces it —
@@ -1135,7 +1286,10 @@ export function createSendGate(config: SendGateConfig): SendGate {
1135
1286
  state.pending.hash = hash
1136
1287
  state.pending.fn = fn as () => Promise<unknown>
1137
1288
  }
1138
- return state.pending.promise as Promise<T>
1289
+ // The queued edit is critical now — cut short any non-critical admission
1290
+ // the driver is waiting out for this message (#3716).
1291
+ if (state.pending.priorityClass === 'critical') state.criticalWake?.()
1292
+ return settleForCaller<T>(state, state.pending, opts, opts.priorityClass ?? 'useful', now)
1139
1293
  }
1140
1294
 
1141
1295
  // No edit queued → a repeat of the last payload we actually sent is a plain
@@ -1164,8 +1318,15 @@ export function createSendGate(config: SendGateConfig): SendGate {
1164
1318
  priorityClass: opts.priorityClass ?? 'useful',
1165
1319
  }
1166
1320
  state.pending = pending
1167
- if (!state.running) void drive(state, opts)
1168
- return promise as Promise<T>
1321
+ if (state.running) {
1322
+ // A driver is mid-flight. If it is waiting out a non-critical admission
1323
+ // and THIS edit is critical, wake it so the critical work does not queue
1324
+ // behind a cosmetic edit's unbounded wait (#3716).
1325
+ if (pending.priorityClass === 'critical') state.criticalWake?.()
1326
+ } else {
1327
+ void drive(state, opts)
1328
+ }
1329
+ return settleForCaller<T>(state, pending, opts, pending.priorityClass, now)
1169
1330
  }
1170
1331
 
1171
1332
  async function gate<T>(fn: () => Promise<T>, opts?: SendGateOpts): Promise<T> {
@@ -239,10 +239,10 @@ describe('activity card ↔ send gate (#3620)', () => {
239
239
  }
240
240
  })
241
241
 
242
- it('a shed card edit is not treated as painted — the newest render survives for the next drain', async () => {
242
+ it('a card edit starved of tokens is HELD, not shed — the newest render still lands (#3716)', async () => {
243
243
  // A one-token-per-minute chat bucket: the card open consumes it, so the
244
- // next COSMETIC card edit sheds.
245
- const { lane, calls, clock } = makeGatedLane({ perChatPerSec: 1 / 60, perChatBurst: 1 })
244
+ // next COSMETIC card edit cannot be admitted.
245
+ const { lane, calls, clock, gate } = makeGatedLane({ perChatPerSec: 1 / 60, perChatBurst: 1 })
246
246
  const turn = makeLaneTurn(lane)
247
247
 
248
248
  lane.showNarrativeStep(turn, 'Opening the card')
@@ -250,17 +250,17 @@ describe('activity card ↔ send gate (#3620)', () => {
250
250
  await turn.activityInFlight
251
251
  expect(calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(1)
252
252
 
253
- lane.showNarrativeStep(turn, 'A shed update')
253
+ lane.showNarrativeStep(turn, 'An update with no token available')
254
254
  await clock.advance(10)
255
255
  await turn.activityInFlight
256
- // Shed nothing hit the API, and the render is still PENDING (not
257
- // mis-recorded as sent), so it is not silently lost.
256
+ // Nothing hits the API while the bucket is empty unchanged. What changed
257
+ // is that the edit is now QUEUED rather than discarded, so the card can no
258
+ // longer be stranded on a stale body by a burst that ends under pressure.
258
259
  expect(calls.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
259
- expect(turn.activityPendingRender).not.toBeNull()
260
- expect(turn.activityPendingRender).not.toBe(turn.activityLastSentRender)
260
+ expect(gate.stats().global.shed).toBe(0)
261
261
 
262
262
  // Once the bucket refills, the next drain paints the NEWEST body — not the
263
- // shed intermediate one.
263
+ // superseded intermediate one.
264
264
  lane.showNarrativeStep(turn, 'The newest update')
265
265
  await clock.advance(120_000)
266
266
  await turn.activityInFlight
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { existsSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+
5
+ /**
6
+ * Runtime alarm for the BUN half of the state-dir hermeticity guard.
7
+ *
8
+ * vitest loads `tests/vitest-setup/agent-state-dir-guard.mjs` via
9
+ * `test.setupFiles`; `bun test` loads the same file via `[test] preload` in
10
+ * bunfig.toml (repo root) and telegram-plugin/bunfig.toml (CI's bun-test-run
11
+ * has `working-directory: telegram-plugin`, and bun reads the bunfig in its CWD
12
+ * only). Without the bun half, ~75 bun-run test files still default every agent
13
+ * state writer to `/state/agent` — inside an agent container that is a LIVE
14
+ * agent's bind-mounted state dir, which is exactly how 356 synthetic turn rows
15
+ * ended up in production `turns.jsonl` files and were scored as real drift by
16
+ * the fleet-health sensor.
17
+ *
18
+ * `npm run lint:agent-state-dir-hermeticity` pins the WIRING statically; this
19
+ * pins the EFFECT, so a bunfig that is present but no longer loading the guard
20
+ * (wrong relative path, bun config-discovery change) fails a test rather than
21
+ * silently un-protecting the runner.
22
+ */
23
+ describe('bun test runs with the agent state dir redirected', () => {
24
+ for (const k of ['SWITCHROOM_AGENT_STATE_DIR', 'SWITCHROOM_RUNTIME_STATE_DIR']) {
25
+ it(`${k} points at a tmp dir, not a live agent state dir`, () => {
26
+ const v = process.env[k]
27
+ expect(v, `${k} unset — bunfig.toml \`[test] preload\` did not run`).toBeTruthy()
28
+ expect(v).not.toBe('/state/agent')
29
+ expect(v!.startsWith(tmpdir())).toBe(true)
30
+ expect(existsSync(v!)).toBe(true)
31
+ })
32
+ }
33
+ })
@@ -10,6 +10,7 @@ import {
10
10
  } from '../gateway/backstop-delivery.js'
11
11
  import {
12
12
  backstopSendOutcomeGated,
13
+ buildTurnRecord,
13
14
  finalizeBackstopSendGated,
14
15
  computeTurnStatus,
15
16
  } from '../gateway/turn-record-status.js'
@@ -348,25 +349,29 @@ describe('#3278 runBackstopDelivery — read-back drives the delivery outcome',
348
349
  expect(ledger.hasConfirmedChunk('#drop', 0)).toBe(true)
349
350
  })
350
351
 
351
- it('429/ambiguous probe ⇒ NOT re-sent, stays landed-unconfirmed, delivered=false', async () => {
352
+ it('429/ambiguous probe ⇒ NOT re-sent, stays landed-unconfirmed, still DELIVERED', async () => {
352
353
  const ledger = new BackstopDeliveryLedger()
353
354
  const sendChunk = vi.fn(async () => [950])
354
355
  const readBack = vi.fn(async (): Promise<ReadBackResult> => 'ambiguous')
355
356
  const res = await runBackstopDelivery(ledger, '#amb', ['answer'], null, { sendChunk, readBack }, 3)
356
357
  expect(sendChunk).toHaveBeenCalledTimes(1) // never re-sent on ambiguous
357
- expect(res.delivered).toBe(false)
358
- expect(res.exhausted).toBe(true)
358
+ // An inconclusive probe establishes nothing, so the landed-id evidence
359
+ // stands: the answer IS in the chat and the turn is not a failure.
360
+ expect(res.delivered).toBe(true)
361
+ expect(res.confirmed).toBe(false) // landed-unconfirmed, honestly reported
362
+ expect(res.exhausted).toBe(false)
359
363
  expect(ledger.hasConfirmedChunk('#amb', 0)).toBe(false)
360
364
  expect(ledger.landedUnconfirmedIndices('#amb', 1)).toEqual([0]) // still landed-unconfirmed
361
365
  })
362
366
 
363
- it('a read-back adapter THAT THROWS is treated as ambiguous — never re-sent', async () => {
367
+ it('a read-back adapter THAT THROWS is ambiguous — never re-sent, never a failure', async () => {
364
368
  const ledger = new BackstopDeliveryLedger()
365
369
  const sendChunk = vi.fn(async () => [951])
366
370
  const readBack = vi.fn(async () => { throw new Error('boom') })
367
371
  const res = await runBackstopDelivery(ledger, '#throw', ['answer'], null, { sendChunk, readBack }, 3)
368
372
  expect(sendChunk).toHaveBeenCalledTimes(1)
369
- expect(res.delivered).toBe(false)
373
+ expect(res.delivered).toBe(true)
374
+ expect(res.confirmed).toBe(false)
370
375
  })
371
376
 
372
377
  it('#3278 CORE: API-ack fresh id but read-back ABSENT ⇒ send_failed, NOT complete', async () => {
@@ -403,15 +408,207 @@ describe('#3278 runBackstopDelivery — read-back drives the delivery outcome',
403
408
  expect(ledger.hasConfirmedChunk('#noprobe', 0)).toBe(true)
404
409
  })
405
410
 
406
- it('partial: chunk 0 confirmed, chunk 1 ambiguous ⇒ delivered=false, chunk 0 not re-sent', async () => {
411
+ it('partial: chunk 0 confirmed, chunk 1 ambiguous ⇒ both landed ⇒ delivered, chunk 0 not re-sent', async () => {
407
412
  const ledger = new BackstopDeliveryLedger()
408
413
  const calls: number[] = []
409
414
  const sendChunk = vi.fn(async (i: number) => { calls.push(i); return [700 + i] })
410
415
  const readBack = vi.fn(async (i: number): Promise<ReadBackResult> => (i === 0 ? 'exists' : 'ambiguous'))
411
416
  const res = await runBackstopDelivery(ledger, '#part', ['c0', 'c1'], null, { sendChunk, readBack }, 3)
412
- expect(res.delivered).toBe(false)
417
+ expect(res.delivered).toBe(true) // every chunk landed a fresh id
418
+ expect(res.confirmed).toBe(false) // ...but chunk 1 was never corroborated
413
419
  expect(calls.filter(i => i === 0)).toHaveLength(1) // confirmed chunk never re-sent
414
420
  expect(ledger.hasConfirmedChunk('#part', 0)).toBe(true)
415
421
  expect(ledger.hasConfirmedChunk('#part', 1)).toBe(false)
416
422
  })
423
+
424
+ // ── The measurement bug this PR fixes ────────────────────────────────────
425
+ //
426
+ // The probe is issued at cosmetic priority in the same millisecond as the
427
+ // send it probes, so the per-chat token bucket (1/sec, just consumed by that
428
+ // very send) sheds it: in production it resolved `ambiguous` 146 times in two
429
+ // weeks and `absent` ZERO times. Under the old confirmation-gated verdict each
430
+ // of those became `delivered:false` → `send_failed` on a turn whose answer the
431
+ // user had received, plus an error reaction and a still-OPEN obligation.
432
+ it('AMBIGUOUS probe ⇒ turn record `complete`, NOT send_failed (the fixed bug)', async () => {
433
+ const ledger = new BackstopDeliveryLedger()
434
+ const cardId = 500
435
+ const sendChunk = vi.fn(async () => [971]) // a fresh, non-card chat id
436
+ const readBack = vi.fn(async (): Promise<ReadBackResult> => 'ambiguous')
437
+ const res = await runBackstopDelivery(ledger, '#shed', ['answer'], cardId, { sendChunk, readBack }, 3)
438
+ const turn: { finalAnswerDelivered: boolean; deliveryOutcome?: 'delivered' | 'failed' | 'suppressed' } = {
439
+ finalAnswerDelivered: true,
440
+ }
441
+ finalizeBackstopSendGated(turn, {
442
+ threw: !res.delivered, sentIds: res.sentIds, chunkCount: res.chunkCount, cardMessageId: cardId,
443
+ })
444
+ expect(computeTurnStatus(turn)).toBe('complete')
445
+ })
446
+
447
+ it('an ambiguous probe on a CARD-ONLY delivery is still send_failed (guard 7 holds)', async () => {
448
+ const ledger = new BackstopDeliveryLedger()
449
+ const cardId = 501
450
+ // The only id that landed IS the progress card — swept ~60-90s later, so
451
+ // the user receives nothing. Ambiguity must not rescue this.
452
+ const sendChunk = vi.fn(async () => [cardId])
453
+ const readBack = vi.fn(async (): Promise<ReadBackResult> => 'ambiguous')
454
+ const res = await runBackstopDelivery(ledger, '#cardonly', ['answer'], cardId, { sendChunk, readBack }, 3)
455
+ expect(res.delivered).toBe(false)
456
+ const turn: { finalAnswerDelivered: boolean; deliveryOutcome?: 'delivered' | 'failed' | 'suppressed' } = {
457
+ finalAnswerDelivered: true,
458
+ }
459
+ finalizeBackstopSendGated(turn, {
460
+ threw: !res.delivered, sentIds: res.sentIds, chunkCount: res.chunkCount, cardMessageId: cardId,
461
+ })
462
+ expect(computeTurnStatus(turn)).toBe('send_failed')
463
+ })
464
+
465
+ it('an ambiguous probe on a chunk that never landed is still NOT delivered', async () => {
466
+ const ledger = new BackstopDeliveryLedger()
467
+ // chunk 0 lands; chunk 1 throws on every attempt → never landed.
468
+ const sendChunk = vi.fn(async (i: number) => {
469
+ if (i === 1) throw new Error('flood')
470
+ return [800 + i]
471
+ })
472
+ const readBack = vi.fn(async (): Promise<ReadBackResult> => 'ambiguous')
473
+ const res = await runBackstopDelivery(ledger, '#gap', ['c0', 'c1'], null, { sendChunk, readBack }, 2)
474
+ expect(res.delivered).toBe(false)
475
+ expect(res.exhausted).toBe(true)
476
+ })
477
+
478
+ // Mixed sequence: the two probe states that MOVE the verdict, composed in one
479
+ // run. Chunk 0's first send is silently discarded (`absent` ⇒ demote ⇒
480
+ // re-send) and the probe on the RE-SEND is shed (`ambiguous`). Under the old
481
+ // confirmation-gated verdict the re-sent chunk was never `landed-confirmed`,
482
+ // so the whole turn came out `delivered:false` — a `send_failed` for an answer
483
+ // that had just been successfully re-sent. Demote-and-re-send and
484
+ // ambiguity-is-not-failure must compose.
485
+ it('absent ⇒ demote ⇒ re-send, then AMBIGUOUS on the re-send ⇒ delivered, sent exactly twice', async () => {
486
+ const ledger = new BackstopDeliveryLedger()
487
+ let sends = 0
488
+ const sendChunk = vi.fn(async () => { sends++; return [990 + sends] })
489
+ let probes = 0
490
+ const readBack = vi.fn(async (): Promise<ReadBackResult> => {
491
+ probes++
492
+ return probes === 1 ? 'absent' : 'ambiguous'
493
+ })
494
+ const res = await runBackstopDelivery(ledger, '#mixed', ['answer'], null, { sendChunk, readBack }, 3)
495
+ expect(sendChunk).toHaveBeenCalledTimes(2) // initial + exactly one re-send
496
+ expect(readBack).toHaveBeenCalledTimes(2) // probed once per landing
497
+ expect(res.delivered).toBe(true) // the re-send landed a fresh id; ambiguity proves nothing
498
+ expect(res.confirmed).toBe(false) // ...and is honestly reported as uncorroborated
499
+ expect(res.sentIds).toEqual([992]) // the demoted first id is gone; the re-send's id stands
500
+ expect(res.landedUnconfirmedIds).toEqual([992])
501
+ // The outcome the user actually experiences: a `complete` turn record.
502
+ const turn: { finalAnswerDelivered: boolean; deliveryOutcome?: 'delivered' | 'failed' | 'suppressed' } = {
503
+ finalAnswerDelivered: true,
504
+ }
505
+ finalizeBackstopSendGated(turn, {
506
+ threw: !res.delivered, sentIds: res.sentIds, chunkCount: res.chunkCount, cardMessageId: null,
507
+ })
508
+ expect(computeTurnStatus(turn)).toBe('complete')
509
+ })
510
+ })
511
+
512
+ // ── The observability of the new optimism (#3702 L2/L5/L6a) ─────────────────
513
+ //
514
+ // Counting an inconclusive probe as delivered is a BET. These pin the two
515
+ // artifacts that let the fleet find out if it is ever wrong: the per-delivery
516
+ // stderr line, and the `landed_unconfirmed` field on the turns.jsonl row.
517
+ describe('#3702 landed-unconfirmed is MEASURED, not silently assumed', () => {
518
+ it('a delivered-but-unconfirmed turn writes the diagnostic stderr line', async () => {
519
+ const ledger = new BackstopDeliveryLedger()
520
+ const lines: string[] = []
521
+ const res = await runBackstopDelivery(
522
+ ledger, '#obs', ['answer'], null,
523
+ {
524
+ sendChunk: async () => [1001],
525
+ readBack: async (): Promise<ReadBackResult> => 'ambiguous',
526
+ stderr: (s) => lines.push(s),
527
+ },
528
+ 3,
529
+ )
530
+ expect(res.delivered).toBe(true)
531
+ expect(res.confirmed).toBe(false)
532
+ const diag = lines.filter(l => l.includes('backstop delivery landed-unconfirmed'))
533
+ expect(diag).toHaveLength(1)
534
+ expect(diag[0]).toContain('#obs')
535
+ expect(diag[0]).toContain('1 of 1 landed id(s)') // the measured quantity, not just a warning
536
+ })
537
+
538
+ it('a fully CONFIRMED delivery writes NO landed-unconfirmed line (no false alarm)', async () => {
539
+ const ledger = new BackstopDeliveryLedger()
540
+ const lines: string[] = []
541
+ const res = await runBackstopDelivery(
542
+ ledger, '#conf', ['answer'], null,
543
+ {
544
+ sendChunk: async () => [1002],
545
+ readBack: async (): Promise<ReadBackResult> => 'exists',
546
+ stderr: (s) => lines.push(s),
547
+ },
548
+ 3,
549
+ )
550
+ expect(res.confirmed).toBe(true)
551
+ expect(res.landedUnconfirmedIds).toEqual([])
552
+ expect(lines.filter(l => l.includes('backstop delivery landed-unconfirmed'))).toHaveLength(0)
553
+ })
554
+
555
+ it('landedUnconfirmedIds is exactly the uncorroborated subset of sentIds', async () => {
556
+ const ledger = new BackstopDeliveryLedger()
557
+ // chunk 0 confirmed, chunk 1 landed-unconfirmed (shed probe).
558
+ const res = await runBackstopDelivery(
559
+ ledger, '#subset', ['c0', 'c1'], null,
560
+ {
561
+ sendChunk: async (i: number) => [1100 + i],
562
+ readBack: async (i: number): Promise<ReadBackResult> => (i === 0 ? 'exists' : 'ambiguous'),
563
+ },
564
+ 3,
565
+ )
566
+ expect(res.sentIds).toEqual([1100, 1101])
567
+ expect(res.landedUnconfirmedIds).toEqual([1101]) // c0 is corroborated; c1 is the bet
568
+ expect(res.delivered).toBe(true)
569
+ expect(res.confirmed).toBe(false)
570
+ })
571
+
572
+ it('the count reaches turns.jsonl: a complete turn carries `landed_unconfirmed`', async () => {
573
+ const ledger = new BackstopDeliveryLedger()
574
+ const res = await runBackstopDelivery(
575
+ ledger, '#row', ['c0', 'c1'], null,
576
+ {
577
+ sendChunk: async (i: number) => [1200 + i],
578
+ readBack: async (): Promise<ReadBackResult> => 'ambiguous',
579
+ },
580
+ 3,
581
+ )
582
+ const turn = {
583
+ agent: 'test-agent',
584
+ startedAt: 1_000_000,
585
+ toolCallCount: 0,
586
+ turnId: '#row',
587
+ finalAnswerDelivered: true,
588
+ deliveryOutcome: 'delivered' as const,
589
+ landedUnconfirmed: res.landedUnconfirmedIds.length,
590
+ }
591
+ const row = buildTurnRecord(turn, 1_002_000)
592
+ // The status is honest AND the bet is visible on the same row — that pairing
593
+ // is the point: a `complete` we could not corroborate is countable.
594
+ expect(row.status).toBe('complete')
595
+ expect(row.landed_unconfirmed).toBe(2)
596
+ })
597
+
598
+ it('an ordinary confirmed turn omits the field entirely (row shape unchanged)', () => {
599
+ const row = buildTurnRecord(
600
+ {
601
+ agent: 'test-agent',
602
+ startedAt: 1_000_000,
603
+ toolCallCount: 0,
604
+ turnId: '#plain',
605
+ finalAnswerDelivered: true,
606
+ deliveryOutcome: 'delivered',
607
+ landedUnconfirmed: 0,
608
+ },
609
+ 1_002_000,
610
+ )
611
+ expect(row.status).toBe('complete')
612
+ expect('landed_unconfirmed' in row).toBe(false)
613
+ })
417
614
  })
@@ -141,4 +141,16 @@ describe('#3278 createBackstopReadBack — full gate+edit+classify wiring', () =
141
141
  const readBack = createBackstopReadBack(wiring({ gate, editMessageText }))
142
142
  expect(await readBack(0, [55], 'answer')).toBe('ambiguous')
143
143
  })
144
+
145
+ test('gate NO-OP drop (resolves undefined) ⇒ ambiguous, NOT a false exists', async () => {
146
+ // The gate's edit path drops a repeat of the last payload it actually sent
147
+ // (`counters.dropped`) and drops an expired queue entry, resolving
148
+ // `undefined` in both cases. Nothing reached Telegram, so nothing was
149
+ // proven — classifying it `exists` would fabricate a confirmation from a
150
+ // call that never happened.
151
+ const gate = vi.fn(async () => undefined)
152
+ const editMessageText = vi.fn(async () => true)
153
+ const readBack = createBackstopReadBack(wiring({ gate, editMessageText }))
154
+ expect(await readBack(0, [55], 'answer')).toBe('ambiguous')
155
+ })
144
156
  })