switchroom 0.19.19 → 0.19.23
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 +121 -1
- package/dist/cli/notion-write-pretool.mjs +2 -1
- package/dist/cli/switchroom.js +2995 -1583
- package/dist/host-control/main.js +122 -2
- package/dist/vault/approvals/kernel-server.js +124 -4
- package/dist/vault/broker/server.js +124 -4
- package/package.json +7 -4
- package/profiles/_base/start.sh.hbs +101 -0
- package/profiles/_shared/agent-self-service.md.hbs +64 -109
- package/profiles/_shared/delegation-golden-rule.md.hbs +5 -5
- package/profiles/_shared/dev-protocol.md.hbs +13 -42
- package/profiles/_shared/execution-discipline.md.hbs +7 -14
- package/profiles/coding/CLAUDE.md.hbs +0 -6
- package/profiles/default/CLAUDE.md.hbs +21 -50
- package/skills/dev-protocol/SKILL.md +90 -107
- package/skills/switchroom-release/SKILL.md +103 -20
- package/telegram-plugin/bunfig.toml +10 -0
- package/telegram-plugin/card-format.ts +92 -3
- package/telegram-plugin/dist/gateway/gateway.js +873 -184
- package/telegram-plugin/edit-flood-fuse.ts +477 -0
- package/telegram-plugin/format.ts +19 -7
- package/telegram-plugin/gateway/backstop-delivery.ts +97 -16
- package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
- package/telegram-plugin/gateway/captured-answer-resume.ts +46 -17
- package/telegram-plugin/gateway/gateway.ts +75 -63
- package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
- package/telegram-plugin/gateway/narrative-lane.ts +49 -3
- package/telegram-plugin/gateway/outbound-send-path.ts +8 -1
- package/telegram-plugin/gateway/status-pin-api.ts +145 -0
- package/telegram-plugin/gateway/stream-render.ts +6 -0
- package/telegram-plugin/gateway/turn-record-status.ts +19 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +65 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
- 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/agent-state-dir-preload.test.ts +33 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +204 -7
- package/telegram-plugin/tests/backstop-readback-probe.test.ts +12 -0
- 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/captured-answer-resume.test.ts +104 -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/subagent-tracker-hooks.test.ts +631 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +19 -10
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +92 -1
- 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/tool-activity-summary.ts +85 -13
- package/telegram-plugin/worker-activity-feed.ts +5 -1
- package/vendor/hindsight-memory/scripts/drain_pending.py +304 -34
- package/vendor/hindsight-memory/scripts/lib/pending.py +886 -70
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +71 -13
- package/vendor/hindsight-memory/scripts/recall.py +74 -5
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +1602 -14
- 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 +93 -13
- package/vendor/hindsight-memory/tests/test_drain_pending.py +44 -3
- package/vendor/hindsight-memory/tests/test_pending.py +12 -4
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
*
|
|
15
15
|
* ── The fix (design-of-record §2.2) ─────────────────────────────────────────
|
|
16
16
|
* Make the re-present a *byte-identical captured-answer resume* of ONLY the
|
|
17
|
-
* not-yet
|
|
18
|
-
*
|
|
17
|
+
* not-yet-LANDED tail — never a regeneration — so a chunk that already reached
|
|
18
|
+
* the chat is provably never re-posted:
|
|
19
19
|
*
|
|
20
20
|
* 1. On a partial (`!delivered`) turn-flush, persist a {@link
|
|
21
21
|
* CapturedDeliverySnapshot} (the split chunks + each landed chunk's ids and
|
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
* 2. The obligation sweep's `represent` branch becomes source-aware: when the
|
|
26
26
|
* obligation carries a captured-delivery snapshot it drives THIS resume
|
|
27
27
|
* (re-run `runBackstopDelivery` for the SAME `turnId` and the SAME captured
|
|
28
|
-
* chunks, resuming at the first
|
|
29
|
-
*
|
|
30
|
-
*
|
|
28
|
+
* chunks, resuming at the first UNSENT index) instead of pushing a
|
|
29
|
+
* fresh-generation inbound. No snapshot ⇒ the genuine "model wrote nothing
|
|
30
|
+
* / never fired a backstop" case falls through to fresh generation
|
|
31
31
|
* unchanged.
|
|
32
32
|
* 3. The in-memory ledger is rehydrated from the snapshot AND reconciled against
|
|
33
33
|
* the durable outbound-text oracle (`hasOutboundWithText`), so a chunk that
|
|
@@ -39,9 +39,29 @@
|
|
|
39
39
|
* chunks (not a regenerated answer). #3278's per-chunk state machine
|
|
40
40
|
* (`unsent → pending → landed-unconfirmed → {landed-confirmed | unsent}`) binds
|
|
41
41
|
* here too: the resume re-PROBES `landed-unconfirmed` chunks (never re-sends
|
|
42
|
-
* them) and re-SENDS only `unsent` ones, so a
|
|
42
|
+
* them) and re-SENDS only `unsent` ones, so a landed chunk is never duplicated
|
|
43
43
|
* by either the in-fire retry OR the cross-represent resume.
|
|
44
44
|
*
|
|
45
|
+
* ── The CLOSE condition is LANDED, not confirmed (#3702) ────────────────────
|
|
46
|
+
* `runBackstopDelivery`'s verdict is evidence-based: every chunk landed a
|
|
47
|
+
* message id and at least one is a fresh non-card id. An inconclusive read-back
|
|
48
|
+
* does not lower it (only a POSITIVE `absent`, which demotes the chunk back to
|
|
49
|
+
* `unsent`, does). So a resume over a snapshot whose chunks ALL landed but were
|
|
50
|
+
* never corroborated closes the obligation having sent NOTHING — the re-probe
|
|
51
|
+
* ran, resolved nothing, and the landed-id evidence stood.
|
|
52
|
+
*
|
|
53
|
+
* That is deliberate, not an oversight. The alternative — keep the obligation
|
|
54
|
+
* OPEN because nothing corroborated the ids — has no corrective action
|
|
55
|
+
* available: the resume never re-sends a LANDED chunk, so every subsequent
|
|
56
|
+
* represent would re-run the same shed probe, send zero messages, and burn
|
|
57
|
+
* represent budget until the cap escalates a false "the agent never answered
|
|
58
|
+
* you" nudge to the operator. Representing cannot deliver anything the first
|
|
59
|
+
* delivery did not; the only thing it can produce is noise. When the durable
|
|
60
|
+
* outbound-text oracle CAN corroborate (history enabled + the row present) it
|
|
61
|
+
* confirms the chunk at hydration and the same close happens by the older
|
|
62
|
+
* `hasOutboundWithText` route — this path is the same decision when that oracle
|
|
63
|
+
* is unavailable.
|
|
64
|
+
*
|
|
45
65
|
* This module is PURE state + orchestration over injected effects — no Telegram,
|
|
46
66
|
* no SQLite, no gateway module state — so the resume decision is unit-testable
|
|
47
67
|
* (see tests/captured-answer-resume.test.ts). The gateway owns the real
|
|
@@ -55,10 +75,10 @@ import type { CapturedDeliverySnapshot, Obligation } from './obligation-ledger.j
|
|
|
55
75
|
/**
|
|
56
76
|
* Build the durable {@link CapturedDeliverySnapshot} from the live per-chunk
|
|
57
77
|
* ledger after a partial `runBackstopDelivery`. Captures every LANDED chunk's
|
|
58
|
-
* message ids + confirmed flag so the resume can (a) never re-send a
|
|
59
|
-
*
|
|
60
|
-
* it. Chunks that never landed (send threw) are simply absent → the
|
|
61
|
-
* them as `unsent` and re-sends them. Pure.
|
|
78
|
+
* message ids + confirmed flag so the resume can (a) never re-send a chunk that
|
|
79
|
+
* already landed and (b) re-PROBE a landed-unconfirmed chunk instead of blindly
|
|
80
|
+
* re-sending it. Chunks that never landed (send threw) are simply absent → the
|
|
81
|
+
* resume treats them as `unsent` and re-sends them. Pure.
|
|
62
82
|
*/
|
|
63
83
|
export function buildCapturedDeliverySnapshot(
|
|
64
84
|
ledger: BackstopDeliveryLedger,
|
|
@@ -165,9 +185,13 @@ export interface CapturedResumeDispatcher {
|
|
|
165
185
|
* The obligation-sweep's captured-answer resume driver. Owns the in-flight guard
|
|
166
186
|
* + the deliver→close/leave-open orchestration so the gateway sweep stays a
|
|
167
187
|
* two-line dispatch. On each dispatch it consumes one represent-budget unit
|
|
168
|
-
* (bounding the ladder → escalate), re-delivers only the
|
|
188
|
+
* (bounding the ladder → escalate), re-delivers only the UNSENT tail (a landed
|
|
189
|
+
* chunk is re-probed, never re-sent), and:
|
|
169
190
|
* - fully delivered ⇒ record the supersede tail, close the obligation, GC the
|
|
170
|
-
* ledger (the represent ladder stops)
|
|
191
|
+
* ledger (the represent ladder stops). Since #3702 "delivered" means every
|
|
192
|
+
* chunk LANDED — so a snapshot that is already fully landed closes here
|
|
193
|
+
* having sent nothing (see the module header for why that is the right
|
|
194
|
+
* call, and why leaving it OPEN would only manufacture a false escalation);
|
|
171
195
|
* - still partial / error ⇒ leave the obligation OPEN so the next eligible
|
|
172
196
|
* sweep (after the per-represent grace) retries, until the represent cap
|
|
173
197
|
* escalates it to the operator nudge — no infinite loop.
|
|
@@ -176,7 +200,7 @@ export function createCapturedResumeDispatcher(ports: CapturedResumePorts): Capt
|
|
|
176
200
|
const stderr = ports.stderr ?? (() => {})
|
|
177
201
|
const inFlight = new Set<string>()
|
|
178
202
|
|
|
179
|
-
/** Re-deliver the
|
|
203
|
+
/** Re-deliver the UNSENT tail of `o`'s captured answer, byte-identical,
|
|
180
204
|
* by rehydrating the per-chunk ledger from the snapshot reconciled against the
|
|
181
205
|
* durable text oracle. Never regenerates. */
|
|
182
206
|
function deliver(o: Obligation, snapshot: CapturedDeliverySnapshot): Promise<{ delivered: boolean; sentIds: number[] }> {
|
|
@@ -221,16 +245,21 @@ export function createCapturedResumeDispatcher(ports: CapturedResumePorts): Capt
|
|
|
221
245
|
if (delivered) {
|
|
222
246
|
ports.obligationLedger.close(o.originTurnId)
|
|
223
247
|
ports.backstopDeliveryLedger.clear(o.originTurnId)
|
|
248
|
+
// `sentIds` is `ledger.sentIds()` — every LANDED id, which since #3702
|
|
249
|
+
// routinely includes ids no read-back corroborated. Say "landed", not
|
|
250
|
+
// "confirmed": claiming confirmation for an uncorroborated id is exactly
|
|
251
|
+
// the overstatement this PR removed from the delivery verdict.
|
|
224
252
|
stderr(
|
|
225
253
|
`telegram gateway: captured-answer resume delivered — origin=${o.originTurnId} ` +
|
|
226
|
-
`${sentIds.length}
|
|
254
|
+
`${sentIds.length} message id(s) landed; obligation closed\n`,
|
|
227
255
|
)
|
|
228
256
|
} else {
|
|
229
|
-
//
|
|
230
|
-
//
|
|
257
|
+
// Not fully landed (a chunk never got an id, or a positive `absent`
|
|
258
|
+
// demoted one) — leave the obligation OPEN for the next paced sweep /
|
|
259
|
+
// escalation (bounded by the represent cap).
|
|
231
260
|
stderr(
|
|
232
261
|
`telegram gateway: captured-answer resume partial — origin=${o.originTurnId} ` +
|
|
233
|
-
`tail still not
|
|
262
|
+
`tail still not landed; left OPEN for retry\n`,
|
|
234
263
|
)
|
|
235
264
|
}
|
|
236
265
|
} catch (err) {
|
|
@@ -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,
|
|
@@ -715,7 +718,7 @@ import {
|
|
|
715
718
|
import { CurrentTurnMap } from './current-turn-map.js'
|
|
716
719
|
import { resolveAnswerThreadId } from './answer-thread-resolve.js'
|
|
717
720
|
import { decideObligationTurnEnd } from './obligation-turn-end.js'
|
|
718
|
-
import { maybeRotate } from './turns-jsonl-rotate.js'
|
|
721
|
+
import { maybeRotate, resolveAgentStateDir, resolveTurnsJsonlPath } from './turns-jsonl-rotate.js'
|
|
719
722
|
import {
|
|
720
723
|
buildTurnRecord,
|
|
721
724
|
finalizeBackstopSendGated,
|
|
@@ -1029,7 +1032,7 @@ const blockedApprovalStore = createBlockedApprovalStore(
|
|
|
1029
1032
|
// Fallback: the agent's OWN state dir, which the scaffold chowns to the agent
|
|
1030
1033
|
// uid, so a write there always succeeds. Guarantees the record can never be
|
|
1031
1034
|
// silently lost when the shared dir isn't writable by this agent's uid.
|
|
1032
|
-
|
|
1035
|
+
resolveAgentStateDir(),
|
|
1033
1036
|
)
|
|
1034
1037
|
|
|
1035
1038
|
/**
|
|
@@ -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
|
|
|
@@ -2450,9 +2456,9 @@ async function deliverAnswer(args: {
|
|
|
2450
2456
|
* the send alive if the user deleted their message. Null for synthesized
|
|
2451
2457
|
* turns (cron/handback) — those send bare, as before. */
|
|
2452
2458
|
replyToMessageId: number | null
|
|
2453
|
-
/** #3282 captured-answer RESUME (see captured-answer-resume.ts): re-deliver the SAME byte-identical chunks + pre-hydrate the ledger (
|
|
2459
|
+
/** #3282 captured-answer RESUME (see captured-answer-resume.ts): re-deliver the SAME byte-identical chunks + pre-hydrate the ledger (unsent tail only; a landed chunk is re-probed, never re-sent). */
|
|
2454
2460
|
resume?: { snapshot: CapturedDeliverySnapshot; hydrate: (ledger: BackstopDeliveryLedger, turnId: string) => void }
|
|
2455
|
-
}): Promise<{ sentIds: number[]; chunkCount: number; delivered: boolean; exhausted: boolean }> {
|
|
2461
|
+
}): Promise<{ sentIds: number[]; chunkCount: number; delivered: boolean; exhausted: boolean; landedUnconfirmed: number }> {
|
|
2456
2462
|
const { chatId, turnId } = args
|
|
2457
2463
|
// Spacers into `\n\n` gaps then split (as executeReply); a resume re-delivers the EXACT captured chunks (byte-stable, no re-split).
|
|
2458
2464
|
const chunks = args.resume
|
|
@@ -2588,6 +2594,7 @@ async function deliverAnswer(args: {
|
|
|
2588
2594
|
chunkCount: result.chunkCount,
|
|
2589
2595
|
delivered: result.delivered,
|
|
2590
2596
|
exhausted: result.exhausted,
|
|
2597
|
+
landedUnconfirmed: result.landedUnconfirmedIds.length, // #3702 — the caller stamps this on the turn record
|
|
2591
2598
|
}
|
|
2592
2599
|
}
|
|
2593
2600
|
|
|
@@ -3421,6 +3428,7 @@ export type CurrentTurn = {
|
|
|
3421
3428
|
// turn-end paths (reply-tool tail, silent-marker, genuine no-reply), where
|
|
3422
3429
|
// the legacy `finalAnswerDelivered` reading still applies unchanged.
|
|
3423
3430
|
deliveryOutcome?: DeliveryOutcome
|
|
3431
|
+
landedUnconfirmed?: number // #3702 — landed ids no read-back corroborated; emitted as `landed_unconfirmed` (rationale: turn-record-status.ts)
|
|
3424
3432
|
// Feed-reopen-after-ack refinement — whether the reply that set
|
|
3425
3433
|
// `finalAnswerDelivered` was a *substantive* final answer (stream
|
|
3426
3434
|
// `done`, or ≥200 chars) as opposed to a short pinging interim ACK.
|
|
@@ -5006,7 +5014,7 @@ function snapshotContextOccupancy(): void {
|
|
|
5006
5014
|
} catch {
|
|
5007
5015
|
cap = null; // config unreadable → show occupancy without a ratio
|
|
5008
5016
|
}
|
|
5009
|
-
const stateDir =
|
|
5017
|
+
const stateDir = resolveAgentStateDir();
|
|
5010
5018
|
writeContextOccupancySnapshot(
|
|
5011
5019
|
stateDir,
|
|
5012
5020
|
buildContextOccupancy(occupancy, cap, Date.now()),
|
|
@@ -5027,12 +5035,12 @@ function emitTurnRecord(turn: CurrentTurn, endedAt: number): void {
|
|
|
5027
5035
|
toolCallCount: turn.toolCallCount ?? 0,
|
|
5028
5036
|
turnId: turn.turnId,
|
|
5029
5037
|
finalAnswerDelivered: turn.finalAnswerDelivered,
|
|
5030
|
-
deliveryOutcome: turn.deliveryOutcome,
|
|
5038
|
+
deliveryOutcome: turn.deliveryOutcome, landedUnconfirmed: turn.landedUnconfirmed,
|
|
5031
5039
|
},
|
|
5032
5040
|
endedAt,
|
|
5033
5041
|
),
|
|
5034
5042
|
) + '\n'
|
|
5035
|
-
const turnsPath =
|
|
5043
|
+
const turnsPath = resolveTurnsJsonlPath() // honours SWITCHROOM_AGENT_STATE_DIR
|
|
5036
5044
|
// Size-cap rotation: keep at most one rotated generation so the file can't
|
|
5037
5045
|
// grow unbounded on a long-lived agent. Best-effort (never throws).
|
|
5038
5046
|
maybeRotate(turnsPath, {
|
|
@@ -8660,21 +8668,14 @@ function persistBannerRow(row: PersistedStatusPin | null): void {
|
|
|
8660
8668
|
)
|
|
8661
8669
|
}
|
|
8662
8670
|
|
|
8663
|
-
// The Bot API surface the pin driver needs. `lockedBot` is
|
|
8664
|
-
//
|
|
8671
|
+
// The Bot API surface the pin driver needs. `lockedBot` is assigned late (in
|
|
8672
|
+
// initGatewayBot) so it is read lazily; createStatusPinApi also asserts it
|
|
8673
|
+
// exists and converts a send-gate SHED into a throw — see status-pin-api.ts.
|
|
8665
8674
|
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
|
-
}
|
|
8675
|
+
return createStatusPinApi(
|
|
8676
|
+
() => lockedBot as unknown as PinCapableBot | undefined,
|
|
8677
|
+
robustApiCall as RobustApiSeam,
|
|
8678
|
+
)
|
|
8678
8679
|
}
|
|
8679
8680
|
|
|
8680
8681
|
/**
|
|
@@ -9291,42 +9292,46 @@ const dmPinSweeper: DmPinSweeper = createDmPinSweeper({
|
|
|
9291
9292
|
})
|
|
9292
9293
|
|
|
9293
9294
|
/**
|
|
9294
|
-
* Boot-time pin cleanup + DM stale-pin sweep
|
|
9295
|
-
*
|
|
9296
|
-
*
|
|
9297
|
-
*
|
|
9298
|
-
*
|
|
9299
|
-
*
|
|
9295
|
+
* Boot-time pin cleanup + DM stale-pin sweep. Collects the DM chat IDs with a
|
|
9296
|
+
* prior-session pin record BEFORE the store reapers empty the stores, runs the
|
|
9297
|
+
* three boot reapers, marks the DM sweep eligible, then unpin-alls each
|
|
9298
|
+
* recorded DM chat. Ordering and per-step isolation live in
|
|
9299
|
+
* `runBootPinSweepSteps` (boot-sweep-gate.ts) — a throwing reaper must not
|
|
9300
|
+
* strand `enableDmSweep`. Dispatched only via `bootPinSweepGate` below (mutex
|
|
9301
|
+
* won AND `lockedBot` constructed); fire-and-forget — never blocks boot.
|
|
9300
9302
|
*/
|
|
9301
|
-
|
|
9302
|
-
|
|
9303
|
-
|
|
9304
|
-
|
|
9305
|
-
|
|
9306
|
-
|
|
9307
|
-
|
|
9303
|
+
function runBootPinCleanupAndDmSweep(): Promise<void> {
|
|
9304
|
+
return runBootPinSweepSteps({
|
|
9305
|
+
scanDmChatIds: () =>
|
|
9306
|
+
collectDmChatIdsFromStores({
|
|
9307
|
+
statusPins:
|
|
9308
|
+
statusPinPersistEnabled || bannerPinPersistEnabled || toolPinPersistEnabled
|
|
9309
|
+
? loadStatusPins(STATUS_PIN_STORE_PATH, statusPinStoreFs)
|
|
9310
|
+
: [],
|
|
9311
|
+
activityCards: activityCardPersistEnabled
|
|
9312
|
+
? loadActivityCards(ACTIVITY_CARD_STORE_PATH, activityCardStoreFs)
|
|
9308
9313
|
: [],
|
|
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)
|
|
9314
|
+
queuedCards: queuedCardPersistEnabled
|
|
9315
|
+
? loadQueuedCards(QUEUED_CARD_STORE_PATH, queuedCardStoreFs)
|
|
9316
|
+
: [],
|
|
9317
|
+
}),
|
|
9318
|
+
statusPinCleanup: statusPinBootCleanup,
|
|
9319
|
+
activityCardReaper: activityCardBootReaper,
|
|
9320
|
+
queuedCardReaper: queuedCardBootReaper,
|
|
9321
|
+
// This gateway owns the shared pin state — enable the DM unpin-all path
|
|
9322
|
+
// (this sweep AND lazy first-inbound sweeps).
|
|
9323
|
+
enableDmSweep: () => {
|
|
9324
|
+
dmPinSweepEligible = true
|
|
9325
|
+
},
|
|
9326
|
+
sweepDm: (id) => dmPinSweeper.sweep(id),
|
|
9327
|
+
log: (line) => process.stderr.write(line),
|
|
9328
|
+
})
|
|
9328
9329
|
}
|
|
9329
9330
|
|
|
9331
|
+
// #3664: needs BOTH the mutex (arm) and a constructed `lockedBot` (botReady,
|
|
9332
|
+
// end of initGatewayBot) before it may run — see boot-sweep-gate.ts.
|
|
9333
|
+
const bootPinSweepGate = createBootSweepGate({ run: runBootPinCleanupAndDmSweep, onError: (err) => process.stderr.write(`telegram gateway: boot pin cleanup / DM sweep failed: ${(err as Error).message}\n`) })
|
|
9334
|
+
|
|
9330
9335
|
// Activity feed. The gateway streams a live "what it's doing" tool-activity
|
|
9331
9336
|
// feed for every turn. The PreToolUse sidecar emits a `tool_label` per tool
|
|
9332
9337
|
// call (flush-independent, so it stays real-time on fast/clustered-tool
|
|
@@ -9486,14 +9491,11 @@ if (isGatewayMain) { // #2996 P0c: gated in place; guarded await never runs on
|
|
|
9486
9491
|
const carrierAgentDir = resolveAgentDirFromEnv()
|
|
9487
9492
|
if (carrierAgentDir != null) consumeSessionModelCarrierOnHealthyBoot(carrierAgentDir)
|
|
9488
9493
|
}
|
|
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()
|
|
9494
|
+
// We WON the startup mutex — the sole live owner of the shared per-agent
|
|
9495
|
+
// pin store may clear a dead session's orphans (a LOSING double-boot must
|
|
9496
|
+
// not, hence arming here, not at import). #3664: arming is HALF the
|
|
9497
|
+
// precondition — the gate holds the sweep until `lockedBot` exists.
|
|
9498
|
+
bootPinSweepGate.arm()
|
|
9497
9499
|
} catch (err) {
|
|
9498
9500
|
process.stderr.write(
|
|
9499
9501
|
`telegram gateway: boot.lock_acquire_failed err=${(err as Error).message} agent=${SWITCHROOM_AGENT_NAME}\n`,
|
|
@@ -9516,8 +9518,8 @@ if (isGatewayMain) { // #2996 P0c: gated in place; guarded await never runs on
|
|
|
9516
9518
|
const carrierAgentDir = resolveAgentDirFromEnv()
|
|
9517
9519
|
if (carrierAgentDir != null) consumeSessionModelCarrierOnHealthyBoot(carrierAgentDir)
|
|
9518
9520
|
}
|
|
9519
|
-
// #3026: same sequenced cleanup + DM
|
|
9520
|
-
|
|
9521
|
+
// #3026: same sequenced cleanup + DM sweep, same #3664 bot-ready gate.
|
|
9522
|
+
bootPinSweepGate.arm()
|
|
9521
9523
|
} catch (writeErr) {
|
|
9522
9524
|
process.stderr.write(`telegram gateway: writePidFile failed: ${writeErr}\n`)
|
|
9523
9525
|
}
|
|
@@ -22959,6 +22961,13 @@ async function initGatewayBot(): Promise<void> {
|
|
|
22959
22961
|
|
|
22960
22962
|
bot = new Bot(TOKEN)
|
|
22961
22963
|
installTgPostLogger(bot); installRichMarkdownGuard(bot) // #3252/#3463: universal fmt guard installed after logger (composes outermost); see installRichMarkdownGuard docblock
|
|
22964
|
+
// #3620 flood fuse — installed LAST so it composes OUTERMOST: the one seam no
|
|
22965
|
+
// outbound call can bypass (grammY has no route to the network that skips the
|
|
22966
|
+
// transformer stack). Kill-switch SWITCHROOM_EDIT_FUSE=0; see edit-flood-fuse.ts.
|
|
22967
|
+
installEditFloodFuse(bot, {
|
|
22968
|
+
enabled: process.env.SWITCHROOM_EDIT_FUSE !== '0',
|
|
22969
|
+
onTrip: (i) => process.stderr.write(`edit-flood-fuse ${i.action} method=${i.method} key=${i.key}\n`),
|
|
22970
|
+
})
|
|
22962
22971
|
|
|
22963
22972
|
// Diagnostic update tap (#3300): one compact line per received update, logged
|
|
22964
22973
|
// BEFORE any specific handler runs, so a routing-layer drop is diagnosable
|
|
@@ -23081,6 +23090,9 @@ async function initGatewayBot(): Promise<void> {
|
|
|
23081
23090
|
|
|
23082
23091
|
// Install the grammY registration surface (P0a) — once, before the runner.
|
|
23083
23092
|
registerGatewayHandlers(bot, {})
|
|
23093
|
+
|
|
23094
|
+
// #3664: `lockedBot` exists now — release the boot pin sweep iff armed.
|
|
23095
|
+
bootPinSweepGate.botReady()
|
|
23084
23096
|
}
|
|
23085
23097
|
|
|
23086
23098
|
// 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
|
|
@@ -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
|
|
@@ -611,7 +611,14 @@ export function createBackstopReadBack(
|
|
|
611
611
|
}
|
|
612
612
|
try {
|
|
613
613
|
const r = await w.gate(() => w.editMessageText(messageId, body, editApiOpts), gateOpts)
|
|
614
|
-
|
|
614
|
+
// A shed resolves the SEND_GATE_SHED sentinel; a gate no-op drop (the
|
|
615
|
+
// identical payload is already the last one sent for this message id) or
|
|
616
|
+
// an expired queue entry resolves `undefined`. In BOTH cases the edit
|
|
617
|
+
// never reached Telegram, so there is no evidence of existence —
|
|
618
|
+
// `ambiguous`, never a fabricated `exists`. Only a real API result
|
|
619
|
+
// (grammy resolves `true` or the edited Message) proves presence.
|
|
620
|
+
if (w.isShed(r) || r === undefined) return 'ambiguous'
|
|
621
|
+
return 'exists'
|
|
615
622
|
} catch (err) {
|
|
616
623
|
return classifyReadBackError(err)
|
|
617
624
|
}
|