switchroom 0.18.6 → 0.18.8
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 +1 -0
- package/dist/auth-broker/index.js +1 -0
- package/dist/cli/autoaccept-poll.js +140 -33
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/switchroom.js +1172 -812
- package/dist/host-control/main.js +2 -1
- package/dist/vault/approvals/kernel-server.js +1 -0
- package/dist/vault/broker/server.js +1 -0
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +55 -16
- package/profiles/_base/start.sh.hbs +146 -50
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/skills/switchroom-runtime/SKILL.md +2 -0
- package/telegram-plugin/dist/bridge/bridge.js +22 -0
- package/telegram-plugin/dist/gateway/gateway.js +2965 -862
- package/telegram-plugin/dist/server.js +24 -0
- package/telegram-plugin/flood-circuit-breaker.ts +123 -0
- package/telegram-plugin/gateway/activity-card-store.ts +63 -18
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
- package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
- package/telegram-plugin/gateway/boot-card.ts +27 -0
- package/telegram-plugin/gateway/busy-ack.ts +106 -0
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
- package/telegram-plugin/gateway/gateway.ts +1618 -198
- package/telegram-plugin/gateway/inbound-spool.ts +2 -1
- package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
- package/telegram-plugin/gateway/inject-handler.ts +17 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
- package/telegram-plugin/gateway/ipc-server.ts +40 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
- package/telegram-plugin/gateway/model-command.ts +227 -54
- package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
- package/telegram-plugin/gateway/pending-card-store.ts +173 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
- package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
- package/telegram-plugin/gateway/session-model-file.ts +198 -0
- package/telegram-plugin/gateway/session-model-source.ts +73 -0
- package/telegram-plugin/gateway/status-pin-store.ts +82 -22
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
- package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
- package/telegram-plugin/hooks/hooks.json +10 -10
- package/telegram-plugin/hooks/run-hook.sh +84 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
- package/telegram-plugin/model-label.ts +69 -0
- package/telegram-plugin/model-unavailable.ts +26 -0
- package/telegram-plugin/operator-events.ts +24 -0
- package/telegram-plugin/permission-diff.ts +128 -0
- package/telegram-plugin/pty-partial-handler.ts +39 -0
- package/telegram-plugin/registry/subagents-schema.ts +80 -1
- package/telegram-plugin/registry/subagents.test.ts +90 -0
- package/telegram-plugin/render/rich-render.ts +79 -1
- package/telegram-plugin/retry-api-call.ts +62 -0
- package/telegram-plugin/session-tail.ts +28 -0
- package/telegram-plugin/shared/bot-runtime.ts +8 -1
- package/telegram-plugin/silence-poke.ts +14 -0
- package/telegram-plugin/silent-end.ts +49 -4
- package/telegram-plugin/stream-controller.ts +156 -38
- package/telegram-plugin/subagent-watcher.ts +222 -37
- package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
- package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
- package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
- package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
- package/telegram-plugin/tests/busy-ack.test.ts +121 -0
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
- package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +179 -25
- package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
- package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +203 -43
- package/telegram-plugin/tests/model-label.test.ts +64 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
- package/telegram-plugin/tests/operator-events.test.ts +1 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
- package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
- package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
- package/telegram-plugin/tests/permission-diff.test.ts +111 -0
- package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
- package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
- package/telegram-plugin/tests/session-model-file.test.ts +132 -0
- package/telegram-plugin/tests/session-model-source.test.ts +67 -0
- package/telegram-plugin/tests/session-tail.test.ts +64 -0
- package/telegram-plugin/tests/silent-end.test.ts +46 -1
- package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
- package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
- package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +54 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
- package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
- package/telegram-plugin/tests/voice-send.test.ts +308 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +11 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
- package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
- package/telegram-plugin/tool-activity-summary.ts +22 -2
- package/telegram-plugin/typing-wrap.ts +72 -25
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
- package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
- package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
- package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
- package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
- package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
- package/telegram-plugin/voice-ondemand.ts +25 -1
- package/telegram-plugin/voice-send.ts +154 -0
- package/telegram-plugin/worker-activity-feed.ts +9 -0
|
@@ -17160,6 +17160,21 @@ function createToolLabelSidecar(opts) {
|
|
|
17160
17160
|
}
|
|
17161
17161
|
var init_tool_label_sidecar = () => {};
|
|
17162
17162
|
|
|
17163
|
+
// model-label.ts
|
|
17164
|
+
function isModelSentinel(model) {
|
|
17165
|
+
if (typeof model !== "string")
|
|
17166
|
+
return true;
|
|
17167
|
+
const m = model.trim();
|
|
17168
|
+
if (m.length === 0)
|
|
17169
|
+
return true;
|
|
17170
|
+
if (m.startsWith("<"))
|
|
17171
|
+
return true;
|
|
17172
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(m))
|
|
17173
|
+
return true;
|
|
17174
|
+
return false;
|
|
17175
|
+
}
|
|
17176
|
+
var init_model_label = () => {};
|
|
17177
|
+
|
|
17163
17178
|
// session-tail.ts
|
|
17164
17179
|
import {
|
|
17165
17180
|
closeSync,
|
|
@@ -17300,6 +17315,10 @@ function projectTranscriptLine(line) {
|
|
|
17300
17315
|
if (!Array.isArray(content))
|
|
17301
17316
|
return [];
|
|
17302
17317
|
const events = [];
|
|
17318
|
+
const mainModel = message?.model;
|
|
17319
|
+
if (typeof mainModel === "string" && !isModelSentinel(mainModel)) {
|
|
17320
|
+
events.push({ kind: "model", model: mainModel });
|
|
17321
|
+
}
|
|
17303
17322
|
const textEvents = projectAssistantTextBlocks(content, (text, blockIndex, lastInMessage) => ({ kind: "text", text, blockIndex, lastInMessage }));
|
|
17304
17323
|
content.forEach((c, i) => {
|
|
17305
17324
|
const ct = c.type;
|
|
@@ -17402,6 +17421,10 @@ function projectSubagentLine(line, agentId, state) {
|
|
|
17402
17421
|
if (!Array.isArray(content))
|
|
17403
17422
|
return [];
|
|
17404
17423
|
const events = [];
|
|
17424
|
+
const subModel = message?.model;
|
|
17425
|
+
if (typeof subModel === "string" && !isModelSentinel(subModel)) {
|
|
17426
|
+
events.push({ kind: "sub_agent_model", agentId, model: subModel });
|
|
17427
|
+
}
|
|
17405
17428
|
const textEvents = projectAssistantTextBlocks(content, (text, blockIndex, lastInMessage) => ({
|
|
17406
17429
|
kind: "sub_agent_text",
|
|
17407
17430
|
agentId,
|
|
@@ -17903,6 +17926,7 @@ var MAX_JSONL_LINE_BYTES, MAX_ERROR_TEXT_CHARS = 500;
|
|
|
17903
17926
|
var init_session_tail = __esm(() => {
|
|
17904
17927
|
init_operator_events();
|
|
17905
17928
|
init_tool_label_sidecar();
|
|
17929
|
+
init_model_label();
|
|
17906
17930
|
MAX_JSONL_LINE_BYTES = 2 * 1024 * 1024;
|
|
17907
17931
|
});
|
|
17908
17932
|
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram per-bot flood-wait circuit breaker (issue #2923).
|
|
3
|
+
*
|
|
4
|
+
* When a burst of outbound sends trips Telegram's per-bot-token flood limit,
|
|
5
|
+
* the API returns `429 { retry_after: N }` — a SERVER-SIDE ban on the bot
|
|
6
|
+
* token that no client-side lever clears early (observed retry_after ~4116s,
|
|
7
|
+
* ~68 min). The failure is misleading: the container is `Up`, the gateway is
|
|
8
|
+
* polling, inbound works — but every outbound send is rejected. Worse, each
|
|
9
|
+
* `docker restart` posts a fresh boot/config card = another send INTO the
|
|
10
|
+
* open window, which can reset/extend the flood counter. A local, recoverable
|
|
11
|
+
* disk-full problem thereby amplifies into a remote, unrecoverable ban.
|
|
12
|
+
*
|
|
13
|
+
* This breaker persists the flood-wait window to disk so that:
|
|
14
|
+
* - `retryApiCall`'s `onFloodWait` hook records it the moment a 429 is seen;
|
|
15
|
+
* - a restart-time NON-ESSENTIAL send (boot card, config summary) consults
|
|
16
|
+
* `isFloodWaitActive` and SKIPS while the ban is open, so a restart during
|
|
17
|
+
* a flood-wait doesn't feed the counter and prolong the ban.
|
|
18
|
+
*
|
|
19
|
+
* The state file is tiny JSON under the agent's telegram state dir. All the
|
|
20
|
+
* logic is pure + injectable so it unit tests without a real clock or bot.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
24
|
+
import { dirname, join } from 'node:path'
|
|
25
|
+
|
|
26
|
+
export interface FloodWaitState {
|
|
27
|
+
/** Epoch ms at which the flood-wait window expires. */
|
|
28
|
+
untilTs: number
|
|
29
|
+
/** The retry_after (seconds) Telegram reported, for diagnostics. */
|
|
30
|
+
retryAfterSec: number
|
|
31
|
+
/** Epoch ms the window was (re)recorded. */
|
|
32
|
+
recordedTs: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Default marker filename inside the telegram state dir. */
|
|
36
|
+
export const FLOOD_STATE_FILE = 'flood-wait.json'
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the flood-wait marker path from a telegram state dir. Kept as a
|
|
40
|
+
* helper so callers share one location.
|
|
41
|
+
*/
|
|
42
|
+
export function floodStatePath(stateDir: string): string {
|
|
43
|
+
return join(stateDir, FLOOD_STATE_FILE)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Compute the flood-wait state for an observed `retry_after`. Extends (never
|
|
48
|
+
* shrinks) an existing window: if a fresh 429 reports a shorter remaining ban
|
|
49
|
+
* than we already recorded, we keep the longer expiry — the server is the
|
|
50
|
+
* authority and being conservative avoids sending back into an open window.
|
|
51
|
+
*/
|
|
52
|
+
export function computeFloodWait(
|
|
53
|
+
prior: FloodWaitState | null,
|
|
54
|
+
retryAfterSec: number,
|
|
55
|
+
now: number,
|
|
56
|
+
): FloodWaitState {
|
|
57
|
+
const candidate = now + Math.max(0, retryAfterSec) * 1000
|
|
58
|
+
const untilTs = prior && prior.untilTs > candidate ? prior.untilTs : candidate
|
|
59
|
+
return { untilTs, retryAfterSec, recordedTs: now }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Remaining ban time in ms (0 when no active window). */
|
|
63
|
+
export function floodWaitRemainingMs(state: FloodWaitState | null, now: number): number {
|
|
64
|
+
if (!state) return 0
|
|
65
|
+
return Math.max(0, state.untilTs - now)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** True while the flood-wait ban is still open. */
|
|
69
|
+
export function isFloodWaitActive(state: FloodWaitState | null, now: number): boolean {
|
|
70
|
+
return floodWaitRemainingMs(state, now) > 0
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Read persisted flood state; null on absence / parse failure. */
|
|
74
|
+
export function readFloodState(path: string): FloodWaitState | null {
|
|
75
|
+
try {
|
|
76
|
+
if (!existsSync(path)) return null
|
|
77
|
+
const raw = JSON.parse(readFileSync(path, 'utf-8')) as Partial<FloodWaitState>
|
|
78
|
+
if (typeof raw.untilTs !== 'number') return null
|
|
79
|
+
return {
|
|
80
|
+
untilTs: raw.untilTs,
|
|
81
|
+
retryAfterSec: typeof raw.retryAfterSec === 'number' ? raw.retryAfterSec : 0,
|
|
82
|
+
recordedTs: typeof raw.recordedTs === 'number' ? raw.recordedTs : 0,
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Persist flood state (best-effort — a write failure must not crash the send path). */
|
|
90
|
+
export function writeFloodState(path: string, state: FloodWaitState): void {
|
|
91
|
+
try {
|
|
92
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
93
|
+
writeFileSync(path, JSON.stringify(state), { mode: 0o600 })
|
|
94
|
+
} catch {
|
|
95
|
+
/* best-effort */
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Build the `onFloodWait` callback for `createRetryApiCall`, wired to persist
|
|
101
|
+
* (and extend) the window at `path`. Reads current state, merges the new
|
|
102
|
+
* retry_after, writes it back.
|
|
103
|
+
*/
|
|
104
|
+
export function makeFloodWaitRecorder(
|
|
105
|
+
path: string,
|
|
106
|
+
now: () => number = Date.now,
|
|
107
|
+
): (retryAfterSec: number) => void {
|
|
108
|
+
return (retryAfterSec: number) => {
|
|
109
|
+
const t = now()
|
|
110
|
+
const next = computeFloodWait(readFloodState(path), retryAfterSec, t)
|
|
111
|
+
writeFloodState(path, next)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Decide whether a NON-ESSENTIAL restart-time send (boot card, config
|
|
117
|
+
* summary) should be suppressed because a flood-wait is active. Returns the
|
|
118
|
+
* remaining ms when suppressed (>0), or 0 to proceed. Reads state fresh so a
|
|
119
|
+
* concurrently-updated window is honoured.
|
|
120
|
+
*/
|
|
121
|
+
export function suppressNonEssentialSendMs(path: string, now: number): number {
|
|
122
|
+
return floodWaitRemainingMs(readFloodState(path), now)
|
|
123
|
+
}
|
|
@@ -43,6 +43,11 @@
|
|
|
43
43
|
* failure than one frozen orphan. A benign-400 (message already
|
|
44
44
|
* deleted/vanished, or "not modified") is NOT counted as a finalize — nothing
|
|
45
45
|
* was delivered — see the `vanished` tally in `runActivityCardBootReaper`.
|
|
46
|
+
*
|
|
47
|
+
* The UNPIN half is different (#3001): unpinning is idempotent, so it gets
|
|
48
|
+
* AT-LEAST-ONCE (capped) semantics — a failed unpin re-persists the record
|
|
49
|
+
* with `finalizeAttempted: true` + an `unpinAttempts` counter so the next boot
|
|
50
|
+
* retries ONLY the unpin (never the edit), up to BOOT_UNPIN_MAX_ATTEMPTS.
|
|
46
51
|
*/
|
|
47
52
|
|
|
48
53
|
export interface ActivityCardStoreFsSeam {
|
|
@@ -73,8 +78,21 @@ export interface ActivityCardRecord {
|
|
|
73
78
|
* forever). Optional so a v1-shape record (pre-unpin-tracking) still
|
|
74
79
|
* loads and degrades to "don't attempt an unpin", never a crash. */
|
|
75
80
|
pinned?: boolean
|
|
81
|
+
/** Set when the boot reaper retains a record ONLY to retry its failed
|
|
82
|
+
* unpin (#3001). The finalizing edit stays AT-MOST-ONCE: a retained
|
|
83
|
+
* record's edit was already attempted, so the next boot skips the edit
|
|
84
|
+
* and only retries the (idempotent) unpin. */
|
|
85
|
+
finalizeAttempted?: boolean
|
|
86
|
+
/** Boot-reaper unpin retry counter (#3001); the record is forfeited at
|
|
87
|
+
* BOOT_UNPIN_MAX_ATTEMPTS. Absent = 0. */
|
|
88
|
+
unpinAttempts?: number
|
|
76
89
|
}
|
|
77
90
|
|
|
91
|
+
/** Cap on cross-boot unpin retries — same rationale as the status-pin
|
|
92
|
+
* store's BOOT_UNPIN_MAX_ATTEMPTS (bound a permanently-undeliverable
|
|
93
|
+
* unpin; unpins themselves are idempotent so retrying is safe). */
|
|
94
|
+
export const BOOT_UNPIN_MAX_ATTEMPTS = 5
|
|
95
|
+
|
|
78
96
|
interface SnapshotEnvelope {
|
|
79
97
|
v: 1
|
|
80
98
|
cards: ActivityCardRecord[]
|
|
@@ -91,7 +109,9 @@ function isCardRow(x: unknown): x is ActivityCardRecord {
|
|
|
91
109
|
(o.threadId === null || typeof o.threadId === 'number') &&
|
|
92
110
|
typeof o.activityMessageId === 'number' &&
|
|
93
111
|
typeof o.startedAt === 'number' &&
|
|
94
|
-
(o.pinned === undefined || typeof o.pinned === 'boolean')
|
|
112
|
+
(o.pinned === undefined || typeof o.pinned === 'boolean') &&
|
|
113
|
+
(o.finalizeAttempted === undefined || typeof o.finalizeAttempted === 'boolean') &&
|
|
114
|
+
(o.unpinAttempts === undefined || typeof o.unpinAttempts === 'number')
|
|
95
115
|
)
|
|
96
116
|
}
|
|
97
117
|
|
|
@@ -245,32 +265,57 @@ export async function runActivityCardBootReaper(args: {
|
|
|
245
265
|
// turn that upserted a fresh card under the same turnKey mid-reap keeps
|
|
246
266
|
// its own (different-id) record.
|
|
247
267
|
clearActivityCardRecord(args.path, args.fs, record.turnKey, record.activityMessageId, log)
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
268
|
+
// The finalizing EDIT stays at-most-once: skip it for a record retained by
|
|
269
|
+
// a prior boot purely to retry its failed unpin (finalizeAttempted).
|
|
270
|
+
if (!record.finalizeAttempted) {
|
|
271
|
+
try {
|
|
272
|
+
// Count a finalize ONLY when the edit actually landed. robustApiCall
|
|
273
|
+
// resolves to undefined on a benign-400 (the card was already deleted or
|
|
274
|
+
// the chat is gone) — that delivered nothing, so it is a `vanished`
|
|
275
|
+
// orphan, not a `finalized` one. Counting it as finalized (the pre-fix
|
|
276
|
+
// behaviour) over-reported the guarantee in the boot log.
|
|
277
|
+
const res = await args.finalizeCard(record)
|
|
278
|
+
if (res != null) finalized++
|
|
279
|
+
else vanished++
|
|
280
|
+
} catch (err) {
|
|
281
|
+
log(
|
|
282
|
+
`activity-card-store: boot reaper finalize failed ` +
|
|
283
|
+
`(chat=${record.chatId} msg=${record.activityMessageId}): ` +
|
|
284
|
+
`${(err as Error).message}\n`,
|
|
285
|
+
)
|
|
286
|
+
}
|
|
263
287
|
}
|
|
264
288
|
if (record.pinned) {
|
|
265
289
|
try {
|
|
266
290
|
await args.unpinCard(record)
|
|
267
291
|
unpinned++
|
|
268
292
|
} catch (err) {
|
|
293
|
+
// Retry-safe unpin (#3001): unlike the edit (at-most-once by design),
|
|
294
|
+
// an unpin is idempotent — so a failed one is RE-PERSISTED with an
|
|
295
|
+
// attempt counter and retried on the next boot, up to the cap, instead
|
|
296
|
+
// of being forfeited. `finalizeAttempted` guarantees the retained
|
|
297
|
+
// record can never re-run its edit.
|
|
298
|
+
const attempts = (record.unpinAttempts ?? 0) + 1
|
|
269
299
|
log(
|
|
270
300
|
`activity-card-store: boot reaper unpin failed ` +
|
|
271
|
-
`(chat=${record.chatId} msg=${record.activityMessageId}
|
|
272
|
-
|
|
301
|
+
`(chat=${record.chatId} msg=${record.activityMessageId} ` +
|
|
302
|
+
`attempt=${attempts}): ${(err as Error).message}\n`,
|
|
273
303
|
)
|
|
304
|
+
if (attempts < BOOT_UNPIN_MAX_ATTEMPTS) {
|
|
305
|
+
writeActivityCardRecord(
|
|
306
|
+
args.path,
|
|
307
|
+
args.fs,
|
|
308
|
+
{ ...record, finalizeAttempted: true, unpinAttempts: attempts },
|
|
309
|
+
log,
|
|
310
|
+
)
|
|
311
|
+
} else {
|
|
312
|
+
log(
|
|
313
|
+
`activity-card-store: boot reaper FORFEITING card unpin after ` +
|
|
314
|
+
`${attempts} failed attempts ` +
|
|
315
|
+
`(chat=${record.chatId} msg=${record.activityMessageId}) — ` +
|
|
316
|
+
`will not retry again\n`,
|
|
317
|
+
)
|
|
318
|
+
}
|
|
274
319
|
}
|
|
275
320
|
}
|
|
276
321
|
}
|