switchroom 0.19.25 → 0.19.26

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 (35) hide show
  1. package/dist/agent-scheduler/index.js +6 -2
  2. package/dist/auth-broker/index.js +9 -2
  3. package/dist/cli/notion-write-pretool.mjs +6 -2
  4. package/dist/cli/switchroom.js +908 -527
  5. package/dist/host-control/main.js +10 -3
  6. package/dist/vault/approvals/kernel-server.js +10 -2
  7. package/dist/vault/broker/server.js +10 -2
  8. package/package.json +1 -1
  9. package/profiles/_base/cron-session.sh.hbs +6 -0
  10. package/profiles/_base/start.sh.hbs +40 -4
  11. package/telegram-plugin/dist/gateway/gateway.js +275 -109
  12. package/telegram-plugin/gateway/gateway.ts +53 -52
  13. package/telegram-plugin/gateway/periodic-sweep-guard.ts +86 -0
  14. package/telegram-plugin/gateway/status-pin-retarget.ts +144 -0
  15. package/telegram-plugin/status-no-truncate.ts +49 -0
  16. package/telegram-plugin/status-pin-driver.ts +28 -0
  17. package/telegram-plugin/status-pin.ts +33 -4
  18. package/telegram-plugin/tests/card-type-distinguishability.test.ts +268 -0
  19. package/telegram-plugin/tests/periodic-sweep-guard.test.ts +151 -0
  20. package/telegram-plugin/tests/pinned-card-collapse.test.ts +29 -18
  21. package/telegram-plugin/tests/status-pin-retarget.test.ts +216 -0
  22. package/telegram-plugin/tests/status-pin-shutdown-wiring.test.ts +94 -0
  23. package/telegram-plugin/tests/status-pin-store.test.ts +87 -21
  24. package/telegram-plugin/tests/status-pin.test.ts +128 -2
  25. package/telegram-plugin/tests/worker-activity-feed.test.ts +10 -10
  26. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +37 -21
  27. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  28. package/telegram-plugin/tier-downgrade.ts +3 -2
  29. package/telegram-plugin/tool-activity-summary.ts +61 -18
  30. package/telegram-plugin/uat/assertions.ts +21 -2
  31. package/telegram-plugin/uat/feed-matcher.test.ts +29 -0
  32. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +9 -2
  33. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +9 -2
  34. package/telegram-plugin/worker-activity-feed.ts +38 -17
  35. package/vendor/hindsight-memory/scripts/tests/test_recall_request_timeout.py +241 -0
@@ -0,0 +1,216 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { runStatusPinReconcile } from "../gateway/status-pin-retarget.js";
3
+ import type { DesiredPin, PinState } from "../status-pin.js";
4
+
5
+ /**
6
+ * The orchestrator the gateway's `reconcileStatusPinInner` delegates to.
7
+ *
8
+ * The defect it closes: a RETARGET (the caller wants a DIFFERENT message pinned
9
+ * for a key it already holds a claim on) used to be reported as a bare `unpin`
10
+ * and stopped there, on the assumption a later reconcile would pin the new
11
+ * message. The FOREGROUND activity card reconciles `fg:<statusKey>` exactly
12
+ * once (narrative-lane.ts, the `activityMessageId == null` OPEN branch), so an
13
+ * ack-first mid-turn reopen left the live card unpinned for the rest of the
14
+ * turn AND deleted the durable row, putting it out of reach of both the
15
+ * mid-session reaper and the next boot's sweep.
16
+ *
17
+ * These tests drive the module with a fake leg runner and assert the LEG
18
+ * SEQUENCE — the thing that was wrong — plus the registry bookkeeping.
19
+ */
20
+
21
+ /** Records every leg and returns a scripted outcome for it. */
22
+ function legRecorder(outcomes: Array<PinState | null>) {
23
+ const legs: Array<{ from: PinState | null; want: DesiredPin }> = [];
24
+ let i = 0;
25
+ const runPin = async (from: PinState | null, want: DesiredPin) => {
26
+ legs.push({ from, want });
27
+ const out = outcomes[i];
28
+ i += 1;
29
+ return out ?? null;
30
+ };
31
+ return { legs, runPin };
32
+ }
33
+
34
+ function registries(seed?: { key: string; state: PinState; chatId: string; at: number }) {
35
+ const r = {
36
+ state: new Map<string, PinState>(),
37
+ chatIds: new Map<string, string>(),
38
+ pinnedAt: new Map<string, number>(),
39
+ };
40
+ if (seed) {
41
+ r.state.set(seed.key, seed.state);
42
+ r.chatIds.set(seed.key, seed.chatId);
43
+ r.pinnedAt.set(seed.key, seed.at);
44
+ }
45
+ return r;
46
+ }
47
+
48
+ const KEY = "fg:c:7";
49
+ const CHAT = "-100123";
50
+
51
+ describe("runStatusPinReconcile — RETARGET", () => {
52
+ it("runs BOTH legs: unpins the stale claim, then pins the new message", async () => {
53
+ const { legs, runPin } = legRecorder([null, { messageId: 901 }]);
54
+ const reg = registries({ key: KEY, state: { messageId: 900 }, chatId: CHAT, at: 1000 });
55
+
56
+ await runStatusPinReconcile({
57
+ pinKey: KEY,
58
+ chatId: CHAT,
59
+ prev: { messageId: 900 },
60
+ desired: { pinned: true, messageId: 901 },
61
+ persist: null,
62
+ runPin,
63
+ registries: reg,
64
+ });
65
+
66
+ // The pre-fix behaviour was legs === [unpin] and nothing pinned.
67
+ expect(legs).toEqual([
68
+ { from: { messageId: 900 }, want: { pinned: false } },
69
+ { from: null, want: { pinned: true, messageId: 901 } },
70
+ ]);
71
+ expect(reg.state.get(KEY)).toEqual({ messageId: 901 });
72
+ expect(reg.chatIds.get(KEY)).toBe(CHAT);
73
+ });
74
+
75
+ it("leg 2 starts from a NULL claim, so it decides a real `pin` (not a noop)", async () => {
76
+ const { legs, runPin } = legRecorder([null, { messageId: 901 }]);
77
+ await runStatusPinReconcile({
78
+ pinKey: KEY,
79
+ chatId: CHAT,
80
+ prev: { messageId: 900 },
81
+ desired: { pinned: true, messageId: 901 },
82
+ persist: null,
83
+ runPin,
84
+ registries: registries(),
85
+ });
86
+ expect(legs[1].from).toBeNull();
87
+ });
88
+
89
+ it("re-stamps pinnedAt for the NEW card, so the reaper ages the live message", async () => {
90
+ const { runPin } = legRecorder([null, { messageId: 901 }]);
91
+ const reg = registries({ key: KEY, state: { messageId: 900 }, chatId: CHAT, at: 1000 });
92
+ await runStatusPinReconcile({
93
+ pinKey: KEY,
94
+ chatId: CHAT,
95
+ prev: { messageId: 900 },
96
+ desired: { pinned: true, messageId: 901 },
97
+ persist: null,
98
+ runPin,
99
+ registries: reg,
100
+ });
101
+ // Leg 1 cleared the claim (dropping the old stamp), leg 2 took a fresh one.
102
+ expect(reg.pinnedAt.get(KEY)).toBeGreaterThan(1000);
103
+ });
104
+
105
+ it("NEVER-CONFIRMED unpin (#3664 Defect B): SKIPS the pin leg and retains the OLD claim", async () => {
106
+ // A non-null leg-1 result means reconcilePin deliberately kept the claim —
107
+ // the old message is provably still pinned. Pinning the new one now would
108
+ // leave TWO pins in the chat with a durable record of one.
109
+ const { legs, runPin } = legRecorder([{ messageId: 900 }]);
110
+ const reg = registries({ key: KEY, state: { messageId: 900 }, chatId: CHAT, at: 1000 });
111
+
112
+ await runStatusPinReconcile({
113
+ pinKey: KEY,
114
+ chatId: CHAT,
115
+ prev: { messageId: 900 },
116
+ desired: { pinned: true, messageId: 901 },
117
+ persist: null,
118
+ runPin,
119
+ registries: reg,
120
+ });
121
+
122
+ expect(legs).toHaveLength(1);
123
+ expect(reg.state.get(KEY)).toEqual({ messageId: 900 });
124
+ expect(reg.chatIds.get(KEY)).toBe(CHAT);
125
+ expect(reg.pinnedAt.get(KEY)).toBe(1000); // original age preserved for the TTL gate
126
+ });
127
+
128
+ it("a FAILED pin leg drops the claim entirely (nothing is pinned, nothing is tracked)", async () => {
129
+ // reconcilePin returns its `from` on a failed pin — here `null`.
130
+ const { runPin } = legRecorder([null, null]);
131
+ const reg = registries({ key: KEY, state: { messageId: 900 }, chatId: CHAT, at: 1000 });
132
+ await runStatusPinReconcile({
133
+ pinKey: KEY,
134
+ chatId: CHAT,
135
+ prev: { messageId: 900 },
136
+ desired: { pinned: true, messageId: 901 },
137
+ persist: null,
138
+ runPin,
139
+ registries: reg,
140
+ });
141
+ expect(reg.state.has(KEY)).toBe(false);
142
+ expect(reg.chatIds.has(KEY)).toBe(false);
143
+ expect(reg.pinnedAt.has(KEY)).toBe(false);
144
+ });
145
+ });
146
+
147
+ describe("runStatusPinReconcile — single-leg actions are unchanged", () => {
148
+ it("first pin: one leg, claim + chat + age recorded", async () => {
149
+ const { legs, runPin } = legRecorder([{ messageId: 900 }]);
150
+ const reg = registries();
151
+ await runStatusPinReconcile({
152
+ pinKey: KEY,
153
+ chatId: CHAT,
154
+ prev: null,
155
+ desired: { pinned: true, messageId: 900 },
156
+ persist: null,
157
+ runPin,
158
+ registries: reg,
159
+ });
160
+ expect(legs).toEqual([{ from: null, want: { pinned: true, messageId: 900 } }]);
161
+ expect(reg.state.get(KEY)).toEqual({ messageId: 900 });
162
+ expect(reg.pinnedAt.has(KEY)).toBe(true);
163
+ });
164
+
165
+ it("turn-end unpin: one leg, all three registries cleared", async () => {
166
+ const { legs, runPin } = legRecorder([null]);
167
+ const reg = registries({ key: KEY, state: { messageId: 900 }, chatId: CHAT, at: 1000 });
168
+ await runStatusPinReconcile({
169
+ pinKey: KEY,
170
+ chatId: CHAT,
171
+ prev: { messageId: 900 },
172
+ desired: { pinned: false },
173
+ persist: null,
174
+ runPin,
175
+ registries: reg,
176
+ });
177
+ expect(legs).toEqual([{ from: { messageId: 900 }, want: { pinned: false } }]);
178
+ expect(reg.state.has(KEY)).toBe(false);
179
+ expect(reg.chatIds.has(KEY)).toBe(false);
180
+ expect(reg.pinnedAt.has(KEY)).toBe(false);
181
+ });
182
+
183
+ it("steady-state re-pin of the SAME id: still one leg, and the ORIGINAL age is kept", async () => {
184
+ // 20 edits/turn each re-drive this; the reaper's TTL must measure from the
185
+ // first pin, not be reset by every edit.
186
+ const { legs, runPin } = legRecorder([{ messageId: 900 }]);
187
+ const reg = registries({ key: KEY, state: { messageId: 900 }, chatId: CHAT, at: 1000 });
188
+ await runStatusPinReconcile({
189
+ pinKey: KEY,
190
+ chatId: CHAT,
191
+ prev: { messageId: 900 },
192
+ desired: { pinned: true, messageId: 900 },
193
+ persist: null,
194
+ runPin,
195
+ registries: reg,
196
+ });
197
+ expect(legs).toHaveLength(1);
198
+ expect(reg.pinnedAt.get(KEY)).toBe(1000);
199
+ });
200
+
201
+ it("unpin with no claim: one no-op leg, registries stay empty", async () => {
202
+ const { legs, runPin } = legRecorder([null]);
203
+ const reg = registries();
204
+ await runStatusPinReconcile({
205
+ pinKey: KEY,
206
+ chatId: CHAT,
207
+ prev: null,
208
+ desired: { pinned: false },
209
+ persist: null,
210
+ runPin,
211
+ registries: reg,
212
+ });
213
+ expect(legs).toHaveLength(1);
214
+ expect(reg.state.size).toBe(0);
215
+ });
216
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Status-pin SHUTDOWN + REAPER-DISPATCH wiring fence.
3
+ *
4
+ * Both defects here are wiring defects, and gateway.ts cannot be instantiated
5
+ * in-process (module-eval side effects), so — same pattern as
6
+ * `boot-pin-sweep-wiring.test.ts` and `silence-liveness-wiring.test.ts` — these
7
+ * are structural assertions over the source. The BEHAVIOUR they make apply to
8
+ * the gateway is proven in `status-pin-retarget.test.ts`,
9
+ * `periodic-sweep-guard.test.ts` and `status-pin.test.ts`.
10
+ *
11
+ * D2: `shutdown()` never unpinned. `sweepBeforeSelfRestart()` covered the
12
+ * gateway's own restart verbs, but SIGTERM/SIGINT — `docker restart`,
13
+ * `switchroom agent restart`, a compose bounce, a host reboot — reached
14
+ * `process.exit(0)` with every live pin still pinned (empirically: overlord
15
+ * logged `status-pin: cleared 1/1 orphaned pin(s) from a prior session` on
16
+ * a routine 2026-07-27 restart, i.e. the NEXT boot had to clean it up).
17
+ *
18
+ * D3: the mid-session reaper interval dispatched `void runMidSessionCardReaper()`
19
+ * with no single-flight guard, while the pass awaits flood-wait-prone
20
+ * Telegram calls.
21
+ */
22
+ import { describe, it, expect } from "vitest";
23
+ import { readFileSync } from "node:fs";
24
+ import { resolve } from "node:path";
25
+
26
+ const gatewaySrc = readFileSync(resolve(__dirname, "..", "gateway", "gateway.ts"), "utf-8");
27
+
28
+ /** Body of `async function shutdown(...)` up to the next top-level `\nasync function`/`\nfunction`. */
29
+ function shutdownBody(): string {
30
+ const start = gatewaySrc.indexOf("async function shutdown(signal: string)");
31
+ expect(start).toBeGreaterThan(-1);
32
+ const rest = gatewaySrc.slice(start + 1);
33
+ const end = rest.search(/\n(?:async )?function /);
34
+ return end === -1 ? rest : rest.slice(0, end);
35
+ }
36
+
37
+ describe("status-pin shutdown wiring (D2)", () => {
38
+ it("shutdown() unpins the live status pins before exiting", () => {
39
+ expect(shutdownBody()).toContain("unpinAllStatusPins()");
40
+ });
41
+
42
+ it("the shutdown unpin is guarded, so a throw cannot skip the rest of shutdown", () => {
43
+ const body = shutdownBody();
44
+ const idx = body.indexOf("unpinAllStatusPins()");
45
+ // The nearest preceding statement must open a try — the sweep is
46
+ // best-effort and must never abort the drain/cleanup that follows it.
47
+ expect(body.slice(0, idx)).toMatch(/try\s*\{\s*\n\s*await withDeadline\($/);
48
+ });
49
+
50
+ it("the sweep is deadline-bounded, so it cannot push shutdown into force-exit", () => {
51
+ // forceExitTimer fires at budget+5s and deliberately SKIPS
52
+ // releaseStartupLock; an unbounded flood-wait-parked unpin here would turn
53
+ // a clean restart into a stale-lock recovery on the next boot.
54
+ expect(shutdownBody()).toMatch(/await withDeadline\(unpinAllStatusPins\(\), \d[\d_]*,/);
55
+ });
56
+
57
+ it("runs AFTER the drain and BEFORE the coalescer reset (bot.api still usable)", () => {
58
+ const body = shutdownBody();
59
+ const drain = body.indexOf("drainShutdown");
60
+ const unpin = body.indexOf("unpinAllStatusPins()");
61
+ const reset = body.indexOf("\n inboundCoalescer.reset()"); // the statement, not the comment above it
62
+ expect(drain).toBeGreaterThan(-1);
63
+ expect(unpin).toBeGreaterThan(drain);
64
+ expect(reset).toBeGreaterThan(unpin);
65
+ });
66
+ });
67
+
68
+ describe("mid-session card reaper dispatch wiring (D3)", () => {
69
+ it("the interval never calls runMidSessionCardReaper() directly", () => {
70
+ // The pre-fix shape, verbatim: `void runMidSessionCardReaper()` on a timer.
71
+ // Only the declaration and the guard's `run:` reference may name it.
72
+ const invocations =
73
+ gatewaySrc.match(/(?<!function\s)(?<![.\w])runMidSessionCardReaper\s*\(/g) ?? [];
74
+ expect(invocations).toEqual([]);
75
+ });
76
+
77
+ it("dispatches through the single-flight guard instead", () => {
78
+ expect(gatewaySrc).toContain("createPeriodicSweepGuard({");
79
+ expect(gatewaySrc).toContain("run: runMidSessionCardReaper,");
80
+ expect(gatewaySrc).toContain("void midSessionReaperGuard.tick()");
81
+ });
82
+ });
83
+
84
+ describe("status-pin reconcile wiring (D1)", () => {
85
+ it("reconcileStatusPinInner delegates to the retarget-aware orchestrator", () => {
86
+ const start = gatewaySrc.indexOf("async function reconcileStatusPinInner(");
87
+ expect(start).toBeGreaterThan(-1);
88
+ const body = gatewaySrc.slice(start, gatewaySrc.indexOf("\n}", start));
89
+ // A single-leg `reconcileAndPersistStatusPin(...)` call here is exactly the
90
+ // shape that dropped the pin leg of a retarget on the floor.
91
+ expect(body).toContain("await runStatusPinReconcile({");
92
+ expect(body).not.toContain("reconcileAndPersistStatusPin({");
93
+ });
94
+ });
@@ -14,6 +14,8 @@ import {
14
14
  } from "../gateway/status-pin-store.js";
15
15
  import { decidePinAction, type PinState, type DesiredPin } from "../status-pin.js";
16
16
  import { reconcilePin, type PinBotApi } from "../status-pin-driver.js";
17
+ import { runStatusPinReconcile } from "../gateway/status-pin-retarget.js";
18
+ import { makeFloodWaitActiveError } from "../retry-api-call.js";
17
19
 
18
20
  /** In-memory fs seam with an atomic rename, so the store's tmp→rename
19
21
  * crash-safety contract is exercised without touching the real disk. */
@@ -806,32 +808,24 @@ describe("status-pin-store — F2 per-key reconcile serialization", () => {
806
808
  unpinCalls.push(id);
807
809
  },
808
810
  };
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,
811
+ const chatIds = new Map<string, string>();
812
+ const pinnedAt = new Map<string, number>();
813
+ // Drives the REAL production orchestrator the gateway calls, so the persist
814
+ // ordering and the RETARGET two-leg expansion under test are the shipped
815
+ // ones — not a copy that could drift out from under this suite.
816
+ function core(pinKey: string, chatId: string, desired: DesiredPin, gate?: Promise<void>) {
817
+ return runStatusPinReconcile({
824
818
  pinKey,
825
819
  chatId,
826
- op,
827
- applyPin: async () => {
820
+ prev: claims.get(pinKey) ?? null, // read BEFORE the store op (the F2 window)
821
+ desired,
822
+ persist: { path: PATH, fs },
823
+ runPin: async (from, want) => {
828
824
  if (gate) await gate; // hold the pin open inside the store lock
829
- return reconcilePin({ api, chatId, prevState: prev, desired });
825
+ return reconcilePin({ api, chatId, prevState: from, desired: want });
830
826
  },
831
- log: () => {},
827
+ registries: { state: claims, chatIds, pinnedAt },
832
828
  });
833
- if (next == null) claims.delete(pinKey);
834
- else claims.set(pinKey, next);
835
829
  }
836
830
  function reconcile(
837
831
  pinKey: string,
@@ -916,4 +910,76 @@ describe("status-pin-store — F2 per-key reconcile serialization", () => {
916
910
  { pinKey: "wk:group:g1", chatId: "-100123", messageId: 900 },
917
911
  ]);
918
912
  });
913
+
914
+ // ── RETARGET: a single reconcile must leave the NEW surface pinned ────────
915
+ // The foreground activity card reconciles its `fg:` pin exactly once, when a
916
+ // card OPENs (narrative-lane.ts). The ack-first reopen path nulls
917
+ // `activityMessageId` mid-turn, so a fresh card opens and that ONE reconcile
918
+ // is the only chance to move the pin. It used to emit an unpin and stop:
919
+ // nothing pinned, and a durable row that had been deleted — so neither the
920
+ // mid-session reaper nor the next boot's sweep had anything to work from.
921
+ it("RETARGET: one reconcile unpins the old card, pins the NEW one, and leaves the durable row on the new id", async () => {
922
+ const { fs } = memFs();
923
+ const r = makeReconciler(fs, { serialize: true });
924
+
925
+ await r.reconcile("fg:c:7", "-100123", { pinned: true, messageId: 900 });
926
+ expect(r.pinCalls).toEqual([900]);
927
+ expect(loadStatusPins(PATH, fs)).toEqual([
928
+ { pinKey: "fg:c:7", chatId: "-100123", messageId: 900 },
929
+ ]);
930
+
931
+ // The card is re-posted mid-turn — ONE reconcile, new message id.
932
+ await r.reconcile("fg:c:7", "-100123", { pinned: true, messageId: 901 });
933
+
934
+ expect(r.unpinCalls).toEqual([900]); // stale card unpinned
935
+ expect(r.pinCalls).toEqual([900, 901]); // …AND the new card pinned
936
+ expect(r.claims.get("fg:c:7")).toEqual({ messageId: 901 });
937
+ // The durable row tracks the message that is genuinely pinned, so the
938
+ // mid-session reaper and the boot sweep can both still reach it.
939
+ expect(loadStatusPins(PATH, fs)).toEqual([
940
+ { pinKey: "fg:c:7", chatId: "-100123", messageId: 901 },
941
+ ]);
942
+ });
943
+
944
+ it("RETARGET: a never-confirmed unpin keeps the OLD id in the durable row (no unreapable orphan)", async () => {
945
+ // #3664 Defect B composed with the retarget: the old message is provably
946
+ // still pinned, so we must NOT pin the new one and must NOT lose the row —
947
+ // otherwise the chat holds a pin nothing on disk names.
948
+ const { fs } = memFs();
949
+ const claims = new Map<string, PinState>();
950
+ const pinCalls: number[] = [];
951
+ const unpinCalls: number[] = [];
952
+ let floodTheUnpin = false;
953
+ const api: PinBotApi = {
954
+ pinChatMessage: async (_c, id) => {
955
+ pinCalls.push(id);
956
+ },
957
+ unpinChatMessage: async (_c, id) => {
958
+ unpinCalls.push(id);
959
+ if (floodTheUnpin) throw makeFloodWaitActiveError(300, Date.now() + 300_000, null);
960
+ },
961
+ };
962
+ const reconcile = (desired: DesiredPin) =>
963
+ runStatusPinReconcile({
964
+ pinKey: "fg:c:8",
965
+ chatId: "-100123",
966
+ prev: claims.get("fg:c:8") ?? null,
967
+ desired,
968
+ 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() },
972
+ });
973
+
974
+ await reconcile({ pinned: true, messageId: 900 });
975
+ floodTheUnpin = true;
976
+ await reconcile({ pinned: true, messageId: 901 });
977
+
978
+ expect(unpinCalls).toEqual([900]); // attempted…
979
+ 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([
982
+ { pinKey: "fg:c:8", chatId: "-100123", messageId: 900 },
983
+ ]);
984
+ });
919
985
  });
@@ -37,9 +37,14 @@ describe('decidePinAction (pure)', () => {
37
37
  expect(action.kind).toBe('noop')
38
38
  })
39
39
 
40
- it('unpins the stale claim when the wanted message id changed (feed re-posted)', () => {
40
+ it('RETARGETS (unpin old + pin new) when the wanted message id changed (surface re-posted)', () => {
41
+ // Regression: this used to report a bare `unpin`, on the assumption that a
42
+ // later reconcile would pin the new message. Single-shot callers (the
43
+ // foreground activity card, which reconciles only when a card OPENs) have
44
+ // no later reconcile, so the new card was left unpinned for the rest of
45
+ // the turn. The decision must carry BOTH message ids.
41
46
  const action = decidePinAction({ messageId: 42 }, { pinned: true, messageId: 99 })
42
- expect(action).toEqual({ kind: 'unpin', messageId: 42 })
47
+ expect(action).toEqual({ kind: 'repin', unpinMessageId: 42, pinMessageId: 99 })
43
48
  })
44
49
  })
45
50
 
@@ -177,6 +182,127 @@ describe('reconcilePin (driver)', () => {
177
182
  expect(attempts).toBe(2)
178
183
  })
179
184
 
185
+ // ── Retarget (`repin`) — the single-shot-caller leak ──────────────────────
186
+ // The foreground activity card reconciles its `fg:` pin EXACTLY ONCE, when a
187
+ // card OPENs (narrative-lane.ts; the edit branch never touches the pin). When
188
+ // the ack-first reopen path (feed-reopen-gate.ts) nulls `activityMessageId`
189
+ // and a FRESH card opens mid-turn, that single reconcile must leave the NEW
190
+ // card pinned. Previously it emitted only an unpin and the turn ran on with
191
+ // nothing pinned.
192
+ it('RETARGET: one reconcile unpins the stale message AND pins the new one', async () => {
193
+ const { api, calls } = fakeApi()
194
+ const next = await reconcilePin({
195
+ api,
196
+ chatId: '123',
197
+ prevState: { messageId: 42 },
198
+ desired: { pinned: true, messageId: 99 },
199
+ })
200
+ expect(next).toEqual({ messageId: 99 })
201
+ expect(calls.map((c) => [c.verb, c.messageId])).toEqual([
202
+ ['unpin', 42],
203
+ ['pin', 99],
204
+ ])
205
+ // The new pin must still be silent.
206
+ expect((calls[1].opts as Record<string, unknown>).disable_notification).toBe(true)
207
+ })
208
+
209
+ it('RETARGET: a TERMINAL unpin failure still pins the new message', async () => {
210
+ // "message to unpin not found" means the old surface is already gone — the
211
+ // retarget must complete, not abort.
212
+ const calls: { verb: string; messageId: number }[] = []
213
+ const next = await reconcilePin({
214
+ api: {
215
+ pinChatMessage: async (_c, message_id) => {
216
+ calls.push({ verb: 'pin', messageId: message_id })
217
+ },
218
+ unpinChatMessage: async (_c, message_id) => {
219
+ calls.push({ verb: 'unpin', messageId: message_id })
220
+ throw grammyError(400, 'Bad Request: message to unpin not found')
221
+ },
222
+ },
223
+ chatId: '123',
224
+ prevState: { messageId: 42 },
225
+ desired: { pinned: true, messageId: 99 },
226
+ })
227
+ expect(next).toEqual({ messageId: 99 })
228
+ expect(calls).toEqual([
229
+ { verb: 'unpin', messageId: 42 },
230
+ { verb: 'pin', messageId: 99 },
231
+ ])
232
+ })
233
+
234
+ it('RETARGET: a NEVER-CONFIRMED unpin retains the OLD claim and does NOT pin the new one', async () => {
235
+ // The old message is provably still pinned (#3664 Defect B). Pinning the
236
+ // new one now would leave two pins in the chat with a durable record of
237
+ // only one — the exact orphan class the retain rule exists to prevent.
238
+ const calls: { verb: string; messageId: number }[] = []
239
+ const next = await reconcilePin({
240
+ api: {
241
+ pinChatMessage: async (_c, message_id) => {
242
+ calls.push({ verb: 'pin', messageId: message_id })
243
+ },
244
+ unpinChatMessage: async (_c, message_id) => {
245
+ calls.push({ verb: 'unpin', messageId: message_id })
246
+ throw makeFloodWaitActiveError(300, Date.now() + 300_000, null)
247
+ },
248
+ },
249
+ chatId: '123',
250
+ prevState: { messageId: 42 },
251
+ desired: { pinned: true, messageId: 99 },
252
+ })
253
+ expect(next).toEqual({ messageId: 42 })
254
+ expect(calls).toEqual([{ verb: 'unpin', messageId: 42 }])
255
+ })
256
+
257
+ it('RETARGET: a failed pin leg drops the claim (nothing is pinned, nothing is tracked)', async () => {
258
+ const calls: { verb: string; messageId: number }[] = []
259
+ const next = await reconcilePin({
260
+ api: {
261
+ pinChatMessage: async (_c, message_id) => {
262
+ calls.push({ verb: 'pin', messageId: message_id })
263
+ throw new Error('fetch failed')
264
+ },
265
+ unpinChatMessage: async (_c, message_id) => {
266
+ calls.push({ verb: 'unpin', messageId: message_id })
267
+ },
268
+ },
269
+ chatId: '123',
270
+ prevState: { messageId: 42 },
271
+ desired: { pinned: true, messageId: 99 },
272
+ })
273
+ // prevState for the pin leg is null (the unpin landed), so a failed pin
274
+ // returns null — no phantom claim on a message we never pinned.
275
+ expect(next).toBeNull()
276
+ expect(calls).toEqual([
277
+ { verb: 'unpin', messageId: 42 },
278
+ { verb: 'pin', messageId: 99 },
279
+ ])
280
+ })
281
+
282
+ it('RETARGET: a pin-rights-blocked chat makes NO API calls and drops the claim', async () => {
283
+ const calls: string[] = []
284
+ const cache = new PinRightsCache()
285
+ cache.block('123')
286
+ const next = await reconcilePin({
287
+ api: {
288
+ pinChatMessage: async () => {
289
+ calls.push('pin')
290
+ },
291
+ unpinChatMessage: async () => {
292
+ calls.push('unpin')
293
+ },
294
+ },
295
+ chatId: '123',
296
+ prevState: { messageId: 42 },
297
+ desired: { pinned: true, messageId: 99 },
298
+ rightsCache: cache,
299
+ })
300
+ expect(calls).toEqual([])
301
+ // unpin leg returns null (claim dropped, nothing worth tracking); the pin
302
+ // leg is skipped from a null prev and returns null too.
303
+ expect(next).toBeNull()
304
+ })
305
+
180
306
  it('does NOT claim a message whose pin failed (retries next reconcile)', async () => {
181
307
  const errors: string[] = []
182
308
  const { api } = fakeApi({ pinThrows: true })
@@ -76,7 +76,7 @@ describe('renderWorkerActivity', () => {
76
76
 
77
77
  it('renders the native header + running status + step feed', () => {
78
78
  const out = renderWorkerActivity(view())
79
- expect(out).toContain('🛠 **Worker** · _research competitors_')
79
+ expect(out).toContain('└─ 🛠 **WORKER** · _research competitors_')
80
80
  // Unified header: running shows "<elapsed> · N tools" (no "running ·" word).
81
81
  expect(out).toContain('_10s · 3 tools_')
82
82
  expect(out).toContain('3 tools')
@@ -100,7 +100,7 @@ describe('renderWorkerActivity', () => {
100
100
 
101
101
  it('shows a "starting…" line when no step has run yet', () => {
102
102
  const out = renderWorkerActivity(view({ lastTool: null, latestSummary: '' }))
103
- expect(out).toContain('🛠 **Worker**')
103
+ expect(out).toContain('└─ 🛠 **WORKER**')
104
104
  expect(out).toContain('starting…')
105
105
  expect(out).not.toContain('→')
106
106
  })
@@ -121,7 +121,7 @@ describe('renderWorkerActivity', () => {
121
121
  const out = renderWorkerActivity(
122
122
  view({ state: 'done', toolCount: 5, latestSummary: 'PR #21 opened' }),
123
123
  )
124
- expect(out).toContain('🛠 **Worker** · _research competitors_')
124
+ expect(out).toContain('└─ 🛠 **WORKER** · _research competitors_')
125
125
  // Unified done header: "done · N tools · <elapsed>".
126
126
  expect(out).toContain('_done · 5 tools · ')
127
127
  expect(out).toContain('─────')
@@ -227,7 +227,7 @@ describe('renderWorkerActivity', () => {
227
227
  narrativeLines: ['- ran the **full** suite', '`git push`'],
228
228
  }),
229
229
  )
230
- expect(running).toContain('🛠 **Worker** · _Build the sync_')
230
+ expect(running).toContain('└─ 🛠 **WORKER** · _Build the sync_')
231
231
  expect(running).toContain('ran the full suite')
232
232
  expect(running).toContain('git push')
233
233
  // The CONTENT bold/code markers were stripped — no `**full**`, no backticks.
@@ -298,7 +298,7 @@ describe('createWorkerActivityFeed', () => {
298
298
  // #2669: the worker feed body is raw GFM markdown sent through the rich
299
299
  // path (the gateway wires sendMessage → sendRichMessage). No parse_mode.
300
300
  expect(bot.sent[0].opts?.parse_mode).toBeUndefined()
301
- expect(bot.sent[0].text).toContain('🛠 **Worker**')
301
+ expect(bot.sent[0].text).toContain('└─ 🛠 **WORKER**')
302
302
  expect(feed.has('w1')).toBe(true)
303
303
  })
304
304
 
@@ -932,7 +932,7 @@ describe('createWorkerActivityFeed — heartbeat', () => {
932
932
  await drain()
933
933
  expect(bot.sent).toHaveLength(1)
934
934
  expect(feed.messageIdOf('w1')).toBe(1000)
935
- expect(bot.sent[0].text).toContain('🛠 **Worker**')
935
+ expect(bot.sent[0].text).toContain('└─ 🛠 **WORKER**')
936
936
 
937
937
  // And it keeps updating: a later heartbeat edits the message with a
938
938
  // climbing `· Ns` suffix so the still-alive worker visibly advances.
@@ -1328,7 +1328,7 @@ describe('header row + rolling overflow survive in the unified worker render', (
1328
1328
  )
1329
1329
  const out = renderWorkerActivity(view({ narrativeLines, toolCount: 7 }))
1330
1330
 
1331
- expect(out).toContain('🛠 **Worker**')
1331
+ expect(out).toContain('└─ 🛠 **WORKER**')
1332
1332
  // Unified running status line.
1333
1333
  expect(out).toContain('_10s · 7 tools_')
1334
1334
  expect(out).toContain('7 tools')
@@ -1344,7 +1344,7 @@ describe('header row + rolling overflow survive in the unified worker render', (
1344
1344
 
1345
1345
  expect(out.length).toBeLessThanOrEqual(4096)
1346
1346
  expect(out).toContain('earlier…')
1347
- expect(out).toContain('🛠 **Worker**')
1347
+ expect(out).toContain('└─ 🛠 **WORKER**')
1348
1348
  expect(out).toContain('_10s · ')
1349
1349
  })
1350
1350
 
@@ -1359,7 +1359,7 @@ describe('header row + rolling overflow survive in the unified worker render', (
1359
1359
  expect(out.length).toBeLessThanOrEqual(4096)
1360
1360
  const hasBullet = out.includes('→') || out.includes('✓')
1361
1361
  expect(hasBullet).toBe(true)
1362
- expect(out).toContain('🛠 **Worker**')
1362
+ expect(out).toContain('└─ 🛠 **WORKER**')
1363
1363
  expect(out).toContain('_10s · ')
1364
1364
  expect(isValidWorkerMarkdown(out)).toBe(true)
1365
1365
  })
@@ -1525,7 +1525,7 @@ describe('worker-feed send-gate shed contract', () => {
1525
1525
  await feed.update('w1', 'chat', view({ elapsedMs: clock }))
1526
1526
  expect(bot.sendCalls).toBe(1)
1527
1527
  expect(bot.sent).toHaveLength(1)
1528
- expect(bot.sent[0].text).toContain('🛠 **Worker**')
1528
+ expect(bot.sent[0].text).toContain('└─ 🛠 **WORKER**')
1529
1529
  expect(feed.has('w1')).toBe(true)
1530
1530
  })
1531
1531