switchroom 0.19.38 → 0.19.40
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 +10 -1
- package/dist/auth-broker/index.js +15 -6
- package/dist/cli/notion-write-pretool.mjs +10 -1
- package/dist/cli/switchroom.js +1141 -566
- package/dist/host-control/main.js +220 -8
- package/dist/vault/approvals/kernel-server.js +15 -6
- package/dist/vault/broker/server.js +15 -6
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +10 -0
- package/telegram-plugin/dist/bridge/bridge.js +8 -0
- package/telegram-plugin/dist/gateway/gateway.js +450 -206
- package/telegram-plugin/dist/server.js +8 -0
- package/telegram-plugin/gateway/backstop-delivery.ts +48 -0
- package/telegram-plugin/gateway/compaction-marker.ts +84 -0
- package/telegram-plugin/gateway/gateway.ts +3 -3
- package/telegram-plugin/gateway/handback-preturn-signal.ts +16 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +15 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +20 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +116 -18
- package/telegram-plugin/gateway/silence-poke-session-event.ts +13 -0
- package/telegram-plugin/gateway/stream-render.ts +277 -18
- package/telegram-plugin/hooks/compaction-marker-precompact.mjs +70 -0
- package/telegram-plugin/hooks/hooks.json +11 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +119 -1
- package/telegram-plugin/session-tail.ts +20 -0
- package/telegram-plugin/silence-poke.ts +28 -0
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +19 -12
- package/telegram-plugin/tests/fixtures/pretool-main-2.1.219.json +15 -0
- package/telegram-plugin/tests/fixtures/pretool-subagent-2.1.219.json +16 -0
- package/telegram-plugin/tests/outbox-delivery.test.ts +38 -1
- package/telegram-plugin/tests/outbox-flush-ack-claim-race.test.ts +213 -0
- package/telegram-plugin/tests/outbox-reply-then-recap-e2e.test.ts +1 -1
- package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +4 -4
- package/telegram-plugin/tests/outbox-sweep-listen-button.test.ts +71 -8
- package/telegram-plugin/tests/queued-card-surface.test.ts +262 -0
- package/telegram-plugin/tests/send-reply-golden.test.ts +47 -0
- package/telegram-plugin/tests/sidechain-label-filter-pretool.test.ts +272 -0
- package/telegram-plugin/tests/silence-poke-compaction.test.ts +222 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +7 -4
- package/vendor/hindsight-memory/scripts/lib/config.py +151 -0
- package/vendor/hindsight-memory/scripts/retain.py +19 -15
- package/vendor/hindsight-memory/scripts/tests/test_backfill.py +14 -1
- package/vendor/hindsight-memory/scripts/tests/test_observation_scopes.py +192 -3
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +18 -2
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +57 -4
|
@@ -17627,6 +17627,14 @@ function projectTranscriptLine(line) {
|
|
|
17627
17627
|
{ kind: "turn_end", durationMs: obj.durationMs ?? 0 }
|
|
17628
17628
|
];
|
|
17629
17629
|
}
|
|
17630
|
+
if (type === "system" && obj.subtype === "compact_boundary") {
|
|
17631
|
+
const meta = obj.compactMetadata;
|
|
17632
|
+
return [{
|
|
17633
|
+
kind: "compact_boundary",
|
|
17634
|
+
trigger: typeof meta?.trigger === "string" ? meta.trigger : null,
|
|
17635
|
+
compactDurationMs: typeof meta?.durationMs === "number" ? meta.durationMs : null
|
|
17636
|
+
}];
|
|
17637
|
+
}
|
|
17630
17638
|
return [];
|
|
17631
17639
|
}
|
|
17632
17640
|
function projectSubagentLine(line, agentId, state) {
|
|
@@ -322,6 +322,28 @@ export interface BackstopDeliveryDeps {
|
|
|
322
322
|
messageIds: readonly number[],
|
|
323
323
|
text: string,
|
|
324
324
|
) => Promise<ReadBackResult>
|
|
325
|
+
/**
|
|
326
|
+
* Exactly-once SEND-ACK claim (duplicate-message race fix). Fired EXACTLY ONCE,
|
|
327
|
+
* the first moment every chunk has landed a fresh non-card receipt — i.e. the
|
|
328
|
+
* same condition that makes {@link BackstopDeliveryResult.delivered} true — and
|
|
329
|
+
* crucially BEFORE the read-back probe (step 2 below) is awaited.
|
|
330
|
+
*
|
|
331
|
+
* Why before the probe: the probe is issued at COSMETIC priority and, when the
|
|
332
|
+
* edit-flood-fuse is deferring cosmetic edits, `await deps.readBack` can block
|
|
333
|
+
* ~30s. The caller's durable exactly-once claim (`journalExternalDelivery`)
|
|
334
|
+
* used to run only in its post-`await deliverAnswer` bookkeeping, i.e. AFTER
|
|
335
|
+
* that probe resolved. The out-of-band outbox sweep only waits `OUTBOX_QUIET_MS`
|
|
336
|
+
* (5s) before checking the delivered-keys journal, so during that 5–30s gap it
|
|
337
|
+
* saw no journal entry and sent a SECOND copy of the same answer. Claiming here,
|
|
338
|
+
* at send-ack, closes that window deterministically regardless of probe timing.
|
|
339
|
+
*
|
|
340
|
+
* A later `absent` read-back (positive silent-drop) is recovered by THIS
|
|
341
|
+
* orchestrator's own in-process re-send below — the nonce is NEVER reopened to
|
|
342
|
+
* the sweep, so a possibly-delivered answer is never handed to a second sender.
|
|
343
|
+
* `sentIds` is the landed fresh-receipt set at claim time. Best-effort; a throw
|
|
344
|
+
* is swallowed and never demotes the delivery.
|
|
345
|
+
*/
|
|
346
|
+
onAckClaim?: (sentIds: number[]) => void
|
|
325
347
|
/** Optional stderr sink for progress/resume logging. */
|
|
326
348
|
stderr?: (s: string) => void
|
|
327
349
|
}
|
|
@@ -406,6 +428,8 @@ export async function runBackstopDelivery(
|
|
|
406
428
|
const stderr = deps.stderr ?? (() => {})
|
|
407
429
|
const chunkCount = chunks.length
|
|
408
430
|
let attempts = 0
|
|
431
|
+
// Fires the send-ack claim (see BackstopDeliveryDeps.onAckClaim) at most once.
|
|
432
|
+
let ackClaimed = false
|
|
409
433
|
|
|
410
434
|
for (let attempt = 1; attempt <= Math.max(1, maxAttempts); attempt++) {
|
|
411
435
|
attempts = attempt
|
|
@@ -437,6 +461,30 @@ export async function runBackstopDelivery(
|
|
|
437
461
|
}
|
|
438
462
|
}
|
|
439
463
|
|
|
464
|
+
// 1b. EXACTLY-ONCE SEND-ACK CLAIM (duplicate-message race fix). The instant
|
|
465
|
+
// every chunk has landed a fresh non-card receipt, fire the caller's
|
|
466
|
+
// claim — BEFORE the read-back probe below, which can be deferred ~30s
|
|
467
|
+
// by the edit-flood-fuse (cosmetic priority). This is the same predicate
|
|
468
|
+
// as the final `delivered` verdict (all-landed + fresh receipt); the only
|
|
469
|
+
// way `delivered` can subsequently go false is a POSITIVE `absent` probe
|
|
470
|
+
// demoting a chunk, which this orchestrator re-sends in-process (never
|
|
471
|
+
// reopening the nonce to the out-of-band sweep). See onAckClaim.
|
|
472
|
+
if (!ackClaimed && deps.onAckClaim != null) {
|
|
473
|
+
const allLandedNow =
|
|
474
|
+
chunkCount > 0 && ledger.unsentIndices(turnId, chunkCount).length === 0
|
|
475
|
+
if (allLandedNow && backstopReceiptIds(ledger.sentIds(turnId), cardMessageId).length > 0) {
|
|
476
|
+
ackClaimed = true
|
|
477
|
+
try {
|
|
478
|
+
deps.onAckClaim(ledger.sentIds(turnId))
|
|
479
|
+
} catch (err) {
|
|
480
|
+
stderr(
|
|
481
|
+
`telegram gateway: backstop send-ack claim callback threw for turn ${turnId} ` +
|
|
482
|
+
`(non-fatal): ${err instanceof Error ? err.message : String(err)}\n`,
|
|
483
|
+
)
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
440
488
|
// 2. CONFIRM landed-unconfirmed chunks via read-back (#3278). Without a probe
|
|
441
489
|
// wired, landing IS confirmation (pre-#3278) — the fresh-non-card receipt
|
|
442
490
|
// filter below still rejects a card-only "delivery".
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compaction-in-flight marker (#4058 — spurious 300s silence-fallback fire
|
|
3
|
+
* during mid-turn auto-compaction).
|
|
4
|
+
*
|
|
5
|
+
* The blind spot this closes: when Claude Code hits mid-turn auto-compaction
|
|
6
|
+
* (the session ran out of context and spends minutes summarizing), the model
|
|
7
|
+
* emits ZERO output and runs ZERO tools — the gateway sees pure silence, so
|
|
8
|
+
* the silence-poke 300s framework fallback fired on a perfectly healthy turn:
|
|
9
|
+
* a spurious "⚠️ no output for 5 min — the framework ended that stalled turn"
|
|
10
|
+
* card plus an obligation re-present, right before the turn completed
|
|
11
|
+
* normally. Observed live: ~1m50s of tools then ~3m25s compacting = 304s of
|
|
12
|
+
* "silence" → false fire.
|
|
13
|
+
*
|
|
14
|
+
* The signal problem: the transcript JSONL carries a compaction record ONLY
|
|
15
|
+
* at the END (`{"type":"system","subtype":"compact_boundary",...}` — it
|
|
16
|
+
* embeds the compaction's own durationMs, so it cannot exist before the
|
|
17
|
+
* compaction finishes). Nothing observable lands at the START from the
|
|
18
|
+
* stream. The START therefore comes from Claude Code's PreCompact hook
|
|
19
|
+
* (`hooks/compaction-marker-precompact.mjs`), which writes
|
|
20
|
+
* `<STATE_DIR>/compaction-in-flight.json` the moment compaction begins —
|
|
21
|
+
* the same sidecar-file pattern as `turn-active-marker.ts` and the
|
|
22
|
+
* tool-label sidecar (#783).
|
|
23
|
+
*
|
|
24
|
+
* Consumers:
|
|
25
|
+
* - `liveness-wiring.ts` wires `isCompactionInFlight` into the silence-poke
|
|
26
|
+
* deps: marker present AND younger than the fallback hard ceiling ⇒ the
|
|
27
|
+
* 300s fallback is DEFERRED via the exact same `underCeiling` mechanism
|
|
28
|
+
* as the #1292/#3519 in-flight-tool defers — a genuinely-wedged
|
|
29
|
+
* compaction still unwedges once silence crosses `fallbackHardCeiling`.
|
|
30
|
+
* - `silence-poke-session-event.ts` removes the marker on the
|
|
31
|
+
* `compact_boundary` session event (the deterministic END) and counts
|
|
32
|
+
* the boundary as production, so the resumed turn gets a fresh 300s
|
|
33
|
+
* window instead of firing on the very next tick.
|
|
34
|
+
*
|
|
35
|
+
* Staleness is double-bounded: the reader treats an old marker as absent
|
|
36
|
+
* (age bound supplied by the caller — the hard ceiling), so a marker leaked
|
|
37
|
+
* by a crash between compaction start and boundary can never defer a future
|
|
38
|
+
* genuine wedge for more than one ceiling window.
|
|
39
|
+
*
|
|
40
|
+
* Pure file I/O; clock injectable for tests; never throws.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { statSync, unlinkSync } from 'node:fs'
|
|
44
|
+
import { homedir } from 'node:os'
|
|
45
|
+
import { join } from 'node:path'
|
|
46
|
+
|
|
47
|
+
export const COMPACTION_MARKER_FILE = 'compaction-in-flight.json'
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The gateway's state dir — same resolution as gateway.ts (`STATE_DIR`).
|
|
51
|
+
* Exported so `silence-poke-session-event.ts` (which is handed no stateDir)
|
|
52
|
+
* can clear the marker without widening its call signature.
|
|
53
|
+
*/
|
|
54
|
+
export function resolveTelegramStateDir(): string {
|
|
55
|
+
return process.env.TELEGRAM_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'telegram')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Age (ms) of the compaction marker's mtime, or null when absent/unstattable.
|
|
60
|
+
* The PreCompact hook (re)writes the file at compaction start, so a small age
|
|
61
|
+
* means a compaction began recently and may still be running. Never throws.
|
|
62
|
+
*/
|
|
63
|
+
export function readCompactionMarkerAgeMs(stateDir: string, now?: number): number | null {
|
|
64
|
+
try {
|
|
65
|
+
const st = statSync(join(stateDir, COMPACTION_MARKER_FILE))
|
|
66
|
+
return (now ?? Date.now()) - st.mtimeMs
|
|
67
|
+
} catch {
|
|
68
|
+
return null // ENOENT / unstattable → no compaction in flight
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Remove the marker — called on the `compact_boundary` session event (the
|
|
74
|
+
* compaction's deterministic END record in the transcript). Idempotent;
|
|
75
|
+
* ENOENT and any other unlink failure are swallowed (the age bound in the
|
|
76
|
+
* reader is the correctness backstop, removal is the fast path).
|
|
77
|
+
*/
|
|
78
|
+
export function removeCompactionMarker(stateDir: string): void {
|
|
79
|
+
try {
|
|
80
|
+
unlinkSync(join(stateDir, COMPACTION_MARKER_FILE))
|
|
81
|
+
} catch {
|
|
82
|
+
// best-effort — staleness bound in readCompactionMarkerAgeMs self-heals
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -2463,7 +2463,7 @@ async function deliverAnswer(args: {
|
|
|
2463
2463
|
replyToMessageId: number | null
|
|
2464
2464
|
/** #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). */
|
|
2465
2465
|
resume?: { snapshot: CapturedDeliverySnapshot; hydrate: (ledger: BackstopDeliveryLedger, turnId: string) => void }
|
|
2466
|
-
}): Promise<{ sentIds: number[]; chunkCount: number; delivered: boolean; exhausted: boolean; landedUnconfirmed: number }> {
|
|
2466
|
+
/** Duplicate-message race fix — forwarded to `runBackstopDelivery` as `onAckClaim` (claims the nonce at send-ack, before the read-back probe; see backstop-delivery.ts). */ onAckClaim?: (sentIds: number[]) => void }): Promise<{ sentIds: number[]; chunkCount: number; delivered: boolean; exhausted: boolean; landedUnconfirmed: number }> {
|
|
2467
2467
|
const { chatId, turnId } = args
|
|
2468
2468
|
// Spacers into `\n\n` gaps then split (as executeReply); a resume re-delivers the EXACT captured chunks (byte-stable, no re-split).
|
|
2469
2469
|
const chunks = args.resume
|
|
@@ -2574,7 +2574,7 @@ async function deliverAnswer(args: {
|
|
|
2574
2574
|
args.resume ? null : args.cardMessageId,
|
|
2575
2575
|
{
|
|
2576
2576
|
sendChunk,
|
|
2577
|
-
readBack,
|
|
2577
|
+
readBack, onAckClaim: args.onAckClaim,
|
|
2578
2578
|
recordOutbound: HISTORY_ENABLED
|
|
2579
2579
|
? (messageIds, texts) => {
|
|
2580
2580
|
try {
|
|
@@ -9838,7 +9838,7 @@ function runDeliveryConfirmSweep(): void {
|
|
|
9838
9838
|
const _deliveryConfirmSweep = isGatewayMain ? setInterval(runDeliveryConfirmSweep, DELIVERY_CONFIRM_SWEEP_MS) : undefined
|
|
9839
9839
|
_deliveryConfirmSweep?.unref?.()
|
|
9840
9840
|
|
|
9841
|
-
startOutboxSweep({ isGatewayMain, stateDir: STATE_DIR, getBot: () => bot, getTurnsDb: () => turnsDb, dedupCheck: (c, t, x) => outboundDedup.check(c, t, x, Date.now()) != null, resolveReplyMarkup: makeOutboxListenMarkupResolver({ resolveVoiceOutPlan: (t) => resolveVoiceOutPlan(loadAccess().voice_out, t), cachePut: (token, payload) => voiceOnDemandCache.put(token, payload), eagerVoiceEnabled, enqueuePreSynth: (j) => voicePreSynthQueue.enqueue(j) }), log: (l) => process.stderr.write(l) }) // outbox: single deliverer for Stop-hook prose; resolveReplyMarkup keeps the #3502 Listen button on net-delivered answers (../outbox.ts)
|
|
9841
|
+
startOutboxSweep({ isGatewayMain, stateDir: STATE_DIR, getBot: () => bot, getTurnsDb: () => turnsDb, dedupCheck: (c, t, x) => outboundDedup.check(c, t, x, Date.now()) != null, recordOutbound: HISTORY_ENABLED ? (chatId, threadId, message_ids, texts) => { try { recordOutbound({ chat_id: chatId, thread_id: threadId, message_ids, texts }) } catch { /* best-effort */ } } : undefined, resolveReplyMarkup: makeOutboxListenMarkupResolver({ resolveVoiceOutPlan: (t) => resolveVoiceOutPlan(loadAccess().voice_out, t), cachePut: (token, payload) => voiceOnDemandCache.put(token, payload), eagerVoiceEnabled, enqueuePreSynth: (j) => voicePreSynthQueue.enqueue(j) }), log: (l) => process.stderr.write(l) }) // outbox: single deliverer for Stop-hook prose; resolveReplyMarkup keeps the #3502 Listen button on net-delivered answers; recordOutbound persists the net-delivered answer to history.db (../outbox.ts)
|
|
9842
9842
|
|
|
9843
9843
|
// #1445 cross-turn pending-async ambient. When a turn ends after the
|
|
9844
9844
|
// model dispatched background async work (Agent / Task / Bash run-in-
|
|
@@ -246,6 +246,15 @@ export interface HandbackPreturnSignal {
|
|
|
246
246
|
* re-keyed to the real `statusKey` so the turn's end-of-turn
|
|
247
247
|
* `clearActivitySummary` finalizes it. Cancels the debounce/self-reap. */
|
|
248
248
|
tryAdopt: (turnId: string) => HandbackAdoption | null
|
|
249
|
+
/** True IFF a live (un-consumed) pre-turn entry is armed whose adopting turn
|
|
250
|
+
* is `turnId`. Consulted by the queued-card surface (stream-render Part B) at
|
|
251
|
+
* PARK time: when a handback pre-turn signal already owns the surface for the
|
|
252
|
+
* turn this parked message will mint, the queued card must NOT be posted —
|
|
253
|
+
* otherwise the same message ends up with two cards (the handback card the
|
|
254
|
+
* turn adopts, plus a queued card that then freezes). Turn-scoped, NOT
|
|
255
|
+
* key-scoped, so an unrelated user message parked on the same topic (a
|
|
256
|
+
* DIFFERENT adoptTurnId) is not wrongly suppressed. */
|
|
257
|
+
hasPendingForTurnId: (turnId: string) => boolean
|
|
249
258
|
/** True IFF `turnKey` is a synthetic pre-turn record key. */
|
|
250
259
|
isPreTurnRecord: (turnKey: string) => boolean
|
|
251
260
|
/** Reap hook for the gateway's mid-session / boot card reapers: stop the
|
|
@@ -516,6 +525,13 @@ export function createHandbackPreturnSignal(
|
|
|
516
525
|
return adoption
|
|
517
526
|
},
|
|
518
527
|
|
|
528
|
+
hasPendingForTurnId(turnId) {
|
|
529
|
+
for (const e of byKey.values()) {
|
|
530
|
+
if (e.adoptTurnId === turnId && !e.consumed) return true
|
|
531
|
+
}
|
|
532
|
+
return false
|
|
533
|
+
},
|
|
534
|
+
|
|
519
535
|
isPreTurnRecord(turnKey) {
|
|
520
536
|
return turnKey.startsWith(PRETURN_TURNKEY_PREFIX)
|
|
521
537
|
},
|
|
@@ -25,6 +25,7 @@ import { logStreamingEvent } from '../streaming-metrics.js'
|
|
|
25
25
|
import { clearSilentEndState } from '../silent-end.js'
|
|
26
26
|
import { purgeStaleTurnsForChat } from './turn-state-purge.js'
|
|
27
27
|
import { removeTurnActiveMarker, readTurnActiveMarkerAgeMs } from './turn-active-marker.js'
|
|
28
|
+
import { readCompactionMarkerAgeMs } from './compaction-marker.js'
|
|
28
29
|
import { decideHangRestart } from './hang-restart-decision.js'
|
|
29
30
|
import { recordTurnEnd } from '../registry/turns-schema.js'
|
|
30
31
|
import { hostdGetStatusOnce } from './hostd-dispatch.js'
|
|
@@ -70,6 +71,20 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
|
|
|
70
71
|
thresholdsMs: { fallback: SILENCE_FALLBACK_MS, fallbackHardCeiling: SILENCE_FALLBACK_HARD_MS, floor: SILENCE_FLOOR_MS },
|
|
71
72
|
deferFallbackWhileToolInFlight: SILENCE_DEFER_INFLIGHT_TOOLS,
|
|
72
73
|
isLegitimatelyWorking: (key) => isLegitimatelyWorking(key),
|
|
74
|
+
// #4058 — mid-turn auto-compaction defer. The PreCompact hook writes the
|
|
75
|
+
// compaction marker at compaction START (the transcript only records the
|
|
76
|
+
// END, via `compact_boundary`); while the marker is present AND younger
|
|
77
|
+
// than the fallback hard ceiling, silence-poke defers the 300s fallback the
|
|
78
|
+
// same way it does for an in-flight tool. The age bound makes a marker
|
|
79
|
+
// leaked by a crash (boundary never observed) self-heal: it can never hold
|
|
80
|
+
// a future genuine wedge past one ceiling window. The marker is process-
|
|
81
|
+
// wide, not keyed — the gateway runs ONE Claude session, so a compaction
|
|
82
|
+
// belongs to whichever turn is live (same accepted trade-off as
|
|
83
|
+
// `toolFlightTracker` in gateway.ts `isLegitimatelyWorking`).
|
|
84
|
+
isCompactionInFlight: () => {
|
|
85
|
+
const ageMs = readCompactionMarkerAgeMs(STATE_DIR)
|
|
86
|
+
return ageMs != null && ageMs >= 0 && ageMs < SILENCE_FALLBACK_HARD_MS
|
|
87
|
+
},
|
|
73
88
|
// #3552 — orphan-state reaper predicate. Deliberately the SAME condition the
|
|
74
89
|
// `onFrameworkFallback` late-fire guard below uses: no `activeTurnStartedAt`
|
|
75
90
|
// entry AND no current turn ⇒ the turn this state belongs to is over. Note
|
|
@@ -2663,6 +2663,8 @@ export async function deliverCapturedProse(
|
|
|
2663
2663
|
const plainChunks = splitMarkdownChunks(plain, RICH_MESSAGE_MAX_CHARS)
|
|
2664
2664
|
try {
|
|
2665
2665
|
let liveThreadId: number | undefined = threadId
|
|
2666
|
+
const plainSentIds: number[] = []
|
|
2667
|
+
const plainSentTexts: string[] = []
|
|
2666
2668
|
for (const c of plainChunks) {
|
|
2667
2669
|
// Plain sendMessage — NO parse_mode / rich rendering — so a markdown
|
|
2668
2670
|
// construct that made sendRichMessage 400 is sent verbatim instead.
|
|
@@ -2676,6 +2678,8 @@ export async function deliverCapturedProse(
|
|
|
2676
2678
|
),
|
|
2677
2679
|
{ threadId: liveThreadId, chat_id: chatId, verb: 'captured-prose-plain-fallback.sendMessage' },
|
|
2678
2680
|
)
|
|
2681
|
+
const sentId = (sent as { message_id?: number }).message_id
|
|
2682
|
+
if (sentId != null) { plainSentIds.push(sentId); plainSentTexts.push(c) }
|
|
2679
2683
|
if (liveThreadId != null && (sent as { message_thread_id?: number }).message_thread_id == null) {
|
|
2680
2684
|
liveThreadId = undefined
|
|
2681
2685
|
}
|
|
@@ -2683,6 +2687,22 @@ export async function deliverCapturedProse(
|
|
|
2683
2687
|
// The real answer reached the user via plain text — record it so a late
|
|
2684
2688
|
// reply-tool retry with the same content is deduped at its send site.
|
|
2685
2689
|
outboundDedup.record(chatId, threadId, text, Date.now(), registryKey)
|
|
2690
|
+
// Persist parity: the recovered answer reached the user, so it must land in
|
|
2691
|
+
// history.db too (mirrors the canonical persist above at the recordOutbound
|
|
2692
|
+
// reply site). Without this the plain-text-recovered answer is silently
|
|
2693
|
+
// absent from get_recent_messages / handoff briefings / the represent
|
|
2694
|
+
// guard's outbound counting. Best-effort — the delivery already succeeded.
|
|
2695
|
+
if (HISTORY_ENABLED && plainSentIds.length > 0) {
|
|
2696
|
+
try {
|
|
2697
|
+
recordOutbound({ chat_id: chatId, thread_id: threadId ?? null, message_ids: plainSentIds, texts: plainSentTexts })
|
|
2698
|
+
} catch (histErr) {
|
|
2699
|
+
process.stderr.write(
|
|
2700
|
+
`telegram gateway: history recordOutbound (captured-prose plain fallback) failed: ${
|
|
2701
|
+
histErr instanceof Error ? histErr.message : String(histErr)
|
|
2702
|
+
}\n`,
|
|
2703
|
+
)
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2686
2706
|
process.stderr.write(
|
|
2687
2707
|
`telegram gateway: captured-prose recovered via plain-text fallback ` +
|
|
2688
2708
|
`(chat=${chatId} origin=${originTurnId})\n`,
|
|
@@ -43,6 +43,8 @@ import {
|
|
|
43
43
|
type OutboxRecord,
|
|
44
44
|
} from '../outbox.js'
|
|
45
45
|
import { isShownBlock } from '../shown-ledger.js'
|
|
46
|
+
import { richMessage, isParseEntitiesError } from '../rich-send.js'
|
|
47
|
+
import { splitMarkdownChunks } from '../format.js'
|
|
46
48
|
import { resolveSubagentOriginTurnKey } from '../registry/subagents-schema.js'
|
|
47
49
|
import { createRetryApiCall, retryWithThreadFallback } from '../retry-api-call.js'
|
|
48
50
|
import {
|
|
@@ -51,13 +53,46 @@ import {
|
|
|
51
53
|
makeFloodWaitRecorder,
|
|
52
54
|
} from '../flood-circuit-breaker.js'
|
|
53
55
|
|
|
56
|
+
/**
|
|
57
|
+
* What a sweep `send` reports back. The primary (last) landed message id is the
|
|
58
|
+
* journal's `tgMessageId`; the per-chunk `chunks` are what {@link sweepOutbox}
|
|
59
|
+
* hands to `recordOutbound` so the history row is aligned to the ACTUAL sends
|
|
60
|
+
* (a markdown-aware split can land >1 chunk), exactly like the backstop uses
|
|
61
|
+
* `ledger.entries` (`backstop-delivery.ts`).
|
|
62
|
+
*/
|
|
63
|
+
export interface OutboxSendResult {
|
|
64
|
+
/** Primary (last) landed message id, or `undefined` when nothing was sent. */
|
|
65
|
+
messageId: number | undefined
|
|
66
|
+
/** Every landed chunk in delivery order: its message id + the delivered text. */
|
|
67
|
+
chunks: Array<{ messageId: number; text: string }>
|
|
68
|
+
}
|
|
69
|
+
|
|
54
70
|
export interface OutboxSweepDeps {
|
|
55
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* Deliver `text` to the chat. Resolves to the landed message id(s) + their
|
|
73
|
+
* texts (best-effort) — see {@link OutboxSendResult}. A long body is chunked
|
|
74
|
+
* inside the sender, so more than one message may land.
|
|
75
|
+
*/
|
|
56
76
|
send: (
|
|
57
77
|
chatId: string,
|
|
58
78
|
threadId: number | null,
|
|
59
79
|
text: string,
|
|
60
|
-
) => Promise<
|
|
80
|
+
) => Promise<OutboxSendResult>
|
|
81
|
+
/**
|
|
82
|
+
* Persist the sweep-delivered final answer to history (role='assistant'),
|
|
83
|
+
* the SAME `recordOutbound` the reply path (`outbound-send-path.ts`) and the
|
|
84
|
+
* turn-flush backstop (`backstop-delivery.ts`) call. Without it every
|
|
85
|
+
* net-delivered handback / task-notification answer is silently absent from
|
|
86
|
+
* `history.db`, degrading `get_recent_messages`, the handoff briefing, and the
|
|
87
|
+
* represent-guard's outbound counting. OPTIONAL and called DEFENSIVELY (a
|
|
88
|
+
* missing recorder or a throw never breaks the safety-net delivery).
|
|
89
|
+
*/
|
|
90
|
+
recordOutbound?: (
|
|
91
|
+
chatId: string,
|
|
92
|
+
threadId: number | null,
|
|
93
|
+
messageIds: number[],
|
|
94
|
+
texts: string[],
|
|
95
|
+
) => void
|
|
61
96
|
/** Has this exact text already been delivered to this chat/thread recently? */
|
|
62
97
|
textAlreadyDelivered: (chatId: string, threadId: number | null, text: string) => boolean
|
|
63
98
|
/**
|
|
@@ -241,7 +276,7 @@ export async function sweepOutbox(deps: OutboxSweepDeps): Promise<OutboxSweepSum
|
|
|
241
276
|
const resolvedChat = resolved! // routable implies non-null
|
|
242
277
|
|
|
243
278
|
try {
|
|
244
|
-
const
|
|
279
|
+
const sendResult = await deps.send(
|
|
245
280
|
resolvedChat.chatId,
|
|
246
281
|
resolvedChat.threadId,
|
|
247
282
|
decision.text ?? record.text,
|
|
@@ -250,7 +285,7 @@ export async function sweepOutbox(deps: OutboxSweepDeps): Promise<OutboxSweepSum
|
|
|
250
285
|
{
|
|
251
286
|
turnNonce: record.turnNonce,
|
|
252
287
|
textSha256: record.textSha256,
|
|
253
|
-
tgMessageId: messageId,
|
|
288
|
+
tgMessageId: sendResult.messageId,
|
|
254
289
|
ts: now,
|
|
255
290
|
// #3510 instrumentation: a sweep delivery journals its machine and the
|
|
256
291
|
// record's capture-time reply-already-delivered flag, so a duplicate
|
|
@@ -261,6 +296,22 @@ export async function sweepOutbox(deps: OutboxSweepDeps): Promise<OutboxSweepSum
|
|
|
261
296
|
},
|
|
262
297
|
deps.stateDir,
|
|
263
298
|
)
|
|
299
|
+
// Persist parity (mirrors outbound-send-path.ts + backstop-delivery.ts):
|
|
300
|
+
// record the delivered chunk ids + texts to history so a net-delivered
|
|
301
|
+
// answer is not silently absent from history.db. Best-effort — a missing
|
|
302
|
+
// recorder or a throw must never break this safety-net delivery.
|
|
303
|
+
if (deps.recordOutbound != null && sendResult.chunks.length > 0) {
|
|
304
|
+
try {
|
|
305
|
+
deps.recordOutbound(
|
|
306
|
+
resolvedChat.chatId,
|
|
307
|
+
resolvedChat.threadId,
|
|
308
|
+
sendResult.chunks.map((c) => c.messageId),
|
|
309
|
+
sendResult.chunks.map((c) => c.text),
|
|
310
|
+
)
|
|
311
|
+
} catch {
|
|
312
|
+
/* best-effort — the delivery already landed; never throw here */
|
|
313
|
+
}
|
|
314
|
+
}
|
|
264
315
|
removeClaimed(record.turnNonce, deps.stateDir)
|
|
265
316
|
summary.delivered++
|
|
266
317
|
log(
|
|
@@ -380,7 +431,9 @@ export type OutboxDeliveryMarkup = {
|
|
|
380
431
|
inline_keyboard: Array<Array<{ text: string; callback_data: string }>>
|
|
381
432
|
}
|
|
382
433
|
|
|
383
|
-
/** Minimal bot-api surface the sweep needs to deliver text.
|
|
434
|
+
/** Minimal bot-api surface the sweep needs to deliver text. `sendRichMessage`
|
|
435
|
+
* is the canonical rendered path (raw GFM markdown → entities); `sendMessage`
|
|
436
|
+
* is the plain parse-reject fallback. */
|
|
384
437
|
type OutboxSendBot = {
|
|
385
438
|
api: {
|
|
386
439
|
sendMessage: (
|
|
@@ -388,6 +441,40 @@ type OutboxSendBot = {
|
|
|
388
441
|
text: string,
|
|
389
442
|
opts: object,
|
|
390
443
|
) => Promise<{ message_id?: number }>
|
|
444
|
+
sendRichMessage: (
|
|
445
|
+
chatId: string,
|
|
446
|
+
body: { markdown: string },
|
|
447
|
+
opts: object,
|
|
448
|
+
) => Promise<{ message_id?: number }>
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Send ONE chunk with RENDER PARITY: ship raw GFM markdown through the shared
|
|
454
|
+
* `richMessage` → `sendRichMessage` path — the SAME renderer the reply path
|
|
455
|
+
* uses (`outbound-send-path.ts`) — so a net-delivered answer is formatted
|
|
456
|
+
* identically to a normally-delivered one (no more literal `**bold**`). On a
|
|
457
|
+
* markdown PARSE-REJECT, resend the same chunk as PLAIN text (the raw markdown
|
|
458
|
+
* source is itself readable prose), mirroring the reply path's plaintext
|
|
459
|
+
* fallback (`outbound-send-path.ts`). A length / thread / flood error is NOT
|
|
460
|
+
* caught here — it propagates so `retryWithThreadFallback` and the sweep's
|
|
461
|
+
* claim-release retry handle it, exactly as before.
|
|
462
|
+
*/
|
|
463
|
+
async function sendChunkRich(
|
|
464
|
+
bot: OutboxSendBot,
|
|
465
|
+
chatId: string,
|
|
466
|
+
chunk: string,
|
|
467
|
+
opts: object,
|
|
468
|
+
): Promise<{ message_id?: number }> {
|
|
469
|
+
try {
|
|
470
|
+
// allow-raw-bot-api: caller (createOutboxSend) invokes this inside retryWithThreadFallback + createRetryApiCall
|
|
471
|
+
return await bot.api.sendRichMessage(chatId, richMessage(chunk), opts)
|
|
472
|
+
} catch (err) {
|
|
473
|
+
if (isParseEntitiesError(err)) {
|
|
474
|
+
// allow-raw-bot-api: parse-reject plain fallback, still inside the sweep's retryWithThreadFallback wrapper
|
|
475
|
+
return await bot.api.sendMessage(chatId, chunk, opts)
|
|
476
|
+
}
|
|
477
|
+
throw err
|
|
391
478
|
}
|
|
392
479
|
}
|
|
393
480
|
|
|
@@ -419,19 +506,21 @@ export function createOutboxSend(deps: {
|
|
|
419
506
|
// so sending one chunk of '' would throw every tick and wedge the sweep in
|
|
420
507
|
// a permanent retry (the record never journals → never clears). Return
|
|
421
508
|
// early, matching the pre-refactor loop's zero-chunk behaviour.
|
|
422
|
-
if (text.length === 0) return undefined
|
|
509
|
+
if (text.length === 0) return { messageId: undefined, chunks: [] }
|
|
423
510
|
// Resolve the Listen button / keyboard ONCE from the full answer text; it
|
|
424
511
|
// rides only on the final chunk below.
|
|
425
512
|
const replyMarkup = deps.resolveReplyMarkup?.(chatId, threadId, text)
|
|
426
|
-
// Chunk
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
513
|
+
// Chunk with the markdown-boundary-aware splitter the reply path uses
|
|
514
|
+
// (`splitMarkdownChunks`), NOT a blind fixed-char slice — a raw slice can
|
|
515
|
+
// bisect an inline entity (`**bold**`) and force a parse-reject. Each chunk
|
|
516
|
+
// goes through the standard retry / flood-wait / thread-fallback wrapper. A
|
|
517
|
+
// thrown send propagates so the sweep releases the claim and retries next
|
|
518
|
+
// tick (the record is never journaled → never lost).
|
|
519
|
+
const landed: Array<{ messageId: number; text: string }> = []
|
|
520
|
+
const chunks = splitMarkdownChunks(text)
|
|
521
|
+
for (let idx = 0; idx < chunks.length; idx++) {
|
|
522
|
+
const chunk = chunks[idx]
|
|
523
|
+
const isLast = idx === chunks.length - 1
|
|
435
524
|
const res = await retryWithThreadFallback(
|
|
436
525
|
deps.retry,
|
|
437
526
|
(tid) => {
|
|
@@ -439,13 +528,17 @@ export function createOutboxSend(deps: {
|
|
|
439
528
|
// Button on the LAST chunk only (final visible message).
|
|
440
529
|
const opts =
|
|
441
530
|
isLast && replyMarkup != null ? { ...base, reply_markup: replyMarkup } : base
|
|
442
|
-
return bot
|
|
531
|
+
return sendChunkRich(bot, chatId, chunk, opts)
|
|
443
532
|
},
|
|
444
533
|
{ threadId: threadId ?? undefined, chat_id: chatId, verb: 'outbox-sweep.sendMessage' },
|
|
445
534
|
)
|
|
446
|
-
|
|
535
|
+
const id = res?.message_id
|
|
536
|
+
if (id != null) landed.push({ messageId: id, text: chunk })
|
|
537
|
+
}
|
|
538
|
+
return {
|
|
539
|
+
messageId: landed.length > 0 ? landed[landed.length - 1]!.messageId : undefined,
|
|
540
|
+
chunks: landed,
|
|
447
541
|
}
|
|
448
|
-
return lastId
|
|
449
542
|
}
|
|
450
543
|
}
|
|
451
544
|
|
|
@@ -463,6 +556,10 @@ export function startOutboxSweep(deps: {
|
|
|
463
556
|
threadId: number | null,
|
|
464
557
|
text: string,
|
|
465
558
|
) => OutboxDeliveryMarkup | undefined
|
|
559
|
+
/** Persist a sweep-delivered answer to history — forwarded to {@link sweepOutbox}
|
|
560
|
+
* as {@link OutboxSweepDeps.recordOutbound}. Wired by the gateway to the shared
|
|
561
|
+
* `recordOutbound` (history.ts). Absent → no history row (legacy behaviour). */
|
|
562
|
+
recordOutbound?: OutboxSweepDeps['recordOutbound']
|
|
466
563
|
log?: (line: string) => void
|
|
467
564
|
/** Test seam — override the flood probe (default: the persisted breaker). */
|
|
468
565
|
floodWaitRemainingMs?: () => number
|
|
@@ -500,6 +597,7 @@ export function startOutboxSweep(deps: {
|
|
|
500
597
|
stateDir: deps.stateDir,
|
|
501
598
|
log: deps.log,
|
|
502
599
|
send,
|
|
600
|
+
...(deps.recordOutbound != null ? { recordOutbound: deps.recordOutbound } : {}),
|
|
503
601
|
floodWaitRemainingMs: floodProbe,
|
|
504
602
|
textAlreadyDelivered: (chatId, threadId, text) => deps.dedupCheck(chatId, threadId ?? undefined, text),
|
|
505
603
|
registryChainLookup: (taskId) => {
|
|
@@ -12,6 +12,7 @@ import type { SessionEvent } from '../session-tail.js'
|
|
|
12
12
|
import { isTelegramSurfaceTool } from '../tool-names.js'
|
|
13
13
|
import { toolLabel } from '../tool-labels.js'
|
|
14
14
|
import { applyBackgroundShellLiveness } from './background-shell-liveness.js'
|
|
15
|
+
import { removeCompactionMarker, resolveTelegramStateDir } from './compaction-marker.js'
|
|
15
16
|
|
|
16
17
|
/** The silence-poke module namespace (kept as `typeof` so no surface drift). */
|
|
17
18
|
type SilencePoke = typeof import('../silence-poke.js')
|
|
@@ -83,6 +84,18 @@ export function applySilencePokeSessionEvent(
|
|
|
83
84
|
if (ev.toolUseId != null && ev.toolUseId.length > 0) {
|
|
84
85
|
silencePoke.noteToolEnd(key, ev.toolUseId, Date.now())
|
|
85
86
|
}
|
|
87
|
+
} else if (ev.kind === 'compact_boundary') {
|
|
88
|
+
// #4058 — mid-turn compaction ENDED (the transcript's compact_boundary
|
|
89
|
+
// record only exists once compaction is done). Two effects:
|
|
90
|
+
// 1. Clear the PreCompact marker so the compaction defer stops holding
|
|
91
|
+
// the 300s fallback (a post-compaction wedge fires on schedule).
|
|
92
|
+
// 2. Count the boundary as PRODUCTION: the silence clock ran the whole
|
|
93
|
+
// compaction, so without a reset the fallback would fire on the very
|
|
94
|
+
// next tick — before the resumed model's first output can land. The
|
|
95
|
+
// transcript resuming IS observable progress; a turn that stays
|
|
96
|
+
// silent AFTER compaction still fires one full window later.
|
|
97
|
+
removeCompactionMarker(resolveTelegramStateDir())
|
|
98
|
+
silencePoke.noteProduction(key, Date.now())
|
|
86
99
|
}
|
|
87
100
|
// #3519 sharpen: feed background-shell liveness (ALIVE/DEAD) to silence-poke — see background-shell-liveness.ts.
|
|
88
101
|
applyBackgroundShellLiveness(silencePoke, key, ev)
|