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.
- package/bin/git-agent-attribution-hook.sh +144 -0
- package/dist/agent-scheduler/index.js +61 -2
- package/dist/auth-broker/index.js +125 -8
- package/dist/cli/notion-write-pretool.mjs +61 -2
- package/dist/cli/switchroom.js +2347 -1104
- package/dist/host-control/main.js +126 -9
- package/dist/vault/approvals/kernel-server.js +124 -8
- package/dist/vault/broker/server.js +124 -8
- package/package.json +6 -2
- package/profiles/_base/cron-session.sh.hbs +14 -0
- package/profiles/_base/start.sh.hbs +145 -4
- package/telegram-plugin/card-layout.ts +328 -0
- package/telegram-plugin/dist/bridge/bridge.js +93 -1
- package/telegram-plugin/dist/gateway/gateway.js +2213 -1204
- package/telegram-plugin/dist/server.js +96 -1
- package/telegram-plugin/edit-flood-fuse.ts +637 -56
- package/telegram-plugin/flood-429-ledger.ts +526 -0
- package/telegram-plugin/flood-circuit-breaker.ts +18 -0
- package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
- package/telegram-plugin/gateway/gateway.ts +103 -112
- package/telegram-plugin/gateway/narrative-lane.ts +14 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
- package/telegram-plugin/gateway/periodic-sweep-guard.ts +86 -0
- package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
- package/telegram-plugin/gateway/status-pin-retarget.ts +180 -0
- package/telegram-plugin/gateway/status-pin-store.ts +58 -9
- package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
- package/telegram-plugin/llm-error-present.ts +61 -2
- package/telegram-plugin/model-unavailable.ts +8 -0
- package/telegram-plugin/operator-events.ts +72 -5
- package/telegram-plugin/outbound-class.ts +81 -0
- package/telegram-plugin/provider-credit.ts +237 -0
- package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
- package/telegram-plugin/send-gate.ts +24 -2
- package/telegram-plugin/status-no-truncate.ts +11 -0
- package/telegram-plugin/status-pin-driver.ts +33 -17
- package/telegram-plugin/status-pin.ts +51 -5
- package/telegram-plugin/tests/card-golden.test.ts +69 -0
- package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
- package/telegram-plugin/tests/card-type-distinguishability.test.ts +291 -0
- package/telegram-plugin/tests/card-variants.golden.txt +211 -0
- package/telegram-plugin/tests/card-variants.ts +366 -0
- package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +316 -0
- package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
- package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
- package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
- package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
- package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
- package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
- package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
- package/telegram-plugin/tests/periodic-sweep-guard.test.ts +151 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +24 -18
- package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
- package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
- package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
- package/telegram-plugin/tests/status-pin-retarget.test.ts +244 -0
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
- package/telegram-plugin/tests/status-pin-shutdown-wiring.test.ts +94 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +179 -64
- package/telegram-plugin/tests/status-pin.test.ts +184 -7
- package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +29 -19
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
- package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
- package/telegram-plugin/tier-downgrade.ts +3 -2
- package/telegram-plugin/tool-activity-summary.ts +239 -322
- package/telegram-plugin/uat/assertions.ts +33 -3
- package/telegram-plugin/uat/feed-matcher.test.ts +36 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +9 -2
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +9 -2
- package/telegram-plugin/worker-activity-feed.ts +109 -30
- package/vendor/hindsight-memory/CLAUDE.md +45 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
- package/vendor/hindsight-memory/scripts/recall.py +176 -7
- package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_request_timeout.py +241 -0
- 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 {
|
|
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
|
-
//
|
|
96
|
-
const { fs: f1 } = memFs({ [PATH]: JSON.stringify({ v:
|
|
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
|
|
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(
|
|
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:
|
|
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
|
-
//
|
|
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,
|
|
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
|
|
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` + `
|
|
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
|
|
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,
|
|
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
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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
|
-
|
|
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
|
|
874
|
+
return executePinLeg({ api, chatId, prevState: from, action });
|
|
830
875
|
},
|
|
831
|
-
|
|
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 →
|
|
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
|
});
|