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.
- package/dist/agent-scheduler/index.js +83 -81
- package/dist/auth-broker/index.js +104 -88
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -15
- package/dist/cli/notion-write-pretool.mjs +85 -83
- package/dist/cli/skill-validate-pretool.mjs +91 -91
- package/dist/cli/switchroom.js +1720 -1392
- package/dist/cli/ui/index.html +84 -12
- package/dist/host-control/main.js +209 -173
- package/dist/vault/approvals/kernel-server.js +86 -83
- package/dist/vault/broker/server.js +284 -139
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +1 -1
- package/profiles/_base/start.sh.hbs +54 -3
- package/skills/switchroom-architecture/telegram.md +8 -15
- package/skills/switchroom-cli/SKILL.md +4 -5
- package/skills/telegram-test-harness/SKILL.md +1 -1
- package/telegram-plugin/README.md +18 -29
- package/telegram-plugin/bridge/bridge.ts +1 -41
- package/telegram-plugin/bridge/tool-filter.ts +3 -4
- package/telegram-plugin/dist/bridge/bridge.js +120 -155
- package/telegram-plugin/dist/gateway/gateway.js +1127 -1029
- package/telegram-plugin/dist/server.js +168 -203
- package/telegram-plugin/gateway/busy-key-reaper.ts +113 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +11 -0
- package/telegram-plugin/gateway/escalation-bridge-gate.ts +46 -0
- package/telegram-plugin/gateway/gate-parity-probe.ts +102 -0
- package/telegram-plugin/gateway/gateway.ts +566 -631
- package/telegram-plugin/gateway/inbound-delivery-confirm.ts +89 -7
- package/telegram-plugin/gateway/inbound-spool.ts +108 -10
- package/telegram-plugin/gateway/model-command.ts +51 -3
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +26 -0
- package/telegram-plugin/gateway/represent-guard.ts +28 -11
- package/telegram-plugin/gateway/status-pin-store.ts +124 -45
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +19 -0
- package/telegram-plugin/history.ts +5 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +1 -2
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +9 -1
- package/telegram-plugin/registry/subagents-schema.ts +126 -1
- package/telegram-plugin/registry/turns-schema.ts +65 -1
- package/telegram-plugin/session-tail.ts +26 -4
- package/telegram-plugin/slot-banner-driver.ts +42 -2
- package/telegram-plugin/status-query-telemetry.ts +100 -0
- package/telegram-plugin/stream-reply-handler.ts +15 -16
- package/telegram-plugin/subagent-watcher.ts +182 -30
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +4 -10
- package/telegram-plugin/tests/busy-key-reaper.test.ts +191 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +11 -17
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +5 -26
- package/telegram-plugin/tests/escalation-bridge-gate.test.ts +38 -0
- package/telegram-plugin/tests/gate-parity-probe.test.ts +171 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +13 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +14 -11
- package/telegram-plugin/tests/inbound-delivery-confirm.test.ts +146 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +143 -0
- package/telegram-plugin/tests/model-command.test.ts +54 -1
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +5 -11
- package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +329 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +53 -0
- package/telegram-plugin/tests/progress-update-redact.test.ts +99 -0
- package/telegram-plugin/tests/registry-turns.test.ts +67 -0
- package/telegram-plugin/tests/represent-guard.test.ts +42 -6
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +1 -0
- package/telegram-plugin/tests/session-tail.test.ts +10 -1
- package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +246 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +0 -14
- package/telegram-plugin/tests/status-pin-store.test.ts +220 -5
- package/telegram-plugin/tests/status-query-telemetry.test.ts +115 -0
- package/telegram-plugin/tests/subagent-nested-dispatch.test.ts +209 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +37 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +167 -0
- package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +46 -3
- package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +70 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +16 -0
- package/telegram-plugin/tests/tool-filter.test.ts +1 -3
- package/telegram-plugin/tests/tool-label-pretool.test.ts +1 -4
- package/telegram-plugin/tests/turn-flush-safety.test.ts +222 -1
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +46 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +202 -9
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +25 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +295 -0
- package/telegram-plugin/tool-activity-summary.ts +19 -0
- package/telegram-plugin/turn-flush-safety.ts +16 -1
- package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +8 -9
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +1 -1
- package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +1 -1
- package/telegram-plugin/worker-activity-feed.ts +75 -15
- package/vendor/hindsight-memory/CHANGELOG.md +24 -0
- package/vendor/hindsight-memory/README.md +5 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +31 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +41 -2
- package/vendor/hindsight-memory/scripts/lib/content.py +4 -1
- package/vendor/hindsight-memory/scripts/lib/daemon.py +11 -2
- package/vendor/hindsight-memory/scripts/recall.py +74 -1
- package/vendor/hindsight-memory/scripts/retain.py +8 -1
- package/vendor/hindsight-memory/scripts/tests/test_config_client_casts.py +111 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +85 -1
- package/vendor/hindsight-memory/scripts/tests/test_recall_tag_filters.py +107 -0
- package/vendor/hindsight-memory/settings.json +4 -0
- package/vendor/hindsight-memory/tests/test_client.py +130 -0
- package/vendor/hindsight-memory/tests/test_config.py +47 -0
- package/vendor/hindsight-memory/tests/test_content.py +18 -0
- package/vendor/hindsight-memory/tests/test_hooks.py +62 -0
- package/telegram-plugin/gateway/error-envelope-card.ts +0 -64
- package/telegram-plugin/gateway/resolve-calling-subagent.ts +0 -78
- package/telegram-plugin/silent-reply.ts +0 -58
- package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +0 -79
- package/telegram-plugin/tests/resolve-calling-subagent.test.ts +0 -269
- 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
|
+
})
|