switchroom 0.19.26 → 0.19.27

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 (77) hide show
  1. package/bin/git-agent-attribution-hook.sh +144 -0
  2. package/dist/agent-scheduler/index.js +55 -0
  3. package/dist/auth-broker/index.js +116 -6
  4. package/dist/cli/notion-write-pretool.mjs +55 -0
  5. package/dist/cli/switchroom.js +2055 -1193
  6. package/dist/host-control/main.js +117 -7
  7. package/dist/vault/approvals/kernel-server.js +114 -6
  8. package/dist/vault/broker/server.js +114 -6
  9. package/package.json +6 -2
  10. package/profiles/_base/cron-session.sh.hbs +8 -0
  11. package/profiles/_base/start.sh.hbs +105 -0
  12. package/telegram-plugin/card-layout.ts +328 -0
  13. package/telegram-plugin/dist/bridge/bridge.js +93 -1
  14. package/telegram-plugin/dist/gateway/gateway.js +2009 -1166
  15. package/telegram-plugin/dist/server.js +96 -1
  16. package/telegram-plugin/edit-flood-fuse.ts +637 -56
  17. package/telegram-plugin/flood-429-ledger.ts +526 -0
  18. package/telegram-plugin/flood-circuit-breaker.ts +18 -0
  19. package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
  20. package/telegram-plugin/gateway/gateway.ts +58 -68
  21. package/telegram-plugin/gateway/narrative-lane.ts +14 -0
  22. package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
  23. package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
  24. package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
  25. package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
  26. package/telegram-plugin/gateway/status-pin-store.ts +58 -9
  27. package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
  28. package/telegram-plugin/llm-error-present.ts +61 -2
  29. package/telegram-plugin/model-unavailable.ts +8 -0
  30. package/telegram-plugin/operator-events.ts +72 -5
  31. package/telegram-plugin/outbound-class.ts +81 -0
  32. package/telegram-plugin/provider-credit.ts +237 -0
  33. package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
  34. package/telegram-plugin/send-gate.ts +24 -2
  35. package/telegram-plugin/status-no-truncate.ts +10 -48
  36. package/telegram-plugin/status-pin-driver.ts +33 -45
  37. package/telegram-plugin/status-pin.ts +18 -1
  38. package/telegram-plugin/tests/card-golden.test.ts +69 -0
  39. package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
  40. package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
  41. package/telegram-plugin/tests/card-variants.golden.txt +211 -0
  42. package/telegram-plugin/tests/card-variants.ts +366 -0
  43. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +316 -0
  44. package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
  45. package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
  46. package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
  47. package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
  48. package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
  49. package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
  50. package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
  51. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
  52. package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
  53. package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
  54. package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
  55. package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
  56. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
  57. package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
  58. package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
  59. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
  60. package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
  61. package/telegram-plugin/tests/status-pin.test.ts +56 -5
  62. package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
  63. package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
  64. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
  65. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
  66. package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
  67. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  68. package/telegram-plugin/tool-activity-summary.ts +239 -365
  69. package/telegram-plugin/uat/assertions.ts +22 -11
  70. package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
  71. package/telegram-plugin/worker-activity-feed.ts +105 -47
  72. package/vendor/hindsight-memory/CLAUDE.md +45 -0
  73. package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
  74. package/vendor/hindsight-memory/scripts/recall.py +176 -7
  75. package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
  76. package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
  77. package/vendor/hindsight-memory/settings.json +1 -1
@@ -13,8 +13,11 @@ import {
13
13
  type TrackedStatusPin,
14
14
  } from "../gateway/status-pin-store.js";
15
15
  import { decidePinAction, type PinState, type DesiredPin } from "../status-pin.js";
16
- import { reconcilePin, type PinBotApi } from "../status-pin-driver.js";
17
- import { runStatusPinReconcile } from "../gateway/status-pin-retarget.js";
16
+ import { executePinLeg, type PinBotApi } from "../status-pin-driver.js";
17
+ import {
18
+ runStatusPinReconcile,
19
+ type StatusPinClaim,
20
+ } from "../gateway/status-pin-retarget.js";
18
21
  import { makeFloodWaitActiveError } from "../retry-api-call.js";
19
22
 
20
23
  /** In-memory fs seam with an atomic rename, so the store's tmp→rename
@@ -48,6 +51,26 @@ function pin(over: Partial<PersistedStatusPin> = {}): PersistedStatusPin {
48
51
  return { pinKey: "fg:c:3", chatId: "-100123", messageId: 715, ...over };
49
52
  }
50
53
 
54
+ /**
55
+ * Strip the #3810 claim-age stamp from persisted rows.
56
+ *
57
+ * Every confirmed/pending row now carries `pinnedAt` (the durable claim age the
58
+ * mid-session store-orphan reaper needs). Row assertions that are about pin
59
+ * IDENTITY — which key, which chat, which message, pending or not — stay about
60
+ * identity by normalising the age away. The age has its own dedicated teeth in
61
+ * the `claim age` describe below; it is not silently untested.
62
+ */
63
+ function idOnly(rows: PersistedStatusPin[]): Omit<PersistedStatusPin, "pinnedAt">[] {
64
+ return rows.map(({ pinnedAt: _age, ...rest }) => rest);
65
+ }
66
+
67
+ /** The `prev` snapshot a reconcile reads from the single claim registry (#3809),
68
+ * exactly as gateway.ts's `reconcileStatusPinInner` does. */
69
+ function prevOf(claims: Map<string, StatusPinClaim>, pinKey: string): PinState | null {
70
+ const c = claims.get(pinKey);
71
+ return c == null ? null : { messageId: c.messageId };
72
+ }
73
+
51
74
  describe("status-pin-store", () => {
52
75
  it("round-trips the pin claim set", () => {
53
76
  const { fs } = memFs();
@@ -56,7 +79,7 @@ describe("status-pin-store", () => {
56
79
  pin({ pinKey: "wk:agent-x", chatId: "-100999", messageId: 42 }),
57
80
  ];
58
81
  persistStatusPins(PATH, fs, snap);
59
- expect(loadStatusPins(PATH, fs)).toEqual(snap);
82
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual(snap);
60
83
  });
61
84
 
62
85
  it("writes via tmp + atomic rename (crash-safe)", () => {
@@ -80,22 +103,22 @@ describe("status-pin-store", () => {
80
103
  expect(loadStatusPins(PATH, fs)).toHaveLength(1);
81
104
  // Reconcile deleted the key → snapshot of the now-empty Map.
82
105
  persistStatusPins(PATH, fs, []);
83
- expect(loadStatusPins(PATH, fs)).toEqual([]);
106
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
84
107
  });
85
108
 
86
109
  it("loads [] when no file exists (fresh boot)", () => {
87
110
  const { fs } = memFs();
88
- expect(loadStatusPins(PATH, fs)).toEqual([]);
111
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
89
112
  });
90
113
 
91
114
  it("fail-open: corrupt JSON loads as [] (never crashes boot)", () => {
92
115
  const { fs } = memFs({ [PATH]: "{not json" });
93
- expect(loadStatusPins(PATH, fs)).toEqual([]);
116
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
94
117
  });
95
118
 
96
119
  it("fail-open: wrong envelope version / shape loads as []", () => {
97
- // v3 is an unknown FUTURE version (v1 + v2 are the supported set).
98
- const { fs: f1 } = memFs({ [PATH]: JSON.stringify({ v: 3, pins: [pin()] }) });
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()] }) });
99
122
  expect(loadStatusPins(PATH, f1)).toEqual([]);
100
123
  const { fs: f2 } = memFs({ [PATH]: JSON.stringify({ v: 1, pins: "nope" }) });
101
124
  expect(loadStatusPins(PATH, f2)).toEqual([]);
@@ -203,7 +226,7 @@ describe("runStatusPinBootCleanup", () => {
203
226
  ]);
204
227
  expect(res).toEqual({ cleared: 2, retained: 0, kept: 0, total: 2 });
205
228
  // Store empty afterwards → no re-attempt next boot.
206
- expect(loadStatusPins(PATH, fs)).toEqual([]);
229
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
207
230
  });
208
231
 
209
232
  it("retry-safe (#3001): a failing unpin is non-fatal, RETAINS the row with an attempt counter, and drops only the succeeded one", async () => {
@@ -225,7 +248,7 @@ describe("runStatusPinBootCleanup", () => {
225
248
  // One failed, one succeeded — the failure is retained for a next-boot
226
249
  // retry instead of forfeiting the orphan (the pre-#3001 behaviour).
227
250
  expect(res).toEqual({ cleared: 1, retained: 1, kept: 0, total: 2 });
228
- expect(loadStatusPins(PATH, fs)).toEqual([
251
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([
229
252
  { pinKey: "fg:c:1", chatId: "-100123", messageId: 5, attempts: 1 },
230
253
  ]);
231
254
  });
@@ -251,7 +274,7 @@ describe("runStatusPinBootCleanup", () => {
251
274
  // Final attempt failed too — forfeited, not retained: a permanently-
252
275
  // undeliverable unpin must not re-fail on every future boot.
253
276
  expect(res).toEqual({ cleared: 0, retained: 0, kept: 0, total: 1 });
254
- expect(loadStatusPins(PATH, fs)).toEqual([]);
277
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
255
278
  });
256
279
 
257
280
  it("tool pins (#3001): an UNEXPIRED `tool:` row survives the boot untouched; an EXPIRED one is unpinned and dropped", async () => {
@@ -277,7 +300,7 @@ describe("runStatusPinBootCleanup", () => {
277
300
  // deliberate agent pin with no "work finished" event).
278
301
  expect(unpinned).toEqual([71, 72]);
279
302
  expect(res).toEqual({ cleared: 2, retained: 0, kept: 1, total: 3 });
280
- expect(loadStatusPins(PATH, fs)).toEqual([
303
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([
281
304
  { pinKey: "tool:-100123:70", chatId: "-100123", messageId: 70, expiresAt: now + 1 },
282
305
  ]);
283
306
  });
@@ -316,7 +339,7 @@ describe("runStatusPinBootCleanup", () => {
316
339
  });
317
340
  expect(unpinned).toEqual([["-100777", 314]]);
318
341
  expect(res).toEqual({ cleared: 1, retained: 0, kept: 0, total: 1 });
319
- expect(loadStatusPins(PATH, fs)).toEqual([]);
342
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
320
343
  });
321
344
  });
322
345
 
@@ -360,7 +383,7 @@ describe("status-pin-store — concurrent-writer race", () => {
360
383
  // Let the reconcile reach its applyPin await (pending row now on disk).
361
384
  await Promise.resolve();
362
385
  await Promise.resolve();
363
- expect(onDiskAtPin).toEqual([
386
+ expect(idOnly(onDiskAtPin)).toEqual([
364
387
  { pinKey: "fg:c:3", chatId: "-100123", messageId: 715, pending: true },
365
388
  ]);
366
389
 
@@ -383,12 +406,12 @@ describe("status-pin-store — concurrent-writer race", () => {
383
406
  // BOTH rows survive: the status pin was NOT dropped by the concurrent
384
407
  // banner write (the pre-fix bug), and the banner row is present.
385
408
  const loaded = loadStatusPins(PATH, fs);
386
- expect(loaded).toContainEqual({
409
+ expect(idOnly(loaded)).toContainEqual({
387
410
  pinKey: "fg:c:3",
388
411
  chatId: "-100123",
389
412
  messageId: 715,
390
413
  });
391
- expect(loaded).toContainEqual({
414
+ expect(idOnly(loaded)).toContainEqual({
392
415
  pinKey: "banner:owner",
393
416
  chatId: "-100999",
394
417
  messageId: 42,
@@ -436,12 +459,12 @@ describe("status-pin-store — concurrent-writer race", () => {
436
459
  await reconcileB;
437
460
 
438
461
  const loaded = loadStatusPins(PATH, fs);
439
- expect(loaded).toContainEqual({
462
+ expect(idOnly(loaded)).toContainEqual({
440
463
  pinKey: "fg:c:3",
441
464
  chatId: "-100123",
442
465
  messageId: 715,
443
466
  });
444
- expect(loaded).toContainEqual({
467
+ expect(idOnly(loaded)).toContainEqual({
445
468
  pinKey: "wk:agent-x",
446
469
  chatId: "-100999",
447
470
  messageId: 42,
@@ -525,16 +548,44 @@ describe("status-pin-store — envelope version compat", () => {
525
548
  expect(loaded[0].messageId).toBe(715);
526
549
  });
527
550
 
528
- it("round-trips a v2 pending flag and writes v2 envelopes", () => {
551
+ it("round-trips a v2 pending flag and writes v3 envelopes", () => {
529
552
  const { fs, files } = memFs();
530
553
  persistStatusPins(PATH, fs, [pin({ pending: true })]);
531
- expect(JSON.parse(files.get(PATH)!).v).toBe(2);
554
+ expect(JSON.parse(files.get(PATH)!).v).toBe(3);
532
555
  expect(loadStatusPins(PATH, fs)[0].pending).toBe(true);
533
556
  });
534
557
 
558
+ it("#3810: a v3 row round-trips its pinnedAt claim age; a v1/v2 row loads without one", () => {
559
+ const { fs } = memFs({
560
+ [PATH]: JSON.stringify({
561
+ v: 3,
562
+ pins: [
563
+ { pinKey: "wk:group:g1", chatId: "-100123", messageId: 715, pinnedAt: 1234 },
564
+ { pinKey: "wk:group:g2", chatId: "-100123", messageId: 716 },
565
+ ],
566
+ }),
567
+ });
568
+ const loaded = loadStatusPins(PATH, fs);
569
+ expect(loaded[0].pinnedAt).toBe(1234);
570
+ expect(loaded[1].pinnedAt).toBeUndefined();
571
+ });
572
+
573
+ it("drops a row with a non-numeric pinnedAt", () => {
574
+ const { fs } = memFs({
575
+ [PATH]: JSON.stringify({
576
+ v: 3,
577
+ pins: [
578
+ pin({ messageId: 715 }),
579
+ { pinKey: "k", chatId: "c", messageId: 1, pinnedAt: "soon" },
580
+ ],
581
+ }),
582
+ });
583
+ expect(loadStatusPins(PATH, fs)).toHaveLength(1);
584
+ });
585
+
535
586
  it("rejects an unknown future envelope version (fail-open [])", () => {
536
- const { fs } = memFs({ [PATH]: JSON.stringify({ v: 3, pins: [pin()] }) });
537
- expect(loadStatusPins(PATH, fs)).toEqual([]);
587
+ const { fs } = memFs({ [PATH]: JSON.stringify({ v: 4, pins: [pin()] }) });
588
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
538
589
  });
539
590
 
540
591
  it("drops a row with a non-boolean pending field", () => {
@@ -579,12 +630,12 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
579
630
  log: () => {},
580
631
  });
581
632
  // At the moment the pin API ran, a pending record was already on disk.
582
- expect(onDiskAtPin).toEqual([
633
+ expect(idOnly(onDiskAtPin)).toEqual([
583
634
  { pinKey: "fg:c:3", chatId: "-100123", messageId: 715, pending: true },
584
635
  ]);
585
636
  // After success it's confirmed (pending cleared).
586
637
  expect(next).toEqual({ messageId: 715 });
587
- expect(loadStatusPins(PATH, fs)).toEqual([
638
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([
588
639
  { pinKey: "fg:c:3", chatId: "-100123", messageId: 715 },
589
640
  ]);
590
641
  });
@@ -616,7 +667,7 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
616
667
  // persist-AFTER-pin ordering FAILED to do — the store would have been empty
617
668
  // and boot cleanup blind to the orphan).
618
669
  const persisted = loadStatusPins(PATH, fs);
619
- expect(persisted).toEqual([
670
+ expect(idOnly(persisted)).toEqual([
620
671
  { pinKey: "fg:c:3", chatId: "-100123", messageId: 715, pending: true },
621
672
  ]);
622
673
 
@@ -631,7 +682,7 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
631
682
  log: () => {},
632
683
  });
633
684
  expect(unpinned).toEqual([["-100123", 715]]);
634
- expect(loadStatusPins(PATH, fs)).toEqual([]);
685
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
635
686
  });
636
687
 
637
688
  it("clears the pending record when the pin API fails (no phantom claim)", async () => {
@@ -642,13 +693,13 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
642
693
  pinKey: "fg:c:3",
643
694
  chatId: "-100123",
644
695
  op: { kind: "pin", messageId: 715 },
645
- // reconcilePin returns null (prevState) when the pin API throws.
696
+ // executePinLeg returns null (prevState) when the pin API throws.
646
697
  applyPin: async () => null,
647
698
  log: () => {},
648
699
  });
649
700
  expect(next).toBeNull();
650
701
  // Pending record dropped — nothing to leak, nothing was pinned.
651
- expect(loadStatusPins(PATH, fs)).toEqual([]);
702
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
652
703
  });
653
704
 
654
705
  it("preserves OTHER live claims while flipping one key pending→confirmed", async () => {
@@ -671,8 +722,8 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
671
722
  log: () => {},
672
723
  });
673
724
  const loaded = loadStatusPins(PATH, fs);
674
- expect(loaded).toContainEqual(other);
675
- expect(loaded).toContainEqual({
725
+ expect(idOnly(loaded)).toContainEqual(other);
726
+ expect(idOnly(loaded)).toContainEqual({
676
727
  pinKey: "fg:c:3",
677
728
  chatId: "-100123",
678
729
  messageId: 715,
@@ -696,12 +747,12 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
696
747
  log: () => {},
697
748
  });
698
749
  const loaded = loadStatusPins(PATH, fs);
699
- expect(loaded).toContainEqual({
750
+ expect(idOnly(loaded)).toContainEqual({
700
751
  pinKey: "banner:owner",
701
752
  chatId: "-100999",
702
753
  messageId: 42,
703
754
  });
704
- expect(loaded).toContainEqual({
755
+ expect(idOnly(loaded)).toContainEqual({
705
756
  pinKey: "fg:c:3",
706
757
  chatId: "-100123",
707
758
  messageId: 715,
@@ -721,11 +772,11 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
721
772
  log: () => {},
722
773
  });
723
774
  expect(next).toBeNull();
724
- expect(loadStatusPins(PATH, fs)).toEqual([]);
775
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
725
776
  });
726
777
 
727
778
  // F1 (invisible-worker-cards review): a `clear` op is emitted for BOTH a real
728
- // unpin AND a `noop: already pinned`. For a noop, reconcilePin issues NO
779
+ // unpin AND a `noop: already pinned`. For a noop, executePinLeg issues NO
729
780
  // Telegram call and returns the LIVE claim (non-null). The pre-fix clear
730
781
  // branch dropped the row unconditionally, so the worker feed's per-edit
731
782
  // syncPin (every steady-state edit maps to a noop-clear) erased the durable
@@ -736,7 +787,7 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
736
787
  const { fs, calls } = memFs();
737
788
  persistStatusPins(PATH, fs, [pin({ pinKey: "wk:group:g1", messageId: 715 })]);
738
789
  const before = calls.length;
739
- // applyPin returns the live claim unchanged (what reconcilePin does on noop).
790
+ // applyPin returns the live claim unchanged (what executePinLeg does on noop).
740
791
  const next = await reconcileAndPersistStatusPin({
741
792
  path: PATH,
742
793
  fs,
@@ -748,7 +799,7 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
748
799
  });
749
800
  expect(next).toEqual({ messageId: 715 });
750
801
  // Row survives (rewritten confirmed, no `pending`).
751
- expect(loadStatusPins(PATH, fs)).toEqual([
802
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([
752
803
  { pinKey: "wk:group:g1", chatId: "-100123", messageId: 715 },
753
804
  ]);
754
805
  // Sanity: at least one disk write happened (the confirm rewrite) — the fix
@@ -770,7 +821,7 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
770
821
  log: () => {},
771
822
  });
772
823
  }
773
- expect(loadStatusPins(PATH, fs)).toEqual([
824
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([
774
825
  { pinKey: "wk:group:g1", chatId: "-100123", messageId: 715 },
775
826
  ]);
776
827
  });
@@ -784,7 +835,7 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
784
835
  * row while a flood-delayed open-pin lands, leaving a stuck pin with no row.
785
836
  *
786
837
  * These tests exercise the REAL fix primitives — `withPinReconcileLock` +
787
- * `reconcileAndPersistStatusPin` + `reconcilePin` + `decidePinAction` — composed
838
+ * `reconcileAndPersistStatusPin` + `executePinLeg` + `decidePinAction` — composed
788
839
  * exactly as the gateway wires them (read prev INSIDE the per-key lock), and
789
840
  * prove the row survives. The `noLock` control models the pre-fix ordering
790
841
  * (prev read OUTSIDE the lock) and shows it regresses — so the lock, not luck,
@@ -793,11 +844,11 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
793
844
  describe("status-pin-store — F2 per-key reconcile serialization", () => {
794
845
  // A gateway-faithful reconcile: an in-memory claim map (the `prev` source read
795
846
  // at the TOP of the reconcile), the real decide→op mapping, and
796
- // reconcileAndPersistStatusPin driving reconcilePin against a call-counting
847
+ // reconcileAndPersistStatusPin driving executePinLeg against a call-counting
797
848
  // pin API. An optional gate lets a test hold ONE reconcile's applyPin open to
798
849
  // force the exact interleave. `serialize` toggles the F2 fix.
799
850
  function makeReconciler(fs: StatusPinStoreFsSeam, opts: { serialize: boolean }) {
800
- const claims = new Map<string, PinState>();
851
+ const claims = new Map<string, StatusPinClaim>();
801
852
  const pinCalls: number[] = [];
802
853
  const unpinCalls: number[] = [];
803
854
  const api: PinBotApi = {
@@ -808,8 +859,6 @@ describe("status-pin-store — F2 per-key reconcile serialization", () => {
808
859
  unpinCalls.push(id);
809
860
  },
810
861
  };
811
- const chatIds = new Map<string, string>();
812
- const pinnedAt = new Map<string, number>();
813
862
  // Drives the REAL production orchestrator the gateway calls, so the persist
814
863
  // ordering and the RETARGET two-leg expansion under test are the shipped
815
864
  // ones — not a copy that could drift out from under this suite.
@@ -817,14 +866,14 @@ describe("status-pin-store — F2 per-key reconcile serialization", () => {
817
866
  return runStatusPinReconcile({
818
867
  pinKey,
819
868
  chatId,
820
- prev: claims.get(pinKey) ?? null, // read BEFORE the store op (the F2 window)
869
+ prev: prevOf(claims, pinKey), // read BEFORE the store op (the F2 window)
821
870
  desired,
822
871
  persist: { path: PATH, fs },
823
- runPin: async (from, want) => {
872
+ runPin: async (action, from) => {
824
873
  if (gate) await gate; // hold the pin open inside the store lock
825
- return reconcilePin({ api, chatId, prevState: from, desired: want });
874
+ return executePinLeg({ api, chatId, prevState: from, action });
826
875
  },
827
- registries: { state: claims, chatIds, pinnedAt },
876
+ claims,
828
877
  });
829
878
  }
830
879
  function reconcile(
@@ -872,7 +921,7 @@ describe("status-pin-store — F2 per-key reconcile serialization", () => {
872
921
  // and claim together. The Telegram pin is cleaned up — nothing stuck.
873
922
  expect(pinCalls).toEqual([900]); // A pinned it
874
923
  expect(unpinCalls).toEqual([900]); // B unpinned it (the fix)
875
- expect(rows).toEqual([]);
924
+ expect(idOnly(rows)).toEqual([]);
876
925
  expect(claims.get("fg:c:3") ?? null).toBeNull();
877
926
  });
878
927
 
@@ -886,7 +935,7 @@ describe("status-pin-store — F2 per-key reconcile serialization", () => {
886
935
  // issued → stuck until a boot cleanup that can no longer see it.
887
936
  expect(pinCalls).toEqual([900]); // message 900 WAS pinned
888
937
  expect(unpinCalls).toEqual([]); // …but never unpinned (no-op clear on prev=null)
889
- expect(rows).toEqual([]); // …and the durable row is gone → unreapable
938
+ expect(idOnly(rows)).toEqual([]); // …and the durable row is gone → unreapable
890
939
  });
891
940
 
892
941
  it("serialized steady-state noop reconciles add ZERO pin/unpin API calls (finn-flood guard)", async () => {
@@ -898,7 +947,7 @@ describe("status-pin-store — F2 per-key reconcile serialization", () => {
898
947
  expect(r.unpinCalls).toEqual([]);
899
948
 
900
949
  // 20 steady-state edits — each a re-pin of the SAME id → decidePinAction
901
- // noop → reconcilePin issues NO Telegram call. The serialization + F1
950
+ // noop → executePinLeg issues NO Telegram call. The serialization + F1
902
951
  // row-preservation must not add a single pin/unpin API call.
903
952
  for (let i = 0; i < 20; i++) {
904
953
  await r.reconcile("wk:group:g1", "-100123", { pinned: true, messageId: 900 });
@@ -906,7 +955,7 @@ describe("status-pin-store — F2 per-key reconcile serialization", () => {
906
955
  expect(r.pinCalls).toEqual([900]); // still exactly one pin, ever
907
956
  expect(r.unpinCalls).toEqual([]); // never unpinned
908
957
  // The durable row is intact throughout (F1).
909
- expect(loadStatusPins(PATH, fs)).toEqual([
958
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([
910
959
  { pinKey: "wk:group:g1", chatId: "-100123", messageId: 900 },
911
960
  ]);
912
961
  });
@@ -924,7 +973,7 @@ describe("status-pin-store — F2 per-key reconcile serialization", () => {
924
973
 
925
974
  await r.reconcile("fg:c:7", "-100123", { pinned: true, messageId: 900 });
926
975
  expect(r.pinCalls).toEqual([900]);
927
- expect(loadStatusPins(PATH, fs)).toEqual([
976
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([
928
977
  { pinKey: "fg:c:7", chatId: "-100123", messageId: 900 },
929
978
  ]);
930
979
 
@@ -933,10 +982,10 @@ describe("status-pin-store — F2 per-key reconcile serialization", () => {
933
982
 
934
983
  expect(r.unpinCalls).toEqual([900]); // stale card unpinned
935
984
  expect(r.pinCalls).toEqual([900, 901]); // …AND the new card pinned
936
- expect(r.claims.get("fg:c:7")).toEqual({ messageId: 901 });
985
+ expect(r.claims.get("fg:c:7")?.messageId).toBe(901);
937
986
  // The durable row tracks the message that is genuinely pinned, so the
938
987
  // mid-session reaper and the boot sweep can both still reach it.
939
- expect(loadStatusPins(PATH, fs)).toEqual([
988
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([
940
989
  { pinKey: "fg:c:7", chatId: "-100123", messageId: 901 },
941
990
  ]);
942
991
  });
@@ -946,7 +995,7 @@ describe("status-pin-store — F2 per-key reconcile serialization", () => {
946
995
  // still pinned, so we must NOT pin the new one and must NOT lose the row —
947
996
  // otherwise the chat holds a pin nothing on disk names.
948
997
  const { fs } = memFs();
949
- const claims = new Map<string, PinState>();
998
+ const claims = new Map<string, StatusPinClaim>();
950
999
  const pinCalls: number[] = [];
951
1000
  const unpinCalls: number[] = [];
952
1001
  let floodTheUnpin = false;
@@ -963,12 +1012,12 @@ describe("status-pin-store — F2 per-key reconcile serialization", () => {
963
1012
  runStatusPinReconcile({
964
1013
  pinKey: "fg:c:8",
965
1014
  chatId: "-100123",
966
- prev: claims.get("fg:c:8") ?? null,
1015
+ prev: prevOf(claims, "fg:c:8"),
967
1016
  desired,
968
1017
  persist: { path: PATH, fs },
969
- runPin: (from, want) =>
970
- reconcilePin({ api, chatId: "-100123", prevState: from, desired: want }),
971
- registries: { state: claims, chatIds: new Map(), pinnedAt: new Map() },
1018
+ runPin: (action, from) =>
1019
+ executePinLeg({ api, chatId: "-100123", prevState: from, action }),
1020
+ claims,
972
1021
  });
973
1022
 
974
1023
  await reconcile({ pinned: true, messageId: 900 });
@@ -977,8 +1026,8 @@ describe("status-pin-store — F2 per-key reconcile serialization", () => {
977
1026
 
978
1027
  expect(unpinCalls).toEqual([900]); // attempted…
979
1028
  expect(pinCalls).toEqual([900]); // …and the pin leg was correctly SKIPPED
980
- expect(claims.get("fg:c:8")).toEqual({ messageId: 900 }); // claim retained
981
- expect(loadStatusPins(PATH, fs)).toEqual([
1029
+ expect(claims.get("fg:c:8")?.messageId).toBe(900); // claim retained
1030
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([
982
1031
  { pinKey: "fg:c:8", chatId: "-100123", messageId: 900 },
983
1032
  ]);
984
1033
  });
@@ -2,8 +2,59 @@ import { describe, it, expect } from 'vitest'
2
2
  import { GrammyError } from 'grammy'
3
3
  import { decidePinAction, isPinRightsError, isUnpinTerminalError, PinRightsCache } from '../status-pin.js'
4
4
  import { makeFloodWaitActiveError, GIVE_UP_MESSAGE } from '../retry-api-call.js'
5
- import { reconcilePin, type PinBotApi } from '../status-pin-driver.js'
6
- import type { PinState } from '../status-pin.js'
5
+ import { executePinLeg, type PinBotApi } from '../status-pin-driver.js'
6
+ import type { DesiredPin, PinState, PinRightsCache as _RightsCache } from '../status-pin.js'
7
+ import {
8
+ runStatusPinReconcile,
9
+ type StatusPinClaim,
10
+ } from '../gateway/status-pin-retarget.js'
11
+
12
+ /**
13
+ * Drive a whole desired-state reconcile through the REAL unified path — the
14
+ * orchestrator (`runStatusPinReconcile`, which owns the single retarget
15
+ * expansion) composed with the driver (`executePinLeg`), exactly as gateway.ts
16
+ * wires them. `persist: null` mirrors the STATIC / feature-off binding.
17
+ *
18
+ * These cases used to call `reconcilePin(prev, desired)` directly, back when the
19
+ * driver re-decided the action itself and carried its OWN copy of the retarget
20
+ * expansion (#3831). The driver can no longer express a retarget, so exercising
21
+ * the same behaviours now means exercising the composition — which is what
22
+ * production actually runs.
23
+ */
24
+ async function reconcilePin(args: {
25
+ api: PinBotApi
26
+ chatId: string
27
+ prevState: PinState | null
28
+ desired: DesiredPin
29
+ rightsCache?: _RightsCache
30
+ onError?: (phase: 'pin' | 'unpin', err: unknown) => void
31
+ onPinRightsDisabled?: (chatId: string) => void
32
+ }): Promise<PinState | null> {
33
+ const claims = new Map<string, StatusPinClaim>()
34
+ if (args.prevState != null) {
35
+ claims.set('k', { messageId: args.prevState.messageId, chatId: args.chatId, pinnedAt: 1 })
36
+ }
37
+ await runStatusPinReconcile({
38
+ pinKey: 'k',
39
+ chatId: args.chatId,
40
+ prev: args.prevState,
41
+ desired: args.desired,
42
+ persist: null,
43
+ runPin: (action, from) =>
44
+ executePinLeg({
45
+ api: args.api,
46
+ chatId: args.chatId,
47
+ prevState: from,
48
+ action,
49
+ rightsCache: args.rightsCache,
50
+ onError: args.onError,
51
+ onPinRightsDisabled: args.onPinRightsDisabled,
52
+ }),
53
+ claims,
54
+ })
55
+ const claim = claims.get('k')
56
+ return claim == null ? null : { messageId: claim.messageId }
57
+ }
7
58
 
8
59
  /** A real GrammyError with the given code + description, mirroring the wire
9
60
  * shape Telegram returns for the pin-rights failure that crashed marko. */
@@ -64,7 +115,7 @@ function fakeApi(opts: { pinThrows?: boolean; unpinThrows?: boolean } = {}) {
64
115
  return { api, calls }
65
116
  }
66
117
 
67
- describe('reconcilePin (driver)', () => {
118
+ describe('unified reconcile path (orchestrator + driver)', () => {
68
119
  it('pin on in-progress: pins the EXISTING message SILENTLY and claims it', async () => {
69
120
  const { api, calls } = fakeApi()
70
121
  const next = await reconcilePin({
@@ -333,7 +384,7 @@ describe('reconcilePin (driver)', () => {
333
384
  // the whole gateway down. Auto status-pin is best-effort/cosmetic — a
334
385
  // pin-rights failure must be absorbed, never fatal, and never reach the
335
386
  // process-level unhandledRejection handler.
336
- describe('reconcilePin — pin-rights 400 must never crash (marko 2026-07-01)', () => {
387
+ describe('unified reconcile path — pin-rights 400 must never crash (marko 2026-07-01)', () => {
337
388
  it('absorbs the "not enough rights" 400 via onError and never rejects', async () => {
338
389
  const rightsErr = grammyError(
339
390
  400,
@@ -468,7 +519,7 @@ function countingApi(pinError?: unknown) {
468
519
  }
469
520
  }
470
521
 
471
- describe('reconcilePin — rights-aware negative cache (#3024)', () => {
522
+ describe('unified reconcile path — rights-aware negative cache (#3024)', () => {
472
523
  const rightsErr = () =>
473
524
  grammyError(400, 'Bad Request: not enough rights to manage pinned messages in the chat')
474
525