switchroom 0.19.18 → 0.19.22
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 +2 -1
- package/dist/auth-broker/index.js +56 -1
- package/dist/cli/drive-write-pretool.mjs +48 -5
- package/dist/cli/ms-365-write-pretool.mjs +40 -2
- package/dist/cli/notion-write-pretool.mjs +2 -1
- package/dist/cli/switchroom.js +5242 -2239
- package/dist/host-control/main.js +12241 -11375
- package/dist/vault/approvals/kernel-server.js +113 -7
- package/dist/vault/broker/server.js +259 -76
- package/package.json +6 -3
- package/profiles/_base/start.sh.hbs +61 -1
- package/skills/switchroom-release/SKILL.md +103 -20
- package/telegram-plugin/bridge/bridge.ts +14 -0
- package/telegram-plugin/card-format.ts +92 -3
- package/telegram-plugin/dist/bridge/bridge.js +13 -0
- package/telegram-plugin/dist/gateway/gateway.js +2356 -1159
- package/telegram-plugin/dist/server.js +13 -0
- package/telegram-plugin/edit-flood-fuse.ts +477 -0
- package/telegram-plugin/format.ts +19 -7
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
- package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
- package/telegram-plugin/gateway/gateway.ts +66 -56
- package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
- package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
- package/telegram-plugin/gateway/narrative-lane.ts +49 -3
- package/telegram-plugin/gateway/pending-card-store.ts +46 -16
- package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
- package/telegram-plugin/gateway/status-pin-api.ts +145 -0
- package/telegram-plugin/gateway/store-file.ts +244 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
- package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
- package/telegram-plugin/retry-api-call.ts +15 -2
- package/telegram-plugin/send-gate.ts +1 -1
- package/telegram-plugin/status-no-truncate.ts +64 -1
- package/telegram-plugin/status-pin-driver.ts +50 -27
- package/telegram-plugin/status-pin.ts +43 -5
- package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
- package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
- package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
- package/telegram-plugin/tests/status-pin.test.ts +106 -5
- package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +28 -12
- package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
- package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
- package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
- package/telegram-plugin/tool-activity-summary.ts +85 -13
- package/telegram-plugin/worker-activity-feed.ts +56 -2
- package/vendor/hindsight-memory/scripts/drain_pending.py +847 -67
- package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +944 -33
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +460 -0
- package/vendor/hindsight-memory/scripts/recall.py +74 -5
- package/vendor/hindsight-memory/scripts/session_start.py +48 -0
- package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2275 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +438 -0
- package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +130 -8
- package/vendor/hindsight-memory/tests/test_pending.py +32 -7
|
@@ -32,11 +32,18 @@
|
|
|
32
32
|
* {@link MAX_QUEUE_SIZE} below, and the tests that pin them.
|
|
33
33
|
*
|
|
34
34
|
* File format mirrors `missed-approvals-store.ts`: a single bounded JSON
|
|
35
|
-
* array, written synchronously
|
|
35
|
+
* array, written synchronously and ATOMICALLY (tmp + fsync + rename),
|
|
36
|
+
* mode 0o600 — a crash mid-persist leaves the previous queue intact
|
|
37
|
+
* instead of a torn file. NOTE: atomic REPLACEMENT only —
|
|
38
|
+
* whole-old-or-whole-new, not power-loss durability; the missing
|
|
39
|
+
* parent-directory fsync is tracked in #3603.
|
|
36
40
|
*
|
|
37
41
|
* Failure semantics (hardened post-#2973 adversarial review): a failed
|
|
38
|
-
* READ degrades to an empty list
|
|
39
|
-
*
|
|
42
|
+
* READ degrades to an empty list so the gateway still boots — but a
|
|
43
|
+
* CORRUPT file is no longer silent: the bytes are quarantined to
|
|
44
|
+
* `<file>.corrupt-<ts>` and a loud line goes to the log (see
|
|
45
|
+
* `store-file.ts`), because "queue silently came up empty" is exactly
|
|
46
|
+
* how queued retries disappeared unnoticed. A failed WRITE is a
|
|
40
47
|
* different story: silently swallowing it would mean `enqueue()` tells
|
|
41
48
|
* its caller "queued for retry" when nothing was actually persisted to
|
|
42
49
|
* disk, and a concurrent `recordAttempt()`/`remove()` would silently
|
|
@@ -51,8 +58,14 @@
|
|
|
51
58
|
* success.
|
|
52
59
|
*/
|
|
53
60
|
|
|
54
|
-
import {
|
|
61
|
+
import { writeFileSync, unlinkSync } from 'node:fs'
|
|
55
62
|
import { join } from 'node:path'
|
|
63
|
+
import { atomicWriteFileSync } from '../../src/util/atomic.js'
|
|
64
|
+
import {
|
|
65
|
+
preserveUnreadableStoreFile,
|
|
66
|
+
quarantineCorruptStoreFile,
|
|
67
|
+
readStoreJsonSync,
|
|
68
|
+
} from './store-file.js'
|
|
56
69
|
|
|
57
70
|
/** Hard cap on retry attempts per entry — never retry indefinitely. */
|
|
58
71
|
export const MAX_ATTEMPTS = 5
|
|
@@ -161,13 +174,33 @@ export function computeBackoffMs(attempts: number, retryAfterMs?: number): numbe
|
|
|
161
174
|
return exp
|
|
162
175
|
}
|
|
163
176
|
|
|
177
|
+
/**
|
|
178
|
+
* The real writer: `atomicWriteFileSync` (tmp + fsync + rename) behind a
|
|
179
|
+
* `writeFileSync`-shaped signature, so the injectable seam below keeps its
|
|
180
|
+
* existing type and the fault-injection tests are unaffected. Only `mode`
|
|
181
|
+
* from the options bag is meaningful here; the store always passes 0o600.
|
|
182
|
+
*/
|
|
183
|
+
export const atomicWriteSeam = ((path, data, opts) => {
|
|
184
|
+
const mode = typeof opts === 'object' && opts !== null && typeof opts.mode === 'number' ? opts.mode : 0o600
|
|
185
|
+
atomicWriteFileSync(path as string, data as string, mode)
|
|
186
|
+
}) as typeof writeFileSync
|
|
187
|
+
|
|
164
188
|
export function createAlwaysAllowPersistQueue(
|
|
165
189
|
stateDir: string,
|
|
166
190
|
/** Injectable for tests to force a write failure (disk full / permissions /
|
|
167
191
|
* read-only fs) without real filesystem faults — we run as root in CI/
|
|
168
192
|
* containers, so chmod-based permission tricks don't reliably fail, and
|
|
169
|
-
* bun's test runner doesn't support mocking node:fs built-ins.
|
|
170
|
-
|
|
193
|
+
* bun's test runner doesn't support mocking node:fs built-ins.
|
|
194
|
+
*
|
|
195
|
+
* CAUTION: a test that injects a seam replaces the ATOMIC writer. Such a
|
|
196
|
+
* test proves failure PROPAGATION, never atomicity — the injected function
|
|
197
|
+
* is whatever the test supplies (typically a plain `writeFileSync`, which
|
|
198
|
+
* is exactly the non-atomic writer this store moved off). Tests that mean
|
|
199
|
+
* to exercise the real write path must either leave this defaulted or wrap
|
|
200
|
+
* the exported {@link atomicWriteSeam}. */
|
|
201
|
+
writeFileSyncFn: typeof writeFileSync = atomicWriteSeam,
|
|
202
|
+
/** Log sink — defaults to stderr (the gateway's runtime log). */
|
|
203
|
+
log: (line: string) => void = l => process.stderr.write(l),
|
|
171
204
|
): AlwaysAllowPersistQueue {
|
|
172
205
|
const filePath = join(stateDir, 'always-allow-persist-queue.json')
|
|
173
206
|
|
|
@@ -183,6 +216,26 @@ export function createAlwaysAllowPersistQueue(
|
|
|
183
216
|
// single promise chain, so at most one is ever in flight at a time,
|
|
184
217
|
// regardless of how many callers invoke enqueue/recordAttempt/remove
|
|
185
218
|
// "concurrently".
|
|
219
|
+
//
|
|
220
|
+
// SCOPE (verified, not assumed): this is an IN-PROCESS promise chain, not
|
|
221
|
+
// an OS file lock. It serializes callers inside ONE gateway process only.
|
|
222
|
+
// Two gateway processes sharing a STATE_DIR WOULD still lose updates to
|
|
223
|
+
// each other, and that is not impossible — only rare. `startup-mutex.ts`
|
|
224
|
+
// makes concurrent gateways UNLIKELY, not unreachable: its bootMismatch
|
|
225
|
+
// path steals the lock with NO liveness check when the holder's bootId
|
|
226
|
+
// differs from the current one (exactly the restart-overlap case on a
|
|
227
|
+
// shared STATE_DIR — see the `boot.lock_stale_recovered_boot_mismatch`
|
|
228
|
+
// revert referenced at gateway.ts), `readCurrentBootId()` returns null
|
|
229
|
+
// off-Linux which disables the gate entirely, the lock is taken once at
|
|
230
|
+
// boot and never revalidated, and `isGatewayMain` lets harnesses bypass
|
|
231
|
+
// it. So: rare, not guaranteed.
|
|
232
|
+
//
|
|
233
|
+
// Cross-process mutual exclusion is deliberately OUT OF SCOPE for this
|
|
234
|
+
// change (which is about torn writes, not lost updates), and every write
|
|
235
|
+
// here is now atomic so an overlap can lose an update but can never
|
|
236
|
+
// corrupt the file. A real `flock` (cf. src/vault/flock-concurrent.test.ts)
|
|
237
|
+
// is the durable fix — tracked as follow-up. Do not read the startup mutex
|
|
238
|
+
// as a hard singleton invariant.
|
|
186
239
|
let lock: Promise<unknown> = Promise.resolve()
|
|
187
240
|
function withLock<T>(fn: () => T): Promise<T> {
|
|
188
241
|
const result = lock.then(fn, fn) // run fn even if the previous link rejected
|
|
@@ -192,14 +245,39 @@ export function createAlwaysAllowPersistQueue(
|
|
|
192
245
|
return result
|
|
193
246
|
}
|
|
194
247
|
|
|
248
|
+
/** Set when the last read failed for a non-ENOENT reason — the next write
|
|
249
|
+
* must preserve the file it could not read instead of clobbering it. */
|
|
250
|
+
let unreadable = false
|
|
251
|
+
|
|
195
252
|
function read(): FileShape {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
253
|
+
const result = readStoreJsonSync(filePath, 'always-allow-persist-queue', log)
|
|
254
|
+
unreadable = result.status === 'unreadable'
|
|
255
|
+
if (result.status !== 'ok') return { entries: [] }
|
|
256
|
+
const parsed = result.value as Partial<FileShape>
|
|
257
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
258
|
+
quarantineCorruptStoreFile(
|
|
259
|
+
filePath,
|
|
260
|
+
'always-allow-persist-queue',
|
|
261
|
+
'parsed to a non-object — not a persist-queue file',
|
|
262
|
+
log,
|
|
263
|
+
)
|
|
201
264
|
return { entries: [] }
|
|
202
265
|
}
|
|
266
|
+
// A PRESENT-but-non-array `entries` is corruption, not "empty queue".
|
|
267
|
+
// Coercing it to [] would resurrect the silent-loss bug: a half-written
|
|
268
|
+
// `{"entries": {}}` parses fine, so quarantine would never fire and the
|
|
269
|
+
// queued retries would vanish unnoticed. An ABSENT `entries` is the
|
|
270
|
+
// legitimate cold-start/partial-shape case and stays silent.
|
|
271
|
+
if (parsed.entries !== undefined && !Array.isArray(parsed.entries)) {
|
|
272
|
+
quarantineCorruptStoreFile(
|
|
273
|
+
filePath,
|
|
274
|
+
'always-allow-persist-queue',
|
|
275
|
+
'`entries` is present but not an array — truncated or malformed write',
|
|
276
|
+
log,
|
|
277
|
+
)
|
|
278
|
+
return { entries: [] }
|
|
279
|
+
}
|
|
280
|
+
return { entries: parsed.entries ?? [] }
|
|
203
281
|
}
|
|
204
282
|
|
|
205
283
|
/** Unlike `read()`, a write failure is NOT swallowed — it propagates so
|
|
@@ -207,6 +285,14 @@ export function createAlwaysAllowPersistQueue(
|
|
|
207
285
|
* not actually land on disk (disk full, permissions, etc.) instead of
|
|
208
286
|
* silently proceeding as if it had. */
|
|
209
287
|
function write(f: FileShape): void {
|
|
288
|
+
// Fail closed: never let an overwrite be what destroys a queue we merely
|
|
289
|
+
// failed to READ (flaky mount, transient EACCES) — this throws if the
|
|
290
|
+
// previous bytes can't be preserved, and that throw is exactly the
|
|
291
|
+
// propagate-don't-swallow contract above.
|
|
292
|
+
if (unreadable) {
|
|
293
|
+
preserveUnreadableStoreFile(filePath, 'always-allow-persist-queue', log)
|
|
294
|
+
unreadable = false
|
|
295
|
+
}
|
|
210
296
|
writeFileSyncFn(filePath, JSON.stringify(f), { encoding: 'utf-8', mode: 0o600 })
|
|
211
297
|
}
|
|
212
298
|
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* boot-sweep-gate.ts — control flow for the boot pin sweep (#3664): WHEN it may
|
|
3
|
+
* start (`createBootSweepGate`, the two-condition arming gate) and HOW its steps
|
|
4
|
+
* are sequenced once it does (`runBootPinSweepSteps`, per-step isolation).
|
|
5
|
+
*
|
|
6
|
+
* Why this exists
|
|
7
|
+
* ---------------
|
|
8
|
+
* The boot orphan sweep (`runBootPinCleanupAndDmSweep` → `statusPinBootCleanup`
|
|
9
|
+
* → `unpinChatMessage`) has TWO independent preconditions, and they are
|
|
10
|
+
* satisfied at two different points of the gateway's boot:
|
|
11
|
+
*
|
|
12
|
+
* 1. **Ownership** — this process won the startup mutex, so it is the sole
|
|
13
|
+
* live owner of the shared per-agent pin state. Established during module
|
|
14
|
+
* evaluation (`acquireStartupLock`, plus the non-atomic fallback path).
|
|
15
|
+
* A LOSING double-boot must never unpin the live holder's pins.
|
|
16
|
+
* 2. **A Telegram handle** — `lockedBot` is assigned, so an unpin can
|
|
17
|
+
* actually reach Telegram. Established much later, inside
|
|
18
|
+
* `initGatewayBot()` at boot.
|
|
19
|
+
*
|
|
20
|
+
* Before this gate the sweep was kicked off fire-and-forget the moment (1) was
|
|
21
|
+
* satisfied, i.e. at module-eval time — thousands of lines before (2). Every
|
|
22
|
+
* boot-cleanup unpin therefore threw `undefined is not an object (evaluating
|
|
23
|
+
* 'lockedBot.api')` before touching the network, the row was retained with an
|
|
24
|
+
* incremented attempt counter, and after `BOOT_UNPIN_MAX_ATTEMPTS` the only
|
|
25
|
+
* record of a still-pinned message was forfeited. That was a regression from
|
|
26
|
+
* `caa4d7568` (#3310), which moved the Bot construction — and with it the
|
|
27
|
+
* `lockedBot` assignment — out of module scope into `initGatewayBot()` while
|
|
28
|
+
* leaving the sweep's kick-off where it was.
|
|
29
|
+
*
|
|
30
|
+
* The gate makes the ordering DETERMINISTIC rather than a race: the sweep runs
|
|
31
|
+
* exactly once, when BOTH signals have arrived, in whichever order they arrive.
|
|
32
|
+
* It cannot regress by code motion — moving either signal site around cannot
|
|
33
|
+
* make the sweep fire before the other signal.
|
|
34
|
+
*
|
|
35
|
+
* Deliberately dependency-free and side-effect-free so it is provable in
|
|
36
|
+
* isolation (`telegram-plugin/tests/boot-sweep-gate.test.ts`); the gateway owns
|
|
37
|
+
* the wiring.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
export interface BootSweepGate {
|
|
41
|
+
/** Signal (1): this gateway owns the shared per-agent pin state. */
|
|
42
|
+
arm(): void
|
|
43
|
+
/** Signal (2): the Telegram bot handle exists — API calls can be made. */
|
|
44
|
+
botReady(): void
|
|
45
|
+
/** True once the sweep has been dispatched. Test/introspection seam. */
|
|
46
|
+
hasRun(): boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build a gate that dispatches `run` exactly once, after BOTH `arm()` and
|
|
51
|
+
* `botReady()` have been called. Both signals are idempotent and
|
|
52
|
+
* order-independent. `run` is dispatched fire-and-forget; a rejection is routed
|
|
53
|
+
* to `onError` so it can never surface as an unhandled rejection (the sweep is
|
|
54
|
+
* best-effort and must never take the gateway down).
|
|
55
|
+
*/
|
|
56
|
+
export function createBootSweepGate(args: {
|
|
57
|
+
run: () => Promise<void>
|
|
58
|
+
onError?: (err: unknown) => void
|
|
59
|
+
}): BootSweepGate {
|
|
60
|
+
let armed = false
|
|
61
|
+
let ready = false
|
|
62
|
+
let started = false
|
|
63
|
+
|
|
64
|
+
const maybeRun = (): void => {
|
|
65
|
+
if (started || !armed || !ready) return
|
|
66
|
+
started = true
|
|
67
|
+
void (async () => {
|
|
68
|
+
try {
|
|
69
|
+
await args.run()
|
|
70
|
+
} catch (err) {
|
|
71
|
+
args.onError?.(err)
|
|
72
|
+
}
|
|
73
|
+
})()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
arm(): void {
|
|
78
|
+
armed = true
|
|
79
|
+
maybeRun()
|
|
80
|
+
},
|
|
81
|
+
botReady(): void {
|
|
82
|
+
ready = true
|
|
83
|
+
maybeRun()
|
|
84
|
+
},
|
|
85
|
+
hasRun(): boolean {
|
|
86
|
+
return started
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Dependencies of {@link runBootPinSweepSteps}. All side effects are injected
|
|
92
|
+
* so the sequencing contract is provable without a gateway. */
|
|
93
|
+
export interface BootPinSweepSteps {
|
|
94
|
+
/** Pure fs scan for DM chat ids with a prior-session pin record. MUST run
|
|
95
|
+
* BEFORE the reapers, which empty those same stores. */
|
|
96
|
+
scanDmChatIds: () => string[]
|
|
97
|
+
statusPinCleanup: () => Promise<unknown>
|
|
98
|
+
activityCardReaper: () => Promise<unknown>
|
|
99
|
+
queuedCardReaper: () => Promise<unknown>
|
|
100
|
+
/** Flips the flag authorising the DM unpin-all path — for this sweep AND for
|
|
101
|
+
* later lazy first-inbound sweeps. */
|
|
102
|
+
enableDmSweep: () => void
|
|
103
|
+
sweepDm: (chatId: string) => Promise<unknown>
|
|
104
|
+
log?: (line: string) => void
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Run one sweep step, absorbing (and logging) a throw. See
|
|
109
|
+
* {@link runBootPinSweepSteps} for why isolation is mandatory here.
|
|
110
|
+
*
|
|
111
|
+
* Absorbs unconditionally: a non-`Error` throw is stringified rather than
|
|
112
|
+
* logged as `undefined`, and a `log` that itself throws (a closed stderr on a
|
|
113
|
+
* dying process) is swallowed too — otherwise the one function whose whole job
|
|
114
|
+
* is "never let a step take the sweep down" could take the sweep down.
|
|
115
|
+
*/
|
|
116
|
+
async function step(name: string, fn: () => Promise<unknown>, log: (line: string) => void) {
|
|
117
|
+
try {
|
|
118
|
+
await fn()
|
|
119
|
+
} catch (err) {
|
|
120
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
121
|
+
try {
|
|
122
|
+
log(`telegram gateway: boot pin sweep step '${name}' failed: ${msg}\n`)
|
|
123
|
+
} catch {
|
|
124
|
+
/* stderr is gone — nothing useful left to do */
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Sequence the boot sweep's steps, each individually absorbed.
|
|
131
|
+
*
|
|
132
|
+
* Why isolation is mandatory (#3664 salvage S2). The steps used to run as a
|
|
133
|
+
* bare sequential `await` chain. `runStatusPinBootCleanup` absorbs its own
|
|
134
|
+
* per-row throws, but the two card reapers issue real Bot API calls and can
|
|
135
|
+
* reject outright — and a rejection there did not merely skip the later
|
|
136
|
+
* reapers, it skipped `enableDmSweep()`. That flag authorises the DM
|
|
137
|
+
* unpin-all path for the WHOLE SESSION (the boot sweep AND every later lazy
|
|
138
|
+
* first-inbound sweep), so one throwing reaper silently disabled DM stale-pin
|
|
139
|
+
* cleanup entirely.
|
|
140
|
+
*
|
|
141
|
+
* That was unreachable before #3664 only because the sweep never actually ran
|
|
142
|
+
* — every step died on `undefined is not an object (evaluating
|
|
143
|
+
* 'lockedBot.api')` before doing anything. Making the sweep work for the first
|
|
144
|
+
* time is precisely what makes an earlier step's throw newly reachable, so the
|
|
145
|
+
* isolation lands with the fix rather than after it.
|
|
146
|
+
*
|
|
147
|
+
* Never rejects: every step is best-effort and the caller fire-and-forgets it.
|
|
148
|
+
*/
|
|
149
|
+
export async function runBootPinSweepSteps(deps: BootPinSweepSteps): Promise<void> {
|
|
150
|
+
const log = deps.log ?? ((l: string) => process.stderr.write(l))
|
|
151
|
+
|
|
152
|
+
let dmChatIds: string[] = []
|
|
153
|
+
await step('dm-chat-scan', async () => {
|
|
154
|
+
dmChatIds = deps.scanDmChatIds()
|
|
155
|
+
}, log)
|
|
156
|
+
|
|
157
|
+
await step('status-pin-cleanup', deps.statusPinCleanup, log)
|
|
158
|
+
await step('activity-card-reaper', deps.activityCardReaper, log)
|
|
159
|
+
await step('queued-card-reaper', deps.queuedCardReaper, log)
|
|
160
|
+
|
|
161
|
+
// Unconditional: reached even when every step above threw. See the docblock.
|
|
162
|
+
await step('enable-dm-sweep', async () => deps.enableDmSweep(), log)
|
|
163
|
+
for (const id of dmChatIds) await step(`dm-sweep:${id}`, () => deps.sweepDm(id), log)
|
|
164
|
+
}
|