switchroom 0.19.42 → 0.19.44

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 (75) hide show
  1. package/dist/agent-scheduler/index.js +4 -1
  2. package/dist/auth-broker/index.js +284 -124
  3. package/dist/cli/notion-write-pretool.mjs +4 -1
  4. package/dist/cli/switchroom.js +4094 -2711
  5. package/dist/host-control/main.js +472 -155
  6. package/dist/vault/approvals/kernel-server.js +231 -71
  7. package/dist/vault/broker/server.js +293 -133
  8. package/package.json +3 -3
  9. package/profiles/_base/start.sh.hbs +93 -8
  10. package/telegram-plugin/bridge/bridge.ts +1 -1
  11. package/telegram-plugin/card-layout.ts +63 -1
  12. package/telegram-plugin/dist/bridge/bridge.js +4 -1
  13. package/telegram-plugin/dist/gateway/gateway.js +1682 -936
  14. package/telegram-plugin/dist/server.js +5 -2
  15. package/telegram-plugin/flood-429-ledger.ts +5 -3
  16. package/telegram-plugin/flood-circuit-breaker.ts +11 -3
  17. package/telegram-plugin/format.ts +223 -13
  18. package/telegram-plugin/gateway/boot-card.ts +7 -2
  19. package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
  20. package/telegram-plugin/gateway/gateway.ts +49 -51
  21. package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
  22. package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
  23. package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
  24. package/telegram-plugin/gateway/ipc-server.ts +13 -4
  25. package/telegram-plugin/gateway/obligation-store.ts +55 -0
  26. package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
  27. package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
  28. package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
  29. package/telegram-plugin/gateway/stream-render.ts +5 -0
  30. package/telegram-plugin/gateway/update-announce.ts +11 -4
  31. package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
  32. package/telegram-plugin/hooks/audience-classify.mjs +364 -0
  33. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
  34. package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
  35. package/telegram-plugin/outbox.ts +103 -2
  36. package/telegram-plugin/render/rich-render.ts +10 -2
  37. package/telegram-plugin/reply-quote.ts +150 -0
  38. package/telegram-plugin/retry-api-call.ts +254 -11
  39. package/telegram-plugin/shared/bot-runtime.ts +69 -34
  40. package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
  41. package/telegram-plugin/silent-end.ts +34 -2
  42. package/telegram-plugin/status-no-truncate.ts +31 -16
  43. package/telegram-plugin/stream-controller.ts +44 -9
  44. package/telegram-plugin/stream-reply-handler.ts +5 -3
  45. package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
  46. package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
  47. package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
  48. package/telegram-plugin/tests/card-variants.golden.txt +9 -9
  49. package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
  50. package/telegram-plugin/tests/format-consistency.test.ts +160 -14
  51. package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
  52. package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
  53. package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
  54. package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
  55. package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
  56. package/telegram-plugin/tests/obligation-store.test.ts +51 -0
  57. package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
  58. package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
  59. package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
  60. package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
  61. package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
  62. package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
  63. package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
  64. package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
  65. package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
  66. package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
  67. package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
  68. package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
  69. package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
  70. package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
  71. package/telegram-plugin/tool-activity-summary.ts +58 -30
  72. package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
  73. package/vendor/hindsight-memory/scripts/lib/config.py +15 -0
  74. package/vendor/hindsight-memory/scripts/tests/test_retain_provenance_tag.py +135 -0
  75. package/vendor/hindsight-memory/settings.json +1 -1
@@ -0,0 +1,162 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readFileSync, readdirSync, statSync } from 'node:fs'
3
+ import { join, relative } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { renderBootCard } from '../gateway/boot-card.js'
6
+ import { renderUpdateOutcomeLine } from '../gateway/update-announce.js'
7
+ import { NESTED_PREFIX } from '../status-no-truncate.js'
8
+
9
+ /**
10
+ * #4115 — ONE indent idiom in the repo, and it is the one that actually
11
+ * renders.
12
+ *
13
+ * #3668 fixed the status card's nested lines by moving `NESTED_PREFIX` off
14
+ * ASCII spaces onto U+2800 BRAILLE PATTERN BLANK: card bodies reach Telegram
15
+ * as GFM markdown parsed SERVER-SIDE, and that parser left-trims a leading
16
+ * whitespace run off a content line, so an ASCII (or U+00A0) indent is dropped
17
+ * on the wire and the line renders FLAT. Live-verified on a phone 2026-07-26 —
18
+ * the record is on `WORKER_STEP_INDENT` in `status-no-truncate.ts`.
19
+ *
20
+ * The boot card and the update-announce line were NOT migrated by that PR, so
21
+ * they kept shipping the collapsed non-indent to the user. These tests assert
22
+ * what RENDERS (the leading run survives a server-side left-trim), not merely
23
+ * that a constant was referenced — a byte-equality assertion against the old
24
+ * literal is exactly what let the pre-#3668 indent ship green and inert.
25
+ */
26
+
27
+ /** Every rule a server-side trimmer could plausibly use. */
28
+ const isTrimmableWhitespace = (ch: string): boolean =>
29
+ /\s/u.test(ch) || /\p{White_Space}/u.test(ch) || /\p{Zs}/u.test(ch)
30
+
31
+ /**
32
+ * The assertion that ties to rendering: after a leading-whitespace left-trim
33
+ * of the kind Telegram applies, the line STILL carries its full indent, and it
34
+ * does not lead with either character already proven flat on a phone.
35
+ */
36
+ function expectSurvivesLeftTrim(line: string): void {
37
+ expect(line.startsWith(NESTED_PREFIX)).toBe(true)
38
+ expect(
39
+ line.replace(/^[\s\p{White_Space}]+/u, '').startsWith(NESTED_PREFIX),
40
+ `nested line "${line.slice(0, 40)}…" loses its indent to a leading-whitespace trim — ` +
41
+ `it renders FLAT on a phone (#3668/#4115). Use NESTED_PREFIX.`,
42
+ ).toBe(true)
43
+ expect(line.startsWith(' ')).toBe(false)
44
+ expect(line.startsWith(' ')).toBe(false)
45
+ const indent = [...line.slice(0, line.indexOf('↳'))]
46
+ expect(indent.length).toBeGreaterThan(0)
47
+ for (const ch of indent) expect(isTrimmableWhitespace(ch)).toBe(false)
48
+ }
49
+
50
+ /** Card lines with the stack's own break chrome removed. */
51
+ function linesOf(card: string): string[] {
52
+ return card.split('\n').map((l) => l.replace(/[ \t]+$/, ''))
53
+ }
54
+
55
+ describe('boot card nested lines render as a real indent (#4115)', () => {
56
+ it("a probe's next-step continuation line survives a server-side left-trim", () => {
57
+ // Fail-before: this row was `' ↳ ' + nextStep` — four ASCII spaces, all
58
+ // of them trimmed, so the line rendered flush against its probe row.
59
+ const out = renderBootCard({
60
+ agentName: 'lawgpt',
61
+ version: 'v0.7.16',
62
+ probes: {
63
+ skills: {
64
+ status: 'degraded',
65
+ label: 'Skills',
66
+ detail: '10/10 dangling: a, b, c +7 more',
67
+ nextStep: 'Run `switchroom agent reconcile lawgpt` to rebuild symlinks',
68
+ },
69
+ },
70
+ })
71
+ const nested = linesOf(out).filter((l) => l.includes('↳'))
72
+ expect(nested.length).toBe(1)
73
+ expectSurvivesLeftTrim(nested[0])
74
+ })
75
+
76
+ it('the crash row tail-logs continuation line survives a server-side left-trim', () => {
77
+ const out = renderBootCard({
78
+ agentName: 'lawgpt',
79
+ version: 'v0.7.16',
80
+ restartReason: 'crash',
81
+ restartAgeMs: 6_100,
82
+ })
83
+ const nested = linesOf(out).filter((l) => l.includes('↳'))
84
+ expect(nested.length).toBe(1)
85
+ expect(nested[0]).toContain('Tail logs:')
86
+ expectSurvivesLeftTrim(nested[0])
87
+ })
88
+ })
89
+
90
+ describe('update-announce recovery line renders as a real indent (#4115)', () => {
91
+ it('the failure card\'s recovery continuation survives a server-side left-trim', () => {
92
+ // Fail-before: the recovery row was `' ↳ Recovery: …'`.
93
+ const line = renderUpdateOutcomeLine({
94
+ ts: '2026-05-17T11:59:00.000Z',
95
+ op: 'update_apply',
96
+ caller: { kind: 'operator' },
97
+ request_id: 'req-2',
98
+ result: 'error',
99
+ exit_code: 1,
100
+ duration_ms: 100,
101
+ phase: 'terminal',
102
+ stderr_tail: 'compose pull failed: registry timeout',
103
+ install_context: { install_type: 'binary', detected_at: '2026-05-17T11:00:00Z' },
104
+ })
105
+ const nested = linesOf(line).filter((l) => l.includes('↳'))
106
+ expect(nested.length).toBe(1)
107
+ expect(nested[0]).toContain('Recovery:')
108
+ expectSurvivesLeftTrim(nested[0])
109
+ })
110
+ })
111
+
112
+ describe('GUARD: no ASCII-space nested indent may reappear in the source (#4115)', () => {
113
+ // A run of ASCII spaces/tabs immediately before the `↳` nesting glyph — the
114
+ // exact shape that renders flat. The one exported constant is the only legal
115
+ // way to indent a nested card line.
116
+ const ASCII_INDENT_BEFORE_ARROW = /[ \t]{2,}↳/
117
+
118
+ const here = fileURLToPath(import.meta.url)
119
+ const repoRoot = join(here, '..', '..', '..')
120
+ const ROOTS = ['telegram-plugin', 'src']
121
+ const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', 'coverage'])
122
+
123
+ function walk(dir: string, out: string[]): string[] {
124
+ for (const name of readdirSync(dir)) {
125
+ if (SKIP_DIRS.has(name)) continue
126
+ const p = join(dir, name)
127
+ if (statSync(p).isDirectory()) walk(p, out)
128
+ else if (p.endsWith('.ts') && p !== here) out.push(p)
129
+ }
130
+ return out
131
+ }
132
+
133
+ it('no source file hardcodes an ASCII-indent-plus-↳ nested line', () => {
134
+ const offenders: string[] = []
135
+ for (const root of ROOTS) {
136
+ for (const file of walk(join(repoRoot, root), [])) {
137
+ const text = readFileSync(file, 'utf8')
138
+ text.split('\n').forEach((line, i) => {
139
+ if (ASCII_INDENT_BEFORE_ARROW.test(line)) {
140
+ offenders.push(`${relative(repoRoot, file)}:${i + 1}: ${line.trim().slice(0, 100)}`)
141
+ }
142
+ })
143
+ }
144
+ }
145
+ expect(
146
+ offenders,
147
+ `Hardcoded ASCII indent before a '↳' nesting glyph. Telegram left-trims a leading ` +
148
+ `ASCII-whitespace run off a content line, so this renders FLAT on a phone (#3668/#4115). ` +
149
+ `Import NESTED_PREFIX from status-no-truncate.js instead:\n ${offenders.join('\n ')}`,
150
+ ).toEqual([])
151
+ })
152
+
153
+ it('the guard actually fires on the shape it is guarding against', () => {
154
+ // A guard that cannot fail is not a guard: pin the detector on the exact
155
+ // literal the two migrated call sites used to carry.
156
+ expect(ASCII_INDENT_BEFORE_ARROW.test("degradedRows.push(` ↳ ${next}`)")).toBe(true)
157
+ expect(ASCII_INDENT_BEFORE_ARROW.test(' ↳ Recovery: reinstall')).toBe(true)
158
+ // …and stays quiet on the legal idiom and on prose that merely mentions ↳.
159
+ expect(ASCII_INDENT_BEFORE_ARROW.test(`${NESTED_PREFIX}Recovery: reinstall`)).toBe(false)
160
+ expect(ASCII_INDENT_BEFORE_ARROW.test('the nested ↳ block is windowed')).toBe(false)
161
+ })
162
+ })
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { renderStatusCard } from '../tool-activity-summary.js'
3
+ import { NESTED_PREFIX } from '../status-no-truncate.js'
4
+ import { COLLAPSE_SAFE_SEPARATOR } from '../card-format.js'
5
+
6
+ /**
7
+ * #3668 — the nested (foreground sub-agent) child block must actually LOOK
8
+ * nested on a phone.
9
+ *
10
+ * `NESTED_PREFIX` used to be three ASCII spaces plus `↳ `. Card bodies reach
11
+ * Telegram as GFM markdown parsed SERVER-SIDE, and that parser left-trims a
12
+ * leading whitespace run off a content line, so the indent was dropped on the
13
+ * wire and every child step rendered FLAT against its parent's steps — the
14
+ * whole nesting cue reduced to one `↳` glyph.
15
+ *
16
+ * The replacement is the repo's already-live-verified indent idiom, U+2800
17
+ * BRAILLE PATTERN BLANK: general category So (Symbol, other), not Zs, so no
18
+ * whitespace-trimming rule can classify it as whitespace, yet it renders as
19
+ * blank width. Evidence, in order of strength:
20
+ *
21
+ * 1. LIVE: on 2026-07-26 four candidate indents were sent to a real phone in
22
+ * one message (recorded at `status-no-truncate.ts`, WORKER_STEP_INDENT) —
23
+ * three U+00A0 rendered FLAT, three U+2800 INDENTED CORRECTLY, a leading
24
+ * `↳ ` rendered as visible ink, a leading `· ` was promoted by Telegram
25
+ * into a real list bullet.
26
+ * 2. SHIPPED: `WORKER_STEP_INDENT` (the combined worker card) has used U+2800
27
+ * since that check and renders indented in production.
28
+ * 3. CATEGORY: the property below — U+2800 is not `\s`, not `White_Space`,
29
+ * not `Zs`.
30
+ *
31
+ * Note what is NOT claimed: no character was chosen on inference. #3662 shipped
32
+ * U+00A0 on exactly that kind of inference, was merged green, and was inert on
33
+ * a phone — which is why these tests assert the PROPERTY, not just the bytes.
34
+ */
35
+
36
+ /** Every rule a server-side trimmer could plausibly use. */
37
+ const isTrimmableWhitespace = (ch: string): boolean =>
38
+ /\s/u.test(ch) || /\p{White_Space}/u.test(ch) || /\p{Zs}/u.test(ch)
39
+
40
+ function nestedCard(children: string[]): string {
41
+ return renderStatusCard({
42
+ header: { emoji: '🤖', label: 'Agent', elapsedMs: 95_000, toolCount: 12, state: 'running' },
43
+ steps: ['Reading gateway.ts'],
44
+ childSteps: children,
45
+ final: false,
46
+ })!
47
+ }
48
+
49
+ /** The card's pre-join lines, hard-break chrome removed. */
50
+ function linesOf(card: string): string[] {
51
+ return card
52
+ .split('\n')
53
+ .map((l) => l.replace(/[ \t]+$/, '').replace(new RegExp(`${COLLAPSE_SAFE_SEPARATOR}$`), ''))
54
+ }
55
+
56
+ describe('NESTED_PREFIX renders as a real indent (#3668)', () => {
57
+ it('contains no character any whitespace rule would trim', () => {
58
+ // The `↳ ` glyph is visible ink and is kept; the run BEFORE it is the
59
+ // indent, and every char of THAT must survive a left-trim. Pin the width
60
+ // too — a single blank char reads as near-flat on a phone and would undo
61
+ // the fix while keeping the character-class assertion green.
62
+ const indent = [...NESTED_PREFIX.slice(0, NESTED_PREFIX.indexOf('↳'))]
63
+ expect(indent.length).toBe(3)
64
+ for (const ch of indent) {
65
+ const cp = `U+${ch.codePointAt(0)!.toString(16).toUpperCase().padStart(4, '0')}`
66
+ expect(
67
+ isTrimmableWhitespace(ch),
68
+ `NESTED_PREFIX's indent contains ${cp}, which is Unicode whitespace ` +
69
+ `(\\s / White_Space / Zs). Telegram left-trims a leading whitespace run off a ` +
70
+ `content line, so this indent renders FLAT on a phone — the #3662/#3668 failure. ` +
71
+ `Use U+2800 BRAILLE PATTERN BLANK (category So), live-verified 2026-07-26.`,
72
+ ).toBe(false)
73
+ }
74
+ })
75
+
76
+ it('leads with exactly three U+2800 and keeps the ↳ glyph', () => {
77
+ // Byte pin, companion to the property above: that one says "not
78
+ // whitespace", this one says "the specific glyph we live-tested", in the
79
+ // order that matters (blanks LEAD, so the glyph is never what gets trimmed).
80
+ expect(NESTED_PREFIX).toBe('\u2800\u2800\u2800↳ ')
81
+ // Not a GFM block-structure lead-in: `stackCardLines` promotes every
82
+ // inter-line break to a hard break *because* card lines are never block
83
+ // lines, and a live check showed Telegram promotes a leading `·` to a real
84
+ // list bullet.
85
+ expect(/^[-*+>|#·]/.test(NESTED_PREFIX)).toBe(false)
86
+ })
87
+
88
+ it('every rendered child line survives a server-side leading-whitespace trim with its indent intact', () => {
89
+ const card = nestedCard(['Searching memory', 'Running tests'])
90
+ const childLines = linesOf(card).filter((l) => l.includes('↳'))
91
+ expect(childLines.length).toBe(2)
92
+ for (const line of childLines) {
93
+ // Fail-before assertion: with the old ASCII prefix, `line` started with
94
+ // `' '` and this strip removed the whole indent.
95
+ expect(line.startsWith(NESTED_PREFIX)).toBe(true)
96
+ expect(line.replace(/^[\s\p{White_Space}]+/u, '').startsWith(NESTED_PREFIX)).toBe(true)
97
+ // And it leads with neither of the two characters already proven flat.
98
+ expect(line.startsWith(' ')).toBe(false)
99
+ expect(line.startsWith('\u00A0')).toBe(false)
100
+ }
101
+ })
102
+
103
+ it('golden line ordering — parent steps stay flush, child steps sit one level in', () => {
104
+ // Set-membership assertions alone would pass a render that emitted all the
105
+ // parents and then all the children. This pins the ORDER, which is the
106
+ // thing "nested" actually means.
107
+ const card = nestedCard(['Searching memory', 'Running tests'])
108
+ const body = linesOf(card).slice(2)
109
+ expect(body).toEqual([
110
+ '~~_✓ Reading gateway.ts_~~',
111
+ `${NESTED_PREFIX}~~_Searching memory_~~`,
112
+ `${NESTED_PREFIX}**→ Running tests**`,
113
+ ])
114
+ })
115
+ })
@@ -0,0 +1,62 @@
1
+ /**
2
+ * `createRobustApiCall` must stay deleted (#3863).
3
+ *
4
+ * It was a second `createRetryApiCall` wiring in `shared/bot-runtime.ts`,
5
+ * described as "exactly how gateway.ts constructs its robustApiCall". It had
6
+ * ZERO production callers — and, worse, its flood-breaker hooks were OPTIONAL
7
+ * (`floodStatePath` defaulted to absent), so the default construction was a
8
+ * send path blind to the shared flood window: the exact failure
9
+ * `scripts/check-retry-flood-hooks.mjs` exists to prevent, sitting in the file
10
+ * a future caller would naturally reach for.
11
+ *
12
+ * It was also load-bearing DOCUMENTATION: `flood-circuit-breaker.ts` and
13
+ * `flood-429-ledger.ts` both cited it as a live wiring, so the breaker's
14
+ * "every onFloodWait goes through here" completeness argument was partly
15
+ * anchored to dead code.
16
+ *
17
+ * This is a source-level guard on purpose: the defect is the existence of the
18
+ * identifier, and there is no runtime behaviour to assert.
19
+ *
20
+ * The rule is "the name may appear only inside backticks" — i.e. as PROSE in a
21
+ * docblock explaining why it is gone. Any bare occurrence is code (a
22
+ * declaration, an import, a call) and fails.
23
+ */
24
+ import { describe, it, expect } from 'vitest'
25
+ import { readFileSync, readdirSync, statSync } from 'node:fs'
26
+ import { join, extname } from 'node:path'
27
+ import { fileURLToPath } from 'node:url'
28
+
29
+ const REPO_ROOT = fileURLToPath(new URL('../../', import.meta.url))
30
+ const SEARCH_ROOTS = ['telegram-plugin', 'src', 'bin', 'scripts', 'docs']
31
+ const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', 'vendor', 'coverage'])
32
+ const SEARCH_EXTS = new Set(['.ts', '.tsx', '.js', '.mjs', '.cjs', '.md'])
33
+
34
+ function* walk(dir: string): Generator<string> {
35
+ for (const entry of readdirSync(dir)) {
36
+ if (SKIP_DIRS.has(entry)) continue
37
+ const full = join(dir, entry)
38
+ if (statSync(full).isDirectory()) yield* walk(full)
39
+ else if (SEARCH_EXTS.has(extname(entry))) yield full
40
+ }
41
+ }
42
+
43
+ describe('#3863 — the dead createRobustApiCall factory', () => {
44
+ it('appears in no source, script or doc file except as backticked prose', () => {
45
+ const selfPath = fileURLToPath(import.meta.url)
46
+ const offenders: string[] = []
47
+ for (const root of SEARCH_ROOTS) {
48
+ for (const file of walk(join(REPO_ROOT, root))) {
49
+ if (file === selfPath) continue
50
+ // Drop the backticked (prose) occurrences; anything left is code.
51
+ const bare = readFileSync(file, 'utf8').replaceAll('`createRobustApiCall`', '')
52
+ if (bare.includes('createRobustApiCall')) offenders.push(file.slice(REPO_ROOT.length))
53
+ }
54
+ }
55
+ expect(offenders).toEqual([])
56
+ })
57
+
58
+ it('is not exported by shared/bot-runtime.ts', async () => {
59
+ const mod = (await import('../shared/bot-runtime.js')) as Record<string, unknown>
60
+ expect(Object.keys(mod)).not.toContain('createRobustApiCall')
61
+ })
62
+ })
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
2
2
  import {
3
3
  loadObligations,
4
4
  persistObligations,
5
+ removeUnmaintainedSnapshot,
5
6
  type ObligationStoreFsSeam,
6
7
  } from "../gateway/obligation-store.js";
7
8
  import type { Obligation } from "../gateway/obligation-ledger.js";
@@ -33,6 +34,10 @@ function memFs(seed: Record<string, string> = {}) {
33
34
  fsyncDirSync: (p) => {
34
35
  calls.push(`fsyncDir:${p}`);
35
36
  },
37
+ unlinkSync: (p) => {
38
+ calls.push(`unlink:${p}`);
39
+ files.delete(p);
40
+ },
36
41
  };
37
42
  return { fs, files, calls };
38
43
  }
@@ -240,4 +245,50 @@ describe("obligation-store — power-cut durability (fsync ordering)", () => {
240
245
  expect(files.has(PATH)).toBe(true);
241
246
  expect(loadObligations(PATH, fs)).toHaveLength(1);
242
247
  });
248
+
249
+ // ── #4146: a snapshot nobody maintains must not read as "nobody waiting" ──
250
+
251
+ it("removeUnmaintainedSnapshot deletes a leftover snapshot and says why", () => {
252
+ // The exact hazard: the agent once ran with the ledger on, so an EMPTY
253
+ // open set is on disk. Persistence is now off, so that emptiness is a
254
+ // leftover — and the W1-d audience classifier would read it as positive
255
+ // proof that nobody is waiting, and suppress a message owed to a human.
256
+ const { fs, files } = memFs({ [PATH]: JSON.stringify({ v: 1, obligations: [] }) });
257
+ const logs: string[] = [];
258
+ expect(removeUnmaintainedSnapshot(PATH, fs, "static=true enabled=false", (l) => logs.push(l))).toBe(true);
259
+ expect(files.has(PATH)).toBe(false);
260
+ // ...and the classifier's precondition now genuinely holds: absent file.
261
+ expect(fs.existsSync(PATH)).toBe(false);
262
+ expect(logs.join("")).toContain("static=true enabled=false");
263
+ expect(logs.join("")).toContain("#4146");
264
+ });
265
+
266
+ it("removeUnmaintainedSnapshot is a no-op when there is nothing to remove", () => {
267
+ const { fs } = memFs();
268
+ const logs: string[] = [];
269
+ expect(removeUnmaintainedSnapshot(PATH, fs, "reason", (l) => logs.push(l))).toBe(false);
270
+ expect(logs).toEqual([]);
271
+ });
272
+
273
+ it("removeUnmaintainedSnapshot never throws, and reports failure rather than claiming success", () => {
274
+ // A failure here must degrade to the reader-side ledger-off guard, not
275
+ // crash gateway boot and not be mistaken for a completed cleanup.
276
+ const { fs } = memFs({ [PATH]: "{}" });
277
+ const logs: string[] = [];
278
+ const failing: ObligationStoreFsSeam = {
279
+ ...fs,
280
+ unlinkSync: () => {
281
+ throw new Error("EACCES: permission denied, unlink");
282
+ },
283
+ };
284
+ expect(removeUnmaintainedSnapshot(PATH, failing, "reason", (l) => logs.push(l))).toBe(false);
285
+ expect(logs.join("")).toContain("could not remove unmaintained snapshot");
286
+ });
287
+
288
+ it("a seam with no unlinkSync degrades to no cleanup rather than throwing", () => {
289
+ const { fs, files } = memFs({ [PATH]: "{}" });
290
+ const { unlinkSync: _dropped, ...legacySeam } = fs;
291
+ expect(removeUnmaintainedSnapshot(PATH, legacySeam, "reason", () => {})).toBe(false);
292
+ expect(files.has(PATH)).toBe(true);
293
+ });
243
294
  });