switchroom 0.19.25 → 0.19.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/git-agent-attribution-hook.sh +144 -0
- package/dist/agent-scheduler/index.js +61 -2
- package/dist/auth-broker/index.js +125 -8
- package/dist/cli/notion-write-pretool.mjs +61 -2
- package/dist/cli/switchroom.js +2347 -1104
- package/dist/host-control/main.js +126 -9
- package/dist/vault/approvals/kernel-server.js +124 -8
- package/dist/vault/broker/server.js +124 -8
- package/package.json +6 -2
- package/profiles/_base/cron-session.sh.hbs +14 -0
- package/profiles/_base/start.sh.hbs +145 -4
- package/telegram-plugin/card-layout.ts +328 -0
- package/telegram-plugin/dist/bridge/bridge.js +93 -1
- package/telegram-plugin/dist/gateway/gateway.js +2213 -1204
- package/telegram-plugin/dist/server.js +96 -1
- package/telegram-plugin/edit-flood-fuse.ts +637 -56
- package/telegram-plugin/flood-429-ledger.ts +526 -0
- package/telegram-plugin/flood-circuit-breaker.ts +18 -0
- package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
- package/telegram-plugin/gateway/gateway.ts +103 -112
- package/telegram-plugin/gateway/narrative-lane.ts +14 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
- package/telegram-plugin/gateway/periodic-sweep-guard.ts +86 -0
- package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
- package/telegram-plugin/gateway/status-pin-retarget.ts +180 -0
- package/telegram-plugin/gateway/status-pin-store.ts +58 -9
- package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
- package/telegram-plugin/llm-error-present.ts +61 -2
- package/telegram-plugin/model-unavailable.ts +8 -0
- package/telegram-plugin/operator-events.ts +72 -5
- package/telegram-plugin/outbound-class.ts +81 -0
- package/telegram-plugin/provider-credit.ts +237 -0
- package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
- package/telegram-plugin/send-gate.ts +24 -2
- package/telegram-plugin/status-no-truncate.ts +11 -0
- package/telegram-plugin/status-pin-driver.ts +33 -17
- package/telegram-plugin/status-pin.ts +51 -5
- package/telegram-plugin/tests/card-golden.test.ts +69 -0
- package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
- package/telegram-plugin/tests/card-type-distinguishability.test.ts +291 -0
- package/telegram-plugin/tests/card-variants.golden.txt +211 -0
- package/telegram-plugin/tests/card-variants.ts +366 -0
- package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +316 -0
- package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
- package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
- package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
- package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
- package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
- package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
- package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
- package/telegram-plugin/tests/periodic-sweep-guard.test.ts +151 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +24 -18
- package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
- package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
- package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
- package/telegram-plugin/tests/status-pin-retarget.test.ts +244 -0
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
- package/telegram-plugin/tests/status-pin-shutdown-wiring.test.ts +94 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +179 -64
- package/telegram-plugin/tests/status-pin.test.ts +184 -7
- package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +29 -19
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
- package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
- package/telegram-plugin/tier-downgrade.ts +3 -2
- package/telegram-plugin/tool-activity-summary.ts +239 -322
- package/telegram-plugin/uat/assertions.ts +33 -3
- package/telegram-plugin/uat/feed-matcher.test.ts +36 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +9 -2
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +9 -2
- package/telegram-plugin/worker-activity-feed.ts +109 -30
- package/vendor/hindsight-memory/CLAUDE.md +45 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
- package/vendor/hindsight-memory/scripts/recall.py +176 -7
- package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_request_timeout.py +241 -0
- package/vendor/hindsight-memory/settings.json +1 -1
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
runStatusPinReconcile,
|
|
4
|
+
type StatusPinClaim,
|
|
5
|
+
} from "../gateway/status-pin-retarget.js";
|
|
6
|
+
import type { PinLegAction, PinState } from "../status-pin.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The orchestrator the gateway's `reconcileStatusPinInner` delegates to.
|
|
10
|
+
*
|
|
11
|
+
* The defect it closes: a RETARGET (the caller wants a DIFFERENT message pinned
|
|
12
|
+
* for a key it already holds a claim on) used to be reported as a bare `unpin`
|
|
13
|
+
* and stopped there, on the assumption a later reconcile would pin the new
|
|
14
|
+
* message. The FOREGROUND activity card reconciles `fg:<statusKey>` exactly
|
|
15
|
+
* once (narrative-lane.ts, the `activityMessageId == null` OPEN branch), so an
|
|
16
|
+
* ack-first mid-turn reopen left the live card unpinned for the rest of the
|
|
17
|
+
* turn AND deleted the durable row, putting it out of reach of both the
|
|
18
|
+
* mid-session reaper and the next boot's sweep.
|
|
19
|
+
*
|
|
20
|
+
* #3831: the expansion ALSO existed inside `status-pin-driver.ts`, decided from
|
|
21
|
+
* the driver's own `decidePinAction` call and lacking the per-leg persistence.
|
|
22
|
+
* That copy is gone — the driver now takes a `PinLegAction`, which cannot
|
|
23
|
+
* express a repin — so this module is the only decider. These tests assert the
|
|
24
|
+
* LEG SEQUENCE the driver now receives, plus the claim bookkeeping.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Records every leg and returns a scripted outcome for it. */
|
|
28
|
+
function legRecorder(outcomes: Array<PinState | null>) {
|
|
29
|
+
const legs: Array<{ from: PinState | null; action: PinLegAction }> = [];
|
|
30
|
+
let i = 0;
|
|
31
|
+
const runPin = async (action: PinLegAction, from: PinState | null) => {
|
|
32
|
+
legs.push({ from, action });
|
|
33
|
+
const out = outcomes[i];
|
|
34
|
+
i += 1;
|
|
35
|
+
return out ?? null;
|
|
36
|
+
};
|
|
37
|
+
return { legs, runPin };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function claimsOf(seed?: { key: string; claim: StatusPinClaim }) {
|
|
41
|
+
const claims = new Map<string, StatusPinClaim>();
|
|
42
|
+
if (seed) claims.set(seed.key, seed.claim);
|
|
43
|
+
return claims;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const KEY = "fg:c:7";
|
|
47
|
+
const CHAT = "-100123";
|
|
48
|
+
|
|
49
|
+
describe("runStatusPinReconcile — RETARGET", () => {
|
|
50
|
+
it("runs BOTH legs: unpins the stale claim, then pins the new message", async () => {
|
|
51
|
+
const { legs, runPin } = legRecorder([null, { messageId: 901 }]);
|
|
52
|
+
const claims = claimsOf({
|
|
53
|
+
key: KEY,
|
|
54
|
+
claim: { messageId: 900, chatId: CHAT, pinnedAt: 1000 },
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await runStatusPinReconcile({
|
|
58
|
+
pinKey: KEY,
|
|
59
|
+
chatId: CHAT,
|
|
60
|
+
prev: { messageId: 900 },
|
|
61
|
+
desired: { pinned: true, messageId: 901 },
|
|
62
|
+
persist: null,
|
|
63
|
+
runPin,
|
|
64
|
+
claims,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// The pre-fix behaviour was legs === [unpin] and nothing pinned.
|
|
68
|
+
expect(legs).toEqual([
|
|
69
|
+
{ from: { messageId: 900 }, action: { kind: "unpin", messageId: 900 } },
|
|
70
|
+
{ from: null, action: { kind: "pin", messageId: 901 } },
|
|
71
|
+
]);
|
|
72
|
+
expect(claims.get(KEY)?.messageId).toBe(901);
|
|
73
|
+
expect(claims.get(KEY)?.chatId).toBe(CHAT);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("leg 2 starts from a NULL claim, so the driver pins rather than no-opping", async () => {
|
|
77
|
+
const { legs, runPin } = legRecorder([null, { messageId: 901 }]);
|
|
78
|
+
await runStatusPinReconcile({
|
|
79
|
+
pinKey: KEY,
|
|
80
|
+
chatId: CHAT,
|
|
81
|
+
prev: { messageId: 900 },
|
|
82
|
+
desired: { pinned: true, messageId: 901 },
|
|
83
|
+
persist: null,
|
|
84
|
+
runPin,
|
|
85
|
+
claims: claimsOf(),
|
|
86
|
+
});
|
|
87
|
+
expect(legs[1].from).toBeNull();
|
|
88
|
+
expect(legs[1].action).toEqual({ kind: "pin", messageId: 901 });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("re-stamps pinnedAt for the NEW card, so the reaper ages the live message", async () => {
|
|
92
|
+
const { runPin } = legRecorder([null, { messageId: 901 }]);
|
|
93
|
+
const claims = claimsOf({
|
|
94
|
+
key: KEY,
|
|
95
|
+
claim: { messageId: 900, chatId: CHAT, pinnedAt: 1000 },
|
|
96
|
+
});
|
|
97
|
+
await runStatusPinReconcile({
|
|
98
|
+
pinKey: KEY,
|
|
99
|
+
chatId: CHAT,
|
|
100
|
+
prev: { messageId: 900 },
|
|
101
|
+
desired: { pinned: true, messageId: 901 },
|
|
102
|
+
persist: null,
|
|
103
|
+
runPin,
|
|
104
|
+
claims,
|
|
105
|
+
now: () => 5000,
|
|
106
|
+
});
|
|
107
|
+
// Leg 1 cleared the claim (dropping the old stamp), leg 2 took a fresh one.
|
|
108
|
+
expect(claims.get(KEY)?.pinnedAt).toBe(5000);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("NEVER-CONFIRMED unpin (#3664 Defect B): SKIPS the pin leg and retains the OLD claim", async () => {
|
|
112
|
+
// A non-null leg-1 result means executePinLeg deliberately kept the claim —
|
|
113
|
+
// the old message is provably still pinned. Pinning the new one now would
|
|
114
|
+
// leave TWO pins in the chat with a durable record of one.
|
|
115
|
+
const { legs, runPin } = legRecorder([{ messageId: 900 }]);
|
|
116
|
+
const claims = claimsOf({
|
|
117
|
+
key: KEY,
|
|
118
|
+
claim: { messageId: 900, chatId: CHAT, pinnedAt: 1000 },
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
await runStatusPinReconcile({
|
|
122
|
+
pinKey: KEY,
|
|
123
|
+
chatId: CHAT,
|
|
124
|
+
prev: { messageId: 900 },
|
|
125
|
+
desired: { pinned: true, messageId: 901 },
|
|
126
|
+
persist: null,
|
|
127
|
+
runPin,
|
|
128
|
+
claims,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(legs).toHaveLength(1);
|
|
132
|
+
expect(claims.get(KEY)).toEqual({
|
|
133
|
+
messageId: 900,
|
|
134
|
+
chatId: CHAT,
|
|
135
|
+
pinnedAt: 1000, // original age preserved for the TTL gate
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("a FAILED pin leg drops the claim entirely (nothing is pinned, nothing is tracked)", async () => {
|
|
140
|
+
// executePinLeg returns its `from` on a failed pin — here `null`.
|
|
141
|
+
const { runPin } = legRecorder([null, null]);
|
|
142
|
+
const claims = claimsOf({
|
|
143
|
+
key: KEY,
|
|
144
|
+
claim: { messageId: 900, chatId: CHAT, pinnedAt: 1000 },
|
|
145
|
+
});
|
|
146
|
+
await runStatusPinReconcile({
|
|
147
|
+
pinKey: KEY,
|
|
148
|
+
chatId: CHAT,
|
|
149
|
+
prev: { messageId: 900 },
|
|
150
|
+
desired: { pinned: true, messageId: 901 },
|
|
151
|
+
persist: null,
|
|
152
|
+
runPin,
|
|
153
|
+
claims,
|
|
154
|
+
});
|
|
155
|
+
expect(claims.has(KEY)).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("runStatusPinReconcile — single-leg actions are unchanged", () => {
|
|
160
|
+
it("first pin: one leg, claim + chat + age recorded together", async () => {
|
|
161
|
+
const { legs, runPin } = legRecorder([{ messageId: 900 }]);
|
|
162
|
+
const claims = claimsOf();
|
|
163
|
+
await runStatusPinReconcile({
|
|
164
|
+
pinKey: KEY,
|
|
165
|
+
chatId: CHAT,
|
|
166
|
+
prev: null,
|
|
167
|
+
desired: { pinned: true, messageId: 900 },
|
|
168
|
+
persist: null,
|
|
169
|
+
runPin,
|
|
170
|
+
claims,
|
|
171
|
+
now: () => 4242,
|
|
172
|
+
});
|
|
173
|
+
expect(legs).toEqual([
|
|
174
|
+
{ from: null, action: { kind: "pin", messageId: 900 } },
|
|
175
|
+
]);
|
|
176
|
+
// #3809: the claim is ONE record — a chat-less claim cannot be represented.
|
|
177
|
+
expect(claims.get(KEY)).toEqual({
|
|
178
|
+
messageId: 900,
|
|
179
|
+
chatId: CHAT,
|
|
180
|
+
pinnedAt: 4242,
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("turn-end unpin: one leg, the whole claim is cleared", async () => {
|
|
185
|
+
const { legs, runPin } = legRecorder([null]);
|
|
186
|
+
const claims = claimsOf({
|
|
187
|
+
key: KEY,
|
|
188
|
+
claim: { messageId: 900, chatId: CHAT, pinnedAt: 1000 },
|
|
189
|
+
});
|
|
190
|
+
await runStatusPinReconcile({
|
|
191
|
+
pinKey: KEY,
|
|
192
|
+
chatId: CHAT,
|
|
193
|
+
prev: { messageId: 900 },
|
|
194
|
+
desired: { pinned: false },
|
|
195
|
+
persist: null,
|
|
196
|
+
runPin,
|
|
197
|
+
claims,
|
|
198
|
+
});
|
|
199
|
+
expect(legs).toEqual([
|
|
200
|
+
{ from: { messageId: 900 }, action: { kind: "unpin", messageId: 900 } },
|
|
201
|
+
]);
|
|
202
|
+
expect(claims.has(KEY)).toBe(false);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("steady-state re-pin of the SAME id: still one leg, and the ORIGINAL age is kept", async () => {
|
|
206
|
+
// 20 edits/turn each re-drive this; the reaper's TTL must measure from the
|
|
207
|
+
// first pin, not be reset by every edit.
|
|
208
|
+
const { legs, runPin } = legRecorder([{ messageId: 900 }]);
|
|
209
|
+
const claims = claimsOf({
|
|
210
|
+
key: KEY,
|
|
211
|
+
claim: { messageId: 900, chatId: CHAT, pinnedAt: 1000 },
|
|
212
|
+
});
|
|
213
|
+
await runStatusPinReconcile({
|
|
214
|
+
pinKey: KEY,
|
|
215
|
+
chatId: CHAT,
|
|
216
|
+
prev: { messageId: 900 },
|
|
217
|
+
desired: { pinned: true, messageId: 900 },
|
|
218
|
+
persist: null,
|
|
219
|
+
runPin,
|
|
220
|
+
claims,
|
|
221
|
+
now: () => 9_999_999,
|
|
222
|
+
});
|
|
223
|
+
expect(legs).toHaveLength(1);
|
|
224
|
+
expect(legs[0].action.kind).toBe("noop");
|
|
225
|
+
expect(claims.get(KEY)?.pinnedAt).toBe(1000);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("unpin with no claim: one no-op leg, the registry stays empty", async () => {
|
|
229
|
+
const { legs, runPin } = legRecorder([null]);
|
|
230
|
+
const claims = claimsOf();
|
|
231
|
+
await runStatusPinReconcile({
|
|
232
|
+
pinKey: KEY,
|
|
233
|
+
chatId: CHAT,
|
|
234
|
+
prev: null,
|
|
235
|
+
desired: { pinned: false },
|
|
236
|
+
persist: null,
|
|
237
|
+
runPin,
|
|
238
|
+
claims,
|
|
239
|
+
});
|
|
240
|
+
expect(legs).toHaveLength(1);
|
|
241
|
+
expect(legs[0].action.kind).toBe("noop");
|
|
242
|
+
expect(claims.size).toBe(0);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -43,13 +43,17 @@ describe('status-pin service-message suppression', () => {
|
|
|
43
43
|
// message (which would nuke manual/operator pins too) — AND it must be
|
|
44
44
|
// chat-scoped (pinnedMessageIsOurs requires chatId + messageId), never a
|
|
45
45
|
// messageId-only match.
|
|
46
|
-
expect(MODULE_SRC).toContain('deps.
|
|
46
|
+
expect(MODULE_SRC).toContain('deps.statusPinClaims')
|
|
47
47
|
expect(MODULE_SRC).toMatch(/pinned_message\?\.message_id/)
|
|
48
48
|
expect(MODULE_SRC).toContain('pinnedMessageIsOurs(')
|
|
49
49
|
// The chatId computed from the update must be fed to the guard.
|
|
50
50
|
expect(MODULE_SRC).toMatch(/pinnedMessageIsOurs\(trackedPins\(\), chatId, pinnedId\)/)
|
|
51
|
-
// And the tracked entries must carry their chat association.
|
|
52
|
-
|
|
51
|
+
// And the tracked entries must carry their chat association. Since #3809
|
|
52
|
+
// that association is STRUCTURAL — one claim record per key carrying both
|
|
53
|
+
// messageId and chatId — so the guard reads it off the claim itself and a
|
|
54
|
+
// chat-less tracked pin can no longer be constructed.
|
|
55
|
+
expect(MODULE_SRC).toMatch(/chatId: c\.chatId/)
|
|
56
|
+
expect(MODULE_SRC).toMatch(/messageId: c\.messageId/)
|
|
53
57
|
})
|
|
54
58
|
|
|
55
59
|
it('bails out when the pinned message is not one of ours', () => {
|
|
@@ -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
|
+
});
|