switchroom 0.16.46 → 0.17.0

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 (109) hide show
  1. package/dist/agent-scheduler/index.js +83 -81
  2. package/dist/auth-broker/index.js +104 -88
  3. package/dist/cli/autoaccept-poll.js +8 -8
  4. package/dist/cli/drive-write-pretool.mjs +10 -15
  5. package/dist/cli/notion-write-pretool.mjs +85 -83
  6. package/dist/cli/skill-validate-pretool.mjs +91 -91
  7. package/dist/cli/switchroom.js +1720 -1392
  8. package/dist/cli/ui/index.html +84 -12
  9. package/dist/host-control/main.js +209 -173
  10. package/dist/vault/approvals/kernel-server.js +86 -83
  11. package/dist/vault/broker/server.js +284 -139
  12. package/package.json +3 -3
  13. package/profiles/_base/cron-session.sh.hbs +1 -1
  14. package/profiles/_base/start.sh.hbs +54 -3
  15. package/skills/switchroom-architecture/telegram.md +8 -15
  16. package/skills/switchroom-cli/SKILL.md +4 -5
  17. package/skills/telegram-test-harness/SKILL.md +1 -1
  18. package/telegram-plugin/README.md +18 -29
  19. package/telegram-plugin/bridge/bridge.ts +1 -41
  20. package/telegram-plugin/bridge/tool-filter.ts +3 -4
  21. package/telegram-plugin/dist/bridge/bridge.js +120 -155
  22. package/telegram-plugin/dist/gateway/gateway.js +1127 -1029
  23. package/telegram-plugin/dist/server.js +168 -203
  24. package/telegram-plugin/gateway/busy-key-reaper.ts +113 -0
  25. package/telegram-plugin/gateway/disconnect-flush.ts +11 -0
  26. package/telegram-plugin/gateway/escalation-bridge-gate.ts +46 -0
  27. package/telegram-plugin/gateway/gate-parity-probe.ts +102 -0
  28. package/telegram-plugin/gateway/gateway.ts +566 -631
  29. package/telegram-plugin/gateway/inbound-delivery-confirm.ts +89 -7
  30. package/telegram-plugin/gateway/inbound-spool.ts +108 -10
  31. package/telegram-plugin/gateway/model-command.ts +51 -3
  32. package/telegram-plugin/gateway/pending-inbound-buffer.ts +26 -0
  33. package/telegram-plugin/gateway/represent-guard.ts +28 -11
  34. package/telegram-plugin/gateway/status-pin-store.ts +124 -45
  35. package/telegram-plugin/gateway/worker-feed-dispatch.ts +19 -0
  36. package/telegram-plugin/history.ts +5 -0
  37. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +1 -2
  38. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +9 -1
  39. package/telegram-plugin/registry/subagents-schema.ts +126 -1
  40. package/telegram-plugin/registry/turns-schema.ts +65 -1
  41. package/telegram-plugin/session-tail.ts +26 -4
  42. package/telegram-plugin/slot-banner-driver.ts +42 -2
  43. package/telegram-plugin/status-query-telemetry.ts +100 -0
  44. package/telegram-plugin/stream-reply-handler.ts +15 -16
  45. package/telegram-plugin/subagent-watcher.ts +182 -30
  46. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +4 -10
  47. package/telegram-plugin/tests/busy-key-reaper.test.ts +191 -0
  48. package/telegram-plugin/tests/emission-authority-facade.test.ts +11 -17
  49. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +5 -26
  50. package/telegram-plugin/tests/escalation-bridge-gate.test.ts +38 -0
  51. package/telegram-plugin/tests/gate-parity-probe.test.ts +171 -0
  52. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +13 -0
  53. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +14 -11
  54. package/telegram-plugin/tests/inbound-delivery-confirm.test.ts +146 -0
  55. package/telegram-plugin/tests/inbound-spool.test.ts +143 -0
  56. package/telegram-plugin/tests/model-command.test.ts +54 -1
  57. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +5 -11
  58. package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +329 -0
  59. package/telegram-plugin/tests/pending-inbound-buffer.test.ts +53 -0
  60. package/telegram-plugin/tests/progress-update-redact.test.ts +99 -0
  61. package/telegram-plugin/tests/registry-turns.test.ts +67 -0
  62. package/telegram-plugin/tests/represent-guard.test.ts +42 -6
  63. package/telegram-plugin/tests/resume-inbound-builder.test.ts +1 -0
  64. package/telegram-plugin/tests/session-tail.test.ts +10 -1
  65. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +246 -0
  66. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +0 -14
  67. package/telegram-plugin/tests/status-pin-store.test.ts +220 -5
  68. package/telegram-plugin/tests/status-query-telemetry.test.ts +115 -0
  69. package/telegram-plugin/tests/subagent-nested-dispatch.test.ts +209 -0
  70. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +37 -0
  71. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +167 -0
  72. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +46 -3
  73. package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +70 -0
  74. package/telegram-plugin/tests/tool-activity-summary.test.ts +16 -0
  75. package/telegram-plugin/tests/tool-filter.test.ts +1 -3
  76. package/telegram-plugin/tests/tool-label-pretool.test.ts +1 -4
  77. package/telegram-plugin/tests/turn-flush-safety.test.ts +222 -1
  78. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +46 -0
  79. package/telegram-plugin/tests/worker-activity-feed.test.ts +202 -9
  80. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +25 -0
  81. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +295 -0
  82. package/telegram-plugin/tool-activity-summary.ts +19 -0
  83. package/telegram-plugin/turn-flush-safety.ts +16 -1
  84. package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +8 -9
  85. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +1 -1
  86. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +1 -1
  87. package/telegram-plugin/worker-activity-feed.ts +75 -15
  88. package/vendor/hindsight-memory/CHANGELOG.md +24 -0
  89. package/vendor/hindsight-memory/README.md +5 -0
  90. package/vendor/hindsight-memory/scripts/lib/client.py +31 -1
  91. package/vendor/hindsight-memory/scripts/lib/config.py +41 -2
  92. package/vendor/hindsight-memory/scripts/lib/content.py +4 -1
  93. package/vendor/hindsight-memory/scripts/lib/daemon.py +11 -2
  94. package/vendor/hindsight-memory/scripts/recall.py +74 -1
  95. package/vendor/hindsight-memory/scripts/retain.py +8 -1
  96. package/vendor/hindsight-memory/scripts/tests/test_config_client_casts.py +111 -0
  97. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +85 -1
  98. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_filters.py +107 -0
  99. package/vendor/hindsight-memory/settings.json +4 -0
  100. package/vendor/hindsight-memory/tests/test_client.py +130 -0
  101. package/vendor/hindsight-memory/tests/test_config.py +47 -0
  102. package/vendor/hindsight-memory/tests/test_content.py +18 -0
  103. package/vendor/hindsight-memory/tests/test_hooks.py +62 -0
  104. package/telegram-plugin/gateway/error-envelope-card.ts +0 -64
  105. package/telegram-plugin/gateway/resolve-calling-subagent.ts +0 -78
  106. package/telegram-plugin/silent-reply.ts +0 -58
  107. package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +0 -79
  108. package/telegram-plugin/tests/resolve-calling-subagent.test.ts +0 -269
  109. package/telegram-plugin/tests/silent-reply-guard.test.ts +0 -122
@@ -0,0 +1,246 @@
1
+ /**
2
+ * slot-banner boot-recovery (#421) — the crash-recovery path for the pinned
3
+ * OAuth slot banner, exercised against the REAL shared status-pin store +
4
+ * the REAL banner driver the gateway wires up.
5
+ *
6
+ * The bug this guards: the slot banner's pin state used to live ONLY in a
7
+ * module-global `let pinnedBannerState`. A gateway restart while a banner was
8
+ * pinned lost that record, so the orphaned banner stayed pinned forever. The
9
+ * fix routes the banner pin THROUGH the same status-pins.json store (a distinct
10
+ * `banner:` pinKey), so the ONE existing runStatusPinBootCleanup unpins it on
11
+ * boot — no parallel store, no second boot hook.
12
+ *
13
+ * We model the gateway's banner-persistence wiring faithfully with the SAME
14
+ * functions gateway.ts calls (refreshBanner + its persist hooks →
15
+ * persistStatusPins; runStatusPinBootCleanup) over an in-memory fs + a fake
16
+ * Telegram whose pin set we can inspect. A regression in the ordering / cleanup
17
+ * contract reds here.
18
+ *
19
+ * Variants:
20
+ * (A) confirmed banner pin → crash before any clean unpin → recovered next boot.
21
+ * (B) pending banner pin (crash INSIDE the persist-after-pin window) →
22
+ * recovered next boot.
23
+ * (C) clean unpin (return to default slot) → nothing left for boot cleanup.
24
+ */
25
+ import { describe, it, expect } from "vitest";
26
+ import {
27
+ loadStatusPins,
28
+ persistStatusPins,
29
+ runStatusPinBootCleanup,
30
+ type PersistedStatusPin,
31
+ type StatusPinStoreFsSeam,
32
+ } from "../gateway/status-pin-store.js";
33
+ import { refreshBanner } from "../slot-banner-driver.js";
34
+ import type { BannerState } from "../slot-banner.js";
35
+
36
+ const PATH = "/state/agent/telegram/status-pins.json";
37
+ const OWNER = "-100999";
38
+ const AGENT = "clerk";
39
+ const DEFAULT = "default";
40
+ const BANNER_PIN_KEY = "banner:owner";
41
+
42
+ function memFs(seed: Record<string, string> = {}) {
43
+ const files = new Map<string, string>(Object.entries(seed));
44
+ const fs: StatusPinStoreFsSeam = {
45
+ readFileSync: (p) => {
46
+ if (!files.has(p)) throw new Error(`ENOENT ${p}`);
47
+ return files.get(p)!;
48
+ },
49
+ writeFileSync: (p, d) => files.set(p, d),
50
+ renameSync: (a, b) => {
51
+ if (!files.has(a)) throw new Error(`ENOENT ${a}`);
52
+ files.set(b, files.get(a)!);
53
+ files.delete(a);
54
+ },
55
+ existsSync: (p) => files.has(p),
56
+ };
57
+ return { fs, files };
58
+ }
59
+
60
+ /** A fake Telegram that records the current pinned message set per chat, plus a
61
+ * monotonic message-id source for sends, so we can assert an orphan is left
62
+ * pinned after a crash and unpinned after boot. */
63
+ function fakeTelegram(startMessageId = 500) {
64
+ const pinned = new Set<string>(); // `${chatId}:${messageId}`
65
+ let nextId = startMessageId;
66
+ return {
67
+ pinned,
68
+ bot: {
69
+ api: {
70
+ sendRichMessage: async (chat_id: string | number) => {
71
+ void chat_id;
72
+ return { message_id: nextId++ };
73
+ },
74
+ pinChatMessage: async (chat_id: string | number, message_id: number) => {
75
+ pinned.add(`${chat_id}:${message_id}`);
76
+ },
77
+ unpinChatMessage: async (chat_id: string | number, message_id: number) => {
78
+ pinned.delete(`${chat_id}:${message_id}`);
79
+ },
80
+ // Unused by the pin path but required by the BannerBotApi surface.
81
+ sendMessage: async (chat_id: string | number) => {
82
+ void chat_id;
83
+ return { message_id: nextId++ };
84
+ },
85
+ editMessageText: async () => undefined,
86
+ },
87
+ },
88
+ };
89
+ }
90
+
91
+ /**
92
+ * A faithful stand-in for the gateway's slot-banner persistence wiring — the
93
+ * SAME shape as gateway.ts's `persistBannerRow` + `snapshotStatusPins` (which
94
+ * folds the banner row into the shared status-pin set) + the banner driver's
95
+ * persist hooks + the ONE runStatusPinBootCleanup, with injectable fs + api so a
96
+ * single test can span a "crash" and a fresh boot.
97
+ */
98
+ function makeGateway(fs: StatusPinStoreFsSeam, tg: ReturnType<typeof fakeTelegram>) {
99
+ let bannerPersistedRow: PersistedStatusPin | null = null;
100
+ let pinnedBannerState: BannerState | null = null;
101
+
102
+ // Mirrors gateway.ts snapshotStatusPins(): map-backed status pins (none in
103
+ // this isolated harness) PLUS the banner row.
104
+ const snapshotStatusPins = (): PersistedStatusPin[] =>
105
+ bannerPersistedRow ? [bannerPersistedRow] : [];
106
+
107
+ const persistBannerRow = (row: PersistedStatusPin | null): void => {
108
+ bannerPersistedRow = row;
109
+ persistStatusPins(PATH, fs, snapshotStatusPins(), () => {});
110
+ };
111
+
112
+ async function refreshPinnedBanner(currentSlot: string | null) {
113
+ pinnedBannerState = await refreshBanner({
114
+ bot: tg.bot as never,
115
+ ownerChatId: OWNER,
116
+ agentName: AGENT,
117
+ currentSlot,
118
+ defaultSlot: DEFAULT,
119
+ prevState: pinnedBannerState,
120
+ onError: () => {},
121
+ persist: {
122
+ pending: (chatId, messageId) =>
123
+ persistBannerRow({ pinKey: BANNER_PIN_KEY, chatId, messageId, pending: true }),
124
+ confirm: (chatId, messageId) =>
125
+ persistBannerRow({ pinKey: BANNER_PIN_KEY, chatId, messageId }),
126
+ clear: () => persistBannerRow(null),
127
+ },
128
+ });
129
+ }
130
+
131
+ async function bootCleanup() {
132
+ return runStatusPinBootCleanup({
133
+ path: PATH,
134
+ fs,
135
+ unpin: (chatId, messageId) => tg.bot.api.unpinChatMessage(chatId, messageId),
136
+ log: () => {},
137
+ });
138
+ }
139
+
140
+ return {
141
+ refreshPinnedBanner,
142
+ bootCleanup,
143
+ get bannerState() {
144
+ return pinnedBannerState;
145
+ },
146
+ };
147
+ }
148
+
149
+ describe("slot-banner boot recovery (gateway wiring)", () => {
150
+ it("(A) confirmed banner pin → crash → next boot unpins the orphan + empties the store", async () => {
151
+ const { fs } = memFs();
152
+ const tg = fakeTelegram();
153
+
154
+ // ── Session 1: agent fails over to a non-default slot → banner pinned. ──
155
+ const gw1 = makeGateway(fs, tg);
156
+ await gw1.refreshPinnedBanner("personal");
157
+
158
+ // Banner is live in Telegram and recorded on disk (confirmed).
159
+ expect(gw1.bannerState?.slot).toBe("personal");
160
+ const msgId = gw1.bannerState!.messageId;
161
+ expect(tg.pinned.has(`${OWNER}:${msgId}`)).toBe(true);
162
+ expect(loadStatusPins(PATH, fs)).toEqual([
163
+ { pinKey: BANNER_PIN_KEY, chatId: OWNER, messageId: msgId },
164
+ ]);
165
+
166
+ // ── CRASH: no clean unpin runs; gw1's in-memory state vanishes. ──
167
+ expect(tg.pinned.has(`${OWNER}:${msgId}`)).toBe(true);
168
+
169
+ // ── Session 2: brand-new gateway, empty state, SAME on-disk store. ──
170
+ const gw2 = makeGateway(fs, tg);
171
+ const res = await gw2.bootCleanup();
172
+
173
+ expect(res).toEqual({ cleared: 1, total: 1 });
174
+ expect(tg.pinned.has(`${OWNER}:${msgId}`)).toBe(false); // orphan unpinned
175
+ expect(loadStatusPins(PATH, fs)).toEqual([]); // store emptied
176
+ });
177
+
178
+ it("(B) pending banner pin (crash inside the persist-after-pin window) → recovered next boot", async () => {
179
+ const { fs } = memFs();
180
+ const tg = fakeTelegram();
181
+
182
+ // Simulate the exact crash window: the message is sent + the pending record
183
+ // written, the pin API lands, then the process dies before the confirm
184
+ // rewrite. We reproduce that by calling refreshBanner but faulting the
185
+ // confirm hook (stands in for a SIGKILL between pin and confirm).
186
+ const bannerRow: PersistedStatusPin[] = [];
187
+ const persistRow = (row: PersistedStatusPin | null) => {
188
+ bannerRow.length = 0;
189
+ if (row) bannerRow.push(row);
190
+ persistStatusPins(PATH, fs, [...bannerRow], () => {});
191
+ };
192
+
193
+ await refreshBanner({
194
+ bot: tg.bot as never,
195
+ ownerChatId: OWNER,
196
+ agentName: AGENT,
197
+ currentSlot: "personal",
198
+ defaultSlot: DEFAULT,
199
+ prevState: null,
200
+ onError: () => {},
201
+ persist: {
202
+ pending: (chatId, messageId) =>
203
+ persistRow({ pinKey: BANNER_PIN_KEY, chatId, messageId, pending: true }),
204
+ // The confirm rewrite never runs (crash). The driver guards the hook,
205
+ // so throwing here models the process dying at exactly that point while
206
+ // leaving the pending record + live pin behind.
207
+ confirm: () => {
208
+ throw new Error("SIGKILL before confirm rewrite");
209
+ },
210
+ clear: () => persistRow(null),
211
+ },
212
+ });
213
+
214
+ // Orphan is live in Telegram; a PENDING record is on disk.
215
+ const rec = loadStatusPins(PATH, fs);
216
+ expect(rec).toHaveLength(1);
217
+ expect(rec[0].pending).toBe(true);
218
+ expect(tg.pinned.has(`${OWNER}:${rec[0].messageId}`)).toBe(true);
219
+
220
+ // Fresh boot recovers it from the pending record.
221
+ const gw2 = makeGateway(fs, tg);
222
+ const res = await gw2.bootCleanup();
223
+ expect(res).toEqual({ cleared: 1, total: 1 });
224
+ expect(tg.pinned.has(`${OWNER}:${rec[0].messageId}`)).toBe(false);
225
+ expect(loadStatusPins(PATH, fs)).toEqual([]);
226
+ });
227
+
228
+ it("(C) clean return to default slot unpins + drops the record → boot cleanup is a no-op", async () => {
229
+ const { fs } = memFs();
230
+ const tg = fakeTelegram();
231
+ const gw1 = makeGateway(fs, tg);
232
+
233
+ await gw1.refreshPinnedBanner("personal");
234
+ const msgId = gw1.bannerState!.messageId;
235
+ expect(tg.pinned.has(`${OWNER}:${msgId}`)).toBe(true);
236
+
237
+ // Return to default: banner unpins and drops its persisted claim.
238
+ await gw1.refreshPinnedBanner(DEFAULT);
239
+ expect(gw1.bannerState).toBeNull();
240
+ expect(tg.pinned.has(`${OWNER}:${msgId}`)).toBe(false);
241
+ expect(loadStatusPins(PATH, fs)).toEqual([]);
242
+
243
+ const gw2 = makeGateway(fs, tg);
244
+ expect(await gw2.bootCleanup()).toEqual({ cleared: 0, total: 0 });
245
+ });
246
+ });
@@ -28,7 +28,6 @@ import {
28
28
  persistStatusPins,
29
29
  reconcileAndPersistStatusPin,
30
30
  runStatusPinBootCleanup,
31
- type PersistedStatusPin,
32
31
  type StatusPinStoreFsSeam,
33
32
  } from "../gateway/status-pin-store.js";
34
33
  import { reconcilePin } from "../status-pin-driver.js";
@@ -81,17 +80,6 @@ function makeGateway(fs: StatusPinStoreFsSeam, tg: ReturnType<typeof fakeTelegra
81
80
  const statusPinState = new Map<string, PinState>();
82
81
  const statusPinChatIds = new Map<string, string>();
83
82
 
84
- const snapshotExcept = (exceptKey: string): PersistedStatusPin[] => {
85
- const out: PersistedStatusPin[] = [];
86
- for (const [pinKey, st] of statusPinState) {
87
- if (pinKey === exceptKey) continue;
88
- const chatId = statusPinChatIds.get(pinKey);
89
- if (chatId == null) continue;
90
- out.push({ pinKey, chatId, messageId: st.messageId });
91
- }
92
- return out;
93
- };
94
-
95
83
  async function reconcileStatusPin(pinKey: string, chatId: string, desired: DesiredPin) {
96
84
  const prev = statusPinState.get(pinKey) ?? null;
97
85
  const action = decidePinAction(prev, desired);
@@ -105,7 +93,6 @@ function makeGateway(fs: StatusPinStoreFsSeam, tg: ReturnType<typeof fakeTelegra
105
93
  pinKey,
106
94
  chatId,
107
95
  op,
108
- snapshotOthers: () => snapshotExcept(pinKey),
109
96
  applyPin: () =>
110
97
  reconcilePin({ api: tg.api, chatId, prevState: prev, desired }),
111
98
  log: () => {},
@@ -173,7 +160,6 @@ describe("status-pin boot recovery (gateway wiring)", () => {
173
160
  pinKey: "fg:c:3",
174
161
  chatId: "-100123",
175
162
  op: { kind: "pin", messageId: 715 },
176
- snapshotOthers: () => [],
177
163
  applyPin: async () => {
178
164
  await tg.api.pinChatMessage("-100123", 715); // pin lands in Telegram
179
165
  throw new Error("SIGKILL before confirm rewrite");
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import {
3
3
  loadStatusPins,
4
+ mutateStatusPinRow,
4
5
  persistStatusPins,
5
6
  pinnedMessageIsOurs,
6
7
  reconcileAndPersistStatusPin,
@@ -258,6 +259,193 @@ describe("runStatusPinBootCleanup", () => {
258
259
  });
259
260
  });
260
261
 
262
+ /**
263
+ * Concurrency race (multiple writers of status-pins.json). The store has two
264
+ * concurrent writers: `reconcileAndPersistStatusPin` (fg:/wk: rows) and
265
+ * `mutateStatusPinRow` (the banner:owner row). The race: a `pending` row is
266
+ * written to disk BEFORE the applyPin network call; if a SECOND writer runs
267
+ * DURING that await window and rebuilds the file, it can drop the in-flight
268
+ * pending/confirmed row. The per-path lock + read-modify-write must close it —
269
+ * BOTH rows survive.
270
+ */
271
+ describe("status-pin-store — concurrent-writer race", () => {
272
+ it("a banner persist DURING the applyPin await window does NOT drop the status pin's pending row", async () => {
273
+ const { fs } = memFs();
274
+
275
+ // applyPin is a promise we control: it resolves only when we let it, so we
276
+ // can fire a concurrent writer squarely inside the await window.
277
+ let releaseApplyPin!: () => void;
278
+ const applyPinGate = new Promise<void>((r) => {
279
+ releaseApplyPin = r;
280
+ });
281
+ // Snapshot what's on disk at the moment the pin API is invoked (proves the
282
+ // pending row was written before the network call).
283
+ let onDiskAtPin: PersistedStatusPin[] = [];
284
+
285
+ const reconcile = reconcileAndPersistStatusPin({
286
+ path: PATH,
287
+ fs,
288
+ pinKey: "fg:c:3",
289
+ chatId: "-100123",
290
+ op: { kind: "pin", messageId: 715 },
291
+ applyPin: async () => {
292
+ onDiskAtPin = loadStatusPins(PATH, fs);
293
+ await applyPinGate; // in-flight: hold the pin open
294
+ return { messageId: 715 };
295
+ },
296
+ log: () => {},
297
+ });
298
+
299
+ // Let the reconcile reach its applyPin await (pending row now on disk).
300
+ await Promise.resolve();
301
+ await Promise.resolve();
302
+ expect(onDiskAtPin).toEqual([
303
+ { pinKey: "fg:c:3", chatId: "-100123", messageId: 715, pending: true },
304
+ ]);
305
+
306
+ // Fire the banner persist DURING the await window. Under the per-path lock
307
+ // it must serialise AFTER the reconcile completes — its read-modify-write
308
+ // then sees the confirmed fg: row and preserves it.
309
+ const bannerPersist = mutateStatusPinRow(
310
+ PATH,
311
+ fs,
312
+ "banner:owner",
313
+ { pinKey: "banner:owner", chatId: "-100999", messageId: 42 },
314
+ () => {},
315
+ );
316
+
317
+ // Now release the pin so the reconcile confirms.
318
+ releaseApplyPin();
319
+ await reconcile;
320
+ await bannerPersist;
321
+
322
+ // BOTH rows survive: the status pin was NOT dropped by the concurrent
323
+ // banner write (the pre-fix bug), and the banner row is present.
324
+ const loaded = loadStatusPins(PATH, fs);
325
+ expect(loaded).toContainEqual({
326
+ pinKey: "fg:c:3",
327
+ chatId: "-100123",
328
+ messageId: 715,
329
+ });
330
+ expect(loaded).toContainEqual({
331
+ pinKey: "banner:owner",
332
+ chatId: "-100999",
333
+ messageId: 42,
334
+ });
335
+ expect(loaded).toHaveLength(2);
336
+ });
337
+
338
+ it("two concurrent key reconciles both survive (no lost pending row)", async () => {
339
+ const { fs } = memFs();
340
+
341
+ let releaseA!: () => void;
342
+ const gateA = new Promise<void>((r) => {
343
+ releaseA = r;
344
+ });
345
+
346
+ const reconcileA = reconcileAndPersistStatusPin({
347
+ path: PATH,
348
+ fs,
349
+ pinKey: "fg:c:3",
350
+ chatId: "-100123",
351
+ op: { kind: "pin", messageId: 715 },
352
+ applyPin: async () => {
353
+ await gateA;
354
+ return { messageId: 715 };
355
+ },
356
+ log: () => {},
357
+ });
358
+ await Promise.resolve();
359
+ await Promise.resolve();
360
+
361
+ // Second key's reconcile fires while A's pin is in-flight. Serialised by the
362
+ // lock, it runs after A confirms — its read-modify-write preserves A's row.
363
+ const reconcileB = reconcileAndPersistStatusPin({
364
+ path: PATH,
365
+ fs,
366
+ pinKey: "wk:agent-x",
367
+ chatId: "-100999",
368
+ op: { kind: "pin", messageId: 42 },
369
+ applyPin: async () => ({ messageId: 42 }),
370
+ log: () => {},
371
+ });
372
+
373
+ releaseA();
374
+ await reconcileA;
375
+ await reconcileB;
376
+
377
+ const loaded = loadStatusPins(PATH, fs);
378
+ expect(loaded).toContainEqual({
379
+ pinKey: "fg:c:3",
380
+ chatId: "-100123",
381
+ messageId: 715,
382
+ });
383
+ expect(loaded).toContainEqual({
384
+ pinKey: "wk:agent-x",
385
+ chatId: "-100999",
386
+ messageId: 42,
387
+ });
388
+ expect(loaded).toHaveLength(2);
389
+ });
390
+
391
+ // This is the case the LOCK (not just per-key RMW) is required for: two
392
+ // overlapping ops on the SAME pinKey. Different-key writers survive with or
393
+ // without serialisation (RMW alone preserves them), so the two tests above
394
+ // pass even against a no-op lock. Here a `clear` races a `pin` on one key:
395
+ // without the lock the clear runs inside the pin's await window, removes the
396
+ // pending row, and the pin's later confirm RESURRECTS a pin that was meant to
397
+ // be cleared. With the FIFO lock the clear waits until the pin fully confirms,
398
+ // then removes it — final state is correctly empty. Asserting absence is what
399
+ // distinguishes the real lock from a no-op one.
400
+ it("a clear racing a pin on the SAME key does not resurrect the cleared pin", async () => {
401
+ const { fs } = memFs();
402
+
403
+ let releasePin!: () => void;
404
+ const pinGate = new Promise<void>((r) => {
405
+ releasePin = r;
406
+ });
407
+
408
+ // Pin op acquires the lock first and holds it across its applyPin await.
409
+ const pin = reconcileAndPersistStatusPin({
410
+ path: PATH,
411
+ fs,
412
+ pinKey: "fg:c:3",
413
+ chatId: "-100123",
414
+ op: { kind: "pin", messageId: 715 },
415
+ applyPin: async () => {
416
+ await pinGate;
417
+ return { messageId: 715 };
418
+ },
419
+ log: () => {},
420
+ });
421
+ // Reach the applyPin await — pending row now on disk, lock held.
422
+ await Promise.resolve();
423
+ await Promise.resolve();
424
+
425
+ // Clear the SAME key while the pin is in-flight. FIFO-serialised behind the
426
+ // pin, it must run only AFTER the pin confirms, leaving the key removed.
427
+ const clear = reconcileAndPersistStatusPin({
428
+ path: PATH,
429
+ fs,
430
+ pinKey: "fg:c:3",
431
+ chatId: "-100123",
432
+ op: { kind: "clear" },
433
+ applyPin: async () => null,
434
+ log: () => {},
435
+ });
436
+
437
+ releasePin();
438
+ await pin;
439
+ await clear;
440
+
441
+ // The later-submitted clear wins: the key is gone, NOT resurrected by the
442
+ // pin's confirm write (which would happen under a no-op lock).
443
+ const loaded = loadStatusPins(PATH, fs);
444
+ expect(loaded.find((r) => r.pinKey === "fg:c:3")).toBeUndefined();
445
+ expect(loaded).toHaveLength(0);
446
+ });
447
+ });
448
+
261
449
  /**
262
450
  * Envelope versioning — a v1 snapshot (no `pending` field) must still load
263
451
  * fail-open as a confirmed pin, and v2 must round-trip the `pending` flag.
@@ -322,7 +510,6 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
322
510
  pinKey: "fg:c:3",
323
511
  chatId: "-100123",
324
512
  op: { kind: "pin", messageId: 715 },
325
- snapshotOthers: () => [],
326
513
  applyPin: async () => {
327
514
  order.push("pin-api");
328
515
  onDiskAtPin = loadStatusPins(PATH, fs);
@@ -354,7 +541,6 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
354
541
  pinKey: "fg:c:3",
355
542
  chatId: "-100123",
356
543
  op: { kind: "pin", messageId: 715 },
357
- snapshotOthers: () => [],
358
544
  applyPin: async () => {
359
545
  pinnedInTelegram = 715; // the pin landed in Telegram
360
546
  throw new Error("SIGKILL — process died before confirm rewrite");
@@ -395,7 +581,6 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
395
581
  pinKey: "fg:c:3",
396
582
  chatId: "-100123",
397
583
  op: { kind: "pin", messageId: 715 },
398
- snapshotOthers: () => [],
399
584
  // reconcilePin returns null (prevState) when the pin API throws.
400
585
  applyPin: async () => null,
401
586
  log: () => {},
@@ -412,13 +597,15 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
412
597
  chatId: "-100999",
413
598
  messageId: 42,
414
599
  };
600
+ // Others now come from the on-disk file (read-modify-write), not a caller
601
+ // snapshot — seed the other key's row on disk first.
602
+ persistStatusPins(PATH, fs, [other]);
415
603
  await reconcileAndPersistStatusPin({
416
604
  path: PATH,
417
605
  fs,
418
606
  pinKey: "fg:c:3",
419
607
  chatId: "-100123",
420
608
  op: { kind: "pin", messageId: 715 },
421
- snapshotOthers: () => [other],
422
609
  applyPin: async () => ({ messageId: 715 }),
423
610
  log: () => {},
424
611
  });
@@ -431,6 +618,35 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
431
618
  });
432
619
  });
433
620
 
621
+ it("uses on-disk OTHER rows (not a memory snapshot) — an on-disk key is preserved", async () => {
622
+ // The banner (or another key's) row can exist ON DISK without the reconcile
623
+ // caller knowing about it. Read-modify-write must carry it through.
624
+ const { fs } = memFs();
625
+ persistStatusPins(PATH, fs, [
626
+ { pinKey: "banner:owner", chatId: "-100999", messageId: 42 },
627
+ ]);
628
+ await reconcileAndPersistStatusPin({
629
+ path: PATH,
630
+ fs,
631
+ pinKey: "fg:c:3",
632
+ chatId: "-100123",
633
+ op: { kind: "pin", messageId: 715 },
634
+ applyPin: async () => ({ messageId: 715 }),
635
+ log: () => {},
636
+ });
637
+ const loaded = loadStatusPins(PATH, fs);
638
+ expect(loaded).toContainEqual({
639
+ pinKey: "banner:owner",
640
+ chatId: "-100999",
641
+ messageId: 42,
642
+ });
643
+ expect(loaded).toContainEqual({
644
+ pinKey: "fg:c:3",
645
+ chatId: "-100123",
646
+ messageId: 715,
647
+ });
648
+ });
649
+
434
650
  it("clear op: unpins then drops the record (post-pin ordering is safe)", async () => {
435
651
  const { fs } = memFs();
436
652
  persistStatusPins(PATH, fs, [pin({ pinKey: "fg:c:3", messageId: 715 })]);
@@ -440,7 +656,6 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
440
656
  pinKey: "fg:c:3",
441
657
  chatId: "-100123",
442
658
  op: { kind: "clear" },
443
- snapshotOthers: () => [],
444
659
  applyPin: async () => null,
445
660
  log: () => {},
446
661
  });
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Truthful `status-query` telemetry — issue #109 (truthful-telemetry PR).
3
+ *
4
+ * Regression guard for a lying diagnostic: the mid-turn "status?" telemetry
5
+ * used to peek the pinned progress card via `progressDriver.peek(...)`. That
6
+ * card was retired (#1122/#1126) and `progressDriver` is now permanently null,
7
+ * so the peek ALWAYS reported idle/zero regardless of live background work.
8
+ * These tests assert the replacement reads the LIVE surfaces:
9
+ *
10
+ * - a running background worker (or an active turn, feed entry, or pending
11
+ * async dispatch) ⇒ NON-idle ⇒ NO false `ux-failure`;
12
+ * - nothing running AND no active turn ⇒ idle ⇒ `ux-failure` emitted.
13
+ *
14
+ * The peek path previously had NO coverage — this closes that.
15
+ */
16
+ import { describe, it, expect } from 'bun:test'
17
+ import {
18
+ deriveStatusQueryTelemetry,
19
+ formatStatusQueryLine,
20
+ formatStatusQueryUxFailureLine,
21
+ type StatusQuerySurfaces,
22
+ } from '../status-query-telemetry.js'
23
+
24
+ const IDLE: StatusQuerySurfaces = {
25
+ turnActive: false,
26
+ turnAgeS: -1,
27
+ runningWorkers: 0,
28
+ workerFeedSize: 0,
29
+ pendingAsync: 0,
30
+ }
31
+
32
+ describe('status-query telemetry — reads the live surfaces', () => {
33
+ it('a running background worker ⇒ non-idle (no false ux-failure)', () => {
34
+ const t = deriveStatusQueryTelemetry({ ...IDLE, runningWorkers: 1 })
35
+ expect(t.idle).toBe(false)
36
+ expect(t.stage).toBe('background-work')
37
+ })
38
+
39
+ it('a tracked 🛠 Worker feed entry ⇒ non-idle', () => {
40
+ const t = deriveStatusQueryTelemetry({ ...IDLE, workerFeedSize: 2 })
41
+ expect(t.idle).toBe(false)
42
+ expect(t.stage).toBe('background-work')
43
+ })
44
+
45
+ it('a cross-turn pending-async dispatch ⇒ non-idle', () => {
46
+ const t = deriveStatusQueryTelemetry({ ...IDLE, pendingAsync: 1 })
47
+ expect(t.idle).toBe(false)
48
+ expect(t.stage).toBe('background-work')
49
+ })
50
+
51
+ it('an active turn ⇒ non-idle, stage turn-active', () => {
52
+ const t = deriveStatusQueryTelemetry({ ...IDLE, turnActive: true, turnAgeS: 12 })
53
+ expect(t.idle).toBe(false)
54
+ expect(t.stage).toBe('turn-active')
55
+ })
56
+
57
+ it('an active turn with a background worker still reports turn-active', () => {
58
+ const t = deriveStatusQueryTelemetry({
59
+ ...IDLE,
60
+ turnActive: true,
61
+ turnAgeS: 3,
62
+ runningWorkers: 1,
63
+ })
64
+ expect(t.idle).toBe(false)
65
+ expect(t.stage).toBe('turn-active')
66
+ })
67
+
68
+ it('nothing running AND no active turn ⇒ idle ⇒ ux-failure', () => {
69
+ const t = deriveStatusQueryTelemetry(IDLE)
70
+ expect(t.idle).toBe(true)
71
+ expect(t.stage).toBe('idle')
72
+ })
73
+
74
+ it('treats negative/garbage counts as empty (defensive) ⇒ idle', () => {
75
+ const t = deriveStatusQueryTelemetry({
76
+ ...IDLE,
77
+ runningWorkers: -1,
78
+ workerFeedSize: -3,
79
+ pendingAsync: -1,
80
+ })
81
+ expect(t.idle).toBe(true)
82
+ })
83
+ })
84
+
85
+ describe('status-query telemetry — log line formatting', () => {
86
+ it('primary line carries the "status-query" anchor and live fields', () => {
87
+ const s: StatusQuerySurfaces = {
88
+ turnActive: false,
89
+ turnAgeS: -1,
90
+ runningWorkers: 3,
91
+ workerFeedSize: 1,
92
+ pendingAsync: 0,
93
+ }
94
+ const line = formatStatusQueryLine('klanker', '123', 'none', s, deriveStatusQueryTelemetry(s))
95
+ expect(line).toContain('status-query')
96
+ expect(line).not.toContain('ux-failure')
97
+ expect(line).toContain('stage=background-work')
98
+ expect(line).toContain('running_workers=3')
99
+ expect(line).toContain('worker_feed=1')
100
+ expect(line).toContain('turn_age_s=-1')
101
+ expect(line).toContain('agent=klanker')
102
+ // The retired card fields must be gone — parsers keyed on them would
103
+ // otherwise silently read zeros forever.
104
+ expect(line).not.toContain('card_stage')
105
+ expect(line).not.toContain('card_subagents')
106
+ expect(line).not.toContain('card_items')
107
+ })
108
+
109
+ it('ux-failure line carries the "ux-failure: status-query" anchor', () => {
110
+ const line = formatStatusQueryUxFailureLine('klanker', '123', 'none')
111
+ expect(line).toContain('ux-failure: status-query')
112
+ expect(line).toContain('stage=idle')
113
+ expect(line).toContain('running_workers=0')
114
+ })
115
+ })