switchroom 0.18.23 → 0.18.25
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/cli/switchroom.js +59 -11
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +26 -0
- package/telegram-plugin/dist/gateway/gateway.js +1608 -841
- package/telegram-plugin/dist/server.js +26 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +7 -0
- package/telegram-plugin/gateway/gateway.ts +524 -16
- package/telegram-plugin/gateway/model-command.ts +188 -56
- package/telegram-plugin/gateway/redelivery-decision.ts +139 -0
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +42 -1
- package/telegram-plugin/history.ts +118 -0
- package/telegram-plugin/registry/turns-schema.ts +89 -1
- package/telegram-plugin/reply-owner-resolve.ts +160 -0
- package/telegram-plugin/session-tail.ts +185 -0
- package/telegram-plugin/subagent-watcher.ts +45 -0
- package/telegram-plugin/tests/crash-redelivery-resume-exclusion.test.ts +133 -0
- package/telegram-plugin/tests/crash-redelivery-wiring.test.ts +72 -0
- package/telegram-plugin/tests/history.test.ts +91 -0
- package/telegram-plugin/tests/model-command.test.ts +189 -12
- package/telegram-plugin/tests/redelivery-decision.test.ts +84 -0
- package/telegram-plugin/tests/registry-turns.test.ts +51 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +279 -0
- package/telegram-plugin/tests/session-model-source.test.ts +11 -0
- package/telegram-plugin/tests/session-tail.test.ts +145 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +50 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +109 -0
- package/telegram-plugin/tests/trailing-answer-projector.test.ts +124 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +125 -0
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +117 -1
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +306 -0
- package/telegram-plugin/tool-activity-summary.ts +54 -3
- package/telegram-plugin/worker-activity-feed.ts +222 -10
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +762 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +13 -1
- package/vendor/hindsight-memory/scripts/lib/client.py +14 -4
- package/vendor/hindsight-memory/scripts/lib/config.py +8 -0
- package/vendor/hindsight-memory/scripts/lib/pacing.py +102 -0
- package/vendor/hindsight-memory/scripts/lib/watermark.py +213 -0
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +344 -0
- package/vendor/hindsight-memory/scripts/retain.py +299 -143
- package/vendor/hindsight-memory/scripts/session_start.py +14 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill.py +362 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +350 -0
- package/vendor/hindsight-memory/tests/test_hooks.py +8 -2
|
@@ -88,7 +88,7 @@ import {
|
|
|
88
88
|
type TelegraphAccount,
|
|
89
89
|
} from '../telegraph.js'
|
|
90
90
|
import { OutboundDedupCache } from '../recent-outbound-dedup.js'
|
|
91
|
-
import { FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
|
|
91
|
+
import { FlushedTurnSupersedeRegistry, DEFAULT_SUPERSEDE_TTL_MS } from '../flushed-turn-supersede.js'
|
|
92
92
|
import { createInboundCoalescer, inboundCoalesceKey } from './inbound-coalesce.js'
|
|
93
93
|
import {
|
|
94
94
|
splitCoalescedAttachments,
|
|
@@ -243,7 +243,11 @@ import { isFinalAnswerReply, isSubstantiveFinalReply, FINAL_ANSWER_MIN_CHARS } f
|
|
|
243
243
|
import { deriveTurnRole, decideTerminalReason, parsePostAnswerLivenessMs, evaluatePostAnswerLiveness, type LoopRole } from '../turn-liveness-floor.js'
|
|
244
244
|
import { createAnswerStream, type AnswerStreamHandle } from '../answer-stream.js'
|
|
245
245
|
import { parseVisibleAnswerStreamEnabled, resolveAnswerLaneConfig } from '../answer-stream-flag.js'
|
|
246
|
-
import {
|
|
246
|
+
import {
|
|
247
|
+
type SessionEvent,
|
|
248
|
+
projectTrailingAnswerFromTranscript,
|
|
249
|
+
getProjectsDirForCwd,
|
|
250
|
+
} from '../session-tail.js'
|
|
247
251
|
import {
|
|
248
252
|
shouldSuppressToolActivity,
|
|
249
253
|
} from '../pty-tail.js'
|
|
@@ -303,6 +307,7 @@ import {
|
|
|
303
307
|
checkpointWal as checkpointHistoryWal,
|
|
304
308
|
pruneMessagesOlderThanDays,
|
|
305
309
|
hasOutboundDeliveredSince,
|
|
310
|
+
hasOutboundWithText,
|
|
306
311
|
} from '../history.js'
|
|
307
312
|
import {
|
|
308
313
|
runRegistryReaper,
|
|
@@ -352,6 +357,7 @@ const REPLY_TO_TEXT_MAX = 200
|
|
|
352
357
|
// tests exercise the real string — see PR #2892.
|
|
353
358
|
import { splitMarkdownChunks, repairEscapedWhitespace, normalizeParagraphBreaks, addParagraphSpacers, normalizePunctuation, stripExcessBold, escapeMarkdown, hardenCardBreaks, RICH_MESSAGE_MAX_CHARS } from '../format.js'
|
|
354
359
|
import { richMessage } from '../rich-send.js'
|
|
360
|
+
import { decideRedeliver, decideRedeliverCapture } from './redelivery-decision.js'
|
|
355
361
|
import { scrubVoice } from '../text-voice-scrub.js'
|
|
356
362
|
import {
|
|
357
363
|
normalizeOutboundBody,
|
|
@@ -400,7 +406,12 @@ import {
|
|
|
400
406
|
import {
|
|
401
407
|
decideTurnFlush,
|
|
402
408
|
isTurnFlushSafetyEnabled,
|
|
409
|
+
FLUSH_SUBSTANTIVE_MIN_CHARS,
|
|
403
410
|
} from '../turn-flush-safety.js'
|
|
411
|
+
import {
|
|
412
|
+
resolveReplyOwnerTurnId,
|
|
413
|
+
decideAnswerLatchSuppression,
|
|
414
|
+
} from '../reply-owner-resolve.js'
|
|
404
415
|
// PR A — deterministic answer-ready quiescence flush (late-delivery fix).
|
|
405
416
|
import {
|
|
406
417
|
AnswerReadyFlushController,
|
|
@@ -828,7 +839,10 @@ import {
|
|
|
828
839
|
findRecentTurnsForChat,
|
|
829
840
|
getTurnByKey,
|
|
830
841
|
markTurnResumed,
|
|
842
|
+
markAnswerRedelivered,
|
|
843
|
+
stampTurnSessionId,
|
|
831
844
|
reapStaleOpenTurns,
|
|
845
|
+
type Turn,
|
|
832
846
|
} from '../registry/turns-schema.js'
|
|
833
847
|
import {
|
|
834
848
|
buildResumeInterruptedInbound,
|
|
@@ -1689,6 +1703,13 @@ let turnsDb: ReturnType<typeof openTurnsDb> | null = null
|
|
|
1689
1703
|
// Stashed here; pushed to the spool once it's constructed below. The spool's
|
|
1690
1704
|
// turn_key-keyed dedup makes a re-stash across multiple restarts a no-op.
|
|
1691
1705
|
let bootResumeInbound: { agent: string; msg: InboundMessage } | null = null
|
|
1706
|
+
// Crash-survival redelivery candidate, captured during the boot-resume block
|
|
1707
|
+
// (module init, BEFORE the Telegram client connects) and consumed by
|
|
1708
|
+
// `maybeRedeliverUndeliveredAnswer` in the one-time-setup block AFTER
|
|
1709
|
+
// `bot.api.getMe()` succeeds — so the framed recovered-answer send is sequenced
|
|
1710
|
+
// after the client is ready, never fired mid module-init. `null` when there is
|
|
1711
|
+
// no interrupted turn to consider.
|
|
1712
|
+
let pendingRedelivery: { turn: Turn; maxAgeMs: number } | null = null
|
|
1692
1713
|
// #3038 cross-boot damper: consecutive bridge-dead escalations by PRIOR
|
|
1693
1714
|
// boots (the consumed marker's `count`; 0 when no fresh marker). Set in
|
|
1694
1715
|
// the boot block below, consumed by the watchdog constructor further down.
|
|
@@ -1823,6 +1844,50 @@ try {
|
|
|
1823
1844
|
maxAgeMs: RESUME_MAX_AGE_MS,
|
|
1824
1845
|
})
|
|
1825
1846
|
|
|
1847
|
+
// Crash-survival redelivery (deterministic, zero-token): capture THIS
|
|
1848
|
+
// interrupted turn as a redelivery candidate. The actual re-project + framed
|
|
1849
|
+
// send happens later, AFTER the Telegram client connects (see
|
|
1850
|
+
// `maybeRedeliverUndeliveredAnswer`, fired from the one-time-setup block).
|
|
1851
|
+
// Gated on the same `pending` as the resume synthetic but a SEPARATE concern:
|
|
1852
|
+
// resume re-runs the model on unfinished work; redelivery just re-sends the
|
|
1853
|
+
// finished answer the model already produced and the crash swallowed.
|
|
1854
|
+
//
|
|
1855
|
+
// MUTUAL EXCLUSION with resume (double-send guard): redelivery is captured
|
|
1856
|
+
// ONLY when this turn will NOT be re-run by the resume path — i.e.
|
|
1857
|
+
// `bootResumeKind !== 'resume'`. When the kind IS 'resume', the model re-runs
|
|
1858
|
+
// the interrupted work and emits a FRESH answer that supersedes the recovered
|
|
1859
|
+
// draft; redelivering as well would send the same answer twice (once framed
|
|
1860
|
+
// "Recovered from an interrupted turn:", once fresh). The other kinds do NOT
|
|
1861
|
+
// auto-re-answer, so redelivery is the correct (and only) send there:
|
|
1862
|
+
// - 'report' (watchdog-timeout): synthetic only ASKS retry — no auto re-answer
|
|
1863
|
+
// - 'defer-suppressed' (boot_resume: never): no synthetic re-run at all
|
|
1864
|
+
// - 'defer-loop' (resume-of-a-resume guard): no re-run
|
|
1865
|
+
// - 'none' (nothing queued): redelivery is the sole recovery
|
|
1866
|
+
// This gate is deterministic in code, not prompt-dependent.
|
|
1867
|
+
//
|
|
1868
|
+
// Eligibility floor: only a turn with a durably-stamped `session_id` (pinned
|
|
1869
|
+
// live via the session-event path) qualifies — without it we cannot resolve
|
|
1870
|
+
// the EXACT transcript, and a most-recent-mtime heuristic could shadow a
|
|
1871
|
+
// fresh boot session's file. The mutual-exclusion-with-resume rule and this
|
|
1872
|
+
// floor live together in the pure `decideRedeliverCapture` predicate.
|
|
1873
|
+
const redeliverCapture = decideRedeliverCapture({
|
|
1874
|
+
willBeResumed: bootResumeKind === 'resume',
|
|
1875
|
+
hasSessionId: Boolean(pending.session_id),
|
|
1876
|
+
})
|
|
1877
|
+
if (redeliverCapture.capture) {
|
|
1878
|
+
pendingRedelivery = { turn: pending, maxAgeMs: RESUME_MAX_AGE_MS }
|
|
1879
|
+
} else if (redeliverCapture.skipReason === 'will-be-resumed') {
|
|
1880
|
+
process.stderr.write(
|
|
1881
|
+
`telegram gateway: crash-redelivery suppressed — interrupted turnKey=${pending.turn_key} will be ` +
|
|
1882
|
+
`RESUMED (bootResumeKind=resume); the fresh re-answer supersedes the recovered draft (no double-send)\n`,
|
|
1883
|
+
)
|
|
1884
|
+
} else {
|
|
1885
|
+
process.stderr.write(
|
|
1886
|
+
`telegram gateway: crash-redelivery skipped — interrupted turnKey=${pending.turn_key} has no ` +
|
|
1887
|
+
`pinned session_id (pre-feature turn or no session event seen); cannot resolve exact transcript\n`,
|
|
1888
|
+
)
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1826
1891
|
// Sub-agents that were still in flight (running / stalled — non-terminal)
|
|
1827
1892
|
// when the turn was killed. Read HERE, at module top, BEFORE the
|
|
1828
1893
|
// subagent-watcher's boot scan + reaper run: the watcher never deletes
|
|
@@ -2036,6 +2101,40 @@ const WORKER_FEED_FALLBACK_LOG_CAP = 256
|
|
|
2036
2101
|
* slot, never a live-but-quiet worker. 5 min.
|
|
2037
2102
|
*/
|
|
2038
2103
|
const WORKER_FEED_STALE_TTL_MARGIN_MS = 5 * 60_000
|
|
2104
|
+
/**
|
|
2105
|
+
* Multiple of the watcher's in-flight terminal cap used to derive the worker-
|
|
2106
|
+
* feed ABSOLUTE row-lifetime cap (`absoluteRowLifetimeCapMs`). Unlike the
|
|
2107
|
+
* silence-keyed `staleWorkerTtlMs` backstop, the absolute cap is anchored to a
|
|
2108
|
+
* row's immutable creation time, so it reaps an immortal card even when the row
|
|
2109
|
+
* keeps receiving `update()` cues that reset `lastUpdateAt` every heartbeat
|
|
2110
|
+
* (the Carrie 5h zombie-pin leak, re-edited 3000+ times, that the silence sweep
|
|
2111
|
+
* could never match). At the 45-min default cap this yields a 3-hour absolute
|
|
2112
|
+
* ceiling — comfortably above any legitimate single worker's lifetime, so it
|
|
2113
|
+
* can only ever bite a genuine leak, while bounding a ghost card well UNDER the
|
|
2114
|
+
* ~5h symptom Ken hit (the initial 8x/6h was too loose). Derived from the same
|
|
2115
|
+
* base as `staleWorkerTtlMs` — the watcher's effective in-flight terminal cap
|
|
2116
|
+
* (`resolveInflightTerminalCapMs()`: the `SWITCHROOM_SUBAGENT_INFLIGHT_TERMINAL_
|
|
2117
|
+
* CAP_MS` env override, else the 45-min default) — never a bare magic number, so
|
|
2118
|
+
* it tracks the env-level terminal-cap override in lockstep with the silence
|
|
2119
|
+
* backstop. (Both this and `staleWorkerTtlMs` call `resolveInflightTerminalCapMs()`
|
|
2120
|
+
* with NO arg, matching the watcher's OWN effective value here: the gateway does
|
|
2121
|
+
* not pass a config-file `inflightTerminalCapMs` to `startSubagentWatcher`, so
|
|
2122
|
+
* there is no config-file override to thread through — env + default is the
|
|
2123
|
+
* complete override surface on this path.)
|
|
2124
|
+
*/
|
|
2125
|
+
const WORKER_FEED_ABSOLUTE_ROW_LIFETIME_CAP_MULTIPLE = 4
|
|
2126
|
+
/**
|
|
2127
|
+
* ABSOLUTE reused-group-MESSAGE lifetime cap (invisible-worker-cards fix,
|
|
2128
|
+
* 2026-07-15). Bounds how long the shared worker-feed message is reused before
|
|
2129
|
+
* it is force-rotated to a fresh message, re-establishing the pin surface so a
|
|
2130
|
+
* card whose pin was lost out-of-band (and whose in-memory claim went stale)
|
|
2131
|
+
* cannot stay scroll-buried indefinitely. Conservative default 60 min; env
|
|
2132
|
+
* `SWITCHROOM_WORKER_FEED_GROUP_MESSAGE_LIFETIME_CAP_MS` overrides for tuning.
|
|
2133
|
+
*/
|
|
2134
|
+
const WORKER_FEED_GROUP_MESSAGE_LIFETIME_CAP_MS = (() => {
|
|
2135
|
+
const v = Number(process.env.SWITCHROOM_WORKER_FEED_GROUP_MESSAGE_LIFETIME_CAP_MS)
|
|
2136
|
+
return Number.isFinite(v) && v > 0 ? v : 60 * 60_000
|
|
2137
|
+
})()
|
|
2039
2138
|
const workerFeedOwnerDmFallbackLogged = new Set<string>()
|
|
2040
2139
|
|
|
2041
2140
|
/**
|
|
@@ -2950,6 +3049,10 @@ const PENDING_CMD_DRAIN_CAP_MS = 60_000
|
|
|
2950
3049
|
// forwarded by the bridge on every session_event — we read occupancy from
|
|
2951
3050
|
// exactly that file (never an independent findActiveSessionFile re-scan).
|
|
2952
3051
|
let lastSessionActiveFile: string | null = null
|
|
3052
|
+
// Crash-survival redelivery (#session-id pin): the last turn_key whose
|
|
3053
|
+
// `session_id` we durably stamped, so the hot session-event path stamps once
|
|
3054
|
+
// per turn instead of issuing a guarded UPDATE on every event.
|
|
3055
|
+
let lastSessionStampedTurnKey: string | null = null
|
|
2953
3056
|
// Anti-spam state machine lives in ./proactive-compact (pure, unit
|
|
2954
3057
|
// tested). `compactDispatching` is a synchronous re-entrancy guard for
|
|
2955
3058
|
// the async tmux send — purgeReactionTracking can run several times per
|
|
@@ -3104,6 +3207,27 @@ type CurrentTurn = {
|
|
|
3104
3207
|
// false ONLY at turn start, mirroring `activityEverOpened`'s sticky-true
|
|
3105
3208
|
// contract.
|
|
3106
3209
|
finalAnswerEverDelivered: boolean
|
|
3210
|
+
// 2026-07 double-reply-on-DM fix (Part 2 — race backstop). Set true
|
|
3211
|
+
// SYNCHRONOUSLY at turn-flush FIRE time (before the ~500 ms async send and
|
|
3212
|
+
// before `flushedTurnSupersede.record`) when the flush delivers a SUBSTANTIVE
|
|
3213
|
+
// (≥`FLUSH_SUBSTANTIVE_MIN_CHARS`) terminal answer, and also set when a
|
|
3214
|
+
// substantive `reply` sends. It persists on the ended turn in
|
|
3215
|
+
// `recentTurnsById`, so a LATE reply landing in the flush's post-fire
|
|
3216
|
+
// pre-record race window (where `flushedTurnSupersede` finds no record to
|
|
3217
|
+
// delete yet) resolves this turn via the unified owner resolver, sees the
|
|
3218
|
+
// latch already set, and suppresses itself — closing the residual window Part
|
|
3219
|
+
// 1's supersede cannot reach. Scoped to the substantive floor so an interim
|
|
3220
|
+
// sub-floor ack NEITHER sets nor trips it. Reset false at turn start.
|
|
3221
|
+
answerDelivered: boolean
|
|
3222
|
+
// 2026-07 double-reply-on-DM fix (F2 — recency bound). Wall-clock ms the turn
|
|
3223
|
+
// ENDED (stamped once by `endCurrentTurnAtomic`), or null while still live.
|
|
3224
|
+
// The `findLatestEndedTurnForChat` supersede tier carries DESTRUCTIVE
|
|
3225
|
+
// authority (it drives message deletion), so `resolveReplyOwnerTurn` only
|
|
3226
|
+
// honours a latest-ended turn whose `endedAt` is within the supersede TTL —
|
|
3227
|
+
// otherwise a late reply belonging to an OLDER turn could resolve its owner to
|
|
3228
|
+
// a NEWER turn sitting at the registry tail and delete that newer turn's legit
|
|
3229
|
+
// answer. Unbounded routing use of `findLatestEndedTurnForChat` is unaffected.
|
|
3230
|
+
endedAt: number | null
|
|
3107
3231
|
// #1675 (over-ping safety net): wall-clock ms of the first reply
|
|
3108
3232
|
// this turn that landed with `disable_notification: false` (a real
|
|
3109
3233
|
// device ping). The conversational-pacing contract
|
|
@@ -3198,6 +3322,21 @@ type CurrentTurn = {
|
|
|
3198
3322
|
// sync_retain are suppressed at the hook (computeLabel returns null) and
|
|
3199
3323
|
// never arrive as tool_label events — excluded automatically.
|
|
3200
3324
|
labeledToolCount: number
|
|
3325
|
+
// Running total of the PARENT agent's OWN token usage this turn, summed from
|
|
3326
|
+
// the main-tier session-tail `usage` events (input + output + cache_creation
|
|
3327
|
+
// per assistant message, via sumUsageTokens; cache_read is excluded —
|
|
3328
|
+
// replayed cached context, not new work). Rendered on the
|
|
3329
|
+
// 🤖 turn-activity card's metrics line (`… · N tok · model`). This is the
|
|
3330
|
+
// parent alone — sub-agent tokens are NOT folded in here (they report on
|
|
3331
|
+
// their own worker-feed rows; summing them would double-count). 0 until the
|
|
3332
|
+
// turn's first assistant line carries a usage block.
|
|
3333
|
+
totalTokens: number
|
|
3334
|
+
// Dedup guard for `totalTokens`: Claude Code persists one logical assistant
|
|
3335
|
+
// message as MULTIPLE JSONL lines sharing one `message.id`, each stamped with
|
|
3336
|
+
// the SAME `usage` block. We fold a given `message.id` exactly once (same
|
|
3337
|
+
// idempotency as the sub-agent watcher's `seenUsageMessageIds`). A null/absent
|
|
3338
|
+
// messageId is un-dedupable and always counted.
|
|
3339
|
+
seenUsageMessageIds: Set<string>
|
|
3201
3340
|
// Tool-activity summary — mirrors Claude Code's native chat-UI
|
|
3202
3341
|
// rendering ("Ran 5 commands, read a file"). Counters are
|
|
3203
3342
|
// incremented in `case 'tool_use'`; `activityMessageId` holds the
|
|
@@ -3608,6 +3747,55 @@ function findLatestEndedTurnForChat(chatId: string): CurrentTurn | null {
|
|
|
3608
3747
|
return latest
|
|
3609
3748
|
}
|
|
3610
3749
|
|
|
3750
|
+
/**
|
|
3751
|
+
* 2026-07 double-reply-on-DM fix (Part 1). Resolve the turn that OWNS a landing
|
|
3752
|
+
* reply using the SAME full chain the thread-router uses, so the supersede
|
|
3753
|
+
* resolver can never again diverge from routing (the exact bug: supersede
|
|
3754
|
+
* omitted the quoted-message and latest-ended recoveries, so a DM late reply —
|
|
3755
|
+
* no live turn, no `origin_turn_id` — resolved to a null owner and its flush
|
|
3756
|
+
* message was never superseded → duplicate).
|
|
3757
|
+
*
|
|
3758
|
+
* Precedence (first non-null wins), delegated to the pure
|
|
3759
|
+
* `resolveReplyOwnerTurnId` so the exact precedence is unit-tested:
|
|
3760
|
+
* 1. the live `currentTurn` passed in (null once the flush nulled the atom);
|
|
3761
|
+
* 2. `findTurnByOriginId(origin_turn_id)` — the model echo;
|
|
3762
|
+
* 3. `findTurnByQuotedMessageId(chat_id, reply_to)` — framework-owned quote;
|
|
3763
|
+
* 4. `findLatestEndedTurnForChat(chat_id)` — the chat's last-ended turn.
|
|
3764
|
+
* Returns the CurrentTurn for the winning id (so callers can read its
|
|
3765
|
+
* `answerDelivered` latch), or null when every lookup missed.
|
|
3766
|
+
*/
|
|
3767
|
+
function resolveReplyOwnerTurn(
|
|
3768
|
+
liveTurn: CurrentTurn | null,
|
|
3769
|
+
chatId: string,
|
|
3770
|
+
args: Record<string, unknown>,
|
|
3771
|
+
): CurrentTurn | null {
|
|
3772
|
+
const origin = findTurnByOriginId(args.origin_turn_id as string | undefined)
|
|
3773
|
+
const quoted = findTurnByQuotedMessageId(chatId, args.reply_to)
|
|
3774
|
+
const latestEnded = findLatestEndedTurnForChat(chatId)
|
|
3775
|
+
const byId = new Map<string, CurrentTurn>()
|
|
3776
|
+
// Populate lowest-precedence first so a higher tier's turn wins the id slot
|
|
3777
|
+
// when two lookups resolve the same turn (they carry the same turnId anyway).
|
|
3778
|
+
for (const t of [latestEnded, quoted, origin, liveTurn]) {
|
|
3779
|
+
if (t != null) byId.set(t.turnId, t)
|
|
3780
|
+
}
|
|
3781
|
+
// F2 — bound the DESTRUCTIVE latest-ended tier to the supersede TTL so a stale
|
|
3782
|
+
// latest-ended turn can't inherit deletion authority over a newer turn's flush
|
|
3783
|
+
// record. `endedAt` is null only for a turn still resolvable but not yet ended
|
|
3784
|
+
// (not a supersede risk); leave the age unset then (unbounded) rather than
|
|
3785
|
+
// fabricate one.
|
|
3786
|
+
const latestEndedAgeMs =
|
|
3787
|
+
latestEnded?.endedAt != null ? Date.now() - latestEnded.endedAt : null
|
|
3788
|
+
const winnerId = resolveReplyOwnerTurnId({
|
|
3789
|
+
liveTurnId: liveTurn?.turnId ?? null,
|
|
3790
|
+
originTurnId: origin?.turnId ?? null,
|
|
3791
|
+
quotedTurnId: quoted?.turnId ?? null,
|
|
3792
|
+
latestEndedTurnId: latestEnded?.turnId ?? null,
|
|
3793
|
+
latestEndedAgeMs,
|
|
3794
|
+
latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
|
|
3795
|
+
})
|
|
3796
|
+
return winnerId != null ? (byId.get(winnerId) ?? null) : null
|
|
3797
|
+
}
|
|
3798
|
+
|
|
3611
3799
|
/**
|
|
3612
3800
|
* Resolve the answer-reply thread AND emit `reply-route` telemetry. The
|
|
3613
3801
|
* 2026-06-05 triage showed reply routing was the blind spot: `reply: invoked`
|
|
@@ -4842,6 +5030,12 @@ function endCurrentTurnAtomic(
|
|
|
4842
5030
|
// the turn got), plus a DEGRADED warning when the turn did tool work but the
|
|
4843
5031
|
// live feed never opened because its sends failed (the resume-400 signature).
|
|
4844
5032
|
const turnEndedAt = Date.now()
|
|
5033
|
+
// 2026-07 double-reply-on-DM fix (F2) — stamp the turn's end time so the
|
|
5034
|
+
// `findLatestEndedTurnForChat` supersede tier can be recency-bounded to the
|
|
5035
|
+
// supersede TTL (a stale latest-ended turn must not inherit deletion
|
|
5036
|
+
// authority over a newer turn's flush record). Set once; idempotent on the
|
|
5037
|
+
// deferRecord flush path (which calls this synchronously before its send).
|
|
5038
|
+
turn.endedAt = turnEndedAt
|
|
4845
5039
|
process.stderr.write(
|
|
4846
5040
|
`telegram gateway: ${formatTurnLifecycle('clear', 'turn_end', turn, turnEndedAt)}\n`,
|
|
4847
5041
|
)
|
|
@@ -8855,6 +9049,14 @@ async function reconcileStatusPinInner(
|
|
|
8855
9049
|
): Promise<void> {
|
|
8856
9050
|
if (!PIN_STATUS_WHILE_WORKING) return
|
|
8857
9051
|
if (chatId.length === 0) return
|
|
9052
|
+
// NOTE (invisible-worker-cards review, intentionally left): this reconcile is
|
|
9053
|
+
// NOT serialized per pinKey — it snapshots `prev` then awaits. Two edits that
|
|
9054
|
+
// fire `syncPin` in the same microtask window after a dropped claim can both
|
|
9055
|
+
// read `prev=null` and both issue a `pinChatMessage` for the SAME id. That is
|
|
9056
|
+
// benign and self-healing: re-pinning an already-pinned id is idempotent on
|
|
9057
|
+
// Telegram, and the first reconcile to set the claim makes every subsequent
|
|
9058
|
+
// edit a no-op — it converges in one round, never a storm. A per-key mutex
|
|
9059
|
+
// would remove the duplicate pin but adds lock complexity for zero UX gain.
|
|
8858
9060
|
const prev = statusPinState.get(pinKey) ?? null
|
|
8859
9061
|
|
|
8860
9062
|
const runReconcile = () =>
|
|
@@ -10355,6 +10557,150 @@ async function deliverCapturedProse(args: {
|
|
|
10355
10557
|
}
|
|
10356
10558
|
}
|
|
10357
10559
|
|
|
10560
|
+
/**
|
|
10561
|
+
* Crash-survival redelivery — the LIVE boot send (deterministic, zero model
|
|
10562
|
+
* tokens). Consumes the `pendingRedelivery` candidate captured during module
|
|
10563
|
+
* init and, if the interrupted turn's finished answer never reached the user,
|
|
10564
|
+
* re-projects it from the durable transcript and sends it FRAMED as a recovered
|
|
10565
|
+
* draft.
|
|
10566
|
+
*
|
|
10567
|
+
* ── Ordering guarantee (why this is NOT called during module init) ───────────
|
|
10568
|
+
* The Telegram raw send (`bot.api.sendRichMessage`) requires a CONNECTED client.
|
|
10569
|
+
* The boot-resume block that computes `pendingRedelivery` runs at module top,
|
|
10570
|
+
* BEFORE `bot.api.getMe()` and the grammy runner start — sending there would
|
|
10571
|
+
* fire against an unconnected client (or block the connect). So the candidate is
|
|
10572
|
+
* only STASHED at module init; this function is invoked exactly once from the
|
|
10573
|
+
* `didOneTimeSetup` block AFTER `getMe()` resolves — i.e. after the client is
|
|
10574
|
+
* connected and ready. This sequencing is a code-path guarantee, not prompt
|
|
10575
|
+
* discipline: the call site is unreachable until the poll loop has authenticated.
|
|
10576
|
+
*
|
|
10577
|
+
* ── Invariants honored ──────────────────────────────────────────────────────
|
|
10578
|
+
* - at-most-once per interruption: the durable text-identity oracle
|
|
10579
|
+
* (`hasOutboundWithText`, scoped to this turn's `started_at`) skips when the
|
|
10580
|
+
* answer already went out, and `markAnswerRedelivered` is stamped SYNCHRONOUSLY
|
|
10581
|
+
* after the send resolves (first-write-wins). The send also writes its own
|
|
10582
|
+
* `role='assistant'` history row, so a re-restart before the marker commits is
|
|
10583
|
+
* still caught by the oracle (residual send→row race documented on the marker).
|
|
10584
|
+
* - RESUME_MAX_AGE_MS (3h) staleness, empty-text, trailing-not-text, and
|
|
10585
|
+
* already-delivered/already-redelivered are all enforced by `decideRedeliver`.
|
|
10586
|
+
* - isApiErrorMessage lines are suppressed at the projector (never resurfaced).
|
|
10587
|
+
* - only trailing TEXT after the last tool_use is redelivered (mid-tool preamble
|
|
10588
|
+
* is refused by `trailingIsText`).
|
|
10589
|
+
*/
|
|
10590
|
+
async function maybeRedeliverUndeliveredAnswer(): Promise<void> {
|
|
10591
|
+
const candidate = pendingRedelivery
|
|
10592
|
+
pendingRedelivery = null // consume once, regardless of outcome
|
|
10593
|
+
if (candidate == null || turnsDb == null) return
|
|
10594
|
+
const { turn, maxAgeMs } = candidate
|
|
10595
|
+
const sessionId = turn.session_id
|
|
10596
|
+
if (!sessionId) return
|
|
10597
|
+
|
|
10598
|
+
// Resolve the EXACT transcript from the pinned session id (never a
|
|
10599
|
+
// most-recent-mtime scan, which a fresh boot session's file could shadow).
|
|
10600
|
+
let transcriptText: string
|
|
10601
|
+
try {
|
|
10602
|
+
const projectsDir = getProjectsDirForCwd()
|
|
10603
|
+
const path = join(projectsDir, `${sessionId}.jsonl`)
|
|
10604
|
+
if (!existsSync(path)) {
|
|
10605
|
+
process.stderr.write(
|
|
10606
|
+
`telegram gateway: crash-redelivery — transcript not found for turnKey=${turn.turn_key} ` +
|
|
10607
|
+
`session=${sessionId} (${path}); skipping\n`,
|
|
10608
|
+
)
|
|
10609
|
+
return
|
|
10610
|
+
}
|
|
10611
|
+
transcriptText = readFileSync(path, 'utf8')
|
|
10612
|
+
} catch (err) {
|
|
10613
|
+
process.stderr.write(
|
|
10614
|
+
`telegram gateway: crash-redelivery — transcript read failed turnKey=${turn.turn_key}: ${(err as Error).message}\n`,
|
|
10615
|
+
)
|
|
10616
|
+
return
|
|
10617
|
+
}
|
|
10618
|
+
|
|
10619
|
+
const projected = projectTrailingAnswerFromTranscript(transcriptText)
|
|
10620
|
+
const threadIdNum =
|
|
10621
|
+
turn.thread_id != null && turn.thread_id !== '' ? Number(turn.thread_id) : undefined
|
|
10622
|
+
const threadIdForOracle: number | null = threadIdNum != null && Number.isFinite(threadIdNum) ? threadIdNum : null
|
|
10623
|
+
|
|
10624
|
+
const decision = decideRedeliver({
|
|
10625
|
+
capturedText: projected.text,
|
|
10626
|
+
trailingIsText: projected.trailingIsText,
|
|
10627
|
+
// Durable text-identity oracle, scoped to THIS turn's window (`started_at`)
|
|
10628
|
+
// so an unrelated earlier turn's message can never false-positive-suppress.
|
|
10629
|
+
hasDeliveredText: HISTORY_ENABLED
|
|
10630
|
+
? hasOutboundWithText(turn.chat_id, projected.text, threadIdForOracle, turn.started_at)
|
|
10631
|
+
: false,
|
|
10632
|
+
alreadyRedelivered: turn.answer_redelivered_at != null,
|
|
10633
|
+
ageMs: Math.max(0, Date.now() - turn.started_at),
|
|
10634
|
+
maxAgeMs,
|
|
10635
|
+
})
|
|
10636
|
+
|
|
10637
|
+
if (!decision.redeliver || decision.framedText == null) {
|
|
10638
|
+
process.stderr.write(
|
|
10639
|
+
`telegram gateway: crash-redelivery skipped turnKey=${turn.turn_key} reason=${decision.skipReason ?? 'unknown'}\n`,
|
|
10640
|
+
)
|
|
10641
|
+
return
|
|
10642
|
+
}
|
|
10643
|
+
|
|
10644
|
+
const chatId = turn.chat_id
|
|
10645
|
+
const out = redactOutboundText(decision.framedText, 'crash_redelivery')
|
|
10646
|
+
const chunks = splitMarkdownChunks(out, RICH_MESSAGE_MAX_CHARS)
|
|
10647
|
+
const sentIds: number[] = []
|
|
10648
|
+
try {
|
|
10649
|
+
let liveThreadId: number | undefined = threadIdNum != null && Number.isFinite(threadIdNum) ? threadIdNum : undefined
|
|
10650
|
+
for (const c of chunks) {
|
|
10651
|
+
const sent = await retryWithThreadFallback(
|
|
10652
|
+
robustApiCall,
|
|
10653
|
+
(tid) => {
|
|
10654
|
+
// Built as a variable (not an inline literal) so excess-property
|
|
10655
|
+
// checks don't reject `link_preview_options` on sendRichMessage's
|
|
10656
|
+
// narrow Other<> type — mirrors the captured-prose / turn-flush sites.
|
|
10657
|
+
const opts = {
|
|
10658
|
+
link_preview_options: { is_disabled: true },
|
|
10659
|
+
...(tid != null ? { message_thread_id: tid } : {}),
|
|
10660
|
+
}
|
|
10661
|
+
return bot.api.sendRichMessage(chatId, richMessage(c), opts)
|
|
10662
|
+
},
|
|
10663
|
+
{ threadId: liveThreadId, chat_id: chatId, verb: 'crash-redelivery.sendMessage' },
|
|
10664
|
+
)
|
|
10665
|
+
if (liveThreadId != null && (sent as { message_thread_id?: number }).message_thread_id == null) {
|
|
10666
|
+
liveThreadId = undefined
|
|
10667
|
+
}
|
|
10668
|
+
sentIds.push(sent.message_id)
|
|
10669
|
+
}
|
|
10670
|
+
// Record the send as a real assistant delivery so the durable oracle catches
|
|
10671
|
+
// a duplicate if we crash before the marker commits (self-idempotent).
|
|
10672
|
+
if (HISTORY_ENABLED && sentIds.length > 0) {
|
|
10673
|
+
try {
|
|
10674
|
+
recordOutbound({
|
|
10675
|
+
chat_id: chatId,
|
|
10676
|
+
thread_id: threadIdForOracle,
|
|
10677
|
+
message_ids: sentIds,
|
|
10678
|
+
texts: chunks,
|
|
10679
|
+
})
|
|
10680
|
+
} catch {}
|
|
10681
|
+
}
|
|
10682
|
+
// Stamp the at-most-once marker SYNCHRONOUSLY after the send resolves.
|
|
10683
|
+
try {
|
|
10684
|
+
markAnswerRedelivered(turnsDb, turn.turn_key)
|
|
10685
|
+
} catch (err) {
|
|
10686
|
+
process.stderr.write(
|
|
10687
|
+
`telegram gateway: crash-redelivery markAnswerRedelivered failed turnKey=${turn.turn_key}: ${(err as Error).message}\n`,
|
|
10688
|
+
)
|
|
10689
|
+
}
|
|
10690
|
+
process.stderr.write(
|
|
10691
|
+
`telegram gateway: crash-redelivery — delivered recovered answer (${out.length} chars, ` +
|
|
10692
|
+
`${chunks.length} chunk(s)) for turnKey=${turn.turn_key} chat=${chatId}\n`,
|
|
10693
|
+
)
|
|
10694
|
+
} catch (err) {
|
|
10695
|
+
// Send failed — leave the marker UNSTAMPED so a later restart retries rather
|
|
10696
|
+
// than silently dropping the recovered answer.
|
|
10697
|
+
process.stderr.write(
|
|
10698
|
+
`telegram gateway: crash-redelivery send failed turnKey=${turn.turn_key}: ${(err as Error).message} ` +
|
|
10699
|
+
`— left un-stamped for a later retry\n`,
|
|
10700
|
+
)
|
|
10701
|
+
}
|
|
10702
|
+
}
|
|
10703
|
+
|
|
10358
10704
|
function obligationSweep(): void {
|
|
10359
10705
|
if (!OBLIGATION_LEDGER_ENABLED) return
|
|
10360
10706
|
if (!obligationLedger.hasOpen()) return
|
|
@@ -11108,6 +11454,29 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
11108
11454
|
// Track the session-tail's attached file for the proactive-
|
|
11109
11455
|
// compaction occupancy read (see maybeProactiveCompact).
|
|
11110
11456
|
if (msg.activeFile) lastSessionActiveFile = msg.activeFile
|
|
11457
|
+
// Crash-survival redelivery: durably pin the claude session id onto the
|
|
11458
|
+
// current turn's row the first time we see a session event for it, WHILE
|
|
11459
|
+
// the turn is live (so a later crash preserves it). Boot redelivery then
|
|
11460
|
+
// resolves the EXACT `<sessionId>.jsonl` for the interrupted turn rather
|
|
11461
|
+
// than a most-recent-mtime heuristic that a fresh boot session shadows.
|
|
11462
|
+
// First-write-wins in SQL (`session_id IS NULL`); the in-memory guard just
|
|
11463
|
+
// avoids a redundant UPDATE on every event of the same turn.
|
|
11464
|
+
if (turnsDb != null && msg.activeFile != null) {
|
|
11465
|
+
const stampKey = currentTurn?.registryKey ?? null
|
|
11466
|
+
if (stampKey != null && stampKey !== lastSessionStampedTurnKey) {
|
|
11467
|
+
const sessionId = basename(msg.activeFile).replace(/\.jsonl$/, '')
|
|
11468
|
+
if (sessionId) {
|
|
11469
|
+
try {
|
|
11470
|
+
stampTurnSessionId(turnsDb, stampKey, sessionId)
|
|
11471
|
+
lastSessionStampedTurnKey = stampKey
|
|
11472
|
+
} catch (err) {
|
|
11473
|
+
process.stderr.write(
|
|
11474
|
+
`telegram gateway: stampTurnSessionId failed turnKey=${stampKey}: ${(err as Error).message}\n`,
|
|
11475
|
+
)
|
|
11476
|
+
}
|
|
11477
|
+
}
|
|
11478
|
+
}
|
|
11479
|
+
}
|
|
11111
11480
|
const ev = msg.event as unknown as SessionEvent
|
|
11112
11481
|
// #1122/#1126: session events used to be ingested into the pinned progress
|
|
11113
11482
|
// card here (`progressDriver.ingest`). The card is retired and the driver
|
|
@@ -12925,17 +13294,21 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
12925
13294
|
// so a fresh turn's answer is never clobbered.
|
|
12926
13295
|
{
|
|
12927
13296
|
const replyThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
12928
|
-
//
|
|
12929
|
-
//
|
|
12930
|
-
//
|
|
12931
|
-
//
|
|
12932
|
-
//
|
|
12933
|
-
//
|
|
12934
|
-
//
|
|
12935
|
-
//
|
|
12936
|
-
//
|
|
12937
|
-
|
|
12938
|
-
|
|
13297
|
+
// 2026-07 double-reply-on-DM fix (Part 1) — resolve the turn this reply
|
|
13298
|
+
// belongs to by IDENTITY, via the SAME full chain the thread-router uses
|
|
13299
|
+
// (`resolveReplyOwnerTurn`): live `currentTurn`, then the model-echoed
|
|
13300
|
+
// `origin_turn_id`, then the framework-owned quoted message id, then the
|
|
13301
|
+
// chat's most-recently-ended turn. The prior chain stopped at
|
|
13302
|
+
// `currentTurn ?? findTurnByOriginId`, so a DM late reply — `currentTurn`
|
|
13303
|
+
// nulled by the flush's synthetic turn_end AND no `origin_turn_id` (a
|
|
13304
|
+
// supergroup-only field) — resolved to a null owner. `decideSupersede`
|
|
13305
|
+
// deliberately never lets a null live turn supersede a turnId-bearing flush
|
|
13306
|
+
// record, so message A survived AND the reply shipped message B (the exact
|
|
13307
|
+
// double-send). The quoted / latest-ended recoveries are precisely what the
|
|
13308
|
+
// router already did for the same reply, so unifying here makes the two
|
|
13309
|
+
// resolvers agree and the late-reply supersede fires by identity.
|
|
13310
|
+
const ownerTurn = resolveReplyOwnerTurn(turn, chat_id, args)
|
|
13311
|
+
const resolvedTurnId = ownerTurn?.turnId ?? null
|
|
12939
13312
|
const decision = flushedTurnSupersede.take(
|
|
12940
13313
|
chat_id,
|
|
12941
13314
|
replyThreadId,
|
|
@@ -12952,6 +13325,46 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
12952
13325
|
{ chat_id, verb: 'reply.supersedeFlushed' },
|
|
12953
13326
|
)
|
|
12954
13327
|
}
|
|
13328
|
+
} else {
|
|
13329
|
+
// 2026-07 double-reply-on-DM fix (Part 2) — answer-delivered race latch.
|
|
13330
|
+
// Supersede found no record. Either there was no flush (normal reply), or
|
|
13331
|
+
// the flush FIRED but has not yet recorded its message ids (the residual
|
|
13332
|
+
// pre-record race Part 1's supersede cannot reach). The flush sets
|
|
13333
|
+
// `answerDelivered = true` synchronously at fire time (before its async
|
|
13334
|
+
// send AND before `record`), and it persists on the ended turn — so when
|
|
13335
|
+
// this LATE, substantive reply resolves its owner turn and sees the latch
|
|
13336
|
+
// already set, the flush's message A is already on its way out and this
|
|
13337
|
+
// reply would ship a duplicate. Suppress it. Scoped to the substantive
|
|
13338
|
+
// ≥`FLUSH_SUBSTANTIVE_MIN_CHARS` floor and the late-reply case so an
|
|
13339
|
+
// interim sub-floor ack, a chunked multi-part answer, or a legitimate
|
|
13340
|
+
// second in-turn substantive reply (live `currentTurn`) is never
|
|
13341
|
+
// suppressed. `isSubstantiveFinalReply` reduces to the ≥200-char test on
|
|
13342
|
+
// the `reply` path (no `done`); pass the model's original notification
|
|
13343
|
+
// intent to mirror the #2533 decoupling call shape.
|
|
13344
|
+
const replySubstantive = isSubstantiveFinalReply({
|
|
13345
|
+
text: rawText,
|
|
13346
|
+
disableNotification: args.disable_notification === true,
|
|
13347
|
+
})
|
|
13348
|
+
const suppressByLatch = decideAnswerLatchSuppression({
|
|
13349
|
+
superseded: false,
|
|
13350
|
+
replySubstantive,
|
|
13351
|
+
isLateReply: turn == null,
|
|
13352
|
+
ownerAnswerDelivered: ownerTurn?.answerDelivered ?? false,
|
|
13353
|
+
})
|
|
13354
|
+
if (suppressByLatch) {
|
|
13355
|
+
process.stderr.write(
|
|
13356
|
+
`telegram gateway: reply: suppressed by answer-delivered latch ` +
|
|
13357
|
+
`(flush already delivered this turn's answer) chatId=${chat_id} ` +
|
|
13358
|
+
`ownerTurnId=${JSON.stringify(resolvedTurnId)}\n`,
|
|
13359
|
+
)
|
|
13360
|
+
return { content: [{ type: 'text', text: 'sent (deduped — answer already delivered via turn-flush)' }] }
|
|
13361
|
+
}
|
|
13362
|
+
// A substantive answer is going out via this reply — set the latch on its
|
|
13363
|
+
// owner turn so a later bridge-replayed / reworded duplicate of the same
|
|
13364
|
+
// answer is caught by the branch above.
|
|
13365
|
+
if (replySubstantive && ownerTurn != null) {
|
|
13366
|
+
ownerTurn.answerDelivered = true
|
|
13367
|
+
}
|
|
12955
13368
|
}
|
|
12956
13369
|
}
|
|
12957
13370
|
|
|
@@ -15848,6 +16261,9 @@ function composeTurnActivity(turn: CurrentTurn, final = false, liveSuffix = ''):
|
|
|
15848
16261
|
toolCount: turn.labeledToolCount,
|
|
15849
16262
|
state: final ? 'done' : 'running',
|
|
15850
16263
|
model: turn.currentModel,
|
|
16264
|
+
// The parent's OWN running token total → `· N tok` on the metrics line.
|
|
16265
|
+
// 0 → tokenSegment omits it (clean, same as the worker feed).
|
|
16266
|
+
totalTokens: turn.totalTokens,
|
|
15851
16267
|
}
|
|
15852
16268
|
return renderActivityFeedWithNested(turn.mirrorLines, childLines, final, liveSuffix, stepCount, header)
|
|
15853
16269
|
}
|
|
@@ -16880,6 +17296,11 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
16880
17296
|
finalAnswerSubstantive: false,
|
|
16881
17297
|
// Sticky latch — reset ONLY here (turn start), never by reopen.
|
|
16882
17298
|
finalAnswerEverDelivered: false,
|
|
17299
|
+
// 2026-07 double-reply-on-DM fix (Part 2) — answer-delivered race
|
|
17300
|
+
// latch, reset at turn start alongside the other answer flags.
|
|
17301
|
+
answerDelivered: false,
|
|
17302
|
+
// 2026-07 double-reply-on-DM fix (F2) — stamped at turn end.
|
|
17303
|
+
endedAt: null,
|
|
16883
17304
|
firstPingAt: null,
|
|
16884
17305
|
// Notification ownership (R8 / PR-2): no slot claimed yet, so the
|
|
16885
17306
|
// "claimer was substantive" flag starts false. Set atomically with
|
|
@@ -16900,6 +17321,8 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
16900
17321
|
lastAssistantDone: false,
|
|
16901
17322
|
toolCallCount: 0,
|
|
16902
17323
|
labeledToolCount: 0,
|
|
17324
|
+
totalTokens: 0,
|
|
17325
|
+
seenUsageMessageIds: new Set<string>(),
|
|
16903
17326
|
activityMessageId: null,
|
|
16904
17327
|
activityInFlight: null,
|
|
16905
17328
|
activityPendingRender: null,
|
|
@@ -17081,6 +17504,22 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
17081
17504
|
sessionModelSource.noteTranscriptModel(ev.model)
|
|
17082
17505
|
return
|
|
17083
17506
|
}
|
|
17507
|
+
case 'usage': {
|
|
17508
|
+
// Fold the parent agent's OWN per-message token usage into the turn's
|
|
17509
|
+
// running total, deduped by message.id (one logical assistant message can
|
|
17510
|
+
// land as several JSONL lines sharing one id + usage block). Rendered on
|
|
17511
|
+
// the 🤖 turn-activity card's metrics line. Sub-agent tokens are NOT
|
|
17512
|
+
// folded here — they surface on their own worker-feed rows; summing them
|
|
17513
|
+
// would double-count. A null messageId is un-dedupable → always counted.
|
|
17514
|
+
const turn = currentTurn
|
|
17515
|
+
if (turn == null) return
|
|
17516
|
+
if (ev.messageId != null) {
|
|
17517
|
+
if (turn.seenUsageMessageIds.has(ev.messageId)) return
|
|
17518
|
+
turn.seenUsageMessageIds.add(ev.messageId)
|
|
17519
|
+
}
|
|
17520
|
+
turn.totalTokens += ev.totalTokens
|
|
17521
|
+
return
|
|
17522
|
+
}
|
|
17084
17523
|
case 'thinking': {
|
|
17085
17524
|
// #1067: snapshot the turn atom at handler entry. Even though this
|
|
17086
17525
|
// handler is sync, the principle is uniform across all event arms
|
|
@@ -18113,6 +18552,20 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
18113
18552
|
// the silent-end re-prompt. (Belt-and-braces, like the set above —
|
|
18114
18553
|
// this branch returns before any further tool_label can arrive.)
|
|
18115
18554
|
turn.finalAnswerSubstantive = true
|
|
18555
|
+
// 2026-07 double-reply-on-DM fix (Part 2) — arm the answer-delivered
|
|
18556
|
+
// race latch NOW, synchronously, BEFORE the ~500 ms async send below and
|
|
18557
|
+
// BEFORE `flushedTurnSupersede.record`. A late reply that lands in the
|
|
18558
|
+
// post-fire pre-record window resolves this turn (via the unified owner
|
|
18559
|
+
// resolver, reading the atom preserved in `recentTurnsById`) and
|
|
18560
|
+
// suppresses itself against this latch — closing the residual race Part
|
|
18561
|
+
// 1's supersede cannot reach. Scoped to a SUBSTANTIVE terminal answer
|
|
18562
|
+
// (the same ≥`FLUSH_SUBSTANTIVE_MIN_CHARS` floor the codebase uses to
|
|
18563
|
+
// recognise a real answer) so a short flush never latches against a
|
|
18564
|
+
// legitimate substantive reply. `capturedText` here is the selected,
|
|
18565
|
+
// normalized flush delivery text.
|
|
18566
|
+
if (capturedText.trim().length >= FLUSH_SUBSTANTIVE_MIN_CHARS) {
|
|
18567
|
+
turn.answerDelivered = true
|
|
18568
|
+
}
|
|
18116
18569
|
|
|
18117
18570
|
// #654 deterministic double-message fix. Hand off the pinned
|
|
18118
18571
|
// progress card BEFORE state reset so the driver doesn't keep
|
|
@@ -18352,6 +18805,22 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
18352
18805
|
} catch (err) {
|
|
18353
18806
|
sendThrew = true
|
|
18354
18807
|
process.stderr.write(`telegram gateway: turn-flush send failed: ${(err as Error).message}\n`)
|
|
18808
|
+
// 2026-07 double-reply-on-DM fix (F1) — the flush armed
|
|
18809
|
+
// `answerDelivered` synchronously at FIRE time, but the send just
|
|
18810
|
+
// FAILED. When nothing was delivered the supersede record was NOT
|
|
18811
|
+
// written (gated on `sentIds.length > 0` above), so Part 1 can never
|
|
18812
|
+
// fire for this turn — leaving the latch armed would make a genuine
|
|
18813
|
+
// late reply suppress itself and the user would get ZERO messages
|
|
18814
|
+
// (silent answer loss; on main that path still delivers the reply).
|
|
18815
|
+
// Reset the latch so the late reply is NOT suppressed. On a PARTIAL
|
|
18816
|
+
// send (sentIds > 0) the record WAS written, so Part 1's supersede
|
|
18817
|
+
// deletes the partial message A and the reply delivers cleanly —
|
|
18818
|
+
// resetting here is harmless in that case too.
|
|
18819
|
+
// FOLLOW-UP (coordinator to file an issue): a reply suppressed
|
|
18820
|
+
// synchronously DURING the in-flight send that then fails is not
|
|
18821
|
+
// fully closable with a boolean latch — a residual micro-window the
|
|
18822
|
+
// supersede+latch pair cannot eliminate. Not addressed in this PR.
|
|
18823
|
+
turn.answerDelivered = false
|
|
18355
18824
|
// #1713: backstop send failed — finalize as error so the
|
|
18356
18825
|
// turn ends cleanly with 😱 rather than leaving it open.
|
|
18357
18826
|
if (backstopCtrl) backstopCtrl.finalize('error')
|
|
@@ -29659,6 +30128,19 @@ void (async () => {
|
|
|
29659
30128
|
)
|
|
29660
30129
|
}
|
|
29661
30130
|
|
|
30131
|
+
// Crash-survival redelivery — LIVE boot send. Now that `bot.api.getMe()`
|
|
30132
|
+
// has resolved (the Telegram client is connected and authenticated), it
|
|
30133
|
+
// is safe to fire the framed recovered-answer send for an interrupted
|
|
30134
|
+
// turn whose finished answer the crash swallowed. Fire-and-forget: it is
|
|
30135
|
+
// self-contained, must not block the rest of one-time setup, and is a
|
|
30136
|
+
// no-op when there is no eligible candidate. See
|
|
30137
|
+
// `maybeRedeliverUndeliveredAnswer` for the ordering guarantee + invariants.
|
|
30138
|
+
void maybeRedeliverUndeliveredAnswer().catch((err) => {
|
|
30139
|
+
process.stderr.write(
|
|
30140
|
+
`telegram gateway: crash-redelivery boot send errored: ${(err as Error).message}\n`,
|
|
30141
|
+
)
|
|
30142
|
+
})
|
|
30143
|
+
|
|
29662
30144
|
// Boot-time pin sweep
|
|
29663
30145
|
try {
|
|
29664
30146
|
const bootAccess = loadAccess()
|
|
@@ -30102,7 +30584,16 @@ void (async () => {
|
|
|
30102
30584
|
// or the turn ended while it kept running — extended autonomous
|
|
30103
30585
|
// work) is surfaced via the worker feed instead of vanishing.
|
|
30104
30586
|
const orphanStatusEnabled = isOrphanSubagentStatusEnabled(process.env.SWITCHROOM_ORPHAN_SUBAGENT_STATUS)
|
|
30105
|
-
|
|
30587
|
+
// Boot/reconnect purge (Ken, PR #3239 review): every row in the
|
|
30588
|
+
// OUTGOING feed is a dead child sub-agent, and its `wk:group:` pins
|
|
30589
|
+
// would otherwise be orphaned — the replacement feed is empty and
|
|
30590
|
+
// never knew those groups, so it can never unpin them, and no full-
|
|
30591
|
+
// boot pin sweep runs on a bare bridge reconnect. Reconcile the old
|
|
30592
|
+
// feed to empty and release all its group pins BEFORE stopping it.
|
|
30593
|
+
if (workerActivityFeed != null) {
|
|
30594
|
+
workerActivityFeed.purgeAllOnBoot()
|
|
30595
|
+
workerActivityFeed.stop()
|
|
30596
|
+
}
|
|
30106
30597
|
workerActivityFeed = createWorkerActivityFeed({
|
|
30107
30598
|
// #2669: worker-feed body is raw GFM markdown — send via rich.
|
|
30108
30599
|
bot: {
|
|
@@ -30167,6 +30658,18 @@ void (async () => {
|
|
|
30167
30658
|
// force-collapsing a row the terminal signals somehow never
|
|
30168
30659
|
// removed.
|
|
30169
30660
|
staleWorkerTtlMs: resolveInflightTerminalCapMs() + WORKER_FEED_STALE_TTL_MARGIN_MS,
|
|
30661
|
+
// ABSOLUTE row-lifetime cap — anchored to a row's creation, immune
|
|
30662
|
+
// to the `lastUpdateAt` reset that lets an immortal-but-updating
|
|
30663
|
+
// row dodge `staleWorkerTtlMs` forever (Carrie 5h zombie pin).
|
|
30664
|
+
// Derived from the same terminal cap so it tracks operator
|
|
30665
|
+
// overrides; 4× → ~3h at the 45-min default.
|
|
30666
|
+
absoluteRowLifetimeCapMs: resolveInflightTerminalCapMs() * WORKER_FEED_ABSOLUTE_ROW_LIFETIME_CAP_MULTIPLE,
|
|
30667
|
+
// ABSOLUTE reused-group-MESSAGE lifetime cap (invisible-worker-
|
|
30668
|
+
// cards fix): force-rotate the shared message past this age while
|
|
30669
|
+
// workers overlap continuously, so a card that lost its pin
|
|
30670
|
+
// out-of-band re-establishes the pin surface via the first-paint
|
|
30671
|
+
// path instead of living buried on one immortal message.
|
|
30672
|
+
groupMessageLifetimeCapMs: WORKER_FEED_GROUP_MESSAGE_LIFETIME_CAP_MS,
|
|
30170
30673
|
// #3207 review: GROUP-level status pin. Workers now coalesce into
|
|
30171
30674
|
// ONE shared message, so the pin must follow the GROUP lifecycle,
|
|
30172
30675
|
// not a single worker's — otherwise a sibling's finish unpins a
|
|
@@ -30349,7 +30852,7 @@ void (async () => {
|
|
|
30349
30852
|
)
|
|
30350
30853
|
}
|
|
30351
30854
|
},
|
|
30352
|
-
onFinish: ({ agentId, outcome, description, resultText, toolCount, durationMs, background: entryBackground }) => {
|
|
30855
|
+
onFinish: ({ agentId, outcome, description, resultText, toolCount, totalTokens, durationMs, background: entryBackground }) => {
|
|
30353
30856
|
// Reaction promotion: if the parent turn already ended
|
|
30354
30857
|
// with this (or another) worker still running, its 👍 was
|
|
30355
30858
|
// deferred (held on ✍️/⚡). Now that a worker finished,
|
|
@@ -30419,6 +30922,7 @@ void (async () => {
|
|
|
30419
30922
|
description: dispatch.feedDescription,
|
|
30420
30923
|
lastTool: null,
|
|
30421
30924
|
toolCount,
|
|
30925
|
+
totalTokens,
|
|
30422
30926
|
latestSummary: resultText,
|
|
30423
30927
|
elapsedMs: durationMs,
|
|
30424
30928
|
state: outcome === 'failed' ? 'failed' : 'done',
|
|
@@ -30500,6 +31004,7 @@ void (async () => {
|
|
|
30500
31004
|
description: dispatch.feedDescription,
|
|
30501
31005
|
lastTool: null,
|
|
30502
31006
|
toolCount,
|
|
31007
|
+
totalTokens,
|
|
30503
31008
|
latestSummary: resultText,
|
|
30504
31009
|
elapsedMs: durationMs,
|
|
30505
31010
|
state: outcome === 'failed' ? 'failed' : 'done',
|
|
@@ -30521,6 +31026,7 @@ void (async () => {
|
|
|
30521
31026
|
description: dispatch.feedDescription,
|
|
30522
31027
|
lastTool: null,
|
|
30523
31028
|
toolCount,
|
|
31029
|
+
totalTokens,
|
|
30524
31030
|
latestSummary: resultText,
|
|
30525
31031
|
elapsedMs: durationMs,
|
|
30526
31032
|
state: outcome === 'failed' ? 'failed' : 'done',
|
|
@@ -30611,7 +31117,7 @@ void (async () => {
|
|
|
30611
31117
|
// suppresses stale-after-restart delivery (a 4-h-old
|
|
30612
31118
|
// "still working (5m)" would be a lie). Sweep on handback
|
|
30613
31119
|
// lives in the `onFinish` block just above.
|
|
30614
|
-
onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine, model, skeleton }) => {
|
|
31120
|
+
onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, totalTokens, progressLine, model, skeleton }) => {
|
|
30615
31121
|
let fleetChatId = ''
|
|
30616
31122
|
try {
|
|
30617
31123
|
const fleets = progressDriver?.peekAllFleets() ?? []
|
|
@@ -30692,6 +31198,7 @@ void (async () => {
|
|
|
30692
31198
|
elapsedMs,
|
|
30693
31199
|
state: 'running',
|
|
30694
31200
|
model: feedModel,
|
|
31201
|
+
totalTokens,
|
|
30695
31202
|
},
|
|
30696
31203
|
wk.threadId,
|
|
30697
31204
|
)
|
|
@@ -30846,6 +31353,7 @@ void (async () => {
|
|
|
30846
31353
|
elapsedMs,
|
|
30847
31354
|
state: 'running',
|
|
30848
31355
|
model: feedModel,
|
|
31356
|
+
totalTokens,
|
|
30849
31357
|
},
|
|
30850
31358
|
wk.threadId,
|
|
30851
31359
|
)
|