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
|
@@ -294,6 +294,7 @@ import {
|
|
|
294
294
|
retryWithThreadFallback,
|
|
295
295
|
isFloodWaitActiveError,
|
|
296
296
|
} from '../retry-api-call.js'
|
|
297
|
+
import { installEditFloodFuse } from '../edit-flood-fuse.js'
|
|
297
298
|
import { createSendGate, sendGateConfigFromEnv, isSendGateShed } from '../send-gate.js'
|
|
298
299
|
import { createStatsLogger, createFloodWindowObserver } from '../send-gate-observability.js'
|
|
299
300
|
import { installTgPostLogger, installRichMarkdownGuard, withTgPostTags } from '../shared/bot-runtime.js'
|
|
@@ -635,6 +636,8 @@ import { formatUpdateStatusLine } from './update-status-line.js'
|
|
|
635
636
|
import type { HostdRequest, HostdResponse } from '../../src/host-control/protocol.js'
|
|
636
637
|
import type { AgentAudit } from '../welcome-text.js'
|
|
637
638
|
import { shouldSweepChatAtBoot } from './boot-sweep-filter.js'
|
|
639
|
+
import { createBootSweepGate, runBootPinSweepSteps } from './boot-sweep-gate.js'
|
|
640
|
+
import { createStatusPinApi, type PinCapableBot, type RobustApiSeam } from './status-pin-api.js'
|
|
638
641
|
import {
|
|
639
642
|
createDmPinSweeper,
|
|
640
643
|
collectDmChatIdsFromStores,
|
|
@@ -1438,6 +1441,9 @@ async function rawEditMessageChecklist(args: {
|
|
|
1438
1441
|
|
|
1439
1442
|
const chatLock = createChatLock()
|
|
1440
1443
|
// P0b (#2996): wrapped in initGatewayBot() once `bot` is constructed at boot.
|
|
1444
|
+
// The `!` is an ASSERTION, not a guarantee — a module-eval-time reader gets
|
|
1445
|
+
// `undefined` and tsc will not warn (#3664). Anything touching the Bot API from
|
|
1446
|
+
// module scope must run behind `bootPinSweepGate` / assert readiness first.
|
|
1441
1447
|
let lockedBot!: Bot<Context>
|
|
1442
1448
|
let botUsername = ''
|
|
1443
1449
|
|
|
@@ -8660,21 +8666,14 @@ function persistBannerRow(row: PersistedStatusPin | null): void {
|
|
|
8660
8666
|
)
|
|
8661
8667
|
}
|
|
8662
8668
|
|
|
8663
|
-
// The Bot API surface the pin driver needs. `lockedBot` is
|
|
8664
|
-
//
|
|
8669
|
+
// The Bot API surface the pin driver needs. `lockedBot` is assigned late (in
|
|
8670
|
+
// initGatewayBot) so it is read lazily; createStatusPinApi also asserts it
|
|
8671
|
+
// exists and converts a send-gate SHED into a throw — see status-pin-api.ts.
|
|
8665
8672
|
function statusPinApi(): PinBotApi {
|
|
8666
|
-
return
|
|
8667
|
-
|
|
8668
|
-
|
|
8669
|
-
|
|
8670
|
-
{ chat_id: String(chat_id), verb: 'status-pin.pin' },
|
|
8671
|
-
),
|
|
8672
|
-
unpinChatMessage: (chat_id, message_id) =>
|
|
8673
|
-
robustApiCall(
|
|
8674
|
-
() => lockedBot.api.unpinChatMessage(chat_id, message_id),
|
|
8675
|
-
{ chat_id: String(chat_id), verb: 'status-pin.unpin' },
|
|
8676
|
-
),
|
|
8677
|
-
}
|
|
8673
|
+
return createStatusPinApi(
|
|
8674
|
+
() => lockedBot as unknown as PinCapableBot | undefined,
|
|
8675
|
+
robustApiCall as RobustApiSeam,
|
|
8676
|
+
)
|
|
8678
8677
|
}
|
|
8679
8678
|
|
|
8680
8679
|
/**
|
|
@@ -9291,42 +9290,46 @@ const dmPinSweeper: DmPinSweeper = createDmPinSweeper({
|
|
|
9291
9290
|
})
|
|
9292
9291
|
|
|
9293
9292
|
/**
|
|
9294
|
-
* Boot-time pin cleanup + DM stale-pin sweep
|
|
9295
|
-
*
|
|
9296
|
-
*
|
|
9297
|
-
*
|
|
9298
|
-
*
|
|
9299
|
-
*
|
|
9293
|
+
* Boot-time pin cleanup + DM stale-pin sweep. Collects the DM chat IDs with a
|
|
9294
|
+
* prior-session pin record BEFORE the store reapers empty the stores, runs the
|
|
9295
|
+
* three boot reapers, marks the DM sweep eligible, then unpin-alls each
|
|
9296
|
+
* recorded DM chat. Ordering and per-step isolation live in
|
|
9297
|
+
* `runBootPinSweepSteps` (boot-sweep-gate.ts) — a throwing reaper must not
|
|
9298
|
+
* strand `enableDmSweep`. Dispatched only via `bootPinSweepGate` below (mutex
|
|
9299
|
+
* won AND `lockedBot` constructed); fire-and-forget — never blocks boot.
|
|
9300
9300
|
*/
|
|
9301
|
-
|
|
9302
|
-
|
|
9303
|
-
|
|
9304
|
-
|
|
9305
|
-
|
|
9306
|
-
|
|
9307
|
-
|
|
9301
|
+
function runBootPinCleanupAndDmSweep(): Promise<void> {
|
|
9302
|
+
return runBootPinSweepSteps({
|
|
9303
|
+
scanDmChatIds: () =>
|
|
9304
|
+
collectDmChatIdsFromStores({
|
|
9305
|
+
statusPins:
|
|
9306
|
+
statusPinPersistEnabled || bannerPinPersistEnabled || toolPinPersistEnabled
|
|
9307
|
+
? loadStatusPins(STATUS_PIN_STORE_PATH, statusPinStoreFs)
|
|
9308
|
+
: [],
|
|
9309
|
+
activityCards: activityCardPersistEnabled
|
|
9310
|
+
? loadActivityCards(ACTIVITY_CARD_STORE_PATH, activityCardStoreFs)
|
|
9308
9311
|
: [],
|
|
9309
|
-
|
|
9310
|
-
|
|
9311
|
-
|
|
9312
|
-
|
|
9313
|
-
|
|
9314
|
-
|
|
9315
|
-
|
|
9316
|
-
|
|
9317
|
-
|
|
9318
|
-
|
|
9319
|
-
|
|
9320
|
-
|
|
9321
|
-
|
|
9322
|
-
|
|
9323
|
-
|
|
9324
|
-
// This gateway now owns the shared per-agent pin state — enable the DM
|
|
9325
|
-
// unpin-all path (both the boot sweep below and lazy first-inbound sweeps).
|
|
9326
|
-
dmPinSweepEligible = true
|
|
9327
|
-
for (const id of dmChatIds) await dmPinSweeper.sweep(id)
|
|
9312
|
+
queuedCards: queuedCardPersistEnabled
|
|
9313
|
+
? loadQueuedCards(QUEUED_CARD_STORE_PATH, queuedCardStoreFs)
|
|
9314
|
+
: [],
|
|
9315
|
+
}),
|
|
9316
|
+
statusPinCleanup: statusPinBootCleanup,
|
|
9317
|
+
activityCardReaper: activityCardBootReaper,
|
|
9318
|
+
queuedCardReaper: queuedCardBootReaper,
|
|
9319
|
+
// This gateway owns the shared pin state — enable the DM unpin-all path
|
|
9320
|
+
// (this sweep AND lazy first-inbound sweeps).
|
|
9321
|
+
enableDmSweep: () => {
|
|
9322
|
+
dmPinSweepEligible = true
|
|
9323
|
+
},
|
|
9324
|
+
sweepDm: (id) => dmPinSweeper.sweep(id),
|
|
9325
|
+
log: (line) => process.stderr.write(line),
|
|
9326
|
+
})
|
|
9328
9327
|
}
|
|
9329
9328
|
|
|
9329
|
+
// #3664: needs BOTH the mutex (arm) and a constructed `lockedBot` (botReady,
|
|
9330
|
+
// end of initGatewayBot) before it may run — see boot-sweep-gate.ts.
|
|
9331
|
+
const bootPinSweepGate = createBootSweepGate({ run: runBootPinCleanupAndDmSweep, onError: (err) => process.stderr.write(`telegram gateway: boot pin cleanup / DM sweep failed: ${(err as Error).message}\n`) })
|
|
9332
|
+
|
|
9330
9333
|
// Activity feed. The gateway streams a live "what it's doing" tool-activity
|
|
9331
9334
|
// feed for every turn. The PreToolUse sidecar emits a `tool_label` per tool
|
|
9332
9335
|
// call (flush-independent, so it stays real-time on fast/clustered-tool
|
|
@@ -9486,14 +9489,11 @@ if (isGatewayMain) { // #2996 P0c: gated in place; guarded await never runs on
|
|
|
9486
9489
|
const carrierAgentDir = resolveAgentDirFromEnv()
|
|
9487
9490
|
if (carrierAgentDir != null) consumeSessionModelCarrierOnHealthyBoot(carrierAgentDir)
|
|
9488
9491
|
}
|
|
9489
|
-
// We WON the startup mutex —
|
|
9490
|
-
//
|
|
9491
|
-
//
|
|
9492
|
-
//
|
|
9493
|
-
|
|
9494
|
-
// #3026: sequenced so the DM stale-pin sweep runs after the reapers and
|
|
9495
|
-
// only once this gateway owns the shared state.
|
|
9496
|
-
void runBootPinCleanupAndDmSweep()
|
|
9492
|
+
// We WON the startup mutex — the sole live owner of the shared per-agent
|
|
9493
|
+
// pin store may clear a dead session's orphans (a LOSING double-boot must
|
|
9494
|
+
// not, hence arming here, not at import). #3664: arming is HALF the
|
|
9495
|
+
// precondition — the gate holds the sweep until `lockedBot` exists.
|
|
9496
|
+
bootPinSweepGate.arm()
|
|
9497
9497
|
} catch (err) {
|
|
9498
9498
|
process.stderr.write(
|
|
9499
9499
|
`telegram gateway: boot.lock_acquire_failed err=${(err as Error).message} agent=${SWITCHROOM_AGENT_NAME}\n`,
|
|
@@ -9516,8 +9516,8 @@ if (isGatewayMain) { // #2996 P0c: gated in place; guarded await never runs on
|
|
|
9516
9516
|
const carrierAgentDir = resolveAgentDirFromEnv()
|
|
9517
9517
|
if (carrierAgentDir != null) consumeSessionModelCarrierOnHealthyBoot(carrierAgentDir)
|
|
9518
9518
|
}
|
|
9519
|
-
// #3026: same sequenced cleanup + DM
|
|
9520
|
-
|
|
9519
|
+
// #3026: same sequenced cleanup + DM sweep, same #3664 bot-ready gate.
|
|
9520
|
+
bootPinSweepGate.arm()
|
|
9521
9521
|
} catch (writeErr) {
|
|
9522
9522
|
process.stderr.write(`telegram gateway: writePidFile failed: ${writeErr}\n`)
|
|
9523
9523
|
}
|
|
@@ -22959,6 +22959,13 @@ async function initGatewayBot(): Promise<void> {
|
|
|
22959
22959
|
|
|
22960
22960
|
bot = new Bot(TOKEN)
|
|
22961
22961
|
installTgPostLogger(bot); installRichMarkdownGuard(bot) // #3252/#3463: universal fmt guard installed after logger (composes outermost); see installRichMarkdownGuard docblock
|
|
22962
|
+
// #3620 flood fuse — installed LAST so it composes OUTERMOST: the one seam no
|
|
22963
|
+
// outbound call can bypass (grammY has no route to the network that skips the
|
|
22964
|
+
// transformer stack). Kill-switch SWITCHROOM_EDIT_FUSE=0; see edit-flood-fuse.ts.
|
|
22965
|
+
installEditFloodFuse(bot, {
|
|
22966
|
+
enabled: process.env.SWITCHROOM_EDIT_FUSE !== '0',
|
|
22967
|
+
onTrip: (i) => process.stderr.write(`edit-flood-fuse ${i.action} method=${i.method} key=${i.key}\n`),
|
|
22968
|
+
})
|
|
22962
22969
|
|
|
22963
22970
|
// Diagnostic update tap (#3300): one compact line per received update, logged
|
|
22964
22971
|
// BEFORE any specific handler runs, so a routing-layer drop is diagnosable
|
|
@@ -23081,6 +23088,9 @@ async function initGatewayBot(): Promise<void> {
|
|
|
23081
23088
|
|
|
23082
23089
|
// Install the grammY registration surface (P0a) — once, before the runner.
|
|
23083
23090
|
registerGatewayHandlers(bot, {})
|
|
23091
|
+
|
|
23092
|
+
// #3664: `lockedBot` exists now — release the boot pin sweep iff armed.
|
|
23093
|
+
bootPinSweepGate.botReady()
|
|
23084
23094
|
}
|
|
23085
23095
|
|
|
23086
23096
|
// One-shot startup guard. The outer for-loop below re-enters its try block
|
|
@@ -169,6 +169,9 @@ export interface InboundInterceptorDeps {
|
|
|
169
169
|
ReturnType<typeof createCallbackQueryHandlers>,
|
|
170
170
|
| 'executeDeferredSecretSave'
|
|
171
171
|
| 'performVaultAccessApproval'
|
|
172
|
+
// #3627: owns the per-passphrase-entry attempt counter + re-prompt /
|
|
173
|
+
// lockout decision after the broker refuses a mint as a mismatch.
|
|
174
|
+
| 'resolveAccessApprovalPassphraseMismatch'
|
|
172
175
|
| 'parseGrantDuration'
|
|
173
176
|
| 'grantWizardConfirm'
|
|
174
177
|
>
|
|
@@ -750,10 +753,13 @@ export async function interceptVault(
|
|
|
750
753
|
// unlocking. We captured the next message as the passphrase,
|
|
751
754
|
// cache it, delete the chat copy, and resume the approve
|
|
752
755
|
// flow for EVERY queued stage (#1051 — without the queue, a
|
|
753
|
-
// concurrent second tap orphaned the first stage).
|
|
756
|
+
// concurrent second tap orphaned the first stage). A wrong
|
|
754
757
|
// passphrase surfaces via the broker's
|
|
755
|
-
// DENIED:passphrase-mismatch path
|
|
756
|
-
//
|
|
758
|
+
// DENIED:passphrase-mismatch path; #3627 keeps those stages
|
|
759
|
+
// ALIVE and re-prompts with the attempts remaining (counted per
|
|
760
|
+
// passphrase ENTRY, since this one entry drains the whole batch)
|
|
761
|
+
// until MAX_VAULT_PASSPHRASE_ATTEMPTS, then falls back to the
|
|
762
|
+
// terminal card edit.
|
|
757
763
|
const passphrase = p.text.trim()
|
|
758
764
|
if (!passphrase) {
|
|
759
765
|
await deps.switchroomReply(p.ctx, 'Passphrase cannot be empty. Ask the agent to re-issue the request card.', { html: true })
|
|
@@ -767,6 +773,11 @@ export async function interceptVault(
|
|
|
767
773
|
// grows monotonically (item 1 mints grant for [keyA]; item 2
|
|
768
774
|
// lists, finds [keyA], unions with keyB → mints [keyA, keyB]).
|
|
769
775
|
// Parallel processing would race on the list-and-merge.
|
|
776
|
+
// #3627: stages the broker refused as a passphrase MISMATCH. They
|
|
777
|
+
// are still staged and their cards still say "waiting" — collected
|
|
778
|
+
// here so one re-prompt covers the whole batch.
|
|
779
|
+
const mismatched: typeof pendingVault.items = []
|
|
780
|
+
let mismatchMsg = ''
|
|
770
781
|
for (const item of pendingVault.items) {
|
|
771
782
|
const stagedAccess = deps.pendingVaultRequestAccesses.get(item.stageId)
|
|
772
783
|
if (!stagedAccess) {
|
|
@@ -783,7 +794,19 @@ export async function interceptVault(
|
|
|
783
794
|
.catch(() => {})
|
|
784
795
|
continue
|
|
785
796
|
}
|
|
786
|
-
await deps.callbackQueryHandlers().performVaultAccessApproval(p.ctx, stagedAccess, item.stageId, item.senderId, { kind: 'passphrase', passphrase })
|
|
797
|
+
const outcome = await deps.callbackQueryHandlers().performVaultAccessApproval(p.ctx, stagedAccess, item.stageId, item.senderId, { kind: 'passphrase', passphrase })
|
|
798
|
+
if (outcome?.kind === 'passphrase-mismatch') {
|
|
799
|
+
mismatched.push(item)
|
|
800
|
+
mismatchMsg = outcome.msg
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
if (mismatched.length > 0) {
|
|
804
|
+
await deps.callbackQueryHandlers().resolveAccessApprovalPassphraseMismatch(p.ctx, {
|
|
805
|
+
chat_id: p.chat_id,
|
|
806
|
+
failed: mismatched,
|
|
807
|
+
priorAttempts: pendingVault.attempts ?? 0,
|
|
808
|
+
brokerMsg: mismatchMsg,
|
|
809
|
+
})
|
|
787
810
|
}
|
|
788
811
|
} else if (pendingVault.kind === 'grant-wizard' && pendingVault.awaitingCustomDuration) {
|
|
789
812
|
// Issue #227: custom duration text reply for grant wizard
|
|
@@ -19,13 +19,24 @@
|
|
|
19
19
|
* record and edits the card closed.
|
|
20
20
|
*
|
|
21
21
|
* File format: a single JSON object `{ pending, delivered }`, written
|
|
22
|
-
* synchronously to avoid interleaving on concurrent auto-denies, mode 0o600
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* synchronously to avoid interleaving on concurrent auto-denies, mode 0o600,
|
|
23
|
+
* ATOMICALLY (tmp + fsync + rename) so a crash mid-persist can't leave a torn
|
|
24
|
+
* file. Mirrors the `permission-card-store.ts` pattern. Both lists are
|
|
25
|
+
* hard-capped so the file stays tiny under a runaway loop. A corrupt file is
|
|
26
|
+
* quarantined and logged loudly rather than silently read as "nothing was
|
|
27
|
+
* missed" — see store-file.ts. NOTE: atomic REPLACEMENT only —
|
|
28
|
+
* whole-old-or-whole-new, not power-loss durability; the missing
|
|
29
|
+
* parent-directory fsync is tracked in #3603.
|
|
25
30
|
*/
|
|
26
31
|
|
|
27
|
-
import {
|
|
32
|
+
import { unlinkSync } from 'node:fs'
|
|
28
33
|
import { join } from 'node:path'
|
|
34
|
+
import { atomicWriteFileSync } from '../../src/util/atomic.js'
|
|
35
|
+
import {
|
|
36
|
+
preserveUnreadableStoreFile,
|
|
37
|
+
quarantineCorruptStoreFile,
|
|
38
|
+
readStoreJsonSync,
|
|
39
|
+
} from './store-file.js'
|
|
29
40
|
|
|
30
41
|
export interface MissedApproval {
|
|
31
42
|
/** The permission request_id that timed out (dedup key). */
|
|
@@ -80,29 +91,67 @@ export interface MissedApprovalsStore {
|
|
|
80
91
|
export const MAX_PENDING = 50
|
|
81
92
|
export const MAX_DELIVERED = 20
|
|
82
93
|
|
|
83
|
-
export function createMissedApprovalsStore(
|
|
94
|
+
export function createMissedApprovalsStore(
|
|
95
|
+
stateDir: string,
|
|
96
|
+
/** Log sink — defaults to stderr (the gateway's runtime log). */
|
|
97
|
+
log: (line: string) => void = l => process.stderr.write(l),
|
|
98
|
+
): MissedApprovalsStore {
|
|
84
99
|
const filePath = join(stateDir, 'missed-approvals.json')
|
|
85
100
|
|
|
101
|
+
const EMPTY = (): FileShape => ({ pending: [], delivered: [] })
|
|
102
|
+
|
|
103
|
+
/** Set when the last read failed for a non-ENOENT reason — the next write
|
|
104
|
+
* must preserve the file it could not read instead of clobbering it. */
|
|
105
|
+
let unreadable = false
|
|
106
|
+
|
|
86
107
|
function read(): FileShape {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
108
|
+
const result = readStoreJsonSync(filePath, 'missed-approvals-store', log)
|
|
109
|
+
unreadable = result.status === 'unreadable'
|
|
110
|
+
if (result.status !== 'ok') return EMPTY()
|
|
111
|
+
const parsed = result.value as Partial<FileShape>
|
|
112
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
113
|
+
quarantineCorruptStoreFile(
|
|
114
|
+
filePath,
|
|
115
|
+
'missed-approvals-store',
|
|
116
|
+
'parsed to a non-object — not a missed-approvals file',
|
|
117
|
+
log,
|
|
118
|
+
)
|
|
119
|
+
return EMPTY()
|
|
120
|
+
}
|
|
121
|
+
// A PRESENT-but-non-array list is corruption, not "empty". Coercing it to
|
|
122
|
+
// [] would resurrect the exact bug this store was hardened against: a
|
|
123
|
+
// half-written `{"pending": null}` parses fine, so quarantine would never
|
|
124
|
+
// fire and the digest would silently come up empty. An ABSENT list is the
|
|
125
|
+
// legitimate cold-start/partial-shape case and stays silent.
|
|
126
|
+
for (const field of ['pending', 'delivered'] as const) {
|
|
127
|
+
if (parsed[field] !== undefined && !Array.isArray(parsed[field])) {
|
|
128
|
+
quarantineCorruptStoreFile(
|
|
129
|
+
filePath,
|
|
130
|
+
'missed-approvals-store',
|
|
131
|
+
`\`${field}\` is present but not an array — truncated or malformed write`,
|
|
132
|
+
log,
|
|
133
|
+
)
|
|
134
|
+
return EMPTY()
|
|
93
135
|
}
|
|
94
|
-
}
|
|
95
|
-
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
pending: parsed.pending ?? [],
|
|
139
|
+
delivered: parsed.delivered ?? [],
|
|
96
140
|
}
|
|
97
141
|
}
|
|
98
142
|
|
|
99
143
|
function write(f: FileShape): void {
|
|
100
144
|
try {
|
|
101
|
-
|
|
145
|
+
// Fail closed: never let an overwrite be what destroys state we merely
|
|
146
|
+
// failed to READ (flaky mount, transient EACCES).
|
|
147
|
+
if (unreadable) {
|
|
148
|
+
preserveUnreadableStoreFile(filePath, 'missed-approvals-store', log)
|
|
149
|
+
unreadable = false
|
|
150
|
+
}
|
|
151
|
+
// tmp + fsync + rename — never truncate the destination in place.
|
|
152
|
+
atomicWriteFileSync(filePath, JSON.stringify(f), 0o600)
|
|
102
153
|
} catch (err) {
|
|
103
|
-
|
|
104
|
-
`telegram gateway: missed-approvals-store write failed: ${(err as Error).message}\n`,
|
|
105
|
-
)
|
|
154
|
+
log(`telegram gateway: missed-approvals-store write failed: ${(err as Error).message}\n`)
|
|
106
155
|
}
|
|
107
156
|
}
|
|
108
157
|
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
import { runSilentTurnHeartbeatTick } from '../feed-heartbeat-climb.js'
|
|
50
50
|
import { NarrativeFlushController, PENDING_NARRATIVE_FLUSH_MS } from '../narrative-flush.js'
|
|
51
51
|
import { richMessage } from '../rich-send.js'
|
|
52
|
+
import { isSendGateShed } from '../send-gate.js'
|
|
52
53
|
import { appendShownBlock } from '../shown-ledger.js'
|
|
53
54
|
import {
|
|
54
55
|
appendActivityLabel, clipNarrative, formatStepSuffix, renderActivityFeedWithNested,
|
|
@@ -446,10 +447,41 @@ export function createNarrativeLane(deps: NarrativeLaneDeps) {
|
|
|
446
447
|
)
|
|
447
448
|
} else {
|
|
448
449
|
const id = turn.activityMessageId
|
|
449
|
-
|
|
450
|
+
// #3620: the live activity card is THE highest-volume repeated
|
|
451
|
+
// editMessageText on one message id in the whole gateway, and until
|
|
452
|
+
// this fix it passed NO `messageId` / `editPayload` to the send
|
|
453
|
+
// gate. `sendGate.gate` only routes to its edit path when BOTH are
|
|
454
|
+
// present (send-gate.ts `gate()`), so every card edit fell through
|
|
455
|
+
// to the plain-send path: no per-message edit floor, no
|
|
456
|
+
// last-write-wins coalescing, no no-op payload skip, and — because
|
|
457
|
+
// an untagged non-edit send defaults to `critical`
|
|
458
|
+
// (UNTAGGED_SEND_CLASS) — never shed and never budget-capped. The
|
|
459
|
+
// card was therefore free to edit one message at the per-chat
|
|
460
|
+
// bucket rate (1/s = 60/min) for as long as a turn ran, which is
|
|
461
|
+
// what earned the 2026-07-25 62-minute flood ban.
|
|
462
|
+
//
|
|
463
|
+
// Tagging it COSMETIC + keying it engages all four protections,
|
|
464
|
+
// including the long-horizon per-message edit budget that is
|
|
465
|
+
// scoped to cosmetic edits.
|
|
466
|
+
const editRes = await robustApiCall(
|
|
450
467
|
() => bot.api.editMessageText(chat, id, richMessage(html), {}),
|
|
451
|
-
{
|
|
468
|
+
{
|
|
469
|
+
chat_id: chat,
|
|
470
|
+
...(thread != null ? { threadId: thread } : {}),
|
|
471
|
+
verb: 'activity-summary.edit',
|
|
472
|
+
priorityClass: 'cosmetic',
|
|
473
|
+
messageId: id,
|
|
474
|
+
editPayload: html,
|
|
475
|
+
},
|
|
452
476
|
)
|
|
477
|
+
// Shed honesty: a shed edit did NOT land (the gate resolves the
|
|
478
|
+
// SEND_GATE_SHED sentinel, not `undefined`). Leaving
|
|
479
|
+
// `activityLastSentRender` behind keeps the newest render PENDING so
|
|
480
|
+
// the next drain (a fresh tool label, or the feed heartbeat)
|
|
481
|
+
// repaints it, and `break`ing stops the drain hot-looping against a
|
|
482
|
+
// closed gate. A no-op drop still resolves `undefined` — that
|
|
483
|
+
// payload IS on screen, so it advances normally below.
|
|
484
|
+
if (isSendGateShed(editRes)) break
|
|
453
485
|
}
|
|
454
486
|
turn.activityLastSentRender = target
|
|
455
487
|
} catch (err) {
|
|
@@ -853,7 +885,21 @@ export function createNarrativeLane(deps: NarrativeLaneDeps) {
|
|
|
853
885
|
try {
|
|
854
886
|
await robustApiCall(
|
|
855
887
|
() => bot.api.editMessageText(chat, id, richMessage(finalHtml), {}),
|
|
856
|
-
{
|
|
888
|
+
{
|
|
889
|
+
chat_id: chat,
|
|
890
|
+
...(thread != null ? { threadId: thread } : {}),
|
|
891
|
+
verb: 'activity-summary.finalize',
|
|
892
|
+
// Keyed for the same reason as the drain edit: this edits the SAME
|
|
893
|
+
// message id the drain has been repainting, so it must share that
|
|
894
|
+
// message's edit floor / coalescer / budget rather than slipping
|
|
895
|
+
// past the gate as an untagged `critical` send. `useful` (not
|
|
896
|
+
// `cosmetic`) because it paints the card's terminal state — it may
|
|
897
|
+
// be deferred, but it should not be shed at the first sign of
|
|
898
|
+
// pressure.
|
|
899
|
+
priorityClass: 'useful',
|
|
900
|
+
messageId: id,
|
|
901
|
+
editPayload: finalHtml,
|
|
902
|
+
},
|
|
857
903
|
)
|
|
858
904
|
} catch (err) {
|
|
859
905
|
// Same transport-class discipline as the delete path: the card
|
|
@@ -32,11 +32,24 @@
|
|
|
32
32
|
*
|
|
33
33
|
* File format: JSON array of PersistedApprovalCard objects. Written
|
|
34
34
|
* synchronously (mode 0o600) to avoid interleaving on concurrent card posts;
|
|
35
|
-
* production rate is a handful of cards, so the file stays tiny.
|
|
35
|
+
* production rate is a handful of cards, so the file stays tiny. The write
|
|
36
|
+
* goes through `atomicWriteFileSync` (tmp + fsync + rename) so a crash
|
|
37
|
+
* mid-persist can never leave a torn file behind — the destination holds
|
|
38
|
+
* either the whole previous array or the whole new one. A file that IS
|
|
39
|
+
* corrupt (from a pre-fix write, or anything else) is quarantined and logged
|
|
40
|
+
* loudly rather than silently read as "no pending cards" — see
|
|
41
|
+
* `store-file.ts`. NOTE: atomic REPLACEMENT only — whole-old-or-whole-new, not
|
|
42
|
+
* power-loss durability; the missing parent-directory fsync is tracked in #3603.
|
|
36
43
|
*/
|
|
37
44
|
|
|
38
|
-
import {
|
|
45
|
+
import { unlinkSync } from 'node:fs'
|
|
39
46
|
import { join } from 'node:path'
|
|
47
|
+
import { atomicWriteFileSync } from '../../src/util/atomic.js'
|
|
48
|
+
import {
|
|
49
|
+
preserveUnreadableStoreFile,
|
|
50
|
+
quarantineCorruptStoreFile,
|
|
51
|
+
readStoreJsonSync,
|
|
52
|
+
} from './store-file.js'
|
|
40
53
|
|
|
41
54
|
/** The four agent-initiated approval-card families we persist. */
|
|
42
55
|
export type ApprovalCardFamily =
|
|
@@ -111,30 +124,47 @@ export interface PendingCardStore {
|
|
|
111
124
|
clear(): void
|
|
112
125
|
}
|
|
113
126
|
|
|
114
|
-
export function createPendingCardStore(
|
|
127
|
+
export function createPendingCardStore(
|
|
128
|
+
stateDir: string,
|
|
129
|
+
/** Log sink — defaults to stderr (the gateway's runtime log). */
|
|
130
|
+
log: (line: string) => void = l => process.stderr.write(l),
|
|
131
|
+
): PendingCardStore {
|
|
115
132
|
const filePath = join(stateDir, 'pending-approval-cards.json')
|
|
116
133
|
|
|
134
|
+
/** Set when the last read failed for a non-ENOENT reason — the next write
|
|
135
|
+
* must preserve the file it could not read instead of clobbering it. */
|
|
136
|
+
let unreadable = false
|
|
137
|
+
|
|
117
138
|
function read(): PersistedApprovalCard[] {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
139
|
+
const result = readStoreJsonSync(filePath, 'pending-card-store', log)
|
|
140
|
+
unreadable = result.status === 'unreadable'
|
|
141
|
+
if (result.status !== 'ok') return []
|
|
142
|
+
if (!Array.isArray(result.value)) {
|
|
143
|
+
quarantineCorruptStoreFile(
|
|
144
|
+
filePath,
|
|
145
|
+
'pending-card-store',
|
|
146
|
+
'parsed to a non-array — not a pending-card file',
|
|
147
|
+
log,
|
|
148
|
+
)
|
|
123
149
|
return []
|
|
124
150
|
}
|
|
151
|
+
return result.value as PersistedApprovalCard[]
|
|
125
152
|
}
|
|
126
153
|
|
|
127
154
|
function write(entries: PersistedApprovalCard[]): void {
|
|
128
155
|
try {
|
|
129
|
-
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
156
|
+
// Fail closed: never let an overwrite be what destroys state we merely
|
|
157
|
+
// failed to READ (flaky mount, transient EACCES) — throws if it can't.
|
|
158
|
+
if (unreadable) {
|
|
159
|
+
preserveUnreadableStoreFile(filePath, 'pending-card-store', log)
|
|
160
|
+
unreadable = false
|
|
161
|
+
}
|
|
162
|
+
// tmp + fsync + rename, mode pinned to 0600 on the tempfile fd (so an
|
|
163
|
+
// existing file can't keep laxer perms, and a crash mid-write leaves
|
|
164
|
+
// the previous good file untouched).
|
|
165
|
+
atomicWriteFileSync(filePath, JSON.stringify(entries), 0o600)
|
|
134
166
|
} catch (err) {
|
|
135
|
-
|
|
136
|
-
`telegram gateway: pending-card-store write failed: ${(err as Error).message}\n`,
|
|
137
|
-
)
|
|
167
|
+
log(`telegram gateway: pending-card-store write failed: ${(err as Error).message}\n`)
|
|
138
168
|
}
|
|
139
169
|
}
|
|
140
170
|
|
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
* immediately. It reads as "my approval didn't stick."
|
|
9
9
|
*
|
|
10
10
|
* Fix: mirror the store to a tiny JSON file in STATE_DIR (same shape as
|
|
11
|
-
* permission-card-store.ts — synchronous writes
|
|
11
|
+
* permission-card-store.ts — synchronous ATOMIC writes (tmp + fsync + rename),
|
|
12
|
+
* mode 0o600, one small file; a corrupt file is quarantined and logged loudly
|
|
13
|
+
* rather than silently read as "no grants" — see store-file.ts). NOTE: atomic
|
|
14
|
+
* REPLACEMENT only — whole-old-or-whole-new, not power-loss durability; the
|
|
15
|
+
* missing parent-directory fsync is tracked in #3603.
|
|
12
16
|
* Write-through on every grant and on sweep-expiry removal; reload at boot,
|
|
13
17
|
* dropping entries already past their ABSOLUTE expiry.
|
|
14
18
|
*
|
|
@@ -23,8 +27,13 @@
|
|
|
23
27
|
* and never write (any pre-existing file is ignored).
|
|
24
28
|
*/
|
|
25
29
|
|
|
26
|
-
import { readFileSync, writeFileSync } from 'node:fs'
|
|
27
30
|
import { join } from 'node:path'
|
|
31
|
+
import { atomicWriteFileSync } from '../../src/util/atomic.js'
|
|
32
|
+
import {
|
|
33
|
+
preserveUnreadableStoreFile,
|
|
34
|
+
quarantineCorruptStoreFile,
|
|
35
|
+
readStoreJsonSync,
|
|
36
|
+
} from './store-file.js'
|
|
28
37
|
import {
|
|
29
38
|
serializeScopedGrants,
|
|
30
39
|
deserializeScopedGrants,
|
|
@@ -50,18 +59,31 @@ export function scopedGrantPersistEnabled(
|
|
|
50
59
|
export function createScopedGrantStore(
|
|
51
60
|
stateDir: string,
|
|
52
61
|
env: Record<string, string | undefined> = process.env,
|
|
62
|
+
/** Log sink — defaults to stderr (the gateway's runtime log). */
|
|
63
|
+
log: (line: string) => void = l => process.stderr.write(l),
|
|
53
64
|
): ScopedGrantPersistence {
|
|
54
65
|
const filePath = join(stateDir, 'scoped-grants.json')
|
|
55
66
|
const enabled = scopedGrantPersistEnabled(env)
|
|
56
67
|
|
|
68
|
+
/** Set when the last read failed for a non-ENOENT reason — the next write
|
|
69
|
+
* must preserve the file it could not read instead of clobbering it. */
|
|
70
|
+
let unreadable = false
|
|
71
|
+
|
|
57
72
|
function read(): unknown[] {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
73
|
+
const result = readStoreJsonSync(filePath, 'scoped-grant-store', log)
|
|
74
|
+
unreadable = result.status === 'unreadable'
|
|
75
|
+
if (result.status !== 'ok') return []
|
|
76
|
+
const parsed = result.value
|
|
77
|
+
if (!Array.isArray(parsed)) {
|
|
78
|
+
quarantineCorruptStoreFile(
|
|
79
|
+
filePath,
|
|
80
|
+
'scoped-grant-store',
|
|
81
|
+
'parsed to a non-array — not a scoped-grants file',
|
|
82
|
+
log,
|
|
83
|
+
)
|
|
63
84
|
return []
|
|
64
85
|
}
|
|
86
|
+
return parsed
|
|
65
87
|
}
|
|
66
88
|
|
|
67
89
|
return {
|
|
@@ -75,14 +97,17 @@ export function createScopedGrantStore(
|
|
|
75
97
|
save(store) {
|
|
76
98
|
if (!enabled) return
|
|
77
99
|
try {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
100
|
+
// Fail closed: never let an overwrite be what destroys grants we
|
|
101
|
+
// merely failed to READ (flaky mount, transient EACCES).
|
|
102
|
+
if (unreadable) {
|
|
103
|
+
preserveUnreadableStoreFile(filePath, 'scoped-grant-store', log)
|
|
104
|
+
unreadable = false
|
|
105
|
+
}
|
|
106
|
+
// tmp + fsync + rename — a crash mid-persist leaves the previous
|
|
107
|
+
// grant set intact rather than a torn file that reads as "no grants".
|
|
108
|
+
atomicWriteFileSync(filePath, JSON.stringify(serializeScopedGrants(store)), 0o600)
|
|
82
109
|
} catch (err) {
|
|
83
|
-
|
|
84
|
-
`telegram gateway: scoped-grant-store write failed: ${(err as Error).message}\n`,
|
|
85
|
-
)
|
|
110
|
+
log(`telegram gateway: scoped-grant-store write failed: ${(err as Error).message}\n`)
|
|
86
111
|
}
|
|
87
112
|
},
|
|
88
113
|
}
|