switchroom 0.19.29 → 0.19.31

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 (43) hide show
  1. package/dist/cli/switchroom.js +2044 -900
  2. package/dist/host-control/main.js +162 -80
  3. package/package.json +1 -1
  4. package/profiles/_base/cron-session.sh.hbs +5 -1
  5. package/profiles/_base/start.sh.hbs +15 -1
  6. package/telegram-plugin/dist/gateway/gateway.js +1435 -551
  7. package/telegram-plugin/edit-flood-fuse.ts +70 -20
  8. package/telegram-plugin/gateway/boot-beacon.ts +364 -0
  9. package/telegram-plugin/gateway/boot-sweep-gate.ts +20 -15
  10. package/telegram-plugin/gateway/gateway.ts +87 -88
  11. package/telegram-plugin/gateway/inbound-spool.ts +39 -0
  12. package/telegram-plugin/gateway/narrative-lane.ts +12 -0
  13. package/telegram-plugin/gateway/obligation-store.ts +28 -0
  14. package/telegram-plugin/gateway/stale-pin-sweep-store.ts +221 -0
  15. package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +211 -0
  16. package/telegram-plugin/gateway/stale-pin-sweep.test.ts +804 -0
  17. package/telegram-plugin/gateway/stale-pin-sweep.ts +1146 -0
  18. package/telegram-plugin/gateway/status-pin-retarget.ts +15 -2
  19. package/telegram-plugin/gateway/status-pin-store.ts +33 -11
  20. package/telegram-plugin/registry/turns-schema.ts +21 -1
  21. package/telegram-plugin/retry-api-call.ts +46 -21
  22. package/telegram-plugin/shared/bot-runtime.ts +61 -17
  23. package/telegram-plugin/shared/gw-trace-gate.ts +18 -2
  24. package/telegram-plugin/tests/activity-card-wiring.test.ts +7 -7
  25. package/telegram-plugin/tests/activity-drain-fuse-drop-not-failure.test.ts +324 -0
  26. package/telegram-plugin/tests/agent-card-result-footer.test.ts +193 -0
  27. package/telegram-plugin/tests/boot-beacon.test.ts +462 -0
  28. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +6 -6
  29. package/telegram-plugin/tests/boot-sweep-gate.test.ts +42 -31
  30. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +2 -0
  31. package/telegram-plugin/tests/inbound-spool-progress.test.ts +2 -0
  32. package/telegram-plugin/tests/inbound-spool.test.ts +134 -5
  33. package/telegram-plugin/tests/narrative-lane-golden.test.ts +28 -0
  34. package/telegram-plugin/tests/obligation-determinism.test.ts +2 -0
  35. package/telegram-plugin/tests/obligation-store.test.ts +67 -1
  36. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
  37. package/telegram-plugin/tests/status-pin-store.test.ts +26 -5
  38. package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +161 -0
  39. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +30 -0
  40. package/telegram-plugin/tool-activity-summary.ts +104 -38
  41. package/telegram-plugin/worker-activity-feed.ts +33 -16
  42. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +0 -251
  43. package/telegram-plugin/gateway/dm-pin-sweep.ts +0 -178
@@ -60,6 +60,8 @@ function fakeFs(): InboundSpoolFsSeam {
60
60
  },
61
61
  existsSync: (p) => files.has(p),
62
62
  statSizeSync: (p) => Buffer.byteLength(files.get(p) ?? ''),
63
+ fsyncFileSync: () => {},
64
+ fsyncDirSync: () => {},
63
65
  }
64
66
  }
65
67
 
@@ -33,20 +33,33 @@ function msg(over: Partial<InboundMessage> = {}): InboundMessage {
33
33
  }
34
34
 
35
35
  /** In-memory fake fs keyed by path. Models append, full rewrite, and
36
- * atomic rename (so the tmp→rename compaction path is exercised). */
37
- function fakeFs(): InboundSpoolFsSeam & { dump(p?: string): string } {
36
+ * atomic rename (so the tmp→rename compaction path is exercised).
37
+ * `ops` records the mutating/durability calls in order, so tests can pin
38
+ * the fsync ORDERING the crash-durability guarantee depends on. */
39
+ function fakeFs(): InboundSpoolFsSeam & { dump(p?: string): string; ops: string[] } {
38
40
  const files = new Map<string, string>()
41
+ const ops: string[] = []
39
42
  return {
40
- appendFileSync: (p, d) => files.set(p, (files.get(p) ?? '') + d),
43
+ appendFileSync: (p, d) => {
44
+ ops.push(`append:${p}`)
45
+ files.set(p, (files.get(p) ?? '') + d)
46
+ },
41
47
  readFileSync: (p) => files.get(p) ?? '',
42
- writeFileSync: (p, d) => files.set(p, d),
48
+ writeFileSync: (p, d) => {
49
+ ops.push(`write:${p}`)
50
+ files.set(p, d)
51
+ },
43
52
  renameSync: (from, to) => {
53
+ ops.push(`rename:${from}->${to}`)
44
54
  files.set(to, files.get(from) ?? '')
45
55
  files.delete(from)
46
56
  },
47
57
  existsSync: (p) => files.has(p),
48
58
  statSizeSync: (p) => Buffer.byteLength(files.get(p) ?? ''),
59
+ fsyncFileSync: (p) => ops.push(`fsyncFile:${p}`),
60
+ fsyncDirSync: (p) => ops.push(`fsyncDir:${p}`),
49
61
  dump: (p = PATH) => files.get(p) ?? '',
62
+ ops,
50
63
  }
51
64
  }
52
65
 
@@ -478,6 +491,92 @@ describe('inbound-spool — robustness', () => {
478
491
  })
479
492
  })
480
493
 
494
+ describe('inbound-spool — power-cut durability (fsync)', () => {
495
+ it('fsyncs the spool file on every appended record', () => {
496
+ const fs = fakeFs()
497
+ const s = createInboundSpool({ path: PATH, fs, log: () => {} })
498
+ s.put('a', msg({ messageId: 1 }))
499
+ s.ack(msg({ messageId: 1 }))
500
+ // Both the put record and the ack tombstone are durability-critical:
501
+ // an un-synced ack replays an already-answered message after a power
502
+ // cut, an un-synced put loses it entirely.
503
+ expect(fs.ops).toEqual([
504
+ `append:${PATH}`,
505
+ `fsyncFile:${PATH}`,
506
+ `append:${PATH}`,
507
+ `fsyncFile:${PATH}`,
508
+ ])
509
+ })
510
+
511
+ it('compaction fsyncs the tmp file BEFORE the rename and the directory AFTER', () => {
512
+ const fs = fakeFs()
513
+ const s = createInboundSpool({ path: PATH, fs, compactAtBytes: 200, log: () => {} })
514
+ for (let i = 1; i <= 10; i++) s.put('a', msg({ messageId: i, text: 'y'.repeat(50) }))
515
+ // The bound triggers several compactions across the 10 puts; each one
516
+ // must follow the same four-step sequence, so pin the last.
517
+ expect(fs.ops.slice(-4)).toEqual([
518
+ `write:${PATH}.compact.tmp`,
519
+ `fsyncFile:${PATH}.compact.tmp`,
520
+ `rename:${PATH}.compact.tmp->${PATH}`,
521
+ 'fsyncDir:/state/agent/telegram',
522
+ ])
523
+ })
524
+
525
+ it('a failed compaction directory fsync is not reported as a failed compaction', () => {
526
+ // The rename already landed, so the compacted log IS on disk; only the
527
+ // power-cut upgrade is missing. Calling it "compact FAILED" would send an
528
+ // operator hunting a rewrite that actually worked.
529
+ const fs = fakeFs()
530
+ const logs: string[] = []
531
+ fs.fsyncDirSync = () => {
532
+ throw new Error('EIO: i/o error, fsync dir')
533
+ }
534
+ const s = createInboundSpool({ path: PATH, fs, compactAtBytes: 200, log: (l) => logs.push(l) })
535
+ for (let i = 1; i <= 10; i++) s.put('a', msg({ messageId: i, text: 'y'.repeat(50) }))
536
+ expect(logs.join('')).toContain('compact directory fsync FAILED')
537
+ expect(logs.join('')).toContain('compacted path=')
538
+ // And the compacted log is intact and readable — nothing was rolled back.
539
+ const s2 = createInboundSpool({ path: PATH, fs, log: () => {} })
540
+ expect(s2.liveCount()).toBe(10)
541
+ })
542
+
543
+ it('an un-acked entry survives a POWER CUT, not just a process kill', () => {
544
+ // Models the distinction the fsync exists for: `appendFileSync` returning
545
+ // puts the bytes in the page cache (enough to survive SIGKILL — the
546
+ // process dies, the kernel still holds them), but only fsync puts them on
547
+ // stable storage. `powerCut()` discards everything not fsync'd, which is
548
+ // what a host power loss actually does.
549
+ const cache = new Map<string, string>()
550
+ const platter = new Map<string, string>()
551
+ const fs: InboundSpoolFsSeam = {
552
+ appendFileSync: (p, d) => cache.set(p, (cache.get(p) ?? '') + d),
553
+ readFileSync: (p) => cache.get(p) ?? '',
554
+ writeFileSync: (p, d) => cache.set(p, d),
555
+ renameSync: (from, to) => {
556
+ cache.set(to, cache.get(from) ?? '')
557
+ cache.delete(from)
558
+ },
559
+ existsSync: (p) => cache.has(p),
560
+ statSizeSync: (p) => Buffer.byteLength(cache.get(p) ?? ''),
561
+ fsyncFileSync: (p) => {
562
+ if (cache.has(p)) platter.set(p, cache.get(p)!)
563
+ },
564
+ fsyncDirSync: () => {},
565
+ }
566
+ const powerCut = (): void => {
567
+ cache.clear()
568
+ for (const [p, d] of platter) cache.set(p, d)
569
+ }
570
+
571
+ const s = createInboundSpool({ path: PATH, fs, log: () => {} })
572
+ s.put('a', msg({ messageId: 7, text: 'answer me' }))
573
+ powerCut()
574
+
575
+ const rebooted = createInboundSpool({ path: PATH, fs, log: () => {} })
576
+ expect(rebooted.liveEntries().map((e) => e.msg.messageId)).toEqual([7])
577
+ })
578
+ })
579
+
481
580
  describe('inbound-spool — #2789 C: multi-message drop notice reports the real count', () => {
482
581
  it('passes the true per-chat dropped count to the coalesced notice', () => {
483
582
  const fs = fakeFs()
@@ -549,10 +648,14 @@ describe('inbound-spool — #2789 B: spool write failure surfaces a health signa
549
648
  // An fs whose appendFileSync can be toggled to fail, modelling a full /
550
649
  // unwritable persistent volume. The append swallows the error (delivery
551
650
  // must not break) but the degradation must be SURFACED, not silent.
552
- function toggleableFs(): InboundSpoolFsSeam & { fail: boolean } {
651
+ function toggleableFs(): InboundSpoolFsSeam & { fail: boolean; failFsync: boolean } {
553
652
  const files = new Map<string, string>()
554
653
  const seam = {
555
654
  fail: false,
655
+ // A failing fsync is its own degradation mode: the bytes reached the
656
+ // page cache but NOT stable storage, so the durable promise is gone
657
+ // even though appendFileSync returned cleanly.
658
+ failFsync: false,
556
659
  appendFileSync(p: string, d: string) {
557
660
  if (seam.fail) throw new Error('ENOSPC: no space left on device')
558
661
  files.set(p, (files.get(p) ?? '') + d)
@@ -565,6 +668,10 @@ describe('inbound-spool — #2789 B: spool write failure surfaces a health signa
565
668
  },
566
669
  existsSync: (p: string) => files.has(p),
567
670
  statSizeSync: (p: string) => Buffer.byteLength(files.get(p) ?? ''),
671
+ fsyncFileSync: (_p: string) => {
672
+ if (seam.failFsync) throw new Error('EIO: i/o error, fsync')
673
+ },
674
+ fsyncDirSync: (_p: string) => {},
568
675
  }
569
676
  return seam
570
677
  }
@@ -588,6 +695,28 @@ describe('inbound-spool — #2789 B: spool write failure surfaces a health signa
588
695
  expect(events).toEqual([{ degraded: true, consecutiveFailures: 1 }])
589
696
  })
590
697
 
698
+ it('a failing fsync degrades too — a cached-but-unsynced append is NOT durable', () => {
699
+ // The subtle one: appendFileSync succeeds, so the old code would have
700
+ // reported a healthy spool while the record was one power cut from gone.
701
+ const fs = toggleableFs()
702
+ const events: { degraded: boolean }[] = []
703
+ const logs: string[] = []
704
+ const s = createInboundSpool({
705
+ path: PATH,
706
+ fs,
707
+ log: (l) => logs.push(l),
708
+ onDegraded: (info) => events.push({ degraded: info.degraded }),
709
+ })
710
+ fs.failFsync = true
711
+ expect(() => s.put('a', msg({ messageId: 1, ts: 1 }))).not.toThrow()
712
+ expect(s.isDegraded()).toBe(true)
713
+ expect(events).toEqual([{ degraded: true }])
714
+ expect(logs.join('')).toContain('durability degraded')
715
+ // Live delivery is unaffected — degradation is a durability claim, not a
716
+ // delivery failure.
717
+ expect(s.liveCount()).toBe(1)
718
+ })
719
+
591
720
  it('does not re-fire the degraded signal on every failing append (latched)', () => {
592
721
  const fs = toggleableFs()
593
722
  const events: boolean[] = []
@@ -211,6 +211,34 @@ describe('narrative-lane golden — SHOW path opens then edits ONE feed card', (
211
211
  expect(final!).toMatch(/3 steps/)
212
212
  expect(live!).not.toMatch(/3 steps/)
213
213
  })
214
+
215
+ it('composeTurnActivity: the FINAL render ends with the ✅ + delivered-answer footer', () => {
216
+ // Parity with the 🛠 worker card, which has always closed on `✅ <summary>`.
217
+ // The agent card's analogue of the worker's `latestSummary` is the answer
218
+ // the turn actually delivered (`turn.lastReplyText`); the lane threads it
219
+ // into the header as `resultText` and the SHARED `deriveCardResult` renders
220
+ // it. Fails on the pre-fix lane, which passed no result text at all.
221
+ const { lane } = makeLane()
222
+ const turn = makeLaneTurn(lane, { labeledToolCount: 2 })
223
+ turn.mirrorLines.push('Read the file', 'Ran the tests')
224
+ turn.lastReplyText = 'Standing by for the background merge waiter to complete.'
225
+ const live = lane.composeTurnActivity(turn)
226
+ const final = lane.composeTurnActivity(turn, true)
227
+ expect(final!).toContain('─────')
228
+ expect(final!.endsWith('✅ _Standing by for the background merge waiter to complete._')).toBe(true)
229
+ // The LIVE render must not leak the footer — the turn is not finished.
230
+ expect(live!).not.toContain('✅')
231
+ })
232
+
233
+ it('composeTurnActivity: a turn that delivered no answer renders NO footer (no fabrication)', () => {
234
+ const { lane } = makeLane()
235
+ const turn = makeLaneTurn(lane, { labeledToolCount: 2 })
236
+ turn.mirrorLines.push('Read the file', 'Ran the tests')
237
+ turn.lastReplyText = ''
238
+ const final = lane.composeTurnActivity(turn, true)
239
+ expect(final!).not.toContain('─────')
240
+ expect(final!).not.toContain('✅')
241
+ })
214
242
  })
215
243
 
216
244
  describe('narrative-lane golden — the narrative-dedup gate at turn end', () => {
@@ -64,6 +64,8 @@ function memStore(): { fs: ObligationStoreFsSeam } {
64
64
  files.delete(a);
65
65
  },
66
66
  existsSync: (p) => files.has(p),
67
+ fsyncFileSync: () => {},
68
+ fsyncDirSync: () => {},
67
69
  },
68
70
  };
69
71
  }
@@ -27,6 +27,12 @@ function memFs(seed: Record<string, string> = {}) {
27
27
  files.delete(a);
28
28
  },
29
29
  existsSync: (p) => files.has(p),
30
+ fsyncFileSync: (p) => {
31
+ calls.push(`fsyncFile:${p}`);
32
+ },
33
+ fsyncDirSync: (p) => {
34
+ calls.push(`fsyncDir:${p}`);
35
+ },
30
36
  };
31
37
  return { fs, files, calls };
32
38
  }
@@ -63,7 +69,12 @@ describe("obligation-store", () => {
63
69
  it("persists atomically: writes a sibling .tmp then renames over the path", () => {
64
70
  const { fs, calls, files } = memFs();
65
71
  persistObligations(PATH, fs, [ob("c:3#1")]);
66
- expect(calls).toEqual([`write:${PATH}.tmp`, `rename:${PATH}.tmp->${PATH}`]);
72
+ expect(calls).toEqual([
73
+ `write:${PATH}.tmp`,
74
+ `fsyncFile:${PATH}.tmp`,
75
+ `rename:${PATH}.tmp->${PATH}`,
76
+ "fsyncDir:/state/agent/telegram",
77
+ ]);
67
78
  // The tmp is gone (renamed); only the real path remains.
68
79
  expect(files.has(PATH)).toBe(true);
69
80
  expect(files.has(`${PATH}.tmp`)).toBe(false);
@@ -170,8 +181,63 @@ describe("obligation-store", () => {
170
181
  },
171
182
  renameSync: () => {},
172
183
  existsSync: () => false,
184
+ fsyncFileSync: () => {},
185
+ fsyncDirSync: () => {},
173
186
  };
174
187
  expect(() => persistObligations(PATH, fs, [ob("c:3#1")], (l) => logs.push(l))).not.toThrow();
175
188
  expect(logs.join("")).toContain("persist FAILED");
176
189
  });
177
190
  });
191
+
192
+ describe("obligation-store — power-cut durability (fsync ordering)", () => {
193
+ it("fsyncs the tmp file BEFORE the rename and the directory AFTER it", () => {
194
+ const { fs, calls } = memFs();
195
+ persistObligations(PATH, fs, [ob("c:3#1")]);
196
+ // The exact sequence IS the guarantee: syncing the directory before the
197
+ // rename, or renaming before the tmp file's data is on stable storage,
198
+ // publishes a name that can point at bytes that were never written.
199
+ expect(calls).toEqual([
200
+ `write:${PATH}.tmp`,
201
+ `fsyncFile:${PATH}.tmp`,
202
+ `rename:${PATH}.tmp->${PATH}`,
203
+ "fsyncDir:/state/agent/telegram",
204
+ ]);
205
+ });
206
+
207
+ it("a failed tmp fsync aborts BEFORE the rename — the previous snapshot is left intact", () => {
208
+ // Publishing an unsynced tmp over a good snapshot would be strictly worse
209
+ // than not writing at all, so the rename must not happen.
210
+ const prior = JSON.stringify({ v: 1, obligations: [ob("c:3#1")] });
211
+ const { fs, files } = memFs({ [PATH]: prior });
212
+ const logs: string[] = [];
213
+ const failing: ObligationStoreFsSeam = {
214
+ ...fs,
215
+ fsyncFileSync: () => {
216
+ throw new Error("EIO: i/o error, fsync");
217
+ },
218
+ };
219
+ expect(() => persistObligations(PATH, failing, [ob("c:3#2")], (l) => logs.push(l))).not.toThrow();
220
+ expect(logs.join("")).toContain("persist FAILED");
221
+ expect(files.get(PATH)).toBe(prior);
222
+ expect(loadObligations(PATH, fs)[0]!.originTurnId).toBe("c:3#1");
223
+ });
224
+
225
+ it("a failed DIRECTORY fsync keeps the written snapshot and is not reported as a failed persist", () => {
226
+ // The rename already landed, so the snapshot is correct for a process
227
+ // crash; only the power-cut upgrade is missing. Mislabelling it
228
+ // "persist FAILED" would send an operator hunting a write that worked.
229
+ const { fs, files } = memFs();
230
+ const logs: string[] = [];
231
+ const failing: ObligationStoreFsSeam = {
232
+ ...fs,
233
+ fsyncDirSync: () => {
234
+ throw new Error("EIO: i/o error, fsync dir");
235
+ },
236
+ };
237
+ expect(() => persistObligations(PATH, failing, [ob("c:3#1")], (l) => logs.push(l))).not.toThrow();
238
+ expect(logs.join("")).toContain("directory fsync FAILED");
239
+ expect(logs.join("")).not.toContain("persist FAILED");
240
+ expect(files.has(PATH)).toBe(true);
241
+ expect(loadObligations(PATH, fs)).toHaveLength(1);
242
+ });
243
+ });
@@ -311,7 +311,7 @@ describe("status-pin boot cleanup is mutex-gated (structural)", () => {
311
311
 
312
312
  it("every boot pin-cleanup call site follows the won-lock check", () => {
313
313
  // #3026: boot cleanup is now invoked via the mutex-gated orchestrator
314
- // runBootPinCleanupAndDmSweep(), which awaits statusPinBootCleanup()
314
+ // runBootPinCleanupAndStalePinSweep(), which awaits statusPinBootCleanup()
315
315
  // (+ the other reapers) and then runs the DM stale-pin sweep. The
316
316
  // invariant is unchanged: cleanup must only run after the lock is won.
317
317
  const lines = gatewaySrc.split("\n");
@@ -344,7 +344,7 @@ describe("status-pin boot cleanup is mutex-gated (structural)", () => {
344
344
  );
345
345
  expect(declIdx).toBeGreaterThan(-1);
346
346
  const orchestratorIdx = lines.findIndex((l) =>
347
- /function runBootPinCleanupAndDmSweep/.test(l),
347
+ /function runBootPinCleanupAndStalePinSweep/.test(l),
348
348
  );
349
349
  expect(orchestratorIdx).toBeGreaterThan(-1);
350
350
  const invocationIdxs = lines
@@ -365,7 +365,7 @@ describe("status-pin boot cleanup is mutex-gated (structural)", () => {
365
365
 
366
366
  it("the blocked (losing) boot exits before reaching cleanup", () => {
367
367
  // In the mutex block, `outcome.status === 'blocked'` must lead to
368
- // process.exit(1) BEFORE the winner's runBootPinCleanupAndDmSweep() call
368
+ // process.exit(1) BEFORE the winner's runBootPinCleanupAndStalePinSweep() call
369
369
  // in that block — so a loser never touches the shared store.
370
370
  const blockedIdx = gatewaySrc.indexOf("outcome.status === 'blocked'");
371
371
  expect(blockedIdx).toBeGreaterThan(-1);
@@ -117,8 +117,8 @@ describe("status-pin-store", () => {
117
117
  });
118
118
 
119
119
  it("fail-open: wrong envelope version / shape loads as []", () => {
120
- // v4 is an unknown FUTURE version (v1–v3 are the supported set).
121
- const { fs: f1 } = memFs({ [PATH]: JSON.stringify({ v: 4, pins: [pin()] }) });
120
+ // v5 is an unknown FUTURE version (v1–v4 are the supported set).
121
+ const { fs: f1 } = memFs({ [PATH]: JSON.stringify({ v: 5, pins: [pin()] }) });
122
122
  expect(loadStatusPins(PATH, f1)).toEqual([]);
123
123
  const { fs: f2 } = memFs({ [PATH]: JSON.stringify({ v: 1, pins: "nope" }) });
124
124
  expect(loadStatusPins(PATH, f2)).toEqual([]);
@@ -548,13 +548,34 @@ describe("status-pin-store — envelope version compat", () => {
548
548
  expect(loaded[0].messageId).toBe(715);
549
549
  });
550
550
 
551
- it("round-trips a v2 pending flag and writes v3 envelopes", () => {
551
+ it("round-trips a v2 pending flag and writes v4 envelopes", () => {
552
552
  const { fs, files } = memFs();
553
553
  persistStatusPins(PATH, fs, [pin({ pending: true })]);
554
- expect(JSON.parse(files.get(PATH)!).v).toBe(3);
554
+ expect(JSON.parse(files.get(PATH)!).v).toBe(4);
555
555
  expect(loadStatusPins(PATH, fs)[0].pending).toBe(true);
556
556
  });
557
557
 
558
+ it("v4 persists the forum topic a pin lives in, so a later sweep can aim at it", () => {
559
+ // Telegram's pin stack is chat-wide and `unpinChatMessage` takes no
560
+ // message_thread_id, so `unpinAllForumTopicMessages(chat, thread)` is the
561
+ // only topic-scoped drain a bot has — and it needs this field. Without it a
562
+ // boot after a crash knows a forum chat has orphans but not where.
563
+ const { fs, files } = memFs();
564
+ persistStatusPins(PATH, fs, [pin({ threadId: 77 })]);
565
+ expect(JSON.parse(files.get(PATH)!).v).toBe(4);
566
+ expect(loadStatusPins(PATH, fs)[0].threadId).toBe(77);
567
+ });
568
+
569
+ it("drops a row with a non-numeric threadId", () => {
570
+ const { fs } = memFs({
571
+ [PATH]: JSON.stringify({
572
+ v: 4,
573
+ pins: [pin({ messageId: 715 }), { pinKey: "k", chatId: "c", messageId: 1, threadId: "5" }],
574
+ }),
575
+ });
576
+ expect(loadStatusPins(PATH, fs)).toHaveLength(1);
577
+ });
578
+
558
579
  it("#3810: a v3 row round-trips its pinnedAt claim age; a v1/v2 row loads without one", () => {
559
580
  const { fs } = memFs({
560
581
  [PATH]: JSON.stringify({
@@ -584,7 +605,7 @@ describe("status-pin-store — envelope version compat", () => {
584
605
  });
585
606
 
586
607
  it("rejects an unknown future envelope version (fail-open [])", () => {
587
- const { fs } = memFs({ [PATH]: JSON.stringify({ v: 4, pins: [pin()] }) });
608
+ const { fs } = memFs({ [PATH]: JSON.stringify({ v: 5, pins: [pin()] }) });
588
609
  expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
589
610
  });
590
611
 
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Wire-level outcome tests for the `tg-post` logger's error shape (#3927).
3
+ *
4
+ * The bug: grammY resolves its transformer chain with the raw `ApiResponse`
5
+ * and only converts `{ok:false}` into a thrown `GrammyError` AFTER the chain
6
+ * returns (grammy `out/core/client.js`, `callApi`). Every Telegram-level
7
+ * rejection — 429 flood-wait, 400, 403 — therefore reached
8
+ * `installTgPostLogger` as a RESOLVED value and was logged
9
+ * `status=ok err=- code=- desc=-`. Only transport `HttpError`s ever hit the
10
+ * `catch`. A rate-limited `unpinAllChatMessages` on clerk logged as SUCCESS
11
+ * 3ms before the retry policy logged `429 rate limited, waiting 3s`.
12
+ *
13
+ * These tests MUST go through a REAL grammy `Bot` with a stubbed transport
14
+ * (the same reason `rich-markdown-guard-transformer.test.ts` does): the
15
+ * mock-`api` harnesses sit ABOVE the transformer layer, so `api.config.use`
16
+ * transformers never run there and a test written through them would be a
17
+ * false guard. Stubbing `client.fetch` with an `ok:false` envelope is the
18
+ * only way to reproduce the exact resolved-rejection path.
19
+ */
20
+ import { describe, it, expect, vi, afterEach } from 'vitest'
21
+ import { Bot } from 'grammy'
22
+ import { installTgPostLogger } from '../shared/bot-runtime.js'
23
+
24
+ /**
25
+ * A real Bot whose transport always answers with the given Telegram
26
+ * envelope. HTTP status stays 200 — a Bot API rejection is a 200 response
27
+ * carrying `ok:false`, which is precisely why it resolves the chain.
28
+ */
29
+ function makeBot(envelope: Record<string, unknown>): Bot {
30
+ const fakeFetch = (async () =>
31
+ ({
32
+ ok: true,
33
+ status: 200,
34
+ json: async () => envelope,
35
+ }) as unknown as Response) as unknown as typeof fetch
36
+
37
+ const bot = new Bot('123456:TEST_TOKEN', {
38
+ botInfo: {
39
+ id: 123456,
40
+ is_bot: true,
41
+ first_name: 'Test',
42
+ username: 'test_bot',
43
+ can_join_groups: false,
44
+ can_read_all_group_messages: false,
45
+ supports_inline_queries: false,
46
+ can_connect_to_business: false,
47
+ has_main_web_app: false,
48
+ },
49
+ client: { fetch: fakeFetch },
50
+ })
51
+ installTgPostLogger(bot)
52
+ return bot
53
+ }
54
+
55
+ /** Run one API call against the stub and return every tg-post line emitted. */
56
+ async function tgPostLinesFor(envelope: Record<string, unknown>): Promise<string[]> {
57
+ const written: string[] = []
58
+ const spy = vi
59
+ .spyOn(process.stderr, 'write')
60
+ .mockImplementation(((chunk: unknown) => {
61
+ written.push(String(chunk))
62
+ return true
63
+ }) as unknown as typeof process.stderr.write)
64
+ try {
65
+ const bot = makeBot(envelope)
66
+ // grammy converts the ok:false envelope into a thrown GrammyError once
67
+ // the transformer chain has returned — expected, and not what we assert.
68
+ await bot.api.sendMessage(4242, 'hello').catch(() => undefined)
69
+ } finally {
70
+ spy.mockRestore()
71
+ }
72
+ return written.filter(l => l.startsWith('tg-post '))
73
+ }
74
+
75
+ afterEach(() => {
76
+ vi.restoreAllMocks()
77
+ })
78
+
79
+ describe('tg-post logger — resolved ok:false responses (#3927)', () => {
80
+ it('logs a 429 flood-wait as status=err code=429 (FAILS before the fix: logged status=ok)', async () => {
81
+ const lines = await tgPostLinesFor({
82
+ ok: false,
83
+ error_code: 429,
84
+ description: 'Too Many Requests: retry after 3',
85
+ parameters: { retry_after: 3 },
86
+ })
87
+
88
+ expect(lines).toHaveLength(1)
89
+ const line = lines[0]
90
+ expect(line).toContain('status=err')
91
+ expect(line).toContain('code=429')
92
+ expect(line).toContain('err=telegram_429')
93
+ expect(line).toContain('desc=Too Many Requests: retry after 3')
94
+ // The precise regression: it must NOT claim success.
95
+ expect(line).not.toContain('status=ok')
96
+ })
97
+
98
+ it('logs a non-benign 400 as status=err carrying the rejection reason', async () => {
99
+ const lines = await tgPostLinesFor({
100
+ ok: false,
101
+ error_code: 400,
102
+ description: "Bad Request: can't parse entities: unsupported start tag",
103
+ })
104
+
105
+ expect(lines).toHaveLength(1)
106
+ expect(lines[0]).toContain('status=err')
107
+ expect(lines[0]).toContain('code=400')
108
+ expect(lines[0]).toContain("desc=Bad Request: can't parse entities")
109
+ })
110
+
111
+ it('logs a 403 as status=err', async () => {
112
+ const lines = await tgPostLinesFor({
113
+ ok: false,
114
+ error_code: 403,
115
+ description: 'Forbidden: bot was blocked by the user',
116
+ })
117
+
118
+ expect(lines).toHaveLength(1)
119
+ expect(lines[0]).toContain('status=err')
120
+ expect(lines[0]).toContain('code=403')
121
+ })
122
+ })
123
+
124
+ describe('tg-post logger — benign 400s do not become error noise (#3927)', () => {
125
+ // Promoting every ok:false to status=err would flood the log with the
126
+ // high-volume no-ops the retry policy already swallows, burying the signal
127
+ // this change exists to create. They get their own `benign` tier instead:
128
+ // still logged (same volume as the status=ok line they replace), never
129
+ // matched by `grep status=err`.
130
+ const benign: Array<[string, string]> = [
131
+ ['not modified', 'Bad Request: message is not modified'],
132
+ ['edit target gone', 'Bad Request: message to edit not found'],
133
+ ['delete target gone', 'Bad Request: message to delete not found'],
134
+ ]
135
+
136
+ for (const [label, description] of benign) {
137
+ it(`logs "${label}" as status=benign, never status=err`, async () => {
138
+ const lines = await tgPostLinesFor({ ok: false, error_code: 400, description })
139
+
140
+ expect(lines).toHaveLength(1)
141
+ expect(lines[0]).toContain('status=benign')
142
+ expect(lines[0]).not.toContain('status=err')
143
+ // Still honest about what happened — the reason is on the line.
144
+ expect(lines[0]).toContain('code=400')
145
+ expect(lines[0]).toContain('desc=Bad Request: message')
146
+ })
147
+ }
148
+ })
149
+
150
+ describe('tg-post logger — success is unchanged', () => {
151
+ it('still logs a genuine ok:true response as status=ok with empty error fields', async () => {
152
+ const lines = await tgPostLinesFor({
153
+ ok: true,
154
+ result: { message_id: 7, date: 0, chat: { id: 4242, type: 'private' } },
155
+ })
156
+
157
+ expect(lines).toHaveLength(1)
158
+ expect(lines[0]).toContain('status=ok')
159
+ expect(lines[0]).toContain('err=- code=- desc=-')
160
+ })
161
+ })
@@ -217,6 +217,36 @@ function makePersistingPinHarness(path: string) {
217
217
  describe('worker-feed pin persistence — durable status-pins.json survives steady-state edits (F1)', () => {
218
218
  const PATH = '/state/agent/telegram/status-pins.json'
219
219
 
220
+ it('writes an UNAMBIGUOUS (chat, thread) pin key — no trailing-space topic slot', async () => {
221
+ // The key is persisted verbatim into status-pins.json. It used to be
222
+ // `${chatId} ${threadId ?? ''}`, so a topic-less group wrote
223
+ // `"pinKey": "wk:group:-100123 "` — trailing whitespace that survives a
224
+ // JSON round-trip and is invisible in every log and grep.
225
+ const bot = makeFakeBot()
226
+ const pin = makePersistingPinHarness(PATH)
227
+ let clock = 0
228
+ const feed = createWorkerActivityFeed({
229
+ bot,
230
+ now: () => clock,
231
+ firstPaintMinMs: 0,
232
+ minEditIntervalMs: 0,
233
+ reconcilePin: pin.reconcilePinFn,
234
+ })
235
+
236
+ clock = 1000
237
+ await feed.update('w-topicless', '-100123', view({ elapsedMs: 1000, toolCount: 1 }))
238
+ await flush()
239
+ clock = 2000
240
+ await feed.update('w-topic', '-100123', view({ elapsedMs: 1000, toolCount: 1 }), 7)
241
+ await flush()
242
+
243
+ const keys = pin.rows().map((r) => r.pinKey).sort()
244
+ expect(keys).toEqual(['wk:group:-100123:-', 'wk:group:-100123:7'])
245
+ // Same chat, different topic ⇒ genuinely different rows, and no key can be
246
+ // mistaken for another by an operator or a tool reconciling by chat.
247
+ for (const k of keys) expect(k).toBe(k.trim())
248
+ })
249
+
220
250
  it('preserves the wk:group row across many steady-state edits (noop-clear must NOT delete it)', async () => {
221
251
  const bot = makeFakeBot()
222
252
  const pin = makePersistingPinHarness(PATH)