switchroom 0.19.22 → 0.19.23

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 (37) hide show
  1. package/dist/agent-scheduler/index.js +2 -1
  2. package/dist/auth-broker/index.js +68 -1
  3. package/dist/cli/notion-write-pretool.mjs +2 -1
  4. package/dist/cli/switchroom.js +552 -320
  5. package/dist/host-control/main.js +69 -2
  6. package/dist/vault/approvals/kernel-server.js +71 -4
  7. package/dist/vault/broker/server.js +71 -4
  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 +13 -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 +90 -107
  17. package/telegram-plugin/bunfig.toml +10 -0
  18. package/telegram-plugin/dist/gateway/gateway.js +108 -16
  19. package/telegram-plugin/gateway/backstop-delivery.ts +97 -16
  20. package/telegram-plugin/gateway/captured-answer-resume.ts +46 -17
  21. package/telegram-plugin/gateway/gateway.ts +9 -7
  22. package/telegram-plugin/gateway/outbound-send-path.ts +8 -1
  23. package/telegram-plugin/gateway/stream-render.ts +6 -0
  24. package/telegram-plugin/gateway/turn-record-status.ts +19 -0
  25. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +65 -0
  26. package/telegram-plugin/tests/agent-state-dir-preload.test.ts +33 -0
  27. package/telegram-plugin/tests/backstop-delivery.test.ts +204 -7
  28. package/telegram-plugin/tests/backstop-readback-probe.test.ts +12 -0
  29. package/telegram-plugin/tests/captured-answer-resume.test.ts +104 -0
  30. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +92 -1
  31. package/vendor/hindsight-memory/scripts/drain_pending.py +113 -11
  32. package/vendor/hindsight-memory/scripts/lib/pending.py +802 -65
  33. package/vendor/hindsight-memory/scripts/lib/retain_split.py +54 -7
  34. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +1445 -11
  35. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +78 -6
  36. package/vendor/hindsight-memory/tests/test_drain_pending.py +17 -2
  37. package/vendor/hindsight-memory/tests/test_pending.py +12 -4
@@ -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) {
@@ -718,7 +718,7 @@ import {
718
718
  import { CurrentTurnMap } from './current-turn-map.js'
719
719
  import { resolveAnswerThreadId } from './answer-thread-resolve.js'
720
720
  import { decideObligationTurnEnd } from './obligation-turn-end.js'
721
- import { maybeRotate } from './turns-jsonl-rotate.js'
721
+ import { maybeRotate, resolveAgentStateDir, resolveTurnsJsonlPath } from './turns-jsonl-rotate.js'
722
722
  import {
723
723
  buildTurnRecord,
724
724
  finalizeBackstopSendGated,
@@ -1032,7 +1032,7 @@ const blockedApprovalStore = createBlockedApprovalStore(
1032
1032
  // Fallback: the agent's OWN state dir, which the scaffold chowns to the agent
1033
1033
  // uid, so a write there always succeeds. Guarantees the record can never be
1034
1034
  // silently lost when the shared dir isn't writable by this agent's uid.
1035
- process.env.SWITCHROOM_AGENT_STATE_DIR ?? '/state/agent',
1035
+ resolveAgentStateDir(),
1036
1036
  )
1037
1037
 
1038
1038
  /**
@@ -2456,9 +2456,9 @@ async function deliverAnswer(args: {
2456
2456
  * the send alive if the user deleted their message. Null for synthesized
2457
2457
  * turns (cron/handback) — those send bare, as before. */
2458
2458
  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). */
2459
+ /** #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
2460
  resume?: { snapshot: CapturedDeliverySnapshot; hydrate: (ledger: BackstopDeliveryLedger, turnId: string) => void }
2461
- }): Promise<{ sentIds: number[]; chunkCount: number; delivered: boolean; exhausted: boolean }> {
2461
+ }): Promise<{ sentIds: number[]; chunkCount: number; delivered: boolean; exhausted: boolean; landedUnconfirmed: number }> {
2462
2462
  const { chatId, turnId } = args
2463
2463
  // Spacers into `\n\n` gaps then split (as executeReply); a resume re-delivers the EXACT captured chunks (byte-stable, no re-split).
2464
2464
  const chunks = args.resume
@@ -2594,6 +2594,7 @@ async function deliverAnswer(args: {
2594
2594
  chunkCount: result.chunkCount,
2595
2595
  delivered: result.delivered,
2596
2596
  exhausted: result.exhausted,
2597
+ landedUnconfirmed: result.landedUnconfirmedIds.length, // #3702 — the caller stamps this on the turn record
2597
2598
  }
2598
2599
  }
2599
2600
 
@@ -3427,6 +3428,7 @@ export type CurrentTurn = {
3427
3428
  // turn-end paths (reply-tool tail, silent-marker, genuine no-reply), where
3428
3429
  // the legacy `finalAnswerDelivered` reading still applies unchanged.
3429
3430
  deliveryOutcome?: DeliveryOutcome
3431
+ landedUnconfirmed?: number // #3702 — landed ids no read-back corroborated; emitted as `landed_unconfirmed` (rationale: turn-record-status.ts)
3430
3432
  // Feed-reopen-after-ack refinement — whether the reply that set
3431
3433
  // `finalAnswerDelivered` was a *substantive* final answer (stream
3432
3434
  // `done`, or ≥200 chars) as opposed to a short pinging interim ACK.
@@ -5012,7 +5014,7 @@ function snapshotContextOccupancy(): void {
5012
5014
  } catch {
5013
5015
  cap = null; // config unreadable → show occupancy without a ratio
5014
5016
  }
5015
- const stateDir = process.env.SWITCHROOM_AGENT_STATE_DIR ?? "/state/agent";
5017
+ const stateDir = resolveAgentStateDir();
5016
5018
  writeContextOccupancySnapshot(
5017
5019
  stateDir,
5018
5020
  buildContextOccupancy(occupancy, cap, Date.now()),
@@ -5033,12 +5035,12 @@ function emitTurnRecord(turn: CurrentTurn, endedAt: number): void {
5033
5035
  toolCallCount: turn.toolCallCount ?? 0,
5034
5036
  turnId: turn.turnId,
5035
5037
  finalAnswerDelivered: turn.finalAnswerDelivered,
5036
- deliveryOutcome: turn.deliveryOutcome,
5038
+ deliveryOutcome: turn.deliveryOutcome, landedUnconfirmed: turn.landedUnconfirmed,
5037
5039
  },
5038
5040
  endedAt,
5039
5041
  ),
5040
5042
  ) + '\n'
5041
- const turnsPath = '/state/agent/turns.jsonl'
5043
+ const turnsPath = resolveTurnsJsonlPath() // honours SWITCHROOM_AGENT_STATE_DIR
5042
5044
  // Size-cap rotation: keep at most one rotated generation so the file can't
5043
5045
  // grow unbounded on a long-lived agent. Best-effort (never throws).
5044
5046
  maybeRotate(turnsPath, {
@@ -611,7 +611,14 @@ export function createBackstopReadBack(
611
611
  }
612
612
  try {
613
613
  const r = await w.gate(() => w.editMessageText(messageId, body, editApiOpts), gateOpts)
614
- return w.isShed(r) ? 'ambiguous' : 'exists'
614
+ // A shed resolves the SEND_GATE_SHED sentinel; a gate no-op drop (the
615
+ // identical payload is already the last one sent for this message id) or
616
+ // an expired queue entry resolves `undefined`. In BOTH cases the edit
617
+ // never reached Telegram, so there is no evidence of existence —
618
+ // `ambiguous`, never a fabricated `exists`. Only a real API result
619
+ // (grammy resolves `true` or the edited Message) proves presence.
620
+ if (w.isShed(r) || r === undefined) return 'ambiguous'
621
+ return 'exists'
615
622
  } catch (err) {
616
623
  return classifyReadBackError(err)
617
624
  }
@@ -1856,6 +1856,12 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
1856
1856
  sentIds = delivery.sentIds
1857
1857
  chunkCount = delivery.chunkCount
1858
1858
  delivered = delivery.delivered
1859
+ // #3702 — how many landed ids the read-back never corroborated.
1860
+ // Stamped on the turn so `emitTurnRecord` writes `landed_unconfirmed`
1861
+ // (omitted when 0): the fleet-visible counter for deliveries we call
1862
+ // `complete` on the Bot API's ack alone, because the probe is
1863
+ // inconclusive. Observational only — it never changes the status.
1864
+ turn.landedUnconfirmed = delivery.landedUnconfirmed
1859
1865
 
1860
1866
  // #546 dedup: record what turn-flush just sent so a late-arriving
1861
1867
  // reply / stream_reply with the same content gets suppressed.
@@ -148,6 +148,20 @@ export interface TurnRecordRow {
148
148
  tools: number
149
149
  status: TurnStatus
150
150
  turn_id: string
151
+ /**
152
+ * How many landed message ids of this turn's backstop delivery the read-back
153
+ * probe never corroborated (`sentIds` minus the confirmed subset). OMITTED
154
+ * when zero, so an ordinary row is byte-identical to before.
155
+ *
156
+ * This is the measurable counterpart of the delivery verdict: since an
157
+ * inconclusive probe counts as delivered, a `complete` row carrying
158
+ * `landed_unconfirmed > 0` is a turn we called delivered on the Bot API's
159
+ * ack alone. Counting those is how the fleet can tell whether that optimism
160
+ * is ever wrong (a `landed_unconfirmed` turn followed by a "you never
161
+ * answered me" is the falsifying observation). It is NOT a failure signal and
162
+ * nothing escalates on it.
163
+ */
164
+ landed_unconfirmed?: number
151
165
  }
152
166
 
153
167
  /**
@@ -165,6 +179,7 @@ export function buildTurnRecord(
165
179
  turnId: string
166
180
  finalAnswerDelivered: boolean
167
181
  deliveryOutcome?: DeliveryOutcome
182
+ landedUnconfirmed?: number
168
183
  },
169
184
  endedAt: number,
170
185
  ): TurnRecordRow {
@@ -175,5 +190,9 @@ export function buildTurnRecord(
175
190
  tools: turn.toolCallCount ?? 0,
176
191
  status: computeTurnStatus(turn),
177
192
  turn_id: turn.turnId,
193
+ // Emitted ONLY when non-zero (see `TurnRecordRow.landed_unconfirmed`).
194
+ ...(turn.landedUnconfirmed != null && turn.landedUnconfirmed > 0
195
+ ? { landed_unconfirmed: turn.landedUnconfirmed }
196
+ : {}),
178
197
  }
179
198
  }
@@ -13,6 +13,71 @@
13
13
  */
14
14
  export const TURNS_JSONL_MAX_BYTES = 5 * 1024 * 1024 // 5 MiB
15
15
 
16
+ /** The agent state dir inside a switchroom agent container (bind-mounted to
17
+ * `~/.switchroom/agents/<name>/` on the host). */
18
+ export const DEFAULT_AGENT_STATE_DIR = '/state/agent'
19
+
20
+ /**
21
+ * Resolve the agent state dir from the environment — the ONE reader for
22
+ * `SWITCHROOM_AGENT_STATE_DIR` that every writer into that dir must use.
23
+ *
24
+ * Normalisation is the point. The gateway had two writers into this dir a few
25
+ * lines apart (the context-occupancy snapshot and the turn record) reading the
26
+ * env var with two different expressions: a bare
27
+ * `process.env.SWITCHROOM_AGENT_STATE_DIR ?? '/state/agent'` and this one. For
28
+ * a value like `"/x/ "` or `"/x/"` those resolve to DIFFERENT directories, so
29
+ * the two artifacts of the same turn would land in two places — exactly the
30
+ * kind of split-brain state that made the turn-record leak hard to see. Both
31
+ * call sites now share this function.
32
+ *
33
+ * Blank/whitespace-only is treated as unset (a compose file that renders an
34
+ * empty value must not send state to `/turns.jsonl` at the filesystem root),
35
+ * and a trailing slash is stripped so joins never double it.
36
+ */
37
+ export function resolveAgentStateDir(
38
+ env: Record<string, string | undefined> = process.env,
39
+ ): string {
40
+ const dir = env.SWITCHROOM_AGENT_STATE_DIR?.trim()
41
+ return dir != null && dir !== '' ? dir.replace(/\/+$/, '') : DEFAULT_AGENT_STATE_DIR
42
+ }
43
+
44
+ /**
45
+ * Resolve the turn-record path from the environment.
46
+ *
47
+ * `emitTurnRecord` used to hard-code `/state/agent/turns.jsonl`, ignoring
48
+ * `SWITCHROOM_AGENT_STATE_DIR` — which the sibling context-occupancy writer a
49
+ * few lines above it in `gateway.ts` already honours. Inside an agent container
50
+ * that path is the bind-mounted PRODUCTION `~/.switchroom/agents/<name>/
51
+ * turns.jsonl`, so any test that drove the real turn-end funnel while running
52
+ * in an agent container appended its synthetic rows straight into that agent's
53
+ * production turn record — even when the test had pointed every state-dir env
54
+ * var at a tmpdir. Those rows are then read back by the fleet-health L0 sensor
55
+ * (`src/fleet-health/scan.ts`) as that agent's real production turns.
56
+ *
57
+ * Honouring the env var is the root-cause fix: production containers do not set
58
+ * it (default unchanged), and a test that isolates its state dir now isolates
59
+ * its turn records with it.
60
+ *
61
+ * ── Operator coupling: setting this var RELOCATES the fleet-health input ──
62
+ *
63
+ * `agent.env` in `switchroom.yaml` is propagated verbatim into the container
64
+ * (`src/agents/compose.ts` `userEnv`), so an operator CAN set
65
+ * `SWITCHROOM_AGENT_STATE_DIR` on an agent. If they point it anywhere other
66
+ * than the bind-mounted `/state/agent`, this file moves with it — but the
67
+ * fleet-health L0 sensor reads `~/.switchroom/agents/<name>/turns.jsonl` at a
68
+ * FIXED host path (`src/fleet-health/scan.ts`). That agent then presents no
69
+ * turns artifact, lands in the scan's `skipped[]`, and goes quiet on the health
70
+ * board: no findings, no ledger entries, indistinguishable from a healthy
71
+ * agent. Do not set this var in production `agent.env`; it exists so tests (and
72
+ * the vitest `agent-state-dir-guard` setup file) can isolate agent state into a
73
+ * tmpdir.
74
+ */
75
+ export function resolveTurnsJsonlPath(
76
+ env: Record<string, string | undefined> = process.env,
77
+ ): string {
78
+ return `${resolveAgentStateDir(env)}/turns.jsonl`
79
+ }
80
+
16
81
  export interface RotateFs {
17
82
  statSize: (path: string) => number | undefined // undefined ⇒ file absent
18
83
  rename: (from: string, to: string) => void
@@ -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
  })