switchroom 0.19.25 → 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 (84) hide show
  1. package/bin/git-agent-attribution-hook.sh +144 -0
  2. package/dist/agent-scheduler/index.js +61 -2
  3. package/dist/auth-broker/index.js +125 -8
  4. package/dist/cli/notion-write-pretool.mjs +61 -2
  5. package/dist/cli/switchroom.js +2347 -1104
  6. package/dist/host-control/main.js +126 -9
  7. package/dist/vault/approvals/kernel-server.js +124 -8
  8. package/dist/vault/broker/server.js +124 -8
  9. package/package.json +6 -2
  10. package/profiles/_base/cron-session.sh.hbs +14 -0
  11. package/profiles/_base/start.sh.hbs +145 -4
  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 +2213 -1204
  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 +103 -112
  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/periodic-sweep-guard.ts +86 -0
  25. package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
  26. package/telegram-plugin/gateway/status-pin-retarget.ts +180 -0
  27. package/telegram-plugin/gateway/status-pin-store.ts +58 -9
  28. package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
  29. package/telegram-plugin/llm-error-present.ts +61 -2
  30. package/telegram-plugin/model-unavailable.ts +8 -0
  31. package/telegram-plugin/operator-events.ts +72 -5
  32. package/telegram-plugin/outbound-class.ts +81 -0
  33. package/telegram-plugin/provider-credit.ts +237 -0
  34. package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
  35. package/telegram-plugin/send-gate.ts +24 -2
  36. package/telegram-plugin/status-no-truncate.ts +11 -0
  37. package/telegram-plugin/status-pin-driver.ts +33 -17
  38. package/telegram-plugin/status-pin.ts +51 -5
  39. package/telegram-plugin/tests/card-golden.test.ts +69 -0
  40. package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
  41. package/telegram-plugin/tests/card-type-distinguishability.test.ts +291 -0
  42. package/telegram-plugin/tests/card-variants.golden.txt +211 -0
  43. package/telegram-plugin/tests/card-variants.ts +366 -0
  44. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +316 -0
  45. package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
  46. package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
  47. package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
  48. package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
  49. package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
  50. package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
  51. package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
  52. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
  53. package/telegram-plugin/tests/periodic-sweep-guard.test.ts +151 -0
  54. package/telegram-plugin/tests/pinned-card-collapse.test.ts +24 -18
  55. package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
  56. package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
  57. package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
  58. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
  59. package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
  60. package/telegram-plugin/tests/status-pin-retarget.test.ts +244 -0
  61. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
  62. package/telegram-plugin/tests/status-pin-shutdown-wiring.test.ts +94 -0
  63. package/telegram-plugin/tests/status-pin-store.test.ts +179 -64
  64. package/telegram-plugin/tests/status-pin.test.ts +184 -7
  65. package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
  66. package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
  67. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +29 -19
  68. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
  69. package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
  70. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  71. package/telegram-plugin/tier-downgrade.ts +3 -2
  72. package/telegram-plugin/tool-activity-summary.ts +239 -322
  73. package/telegram-plugin/uat/assertions.ts +33 -3
  74. package/telegram-plugin/uat/feed-matcher.test.ts +36 -0
  75. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +9 -2
  76. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +9 -2
  77. package/telegram-plugin/worker-activity-feed.ts +109 -30
  78. package/vendor/hindsight-memory/CLAUDE.md +45 -0
  79. package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
  80. package/vendor/hindsight-memory/scripts/recall.py +176 -7
  81. package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
  82. package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
  83. package/vendor/hindsight-memory/scripts/tests/test_recall_request_timeout.py +241 -0
  84. package/vendor/hindsight-memory/settings.json +1 -1
@@ -13,7 +13,12 @@ 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";
16
+ import { executePinLeg, type PinBotApi } from "../status-pin-driver.js";
17
+ import {
18
+ runStatusPinReconcile,
19
+ type StatusPinClaim,
20
+ } from "../gateway/status-pin-retarget.js";
21
+ import { makeFloodWaitActiveError } from "../retry-api-call.js";
17
22
 
18
23
  /** In-memory fs seam with an atomic rename, so the store's tmp→rename
19
24
  * crash-safety contract is exercised without touching the real disk. */
@@ -46,6 +51,26 @@ function pin(over: Partial<PersistedStatusPin> = {}): PersistedStatusPin {
46
51
  return { pinKey: "fg:c:3", chatId: "-100123", messageId: 715, ...over };
47
52
  }
48
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
+
49
74
  describe("status-pin-store", () => {
50
75
  it("round-trips the pin claim set", () => {
51
76
  const { fs } = memFs();
@@ -54,7 +79,7 @@ describe("status-pin-store", () => {
54
79
  pin({ pinKey: "wk:agent-x", chatId: "-100999", messageId: 42 }),
55
80
  ];
56
81
  persistStatusPins(PATH, fs, snap);
57
- expect(loadStatusPins(PATH, fs)).toEqual(snap);
82
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual(snap);
58
83
  });
59
84
 
60
85
  it("writes via tmp + atomic rename (crash-safe)", () => {
@@ -78,22 +103,22 @@ describe("status-pin-store", () => {
78
103
  expect(loadStatusPins(PATH, fs)).toHaveLength(1);
79
104
  // Reconcile deleted the key → snapshot of the now-empty Map.
80
105
  persistStatusPins(PATH, fs, []);
81
- expect(loadStatusPins(PATH, fs)).toEqual([]);
106
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
82
107
  });
83
108
 
84
109
  it("loads [] when no file exists (fresh boot)", () => {
85
110
  const { fs } = memFs();
86
- expect(loadStatusPins(PATH, fs)).toEqual([]);
111
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
87
112
  });
88
113
 
89
114
  it("fail-open: corrupt JSON loads as [] (never crashes boot)", () => {
90
115
  const { fs } = memFs({ [PATH]: "{not json" });
91
- expect(loadStatusPins(PATH, fs)).toEqual([]);
116
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
92
117
  });
93
118
 
94
119
  it("fail-open: wrong envelope version / shape loads as []", () => {
95
- // v3 is an unknown FUTURE version (v1 + v2 are the supported set).
96
- 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()] }) });
97
122
  expect(loadStatusPins(PATH, f1)).toEqual([]);
98
123
  const { fs: f2 } = memFs({ [PATH]: JSON.stringify({ v: 1, pins: "nope" }) });
99
124
  expect(loadStatusPins(PATH, f2)).toEqual([]);
@@ -201,7 +226,7 @@ describe("runStatusPinBootCleanup", () => {
201
226
  ]);
202
227
  expect(res).toEqual({ cleared: 2, retained: 0, kept: 0, total: 2 });
203
228
  // Store empty afterwards → no re-attempt next boot.
204
- expect(loadStatusPins(PATH, fs)).toEqual([]);
229
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
205
230
  });
206
231
 
207
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 () => {
@@ -223,7 +248,7 @@ describe("runStatusPinBootCleanup", () => {
223
248
  // One failed, one succeeded — the failure is retained for a next-boot
224
249
  // retry instead of forfeiting the orphan (the pre-#3001 behaviour).
225
250
  expect(res).toEqual({ cleared: 1, retained: 1, kept: 0, total: 2 });
226
- expect(loadStatusPins(PATH, fs)).toEqual([
251
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([
227
252
  { pinKey: "fg:c:1", chatId: "-100123", messageId: 5, attempts: 1 },
228
253
  ]);
229
254
  });
@@ -249,7 +274,7 @@ describe("runStatusPinBootCleanup", () => {
249
274
  // Final attempt failed too — forfeited, not retained: a permanently-
250
275
  // undeliverable unpin must not re-fail on every future boot.
251
276
  expect(res).toEqual({ cleared: 0, retained: 0, kept: 0, total: 1 });
252
- expect(loadStatusPins(PATH, fs)).toEqual([]);
277
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
253
278
  });
254
279
 
255
280
  it("tool pins (#3001): an UNEXPIRED `tool:` row survives the boot untouched; an EXPIRED one is unpinned and dropped", async () => {
@@ -275,7 +300,7 @@ describe("runStatusPinBootCleanup", () => {
275
300
  // deliberate agent pin with no "work finished" event).
276
301
  expect(unpinned).toEqual([71, 72]);
277
302
  expect(res).toEqual({ cleared: 2, retained: 0, kept: 1, total: 3 });
278
- expect(loadStatusPins(PATH, fs)).toEqual([
303
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([
279
304
  { pinKey: "tool:-100123:70", chatId: "-100123", messageId: 70, expiresAt: now + 1 },
280
305
  ]);
281
306
  });
@@ -314,7 +339,7 @@ describe("runStatusPinBootCleanup", () => {
314
339
  });
315
340
  expect(unpinned).toEqual([["-100777", 314]]);
316
341
  expect(res).toEqual({ cleared: 1, retained: 0, kept: 0, total: 1 });
317
- expect(loadStatusPins(PATH, fs)).toEqual([]);
342
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
318
343
  });
319
344
  });
320
345
 
@@ -358,7 +383,7 @@ describe("status-pin-store — concurrent-writer race", () => {
358
383
  // Let the reconcile reach its applyPin await (pending row now on disk).
359
384
  await Promise.resolve();
360
385
  await Promise.resolve();
361
- expect(onDiskAtPin).toEqual([
386
+ expect(idOnly(onDiskAtPin)).toEqual([
362
387
  { pinKey: "fg:c:3", chatId: "-100123", messageId: 715, pending: true },
363
388
  ]);
364
389
 
@@ -381,12 +406,12 @@ describe("status-pin-store — concurrent-writer race", () => {
381
406
  // BOTH rows survive: the status pin was NOT dropped by the concurrent
382
407
  // banner write (the pre-fix bug), and the banner row is present.
383
408
  const loaded = loadStatusPins(PATH, fs);
384
- expect(loaded).toContainEqual({
409
+ expect(idOnly(loaded)).toContainEqual({
385
410
  pinKey: "fg:c:3",
386
411
  chatId: "-100123",
387
412
  messageId: 715,
388
413
  });
389
- expect(loaded).toContainEqual({
414
+ expect(idOnly(loaded)).toContainEqual({
390
415
  pinKey: "banner:owner",
391
416
  chatId: "-100999",
392
417
  messageId: 42,
@@ -434,12 +459,12 @@ describe("status-pin-store — concurrent-writer race", () => {
434
459
  await reconcileB;
435
460
 
436
461
  const loaded = loadStatusPins(PATH, fs);
437
- expect(loaded).toContainEqual({
462
+ expect(idOnly(loaded)).toContainEqual({
438
463
  pinKey: "fg:c:3",
439
464
  chatId: "-100123",
440
465
  messageId: 715,
441
466
  });
442
- expect(loaded).toContainEqual({
467
+ expect(idOnly(loaded)).toContainEqual({
443
468
  pinKey: "wk:agent-x",
444
469
  chatId: "-100999",
445
470
  messageId: 42,
@@ -523,16 +548,44 @@ describe("status-pin-store — envelope version compat", () => {
523
548
  expect(loaded[0].messageId).toBe(715);
524
549
  });
525
550
 
526
- it("round-trips a v2 pending flag and writes v2 envelopes", () => {
551
+ it("round-trips a v2 pending flag and writes v3 envelopes", () => {
527
552
  const { fs, files } = memFs();
528
553
  persistStatusPins(PATH, fs, [pin({ pending: true })]);
529
- expect(JSON.parse(files.get(PATH)!).v).toBe(2);
554
+ expect(JSON.parse(files.get(PATH)!).v).toBe(3);
530
555
  expect(loadStatusPins(PATH, fs)[0].pending).toBe(true);
531
556
  });
532
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
+
533
586
  it("rejects an unknown future envelope version (fail-open [])", () => {
534
- const { fs } = memFs({ [PATH]: JSON.stringify({ v: 3, pins: [pin()] }) });
535
- expect(loadStatusPins(PATH, fs)).toEqual([]);
587
+ const { fs } = memFs({ [PATH]: JSON.stringify({ v: 4, pins: [pin()] }) });
588
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
536
589
  });
537
590
 
538
591
  it("drops a row with a non-boolean pending field", () => {
@@ -577,12 +630,12 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
577
630
  log: () => {},
578
631
  });
579
632
  // At the moment the pin API ran, a pending record was already on disk.
580
- expect(onDiskAtPin).toEqual([
633
+ expect(idOnly(onDiskAtPin)).toEqual([
581
634
  { pinKey: "fg:c:3", chatId: "-100123", messageId: 715, pending: true },
582
635
  ]);
583
636
  // After success it's confirmed (pending cleared).
584
637
  expect(next).toEqual({ messageId: 715 });
585
- expect(loadStatusPins(PATH, fs)).toEqual([
638
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([
586
639
  { pinKey: "fg:c:3", chatId: "-100123", messageId: 715 },
587
640
  ]);
588
641
  });
@@ -614,7 +667,7 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
614
667
  // persist-AFTER-pin ordering FAILED to do — the store would have been empty
615
668
  // and boot cleanup blind to the orphan).
616
669
  const persisted = loadStatusPins(PATH, fs);
617
- expect(persisted).toEqual([
670
+ expect(idOnly(persisted)).toEqual([
618
671
  { pinKey: "fg:c:3", chatId: "-100123", messageId: 715, pending: true },
619
672
  ]);
620
673
 
@@ -629,7 +682,7 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
629
682
  log: () => {},
630
683
  });
631
684
  expect(unpinned).toEqual([["-100123", 715]]);
632
- expect(loadStatusPins(PATH, fs)).toEqual([]);
685
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
633
686
  });
634
687
 
635
688
  it("clears the pending record when the pin API fails (no phantom claim)", async () => {
@@ -640,13 +693,13 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
640
693
  pinKey: "fg:c:3",
641
694
  chatId: "-100123",
642
695
  op: { kind: "pin", messageId: 715 },
643
- // reconcilePin returns null (prevState) when the pin API throws.
696
+ // executePinLeg returns null (prevState) when the pin API throws.
644
697
  applyPin: async () => null,
645
698
  log: () => {},
646
699
  });
647
700
  expect(next).toBeNull();
648
701
  // Pending record dropped — nothing to leak, nothing was pinned.
649
- expect(loadStatusPins(PATH, fs)).toEqual([]);
702
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
650
703
  });
651
704
 
652
705
  it("preserves OTHER live claims while flipping one key pending→confirmed", async () => {
@@ -669,8 +722,8 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
669
722
  log: () => {},
670
723
  });
671
724
  const loaded = loadStatusPins(PATH, fs);
672
- expect(loaded).toContainEqual(other);
673
- expect(loaded).toContainEqual({
725
+ expect(idOnly(loaded)).toContainEqual(other);
726
+ expect(idOnly(loaded)).toContainEqual({
674
727
  pinKey: "fg:c:3",
675
728
  chatId: "-100123",
676
729
  messageId: 715,
@@ -694,12 +747,12 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
694
747
  log: () => {},
695
748
  });
696
749
  const loaded = loadStatusPins(PATH, fs);
697
- expect(loaded).toContainEqual({
750
+ expect(idOnly(loaded)).toContainEqual({
698
751
  pinKey: "banner:owner",
699
752
  chatId: "-100999",
700
753
  messageId: 42,
701
754
  });
702
- expect(loaded).toContainEqual({
755
+ expect(idOnly(loaded)).toContainEqual({
703
756
  pinKey: "fg:c:3",
704
757
  chatId: "-100123",
705
758
  messageId: 715,
@@ -719,11 +772,11 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
719
772
  log: () => {},
720
773
  });
721
774
  expect(next).toBeNull();
722
- expect(loadStatusPins(PATH, fs)).toEqual([]);
775
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
723
776
  });
724
777
 
725
778
  // F1 (invisible-worker-cards review): a `clear` op is emitted for BOTH a real
726
- // 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
727
780
  // Telegram call and returns the LIVE claim (non-null). The pre-fix clear
728
781
  // branch dropped the row unconditionally, so the worker feed's per-edit
729
782
  // syncPin (every steady-state edit maps to a noop-clear) erased the durable
@@ -734,7 +787,7 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
734
787
  const { fs, calls } = memFs();
735
788
  persistStatusPins(PATH, fs, [pin({ pinKey: "wk:group:g1", messageId: 715 })]);
736
789
  const before = calls.length;
737
- // applyPin returns the live claim unchanged (what reconcilePin does on noop).
790
+ // applyPin returns the live claim unchanged (what executePinLeg does on noop).
738
791
  const next = await reconcileAndPersistStatusPin({
739
792
  path: PATH,
740
793
  fs,
@@ -746,7 +799,7 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
746
799
  });
747
800
  expect(next).toEqual({ messageId: 715 });
748
801
  // Row survives (rewritten confirmed, no `pending`).
749
- expect(loadStatusPins(PATH, fs)).toEqual([
802
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([
750
803
  { pinKey: "wk:group:g1", chatId: "-100123", messageId: 715 },
751
804
  ]);
752
805
  // Sanity: at least one disk write happened (the confirm rewrite) — the fix
@@ -768,7 +821,7 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
768
821
  log: () => {},
769
822
  });
770
823
  }
771
- expect(loadStatusPins(PATH, fs)).toEqual([
824
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([
772
825
  { pinKey: "wk:group:g1", chatId: "-100123", messageId: 715 },
773
826
  ]);
774
827
  });
@@ -782,7 +835,7 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
782
835
  * row while a flood-delayed open-pin lands, leaving a stuck pin with no row.
783
836
  *
784
837
  * These tests exercise the REAL fix primitives — `withPinReconcileLock` +
785
- * `reconcileAndPersistStatusPin` + `reconcilePin` + `decidePinAction` — composed
838
+ * `reconcileAndPersistStatusPin` + `executePinLeg` + `decidePinAction` — composed
786
839
  * exactly as the gateway wires them (read prev INSIDE the per-key lock), and
787
840
  * prove the row survives. The `noLock` control models the pre-fix ordering
788
841
  * (prev read OUTSIDE the lock) and shows it regresses — so the lock, not luck,
@@ -791,11 +844,11 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
791
844
  describe("status-pin-store — F2 per-key reconcile serialization", () => {
792
845
  // A gateway-faithful reconcile: an in-memory claim map (the `prev` source read
793
846
  // at the TOP of the reconcile), the real decide→op mapping, and
794
- // reconcileAndPersistStatusPin driving reconcilePin against a call-counting
847
+ // reconcileAndPersistStatusPin driving executePinLeg against a call-counting
795
848
  // pin API. An optional gate lets a test hold ONE reconcile's applyPin open to
796
849
  // force the exact interleave. `serialize` toggles the F2 fix.
797
850
  function makeReconciler(fs: StatusPinStoreFsSeam, opts: { serialize: boolean }) {
798
- const claims = new Map<string, PinState>();
851
+ const claims = new Map<string, StatusPinClaim>();
799
852
  const pinCalls: number[] = [];
800
853
  const unpinCalls: number[] = [];
801
854
  const api: PinBotApi = {
@@ -806,32 +859,22 @@ describe("status-pin-store — F2 per-key reconcile serialization", () => {
806
859
  unpinCalls.push(id);
807
860
  },
808
861
  };
809
- async function core(
810
- pinKey: string,
811
- chatId: string,
812
- desired: DesiredPin,
813
- gate?: Promise<void>,
814
- ) {
815
- const prev = claims.get(pinKey) ?? null; // read BEFORE the store op (the F2 window)
816
- const action = decidePinAction(prev, desired);
817
- const op =
818
- action.kind === "pin"
819
- ? ({ kind: "pin", messageId: action.messageId } as const)
820
- : ({ kind: "clear" } as const);
821
- const next = await reconcileAndPersistStatusPin({
822
- path: PATH,
823
- fs,
862
+ // Drives the REAL production orchestrator the gateway calls, so the persist
863
+ // ordering and the RETARGET two-leg expansion under test are the shipped
864
+ // ones — not a copy that could drift out from under this suite.
865
+ function core(pinKey: string, chatId: string, desired: DesiredPin, gate?: Promise<void>) {
866
+ return runStatusPinReconcile({
824
867
  pinKey,
825
868
  chatId,
826
- op,
827
- applyPin: async () => {
869
+ prev: prevOf(claims, pinKey), // read BEFORE the store op (the F2 window)
870
+ desired,
871
+ persist: { path: PATH, fs },
872
+ runPin: async (action, from) => {
828
873
  if (gate) await gate; // hold the pin open inside the store lock
829
- return reconcilePin({ api, chatId, prevState: prev, desired });
874
+ return executePinLeg({ api, chatId, prevState: from, action });
830
875
  },
831
- log: () => {},
876
+ claims,
832
877
  });
833
- if (next == null) claims.delete(pinKey);
834
- else claims.set(pinKey, next);
835
878
  }
836
879
  function reconcile(
837
880
  pinKey: string,
@@ -878,7 +921,7 @@ describe("status-pin-store — F2 per-key reconcile serialization", () => {
878
921
  // and claim together. The Telegram pin is cleaned up — nothing stuck.
879
922
  expect(pinCalls).toEqual([900]); // A pinned it
880
923
  expect(unpinCalls).toEqual([900]); // B unpinned it (the fix)
881
- expect(rows).toEqual([]);
924
+ expect(idOnly(rows)).toEqual([]);
882
925
  expect(claims.get("fg:c:3") ?? null).toBeNull();
883
926
  });
884
927
 
@@ -892,7 +935,7 @@ describe("status-pin-store — F2 per-key reconcile serialization", () => {
892
935
  // issued → stuck until a boot cleanup that can no longer see it.
893
936
  expect(pinCalls).toEqual([900]); // message 900 WAS pinned
894
937
  expect(unpinCalls).toEqual([]); // …but never unpinned (no-op clear on prev=null)
895
- expect(rows).toEqual([]); // …and the durable row is gone → unreapable
938
+ expect(idOnly(rows)).toEqual([]); // …and the durable row is gone → unreapable
896
939
  });
897
940
 
898
941
  it("serialized steady-state noop reconciles add ZERO pin/unpin API calls (finn-flood guard)", async () => {
@@ -904,7 +947,7 @@ describe("status-pin-store — F2 per-key reconcile serialization", () => {
904
947
  expect(r.unpinCalls).toEqual([]);
905
948
 
906
949
  // 20 steady-state edits — each a re-pin of the SAME id → decidePinAction
907
- // noop → reconcilePin issues NO Telegram call. The serialization + F1
950
+ // noop → executePinLeg issues NO Telegram call. The serialization + F1
908
951
  // row-preservation must not add a single pin/unpin API call.
909
952
  for (let i = 0; i < 20; i++) {
910
953
  await r.reconcile("wk:group:g1", "-100123", { pinned: true, messageId: 900 });
@@ -912,8 +955,80 @@ describe("status-pin-store — F2 per-key reconcile serialization", () => {
912
955
  expect(r.pinCalls).toEqual([900]); // still exactly one pin, ever
913
956
  expect(r.unpinCalls).toEqual([]); // never unpinned
914
957
  // The durable row is intact throughout (F1).
915
- expect(loadStatusPins(PATH, fs)).toEqual([
958
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([
916
959
  { pinKey: "wk:group:g1", chatId: "-100123", messageId: 900 },
917
960
  ]);
918
961
  });
962
+
963
+ // ── RETARGET: a single reconcile must leave the NEW surface pinned ────────
964
+ // The foreground activity card reconciles its `fg:` pin exactly once, when a
965
+ // card OPENs (narrative-lane.ts). The ack-first reopen path nulls
966
+ // `activityMessageId` mid-turn, so a fresh card opens and that ONE reconcile
967
+ // is the only chance to move the pin. It used to emit an unpin and stop:
968
+ // nothing pinned, and a durable row that had been deleted — so neither the
969
+ // mid-session reaper nor the next boot's sweep had anything to work from.
970
+ it("RETARGET: one reconcile unpins the old card, pins the NEW one, and leaves the durable row on the new id", async () => {
971
+ const { fs } = memFs();
972
+ const r = makeReconciler(fs, { serialize: true });
973
+
974
+ await r.reconcile("fg:c:7", "-100123", { pinned: true, messageId: 900 });
975
+ expect(r.pinCalls).toEqual([900]);
976
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([
977
+ { pinKey: "fg:c:7", chatId: "-100123", messageId: 900 },
978
+ ]);
979
+
980
+ // The card is re-posted mid-turn — ONE reconcile, new message id.
981
+ await r.reconcile("fg:c:7", "-100123", { pinned: true, messageId: 901 });
982
+
983
+ expect(r.unpinCalls).toEqual([900]); // stale card unpinned
984
+ expect(r.pinCalls).toEqual([900, 901]); // …AND the new card pinned
985
+ expect(r.claims.get("fg:c:7")?.messageId).toBe(901);
986
+ // The durable row tracks the message that is genuinely pinned, so the
987
+ // mid-session reaper and the boot sweep can both still reach it.
988
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([
989
+ { pinKey: "fg:c:7", chatId: "-100123", messageId: 901 },
990
+ ]);
991
+ });
992
+
993
+ it("RETARGET: a never-confirmed unpin keeps the OLD id in the durable row (no unreapable orphan)", async () => {
994
+ // #3664 Defect B composed with the retarget: the old message is provably
995
+ // still pinned, so we must NOT pin the new one and must NOT lose the row —
996
+ // otherwise the chat holds a pin nothing on disk names.
997
+ const { fs } = memFs();
998
+ const claims = new Map<string, StatusPinClaim>();
999
+ const pinCalls: number[] = [];
1000
+ const unpinCalls: number[] = [];
1001
+ let floodTheUnpin = false;
1002
+ const api: PinBotApi = {
1003
+ pinChatMessage: async (_c, id) => {
1004
+ pinCalls.push(id);
1005
+ },
1006
+ unpinChatMessage: async (_c, id) => {
1007
+ unpinCalls.push(id);
1008
+ if (floodTheUnpin) throw makeFloodWaitActiveError(300, Date.now() + 300_000, null);
1009
+ },
1010
+ };
1011
+ const reconcile = (desired: DesiredPin) =>
1012
+ runStatusPinReconcile({
1013
+ pinKey: "fg:c:8",
1014
+ chatId: "-100123",
1015
+ prev: prevOf(claims, "fg:c:8"),
1016
+ desired,
1017
+ persist: { path: PATH, fs },
1018
+ runPin: (action, from) =>
1019
+ executePinLeg({ api, chatId: "-100123", prevState: from, action }),
1020
+ claims,
1021
+ });
1022
+
1023
+ await reconcile({ pinned: true, messageId: 900 });
1024
+ floodTheUnpin = true;
1025
+ await reconcile({ pinned: true, messageId: 901 });
1026
+
1027
+ expect(unpinCalls).toEqual([900]); // attempted…
1028
+ expect(pinCalls).toEqual([900]); // …and the pin leg was correctly SKIPPED
1029
+ expect(claims.get("fg:c:8")?.messageId).toBe(900); // claim retained
1030
+ expect(idOnly(loadStatusPins(PATH, fs))).toEqual([
1031
+ { pinKey: "fg:c:8", chatId: "-100123", messageId: 900 },
1032
+ ]);
1033
+ });
919
1034
  });