switchroom 0.18.18 → 0.18.20
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/ms-365-write-pretool.mjs +92 -20
- package/dist/cli/switchroom.js +36 -6
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/answer-ready-flush.ts +187 -0
- package/telegram-plugin/dist/gateway/gateway.js +1131 -285
- package/telegram-plugin/dist/server.js +6 -0
- package/telegram-plugin/format.ts +208 -125
- package/telegram-plugin/gateway/cron-session.ts +32 -0
- package/telegram-plugin/gateway/gateway.ts +800 -107
- package/telegram-plugin/gateway/idle-clear.ts +170 -0
- package/telegram-plugin/gateway/inject-handler.ts +11 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +5 -3
- package/telegram-plugin/gateway/turn-record-status.ts +134 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +23 -0
- package/telegram-plugin/hooks/silent-end-scan.mjs +98 -8
- package/telegram-plugin/llm-error-present.ts +68 -30
- package/telegram-plugin/narrative-flush.ts +181 -0
- package/telegram-plugin/pending-work-progress.ts +65 -1
- package/telegram-plugin/session-tail.ts +6 -1
- package/telegram-plugin/silent-end.ts +182 -0
- package/telegram-plugin/subagent-watcher.ts +244 -81
- package/telegram-plugin/tests/answer-ready-flush.test.ts +343 -0
- package/telegram-plugin/tests/cron-inject-idle-clock.test.ts +54 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +13 -10
- package/telegram-plugin/tests/format-consistency.test.ts +39 -4
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +26 -0
- package/telegram-plugin/tests/idle-clear.test.ts +315 -37
- package/telegram-plugin/tests/llm-error-present.test.ts +110 -9
- package/telegram-plugin/tests/narrative-flush.test.ts +213 -0
- package/telegram-plugin/tests/narrative-splice-before-finalize.test.ts +167 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +2 -0
- package/telegram-plugin/tests/paragraph-spacer-golden.test.ts +150 -0
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +4 -1
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +194 -0
- package/telegram-plugin/tests/silent-end.test.ts +296 -0
- package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +218 -0
- package/telegram-plugin/tests/telegram-format.test.ts +72 -4
- package/telegram-plugin/tests/turn-record-status.test.ts +119 -0
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +218 -1
- package/telegram-plugin/tests/worker-feed-terminal-cleanup.test.ts +254 -0
- package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +125 -0
- package/telegram-plugin/tool-activity-summary.ts +78 -16
- package/telegram-plugin/turn-flush-safety.ts +2 -1
- package/telegram-plugin/worker-activity-feed.ts +181 -30
|
@@ -192,7 +192,8 @@ import { appendActivityLabel, clipNarrative, renderActivityFeedWithNested, forma
|
|
|
192
192
|
import { formatModelLabel } from '../model-label.js'
|
|
193
193
|
import { createSessionModelSource } from './session-model-source.js'
|
|
194
194
|
import { runSilentTurnHeartbeatTick } from '../feed-heartbeat-climb.js'
|
|
195
|
-
import { REPLY_TOOLS
|
|
195
|
+
import { REPLY_TOOLS } from '../narrative-dedup.js'
|
|
196
|
+
import { NarrativeFlushController, PENDING_NARRATIVE_FLUSH_MS } from '../narrative-flush.js'
|
|
196
197
|
import { toolLabel } from '../tool-labels.js'
|
|
197
198
|
import { createTypingWrapper } from '../typing-wrap.js'
|
|
198
199
|
import { createTurnTypingLoop } from './turn-typing-loop.js'
|
|
@@ -236,7 +237,7 @@ import { decideSilentReplyAnchor } from '../silent-reply-anchor.js'
|
|
|
236
237
|
import { classifyInbound } from '../inbound-classifier.js'
|
|
237
238
|
import * as silencePoke from '../silence-poke.js'
|
|
238
239
|
import * as pendingProgress from '../pending-work-progress.js'
|
|
239
|
-
import { writeSilentEndState, clearSilentEndState, recordUndeliveredTurnEnd, silentEndFallbackText, type SilentEndDeps } from '../silent-end.js'
|
|
240
|
+
import { writeSilentEndState, clearSilentEndState, recordUndeliveredTurnEnd, silentEndFallbackText, decideCapturedProseDelivery, settleCapturedProseDelivery, CAPTURED_PROSE_MIN_CHARS, type SilentEndDeps, type CapturedProseSendOutcome } from '../silent-end.js'
|
|
240
241
|
import { isFinalAnswerReply, isSubstantiveFinalReply, FINAL_ANSWER_MIN_CHARS } from '../final-answer-detect.js'
|
|
241
242
|
import { deriveTurnRole, decideTerminalReason, parsePostAnswerLivenessMs, evaluatePostAnswerLiveness, type LoopRole } from '../turn-liveness-floor.js'
|
|
242
243
|
import { createAnswerStream, type AnswerStreamHandle } from '../answer-stream.js'
|
|
@@ -317,7 +318,7 @@ import {
|
|
|
317
318
|
import { recordOperatorEvent } from '../operator-events-history.js'
|
|
318
319
|
import {
|
|
319
320
|
parseLlmError,
|
|
320
|
-
|
|
321
|
+
renderLlmErrorSafe,
|
|
321
322
|
decideErrorSurface,
|
|
322
323
|
} from '../llm-error-present.js'
|
|
323
324
|
import {
|
|
@@ -399,6 +400,11 @@ import {
|
|
|
399
400
|
decideTurnFlush,
|
|
400
401
|
isTurnFlushSafetyEnabled,
|
|
401
402
|
} from '../turn-flush-safety.js'
|
|
403
|
+
// PR A — deterministic answer-ready quiescence flush (late-delivery fix).
|
|
404
|
+
import {
|
|
405
|
+
AnswerReadyFlushController,
|
|
406
|
+
resolveAnswerReadyFlushMs,
|
|
407
|
+
} from '../answer-ready-flush.js'
|
|
402
408
|
// #1667 — pure decision core for the turn_end answer-delivery gate (#1664).
|
|
403
409
|
import { decideTurnEndGate } from './turn-end-gate.js'
|
|
404
410
|
// #1122 PR3: turn-flush-prose-recovery removed with the progress card.
|
|
@@ -514,7 +520,7 @@ import { resolveOutboundTopic as resolveOutboundTopicHelper, topicForRecipient,
|
|
|
514
520
|
import { readTurnUsages } from '../../src/agents/perf.js'
|
|
515
521
|
import { buildContextOccupancy, writeContextOccupancySnapshot } from './context-occupancy.js'
|
|
516
522
|
import { decideProactiveCompact, initialCompactState, type CompactState } from './proactive-compact.js'
|
|
517
|
-
import {
|
|
523
|
+
import { IdleTracker, idleDurationToMs, DEFAULT_IDLE_CLEAR_MS } from './idle-clear.js'
|
|
518
524
|
import { nextCompactNotify, idleCompactNotifyState, type CompactNotifyState } from './compact-notify.js'
|
|
519
525
|
import {
|
|
520
526
|
tryHostdDispatch,
|
|
@@ -545,7 +551,7 @@ import { handleRequestDriveApproval } from './drive-write-approval.js'
|
|
|
545
551
|
import { handleRequestMs365Approval } from './ms365-write-approval.js'
|
|
546
552
|
import { buildDiffPreviewCard } from './diff-preview-card.js'
|
|
547
553
|
import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick } from './pending-inbound-buffer.js'
|
|
548
|
-
import { isCronIdentity, deliverInjectWithFallback } from './cron-session.js'
|
|
554
|
+
import { isCronIdentity, isCronInjectFire, deliverInjectWithFallback } from './cron-session.js'
|
|
549
555
|
import {
|
|
550
556
|
ObligationLedger,
|
|
551
557
|
buildObligationRepresentInbound,
|
|
@@ -608,6 +614,11 @@ import { resolveAnswerThreadId } from './answer-thread-resolve.js'
|
|
|
608
614
|
import { resolveChatIdFallback } from './chat-id-fallback.js'
|
|
609
615
|
import { decideObligationTurnEnd } from './obligation-turn-end.js'
|
|
610
616
|
import { maybeRotate } from './turns-jsonl-rotate.js'
|
|
617
|
+
import {
|
|
618
|
+
buildTurnRecord,
|
|
619
|
+
finalizeBackstopSend,
|
|
620
|
+
type DeliveryOutcome,
|
|
621
|
+
} from './turn-record-status.js'
|
|
611
622
|
import {
|
|
612
623
|
createDeliveryQueue,
|
|
613
624
|
trackDelivery,
|
|
@@ -713,6 +724,7 @@ import { redact } from '../secret-detect/redact.js'
|
|
|
713
724
|
import { classifyAdminGate } from '../admin-commands/index.js'
|
|
714
725
|
import {
|
|
715
726
|
startSubagentWatcher,
|
|
727
|
+
resolveInflightTerminalCapMs,
|
|
716
728
|
type SubagentWatcherHandle,
|
|
717
729
|
} from '../subagent-watcher.js'
|
|
718
730
|
import { listRecords as listWorktreeRecords, touchHeartbeat as touchWorktreeHeartbeat } from '../../src/worktree/registry.js'
|
|
@@ -2015,6 +2027,14 @@ function resolveSubagentOriginChat(
|
|
|
2015
2027
|
* hits the fallback). A gateway restart clears it.
|
|
2016
2028
|
*/
|
|
2017
2029
|
const WORKER_FEED_FALLBACK_LOG_CAP = 256
|
|
2030
|
+
/**
|
|
2031
|
+
* Margin added to the watcher's in-flight terminal cap to form the worker-feed
|
|
2032
|
+
* backstop TTL (`staleWorkerTtlMs`). The feed reaps a row only once it has been
|
|
2033
|
+
* silent for cap + this margin — comfortably past the watcher's own terminal-
|
|
2034
|
+
* transition latency, so the backstop can only ever bite a genuinely ghosted
|
|
2035
|
+
* slot, never a live-but-quiet worker. 5 min.
|
|
2036
|
+
*/
|
|
2037
|
+
const WORKER_FEED_STALE_TTL_MARGIN_MS = 5 * 60_000
|
|
2018
2038
|
const workerFeedOwnerDmFallbackLogged = new Set<string>()
|
|
2019
2039
|
|
|
2020
2040
|
/**
|
|
@@ -3039,6 +3059,16 @@ type CurrentTurn = {
|
|
|
3039
3059
|
// even though `replyCalled` is true — the #1664 case where the real answer
|
|
3040
3060
|
// ended up as plain transcript text rendered into an ephemeral draft.
|
|
3041
3061
|
finalAnswerDelivered: boolean
|
|
3062
|
+
// PR B (send-honesty). The REAL fate of a backstop (turn-flush) send,
|
|
3063
|
+
// stamped only AFTER the async send resolves inside the IIFE — success →
|
|
3064
|
+
// 'delivered', throw/partial → 'failed', reply-tool-already-sent
|
|
3065
|
+
// short-circuit → 'suppressed'. `emitTurnRecord` derives the recorded
|
|
3066
|
+
// status from THIS (via `computeTurnStatus`) rather than the speculative
|
|
3067
|
+
// `finalAnswerDelivered` flag, so a flood-dropped answer is logged
|
|
3068
|
+
// `send_failed` instead of a false `complete`. Undefined on the synchronous
|
|
3069
|
+
// turn-end paths (reply-tool tail, silent-marker, genuine no-reply), where
|
|
3070
|
+
// the legacy `finalAnswerDelivered` reading still applies unchanged.
|
|
3071
|
+
deliveryOutcome?: DeliveryOutcome
|
|
3042
3072
|
// Feed-reopen-after-ack refinement — whether the reply that set
|
|
3043
3073
|
// `finalAnswerDelivered` was a *substantive* final answer (stream
|
|
3044
3074
|
// `done`, or ≥200 chars) as opposed to a short pinging interim ACK.
|
|
@@ -3100,6 +3130,14 @@ type CurrentTurn = {
|
|
|
3100
3130
|
silentAnchorText: string
|
|
3101
3131
|
capturedText: string[]
|
|
3102
3132
|
orphanedReplyTimeoutId: ReturnType<typeof setTimeout> | null
|
|
3133
|
+
// PR A — answer-ready quiescence flush timer. Armed in `case 'text'` once the
|
|
3134
|
+
// turn has a genuine composed terminal answer and is quiescent; fires a
|
|
3135
|
+
// positive `answer-ready-quiescence` synthetic turn_end after the ~1 s
|
|
3136
|
+
// debounce so the answer is delivered deterministically instead of waiting on
|
|
3137
|
+
// the unreliable turn_duration signal or the ~150 s orphaned-reply backstop.
|
|
3138
|
+
// Cleared on any tool activity, on `endCurrentTurnAtomic`, and re-armed on
|
|
3139
|
+
// each subsequent text chunk (the debounce).
|
|
3140
|
+
answerReadyFlushTimeoutId: ReturnType<typeof setTimeout> | null
|
|
3103
3141
|
// Per-turn liveness tracker for the orphaned-reply backstop. Owns
|
|
3104
3142
|
// `lastStreamEventAt` (stamped on ANY genuine stream event so a model
|
|
3105
3143
|
// reasoning pause keeps the turn "recently streaming" and re-arms the fuse
|
|
@@ -3212,17 +3250,19 @@ type CurrentTurn = {
|
|
|
3212
3250
|
// (via `renderActivityFeed`) as a capped chronological list into the
|
|
3213
3251
|
// in-place edited activity message and clears on reply. Reset per turn.
|
|
3214
3252
|
mirrorLines: string[]
|
|
3215
|
-
// Narrative-dedup gate state (JSONL-text-narrative primitive). A `text`
|
|
3216
|
-
//
|
|
3217
|
-
//
|
|
3218
|
-
//
|
|
3219
|
-
//
|
|
3220
|
-
//
|
|
3221
|
-
//
|
|
3222
|
-
//
|
|
3223
|
-
//
|
|
3224
|
-
//
|
|
3225
|
-
|
|
3253
|
+
// Narrative-dedup gate state (JSONL-text-narrative primitive). A `text` block
|
|
3254
|
+
// is parked for ONE lookahead step so the next event (a tool_use or turn_end)
|
|
3255
|
+
// can decide draft-then-send (SUPPRESS, it duplicates the reply) vs
|
|
3256
|
+
// working-narration (SHOW it as a transient mirrorLines step). The pure park /
|
|
3257
|
+
// timer / retract state machine lives in `narrative-flush.ts`; this controller
|
|
3258
|
+
// is the per-turn instance, wired with the SHOW effect (`showNarrativeStep`),
|
|
3259
|
+
// the RETRACT effect (splice the timer-painted line out of `mirrorLines`), and
|
|
3260
|
+
// a real `unref`'d `setTimeout` scheduler for the ~`PENDING_NARRATIVE_FLUSH_MS`
|
|
3261
|
+
// early paint. Reset per turn. Invariant `chat-is-the-single-source-of-truth`:
|
|
3262
|
+
// a SHOWN narrative renders through the SAME appendActivityLabel→renderStepFeed
|
|
3263
|
+
// path as a tool step — transient, clipped, rolling-window, never a persisted
|
|
3264
|
+
// parallel mirror.
|
|
3265
|
+
narrativeGate: NarrativeFlushController
|
|
3226
3266
|
// Most-recently-seen reply/stream_reply `input.text` for this turn — the
|
|
3227
3267
|
// ACTUAL delivered answer surface. Set wherever a REPLY_TOOL tool_use is
|
|
3228
3268
|
// handled in the reducer. `flushPendingNarrativeAtTurnEnd` compares a
|
|
@@ -4738,14 +4778,19 @@ function releaseTurnBufferGate(key: string, endingTurn?: CurrentTurn): void {
|
|
|
4738
4778
|
function emitTurnRecord(turn: CurrentTurn, endedAt: number): void {
|
|
4739
4779
|
try {
|
|
4740
4780
|
const rec =
|
|
4741
|
-
JSON.stringify(
|
|
4742
|
-
|
|
4743
|
-
|
|
4744
|
-
|
|
4745
|
-
|
|
4746
|
-
|
|
4747
|
-
|
|
4748
|
-
|
|
4781
|
+
JSON.stringify(
|
|
4782
|
+
buildTurnRecord(
|
|
4783
|
+
{
|
|
4784
|
+
agent: process.env.SWITCHROOM_AGENT_NAME ?? 'unknown',
|
|
4785
|
+
startedAt: turn.startedAt,
|
|
4786
|
+
toolCallCount: turn.toolCallCount ?? 0,
|
|
4787
|
+
turnId: turn.turnId,
|
|
4788
|
+
finalAnswerDelivered: turn.finalAnswerDelivered,
|
|
4789
|
+
deliveryOutcome: turn.deliveryOutcome,
|
|
4790
|
+
},
|
|
4791
|
+
endedAt,
|
|
4792
|
+
),
|
|
4793
|
+
) + '\n'
|
|
4749
4794
|
const turnsPath = '/state/agent/turns.jsonl'
|
|
4750
4795
|
// Size-cap rotation: keep at most one rotated generation so the file can't
|
|
4751
4796
|
// grow unbounded on a long-lived agent. Best-effort (never throws).
|
|
@@ -4765,7 +4810,10 @@ function emitTurnRecord(turn: CurrentTurn, endedAt: number): void {
|
|
|
4765
4810
|
}
|
|
4766
4811
|
}
|
|
4767
4812
|
|
|
4768
|
-
function endCurrentTurnAtomic(
|
|
4813
|
+
function endCurrentTurnAtomic(
|
|
4814
|
+
turn: CurrentTurn,
|
|
4815
|
+
opts?: { deferRecord?: boolean },
|
|
4816
|
+
): number | null {
|
|
4769
4817
|
// PR-4e — keyed liveness + keyed clear (leak-close-at-origin). Flag-OFF: the
|
|
4770
4818
|
// guard is `currentTurn === turn` and the clear nulls the singleton, verbatim.
|
|
4771
4819
|
// Flag-ON: the guard becomes `byKey.get(turn'sKey) === turn` (so a flip to
|
|
@@ -4774,7 +4822,12 @@ function endCurrentTurnAtomic(turn: CurrentTurn): void {
|
|
|
4774
4822
|
// `turn`. `endCurrentTurnForKey` returns false (no delete) when the entry no
|
|
4775
4823
|
// longer matches — the same early-return semantics as the old `!== turn` guard.
|
|
4776
4824
|
const key = statusKey(turn.sessionChatId, turn.sessionThreadId)
|
|
4777
|
-
if (!turnLiveForItsTopic(turn)) return
|
|
4825
|
+
if (!turnLiveForItsTopic(turn)) return null
|
|
4826
|
+
// PR A — the turn is ending (this is the ONE place every turn-end path funnels
|
|
4827
|
+
// through, incl. the answer-ready flush's own synthetic turn_end). Clear the
|
|
4828
|
+
// quiescence timer so a real turn_end that lands first cancels a pending flush,
|
|
4829
|
+
// guaranteeing exactly-once delivery.
|
|
4830
|
+
clearAnswerReadyFlushTimeout(turn)
|
|
4778
4831
|
endCurrentTurnForKey(turn, key) // currentTurnByKey.delete(key) + mirror clear
|
|
4779
4832
|
// Status-surface observability: one line at every turn CLEAR (with how far
|
|
4780
4833
|
// the turn got), plus a DEGRADED warning when the turn did tool work but the
|
|
@@ -4783,7 +4836,15 @@ function endCurrentTurnAtomic(turn: CurrentTurn): void {
|
|
|
4783
4836
|
process.stderr.write(
|
|
4784
4837
|
`telegram gateway: ${formatTurnLifecycle('clear', 'turn_end', turn, turnEndedAt)}\n`,
|
|
4785
4838
|
)
|
|
4786
|
-
|
|
4839
|
+
// PR B — the turn-flush backstop defers the record write to its async send
|
|
4840
|
+
// IIFE (passing `{ deferRecord: true }`) so the recorded `status` reflects the
|
|
4841
|
+
// REAL send outcome (`turn.deliveryOutcome`) rather than the speculative
|
|
4842
|
+
// `finalAnswerDelivered` flag set before the send ran. All synchronous
|
|
4843
|
+
// turn-end paths still emit here, unchanged. `turnEndedAt` is returned so the
|
|
4844
|
+
// deferred caller stamps the same ended-at (stable `duration_ms`).
|
|
4845
|
+
if (opts?.deferRecord !== true) {
|
|
4846
|
+
emitTurnRecord(turn, turnEndedAt)
|
|
4847
|
+
}
|
|
4787
4848
|
const degraded = detectStatusSurfaceDegraded(turn)
|
|
4788
4849
|
if (degraded != null) {
|
|
4789
4850
|
process.stderr.write(
|
|
@@ -4831,6 +4892,11 @@ function endCurrentTurnAtomic(turn: CurrentTurn): void {
|
|
|
4831
4892
|
clearTimeout(turn.noReplyDrainTimer)
|
|
4832
4893
|
turn.noReplyDrainTimer = null
|
|
4833
4894
|
}
|
|
4895
|
+
// Teardown the narrative gate's early-paint timer so it can neither leak past
|
|
4896
|
+
// the turn nor fire against a torn-down turn. (Idempotent — no-op when never
|
|
4897
|
+
// armed / already fired. `flushPendingNarrativeAtTurnEnd` on the turn_end event
|
|
4898
|
+
// normally disarms it first; this is the belt-and-braces teardown net.)
|
|
4899
|
+
turn.narrativeGate?.teardown()
|
|
4834
4900
|
// Pass `turn` so purgeReactionTracking sees the authoritative
|
|
4835
4901
|
// replyCalled flag even though we just nulled module-scope
|
|
4836
4902
|
// currentTurn. Without this, the shadow trace's outboundEmitted
|
|
@@ -4846,6 +4912,7 @@ function endCurrentTurnAtomic(turn: CurrentTurn): void {
|
|
|
4846
4912
|
// wedging forever. No-op when this turn delivered, when nothing is
|
|
4847
4913
|
// buffered, or when the serialize feature is off.
|
|
4848
4914
|
armNoReplyDrainTimer(turn)
|
|
4915
|
+
return turnEndedAt
|
|
4849
4916
|
}
|
|
4850
4917
|
|
|
4851
4918
|
/**
|
|
@@ -4961,31 +5028,25 @@ function maybeProactiveCompact(): void {
|
|
|
4961
5028
|
// turn, so this runs on its own interval. "Idle" means NOTHING HAS HAPPENED
|
|
4962
5029
|
// since the last thing happened — inbound, cron fire, or ANY claude session
|
|
4963
5030
|
// event (turn start, tool call, tool result, text, sub-agent event, turn end)
|
|
4964
|
-
// resets the timer via
|
|
4965
|
-
//
|
|
5031
|
+
// resets the timer via idleTracker.noteInbound/noteEvent; a turn ending
|
|
5032
|
+
// additionally stamps the turn-end clock. Fires once per idle period; never mid-turn
|
|
4966
5033
|
// (turnInFlightForGate, the same gate compaction uses).
|
|
4967
5034
|
//
|
|
4968
5035
|
// It is emphatically NOT "no turn has *started* recently" — that reading wiped
|
|
4969
5036
|
// overlord's 3h of working context on 2026-07-11 for the crime of being busy.
|
|
4970
5037
|
// See the idle-clear.ts header.
|
|
4971
|
-
|
|
4972
|
-
let
|
|
4973
|
-
|
|
4974
|
-
|
|
5038
|
+
// #3115 — idle bookkeeping is ONE stateful object the gateway holds, not four
|
|
5039
|
+
// bare `let`s + scattered inline stamp/decide logic. The tracker owns the
|
|
5040
|
+
// clocks (lastActivity / lastTurnEnd) and the fire-once + re-entrancy latches;
|
|
5041
|
+
// the gateway feeds it environment inputs (window, turn gate, background-work
|
|
5042
|
+
// suppressor) at decision time. Extracting it makes the wiring importable and
|
|
5043
|
+
// testable — a deleted stamp call now fails a test instead of silently
|
|
5044
|
+
// re-introducing the #3113 "productive work gets wiped" bug. See idle-clear.ts.
|
|
5045
|
+
const idleTracker = new IdleTracker(Date.now());
|
|
4975
5046
|
|
|
4976
5047
|
/** Reset the idle timer + re-arm auto-clear. Call on ANY activity. */
|
|
4977
5048
|
function markIdleActivity(): void {
|
|
4978
|
-
|
|
4979
|
-
idleAutoCleared = false;
|
|
4980
|
-
}
|
|
4981
|
-
|
|
4982
|
-
/**
|
|
4983
|
-
* Stamp "a turn just ended". The idle window is measured from
|
|
4984
|
-
* max(lastActivityAt, lastTurnEndedAt), so a turn that ran LONGER than the
|
|
4985
|
-
* window can't be cleared on the first tick after `turnInFlight` goes false.
|
|
4986
|
-
*/
|
|
4987
|
-
function markIdleTurnEnd(): void {
|
|
4988
|
-
lastIdleTurnEndAt = Date.now();
|
|
5049
|
+
idleTracker.noteInbound(Date.now());
|
|
4989
5050
|
}
|
|
4990
5051
|
|
|
4991
5052
|
/** Idle window in ms: env override → per-agent config → 3h default. 0 disables. */
|
|
@@ -5012,40 +5073,77 @@ function resolveIdleClearMs(): number {
|
|
|
5012
5073
|
|
|
5013
5074
|
/** Evaluate idle auto-clear (runs on IDLE_CLEAR_CHECK_MS interval). */
|
|
5014
5075
|
function maybeIdleClear(): void {
|
|
5015
|
-
if (
|
|
5076
|
+
if (idleTracker.isDispatching) return;
|
|
5016
5077
|
const agentName = process.env.SWITCHROOM_AGENT_NAME;
|
|
5017
5078
|
if (!agentName) return;
|
|
5018
5079
|
const idleClearMs = resolveIdleClearMs();
|
|
5019
|
-
const decision =
|
|
5020
|
-
|
|
5021
|
-
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
|
|
5025
|
-
|
|
5026
|
-
|
|
5027
|
-
|
|
5028
|
-
|
|
5080
|
+
const decision = idleTracker.decide(Date.now(), {
|
|
5081
|
+
idleClearMs,
|
|
5082
|
+
turnInFlight: turnInFlightForGate(),
|
|
5083
|
+
// #3117 — TTL-bounded background-work suppressor. A detached sub-agent
|
|
5084
|
+
// (Agent/Task dispatched, main turn ended before it returned) is invisible
|
|
5085
|
+
// to turnInFlightForGate(); consult the pending-dispatch flag directly so a
|
|
5086
|
+
// silent long-running worker isn't /clear'ed out from under its handback.
|
|
5087
|
+
// The TTL keeps a leaked flag from disabling idle-clear forever.
|
|
5088
|
+
backgroundWorkInFlight: pendingProgress.anyPendingAsyncDispatchWithin(
|
|
5089
|
+
pendingProgress.BACKGROUND_WORK_SUPPRESS_TTL_MS,
|
|
5090
|
+
),
|
|
5091
|
+
});
|
|
5029
5092
|
if (!decision.clear) return;
|
|
5030
5093
|
// Fire once per idle period — set BEFORE the await so the next tick can't
|
|
5031
5094
|
// double-dispatch. markIdleActivity() re-arms on the next real activity.
|
|
5032
|
-
|
|
5033
|
-
|
|
5095
|
+
idleTracker.markClearFired();
|
|
5096
|
+
idleTracker.beginDispatch();
|
|
5034
5097
|
process.stderr.write(
|
|
5035
5098
|
`telegram gateway: idle auto-/clear for ${agentName} ` +
|
|
5036
5099
|
`(idle >= ${Math.round(idleClearMs / 60_000)}m)\n`,
|
|
5037
5100
|
);
|
|
5038
|
-
//
|
|
5039
|
-
// could arrive between the gate
|
|
5040
|
-
// claude's prompt buffer and runs at
|
|
5041
|
-
|
|
5101
|
+
// #3116 — write-time precondition closes (most of) the check-to-send race.
|
|
5102
|
+
// A new inbound could arrive between the gate decision above and the tmux
|
|
5103
|
+
// send; without a guard /clear lands in claude's prompt buffer and runs at
|
|
5104
|
+
// the next idle prompt (inject.ts FUTURE-GAP), clobbering that inbound's
|
|
5105
|
+
// session. The precondition below is re-evaluated INSIDE the pane lock,
|
|
5106
|
+
// immediately before send-keys, and RE-RUNS THE FULL idle decision against
|
|
5107
|
+
// the live clocks + turnInFlightForGate() — not a bespoke "activity
|
|
5108
|
+
// unchanged" check — so any new activity in the gap suppresses the /clear
|
|
5109
|
+
// (and so a future background-work input to the idle decision is honoured at
|
|
5110
|
+
// write time for free). Residual: an inbound landing AFTER send-keys but
|
|
5111
|
+
// before claude submits the buffered /clear is still clobbered — full
|
|
5112
|
+
// closure needs buffer-cancel (tracked as a follow-up issue).
|
|
5113
|
+
// alreadyCleared is latched true above for re-entrancy; decideIgnoringLatch
|
|
5114
|
+
// judges idleness on the live clocks + turn/background gates only, so any
|
|
5115
|
+
// activity that arrived in the check-to-send gap still suppresses the /clear.
|
|
5116
|
+
// #3117 — the background-work suppressor is re-sampled at write time too, so a
|
|
5117
|
+
// sub-agent dispatched in the gap (or still fresh within its TTL) suppresses
|
|
5118
|
+
// the buffered /clear. This composes with #3116's write-time re-eval
|
|
5119
|
+
// automatically — same tracker/decider, live inputs.
|
|
5120
|
+
const stillIdleAtWrite = (): boolean =>
|
|
5121
|
+
idleTracker.decideIgnoringLatch(Date.now(), {
|
|
5122
|
+
idleClearMs: resolveIdleClearMs(),
|
|
5123
|
+
turnInFlight: turnInFlightForGate(),
|
|
5124
|
+
backgroundWorkInFlight: pendingProgress.anyPendingAsyncDispatchWithin(
|
|
5125
|
+
pendingProgress.BACKGROUND_WORK_SUPPRESS_TTL_MS,
|
|
5126
|
+
),
|
|
5127
|
+
}).clear;
|
|
5128
|
+
void injectSlashCommandImpl(agentName, '/clear', { precondition: stillIdleAtWrite })
|
|
5129
|
+
.then((result) => {
|
|
5130
|
+
if (result.outcome === 'skipped') {
|
|
5131
|
+
// Activity arrived in the check-to-send gap; re-arm so the next idle
|
|
5132
|
+
// period can clear again rather than staying latched.
|
|
5133
|
+
idleTracker.reArm();
|
|
5134
|
+
process.stderr.write(
|
|
5135
|
+
`telegram gateway: idle /clear suppressed for ${agentName} ` +
|
|
5136
|
+
`(activity in check-to-send gap)\n`,
|
|
5137
|
+
);
|
|
5138
|
+
}
|
|
5139
|
+
})
|
|
5042
5140
|
.catch((err: unknown) => {
|
|
5043
5141
|
process.stderr.write(
|
|
5044
5142
|
`telegram gateway: idle /clear inject failed for ` +
|
|
5045
5143
|
`${agentName}: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
5046
5144
|
);
|
|
5047
5145
|
})
|
|
5048
|
-
.finally(() => {
|
|
5146
|
+
.finally(() => { idleTracker.endDispatch(); });
|
|
5049
5147
|
}
|
|
5050
5148
|
|
|
5051
5149
|
/**
|
|
@@ -7690,6 +7788,21 @@ const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
|
|
|
7690
7788
|
function emitGatewayOperatorEvent(event: OperatorEvent): void {
|
|
7691
7789
|
const { agent, kind } = event
|
|
7692
7790
|
|
|
7791
|
+
// #llm-error-surfacing FIX 2 (secret leak): the operator-event cards are sent
|
|
7792
|
+
// via a raw bot.api.sendRichMessage that BYPASSES the normal outbound redact
|
|
7793
|
+
// chokepoint (normalizeOutboundBody → redact, outbound-send-path.ts). The only
|
|
7794
|
+
// scrub the renderers apply is stripRawErrorBytes — a JSON-SHAPE scrub, NOT a
|
|
7795
|
+
// secret scrubber — so a bearer token / `sk-…` key / url-embedded credential
|
|
7796
|
+
// smuggled in an error `detail` would reach the operator card verbatim on the
|
|
7797
|
+
// credentials-expired / credit-exhausted / unknown-4xx paths. Redact the detail
|
|
7798
|
+
// ONCE here, up front, through the SAME redact() the reply path uses — and
|
|
7799
|
+
// crucially BEFORE renderOperatorEvent runs escapeMarkdown on it (redacting the
|
|
7800
|
+
// already-escaped text would let url-query-param secrets slip past url-redact,
|
|
7801
|
+
// exactly the order the outbound pipeline documents: redact before markdown).
|
|
7802
|
+
// Doing it at the top also scrubs the recorded operator-event history and the
|
|
7803
|
+
// 429 metrics — defense in depth, no secret survives in ANY downstream sink.
|
|
7804
|
+
event = { ...event, detail: redactOutboundText(event.detail, 'operator_event') }
|
|
7805
|
+
|
|
7693
7806
|
// ── 429 throttle tier (operator spec: "retry in place under 5 min, else
|
|
7694
7807
|
// mark + failover, honest reset messaging") ────────────────────────────
|
|
7695
7808
|
// A terminal TRANSIENT ACCOUNT-scoped 429 — kind `rate-limited` carrying
|
|
@@ -7957,9 +8070,16 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
|
|
|
7957
8070
|
return
|
|
7958
8071
|
}
|
|
7959
8072
|
const tz = process.env.SWITCHROOM_TIMEZONE ?? process.env.TZ ?? 'UTC'
|
|
7960
|
-
|
|
8073
|
+
// #llm-error-surfacing FIX 3 (crash guard): renderLlmErrorSafe wraps the
|
|
8074
|
+
// tz-formatting render — an invalid IANA `SWITCHROOM_TIMEZONE`/`TZ` throws a
|
|
8075
|
+
// RangeError out of Intl.DateTimeFormat (local-time.ts's "never throws" claim
|
|
8076
|
+
// does NOT hold for construction-time zone validation). Pre-fix this branch
|
|
8077
|
+
// had no guard, so a bad tz crashed the whole operator-event turn; now it
|
|
8078
|
+
// degrades to a minimal tz-free line. There are no action buttons on this
|
|
8079
|
+
// card (FIX 1) — the humanized text carries any recommendation inline.
|
|
8080
|
+
const r = renderLlmErrorSafe(parsed, agent, tz, new Date(now))
|
|
7961
8081
|
renderedText = r.text
|
|
7962
|
-
renderedKeyboard =
|
|
8082
|
+
renderedKeyboard = undefined
|
|
7963
8083
|
} else {
|
|
7964
8084
|
try {
|
|
7965
8085
|
const r = renderOperatorEvent(event)
|
|
@@ -8103,6 +8223,10 @@ const STREAM_THROTTLE_MS_OVERRIDE: number | undefined = (() => {
|
|
|
8103
8223
|
})()
|
|
8104
8224
|
const TURN_FLUSH_SAFETY_ENABLED = isTurnFlushSafetyEnabled()
|
|
8105
8225
|
|
|
8226
|
+
// PR A — answer-ready quiescence flush debounce (ms). 0 = kill-switch (never
|
|
8227
|
+
// arm). Resolved once at boot; env-tunable via SWITCHROOM_ANSWER_READY_FLUSH_MS.
|
|
8228
|
+
const ANSWER_READY_FLUSH_MS = resolveAnswerReadyFlushMs(process.env)
|
|
8229
|
+
|
|
8106
8230
|
// When SET, the answer-lane stream (telegram-plugin/answer-stream.ts) renders
|
|
8107
8231
|
// the model's transcript text as a USER-VISIBLE edit-in-place message. Default
|
|
8108
8232
|
// OFF: the lane stays dormant and the reply tool is the single canonical
|
|
@@ -9155,6 +9279,17 @@ const SILENCE_FLOOR_MS = parsePositiveMsEnv('SWITCHROOM_SILENCE_FLOOR_MS', 45_00
|
|
|
9155
9279
|
// #2527 — role-aware terminal reaction honesty (the "thumbs-up false done"
|
|
9156
9280
|
// fix). Default ON; SWITCHROOM_TG_TERMINAL_HONESTY=0 reverts to always-👍.
|
|
9157
9281
|
const LIVENESS_TERMINAL_HONESTY = process.env.SWITCHROOM_TG_TERMINAL_HONESTY !== '0'
|
|
9282
|
+
// Option A transcript-prose delivery bridge. When a user turn ends without a
|
|
9283
|
+
// final answer, the Stop hook scans transcript_path and — if it isolated a
|
|
9284
|
+
// substantive answer the model wrote as plain text but never sent through the
|
|
9285
|
+
// reply tool — persists it in silent-end-pending.json as `pendingText`. On the
|
|
9286
|
+
// FIRST silent-end the gateway reads it back and delivers it directly via the
|
|
9287
|
+
// normal send path, instead of relying on the (unreliable) Stop-hook re-prompt
|
|
9288
|
+
// or waiting ~2-5 min for the obligation represent to recover it. Default ON;
|
|
9289
|
+
// SWITCHROOM_TG_CAPTURED_PROSE_DELIVERY=0 reverts to the pre-bridge behaviour
|
|
9290
|
+
// (re-prompt + represent only).
|
|
9291
|
+
const CAPTURED_PROSE_DELIVERY_ENABLED =
|
|
9292
|
+
process.env.SWITCHROOM_TG_CAPTURED_PROSE_DELIVERY !== '0'
|
|
9158
9293
|
// SILENCE_DEFER_INFLIGHT_TOOLS: previously an opt-in (=1). The new
|
|
9159
9294
|
// isLegitimatelyWorking callback supersedes this — defer is now the DEFAULT
|
|
9160
9295
|
// when the callback is wired. The legacy flag is kept so `=0` still lets
|
|
@@ -9967,6 +10102,250 @@ function agentHasInFlightBackgroundWork(now: number): boolean {
|
|
|
9967
10102
|
// Throttle for the background-work defer diagnostic (the 5s sweep would otherwise
|
|
9968
10103
|
// log every tick across a multi-minute research window).
|
|
9969
10104
|
let lastBgWorkDeferLogMs = 0
|
|
10105
|
+
/**
|
|
10106
|
+
* Option A transcript-prose delivery. Deliver the model's real final answer —
|
|
10107
|
+
* isolated by the Stop hook's transcript scan and persisted as `pendingText`
|
|
10108
|
+
* — directly via the normal send path on the first silent-end, then settle the
|
|
10109
|
+
* bookkeeping so neither the obligation represent nor the exhausted fallback
|
|
10110
|
+
* re-fires for the same answer.
|
|
10111
|
+
*
|
|
10112
|
+
* Double-send guard — content + turnKey dedup, NOT unconditional (Finding 4,
|
|
10113
|
+
* #3228: the earlier "airtight by construction" claim overstated it):
|
|
10114
|
+
* - Before sending, `outboundDedup.check` is consulted. If the EXACT same
|
|
10115
|
+
* content already went out under the SAME `registryKey` (a prior turn_end
|
|
10116
|
+
* delivered it, or the re-prompted model's own reply landed), we SKIP the
|
|
10117
|
+
* send and only settle bookkeeping.
|
|
10118
|
+
* - After a successful send, `outboundDedup.record` is written so a late
|
|
10119
|
+
* reply-tool retry with the same content is suppressed at its send site
|
|
10120
|
+
* (executeReply / executeStreamReply already consult the same cache).
|
|
10121
|
+
* - The obligation is CLOSED and the silent-end state CLEARED, so
|
|
10122
|
+
* obligationSweep's "outbound delivered since open" guard and the represent
|
|
10123
|
+
* never re-fire for this origin.
|
|
10124
|
+
*
|
|
10125
|
+
* Divergence boundary (honest scope): the dedup key is
|
|
10126
|
+
* `(chat, thread, normalized-content, registryKey)`. It suppresses the common
|
|
10127
|
+
* double-send — the SAME answer re-sent within the TTL under the same
|
|
10128
|
+
* registryKey. It does NOT catch a re-present that lands under a DIFFERENT
|
|
10129
|
+
* registryKey with REWORDED content (different hash AND different key → the
|
|
10130
|
+
* cross-turn carve-out in recent-outbound-dedup.ts:150-156 treats two non-null
|
|
10131
|
+
* differing turnKeys as a miss): that is a genuinely different outbound and is
|
|
10132
|
+
* out of scope for content-hash dedup. The obligation close + state clear are
|
|
10133
|
+
* what make captured-prose and the represent mutually exclusive for the
|
|
10134
|
+
* common case; the content dedup is a second, best-effort layer.
|
|
10135
|
+
*
|
|
10136
|
+
* Failure posture (Finding 1 + exhaustion-boundary gap, #3228): if the send
|
|
10137
|
+
* throws, the catch below arms the deterministic Stop-hook re-prompt via
|
|
10138
|
+
* recordUndeliveredTurnEnd (the shared teardown may have already closed the
|
|
10139
|
+
* obligation when replyCalled was true, so "leaving the obligation open" is NOT
|
|
10140
|
+
* a sufficient net on its own). Two sub-cases, honored via the `{exhausted}`
|
|
10141
|
+
* verdict threaded out of settleCapturedProseDelivery:
|
|
10142
|
+
* - budget REMAINING (`exhausted:false`) → the silent-end state is (re)written
|
|
10143
|
+
* and the Stop-hook re-prompt will recover the answer; nothing else to do.
|
|
10144
|
+
* - budget SPENT (`exhausted:true`) → recordUndeliveredTurnEnd has CLEARED the
|
|
10145
|
+
* state (the re-prompt can no longer fire), and the obligation was already
|
|
10146
|
+
* closed by the interim-ack teardown, so the user would get NEITHER the
|
|
10147
|
+
* answer NOR the apology. We therefore deliver a user-facing fallback: first
|
|
10148
|
+
* retry the captured prose as PLAIN TEXT (a non-rich send often survives the
|
|
10149
|
+
* markdown/parse error a rich send rejected — the user gets the real
|
|
10150
|
+
* answer), and only if THAT also fails post the generic silentEndFallbackText
|
|
10151
|
+
* apology. This mirrors the non-captured exhausted path (silent-end.ts /
|
|
10152
|
+
* gateway turn_end #1161) so the captured path is never worse than main.
|
|
10153
|
+
*/
|
|
10154
|
+
async function deliverCapturedProse(args: {
|
|
10155
|
+
chatId: string
|
|
10156
|
+
threadId: number | undefined
|
|
10157
|
+
statusKeyStr: string
|
|
10158
|
+
registryKey: string | null
|
|
10159
|
+
originTurnId: string
|
|
10160
|
+
text: string
|
|
10161
|
+
/** Turn elapsed for the honest "(waited Ns)" apology clause; optional. */
|
|
10162
|
+
turnDurationMs?: number
|
|
10163
|
+
}): Promise<void> {
|
|
10164
|
+
const { chatId, threadId, statusKeyStr, registryKey, originTurnId, text, turnDurationMs } = args
|
|
10165
|
+
const now = Date.now()
|
|
10166
|
+
// #3228 Finding 1 — the three settlement points (sent / skipped-dedup /
|
|
10167
|
+
// failed) all funnel through the pure `settleCapturedProseDelivery` core so
|
|
10168
|
+
// the failure posture is deterministic and unit-tested. `outcome` is set on
|
|
10169
|
+
// each branch and applied ONCE at the bottom.
|
|
10170
|
+
let outcome: CapturedProseSendOutcome
|
|
10171
|
+
const already = outboundDedup.check(chatId, threadId, text, now, registryKey)
|
|
10172
|
+
if (already == null) {
|
|
10173
|
+
let out = normalizeParagraphBreaks(repairEscapedWhitespace(text))
|
|
10174
|
+
out = redactOutboundText(out, 'captured_prose')
|
|
10175
|
+
const chunks = splitMarkdownChunks(out, RICH_MESSAGE_MAX_CHARS)
|
|
10176
|
+
const sentIds: number[] = []
|
|
10177
|
+
try {
|
|
10178
|
+
let liveThreadId: number | undefined = threadId
|
|
10179
|
+
for (const c of chunks) {
|
|
10180
|
+
const sent = await retryWithThreadFallback(
|
|
10181
|
+
robustApiCall,
|
|
10182
|
+
(tid) => {
|
|
10183
|
+
// Built as a variable (not an inline literal) so excess-property
|
|
10184
|
+
// checks don't reject `link_preview_options` on sendRichMessage's
|
|
10185
|
+
// narrow Other<> type — mirrors the turn-flush send site.
|
|
10186
|
+
const opts = {
|
|
10187
|
+
link_preview_options: { is_disabled: true },
|
|
10188
|
+
...(tid != null ? { message_thread_id: tid } : {}),
|
|
10189
|
+
}
|
|
10190
|
+
return bot.api.sendRichMessage(chatId, richMessage(c), opts)
|
|
10191
|
+
},
|
|
10192
|
+
{ threadId: liveThreadId, chat_id: chatId, verb: 'captured-prose.sendMessage' },
|
|
10193
|
+
)
|
|
10194
|
+
if (liveThreadId != null && (sent as { message_thread_id?: number }).message_thread_id == null) {
|
|
10195
|
+
liveThreadId = undefined
|
|
10196
|
+
}
|
|
10197
|
+
sentIds.push(sent.message_id)
|
|
10198
|
+
}
|
|
10199
|
+
if (HISTORY_ENABLED && sentIds.length > 0) {
|
|
10200
|
+
try {
|
|
10201
|
+
recordOutbound({
|
|
10202
|
+
chat_id: chatId,
|
|
10203
|
+
thread_id: threadId ?? null,
|
|
10204
|
+
message_ids: sentIds,
|
|
10205
|
+
texts: chunks,
|
|
10206
|
+
})
|
|
10207
|
+
} catch {}
|
|
10208
|
+
}
|
|
10209
|
+
// Record what we just sent so a late reply / stream_reply retry with the
|
|
10210
|
+
// same content is deduped at its send site (the #546 dedup cache).
|
|
10211
|
+
outboundDedup.record(chatId, threadId, text, now, registryKey)
|
|
10212
|
+
process.stderr.write(
|
|
10213
|
+
`telegram gateway: captured-prose delivery — sent ${out.length} chars recovered from ` +
|
|
10214
|
+
`transcript scan (chat=${chatId} origin=${originTurnId})\n`,
|
|
10215
|
+
)
|
|
10216
|
+
outcome = 'sent'
|
|
10217
|
+
} catch (err) {
|
|
10218
|
+
// #3228 Finding 1 — the send threw, so the answer did NOT reach the user.
|
|
10219
|
+
// The `failed` outcome routes to `settleCapturedProseDelivery`'s recovery
|
|
10220
|
+
// path (recordUndelivered), NOT the close/clear path. This is load-bearing:
|
|
10221
|
+
// the shared turn-end teardown (endCurrentTurnAtomic →
|
|
10222
|
+
// decideObligationTurnEnd) already closes the obligation whenever
|
|
10223
|
+
// `replyCalled === true` — exactly the interim-ack case that routes here —
|
|
10224
|
+
// so "leaving the obligation open" is not a real net. Arming the Stop-hook
|
|
10225
|
+
// re-prompt makes the failed send recoverable instead of silently lost.
|
|
10226
|
+
process.stderr.write(
|
|
10227
|
+
`telegram gateway: captured-prose delivery failed: ${(err as Error).message} — ` +
|
|
10228
|
+
`arming the silent-end re-prompt net (recordUndeliveredTurnEnd) so the ` +
|
|
10229
|
+
`answer is recoverable (chat=${chatId} origin=${originTurnId})\n`,
|
|
10230
|
+
)
|
|
10231
|
+
outcome = 'failed'
|
|
10232
|
+
}
|
|
10233
|
+
} else {
|
|
10234
|
+
process.stderr.write(
|
|
10235
|
+
`telegram gateway: captured-prose delivery skipped — this answer already went out ` +
|
|
10236
|
+
`(dedup age=${already.ageMs}ms chat=${chatId} origin=${originTurnId}); settling bookkeeping\n`,
|
|
10237
|
+
)
|
|
10238
|
+
outcome = 'skipped-dedup'
|
|
10239
|
+
}
|
|
10240
|
+
// Apply the settlement bookkeeping through the pure core (#3228 Finding 1):
|
|
10241
|
+
// sent / skipped-dedup → close obligation + clear state (answer is with the
|
|
10242
|
+
// user, so represent + exhausted fallback must not fire)
|
|
10243
|
+
// failed → arm the Stop-hook re-prompt net (recordUndelivered),
|
|
10244
|
+
// do NOT close/clear.
|
|
10245
|
+
const settlement = settleCapturedProseDelivery(outcome, {
|
|
10246
|
+
closeObligation: () => {
|
|
10247
|
+
if (OBLIGATION_LEDGER_ENABLED) {
|
|
10248
|
+
try { obligationLedger.close(originTurnId) } catch {}
|
|
10249
|
+
}
|
|
10250
|
+
},
|
|
10251
|
+
clearState: () => clearSilentEndState(statusKeyStr),
|
|
10252
|
+
recordUndelivered: () => {
|
|
10253
|
+
try {
|
|
10254
|
+
const silentEndDeps: SilentEndDeps | undefined = HISTORY_ENABLED
|
|
10255
|
+
? {
|
|
10256
|
+
hasOutboundDeliveredSince: (cid, sinceMs, tid) =>
|
|
10257
|
+
hasOutboundDeliveredSince(cid, sinceMs, tid, 1),
|
|
10258
|
+
}
|
|
10259
|
+
: undefined
|
|
10260
|
+
return recordUndeliveredTurnEnd(
|
|
10261
|
+
{ chatId, threadId: threadId ?? null, turnKey: statusKeyStr },
|
|
10262
|
+
silentEndDeps,
|
|
10263
|
+
)
|
|
10264
|
+
} catch (netErr) {
|
|
10265
|
+
process.stderr.write(
|
|
10266
|
+
`telegram gateway: captured-prose recovery-net arm failed: ${
|
|
10267
|
+
(netErr as Error).message
|
|
10268
|
+
} (chat=${chatId} origin=${originTurnId})\n`,
|
|
10269
|
+
)
|
|
10270
|
+
// Could not even record the undelivered turn — do NOT claim exhaustion
|
|
10271
|
+
// (firing a fallback we can't justify). Fail safe: leave recovery to
|
|
10272
|
+
// the obligation represent / next Stop hook.
|
|
10273
|
+
return { exhausted: false }
|
|
10274
|
+
}
|
|
10275
|
+
},
|
|
10276
|
+
})
|
|
10277
|
+
|
|
10278
|
+
// Exhaustion-boundary gap (#3228): the send FAILED on the attempt where the
|
|
10279
|
+
// Stop-hook re-prompt budget was already spent, so recordUndeliveredTurnEnd
|
|
10280
|
+
// cleared the state and the re-prompt can no longer recover the answer — and
|
|
10281
|
+
// the obligation was already closed by the interim-ack teardown. Without this
|
|
10282
|
+
// the user gets NEITHER the answer NOR the apology. Deliver a user-facing
|
|
10283
|
+
// fallback, preferring the REAL answer as plain text (a non-rich send often
|
|
10284
|
+
// survives the markdown/parse error the rich send threw on) before the
|
|
10285
|
+
// generic apology.
|
|
10286
|
+
if (outcome === 'failed' && settlement.exhausted) {
|
|
10287
|
+
process.stderr.write(
|
|
10288
|
+
`telegram gateway: WARN captured-prose exhausted-boundary fallback — rich send ` +
|
|
10289
|
+
`failed with the re-prompt budget already spent; attempting a plain-text ` +
|
|
10290
|
+
`delivery of the recovered answer before the generic apology ` +
|
|
10291
|
+
`(chat=${chatId} origin=${originTurnId})\n`,
|
|
10292
|
+
)
|
|
10293
|
+
const plain = redactOutboundText(text, 'captured_prose')
|
|
10294
|
+
const plainChunks = splitMarkdownChunks(plain, RICH_MESSAGE_MAX_CHARS)
|
|
10295
|
+
try {
|
|
10296
|
+
let liveThreadId: number | undefined = threadId
|
|
10297
|
+
for (const c of plainChunks) {
|
|
10298
|
+
// Plain sendMessage — NO parse_mode / rich rendering — so a markdown
|
|
10299
|
+
// construct that made sendRichMessage 400 is sent verbatim instead.
|
|
10300
|
+
const sent = await retryWithThreadFallback(
|
|
10301
|
+
robustApiCall,
|
|
10302
|
+
(tid) =>
|
|
10303
|
+
bot.api.sendMessage(
|
|
10304
|
+
chatId,
|
|
10305
|
+
c,
|
|
10306
|
+
tid != null ? { message_thread_id: tid } : {},
|
|
10307
|
+
),
|
|
10308
|
+
{ threadId: liveThreadId, chat_id: chatId, verb: 'captured-prose-plain-fallback.sendMessage' },
|
|
10309
|
+
)
|
|
10310
|
+
if (liveThreadId != null && (sent as { message_thread_id?: number }).message_thread_id == null) {
|
|
10311
|
+
liveThreadId = undefined
|
|
10312
|
+
}
|
|
10313
|
+
}
|
|
10314
|
+
// The real answer reached the user via plain text — record it so a late
|
|
10315
|
+
// reply-tool retry with the same content is deduped at its send site.
|
|
10316
|
+
outboundDedup.record(chatId, threadId, text, Date.now(), registryKey)
|
|
10317
|
+
process.stderr.write(
|
|
10318
|
+
`telegram gateway: captured-prose recovered via plain-text fallback ` +
|
|
10319
|
+
`(chat=${chatId} origin=${originTurnId})\n`,
|
|
10320
|
+
)
|
|
10321
|
+
} catch (plainErr) {
|
|
10322
|
+
// Plain text ALSO failed — post the generic apology so the turn is never
|
|
10323
|
+
// silent (mirrors the non-captured exhausted path, gateway turn_end #1161).
|
|
10324
|
+
process.stderr.write(
|
|
10325
|
+
`telegram gateway: captured-prose plain-text fallback ALSO failed: ${
|
|
10326
|
+
(plainErr as Error).message
|
|
10327
|
+
} — posting the generic silent-end apology (chat=${chatId} origin=${originTurnId})\n`,
|
|
10328
|
+
)
|
|
10329
|
+
void retryWithThreadFallback(
|
|
10330
|
+
robustApiCall,
|
|
10331
|
+
(tid) =>
|
|
10332
|
+
bot.api.sendMessage(
|
|
10333
|
+
chatId,
|
|
10334
|
+
silentEndFallbackText(turnDurationMs),
|
|
10335
|
+
tid != null ? { message_thread_id: tid } : {},
|
|
10336
|
+
),
|
|
10337
|
+
{ threadId, chat_id: chatId, verb: 'captured-prose-apology-fallback.sendMessage' },
|
|
10338
|
+
).catch((err) => {
|
|
10339
|
+
process.stderr.write(
|
|
10340
|
+
`telegram gateway: captured-prose apology fallback send failed: ${
|
|
10341
|
+
err instanceof Error ? err.message : String(err)
|
|
10342
|
+
}\n`,
|
|
10343
|
+
)
|
|
10344
|
+
})
|
|
10345
|
+
}
|
|
10346
|
+
}
|
|
10347
|
+
}
|
|
10348
|
+
|
|
9970
10349
|
function obligationSweep(): void {
|
|
9971
10350
|
if (!OBLIGATION_LEDGER_ENABLED) return
|
|
9972
10351
|
if (!obligationLedger.hasOpen()) return
|
|
@@ -11438,10 +11817,18 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
11438
11817
|
},
|
|
11439
11818
|
|
|
11440
11819
|
onInjectInbound(_client: IpcClient, msg: InjectInboundMessage) {
|
|
11441
|
-
//
|
|
11442
|
-
//
|
|
11443
|
-
//
|
|
11444
|
-
|
|
11820
|
+
// #3114 — do NOT unconditionally stamp the MAIN idle clock here. A cron
|
|
11821
|
+
// fire (Tier-1 cheap-cron routed to `<agent>-cron`, or a Tier-2 main-
|
|
11822
|
+
// session cron) whose cadence is shorter than `idle_clear_after` would
|
|
11823
|
+
// otherwise re-arm the timer on every fire and suppress idle-clear
|
|
11824
|
+
// permanently. After #3113 a cron that does real work already stamps the
|
|
11825
|
+
// main clock via handleSessionEvent, so the blanket fire-time stamp is
|
|
11826
|
+
// redundant for main-bridge fires and wrong for cheap-cron (whose session
|
|
11827
|
+
// events are dropped for the cron identity). Only non-cron injects
|
|
11828
|
+
// (reaction, vault grant, resume — genuine operator/session presence) stamp
|
|
11829
|
+
// at inject time. See isCronInjectFire for the documented main-session-poll
|
|
11830
|
+
// residual (a Tier-2 NO_REPLY poll still warms the clock via its real turn).
|
|
11831
|
+
if (!isCronInjectFire(msg.inbound.meta)) markIdleActivity()
|
|
11445
11832
|
const promptKey = typeof msg.inbound.meta?.prompt_key === 'string'
|
|
11446
11833
|
? msg.inbound.meta.prompt_key
|
|
11447
11834
|
: 'unknown'
|
|
@@ -15000,7 +15387,8 @@ async function executeEditMessage(args: Record<string, unknown>): Promise<unknow
|
|
|
15000
15387
|
editRawText = redactOutboundText(editRawText, 'edit_message')
|
|
15001
15388
|
// Fleet-wide consistent formatting (same order as the reply path: redact
|
|
15002
15389
|
// first so secrets are matched literally, then normalize, then spacers on
|
|
15003
|
-
// the rich path only
|
|
15390
|
+
// the rich path only — the rich GFM renderer renders `\n\n` tight, so the
|
|
15391
|
+
// idempotent U+00A0 spacer restores a visible gap without double-spacing).
|
|
15004
15392
|
if (!editLiteralText) editRawText = addParagraphSpacers(stripExcessBold(normalizePunctuation(editRawText)))
|
|
15005
15393
|
// Voice scrub (#1683): same em-dash scrub as the reply path. Edits
|
|
15006
15394
|
// are how silent-anchor and progress-update mutate already-sent
|
|
@@ -15289,6 +15677,68 @@ function resetOrphanedReplyTimeout(): void {
|
|
|
15289
15677
|
}
|
|
15290
15678
|
}
|
|
15291
15679
|
|
|
15680
|
+
/**
|
|
15681
|
+
* PR A — DETERMINISTIC answer-ready quiescence flush controller.
|
|
15682
|
+
*
|
|
15683
|
+
* The orchestration (arm / debounce / rollover-guard / fire-time re-verify /
|
|
15684
|
+
* disarm) lives in the extracted, unit-tested `AnswerReadyFlushController`
|
|
15685
|
+
* (answer-ready-flush.ts) — the gateway only supplies the thin deps below and
|
|
15686
|
+
* calls `.reset()` / `.clear(turn)`. Behaviour:
|
|
15687
|
+
*
|
|
15688
|
+
* - `reset()` (from `case 'text'`): (re)arm iff the turn has a genuine composed
|
|
15689
|
+
* terminal answer (the SAME `decideTurnFlush` classifier the turn-flush branch
|
|
15690
|
+
* uses) AND is quiescent. Each text chunk re-arms → the debounce.
|
|
15691
|
+
* - `clear(turn)` (from tool activity + `endCurrentTurnAtomic`): the DISARM.
|
|
15692
|
+
* - on fire: re-pin `currentTurn === turn`, re-verify quiescence, then dispatch
|
|
15693
|
+
* a positive `answer-ready-quiescence` synthetic turn_end that routes through
|
|
15694
|
+
* the IDENTICAL turn-flush send path (endCurrentTurnAtomic → send-gated IIFE →
|
|
15695
|
+
* honest PR-B record). Replaces the ~150 s dead wait with ~1 s, in code.
|
|
15696
|
+
*
|
|
15697
|
+
* Exactly-once: the synthetic turn_end's `endCurrentTurnAtomic` nulls the atom,
|
|
15698
|
+
* so a later REAL turn_end (or the orphaned backstop) short-circuits at its
|
|
15699
|
+
* `turn != null` guard; `endCurrentTurnAtomic` also calls `.clear(turn)` so a
|
|
15700
|
+
* real turn_end that lands FIRST cancels a pending flush. `outboundDedup` is the
|
|
15701
|
+
* second layer.
|
|
15702
|
+
*
|
|
15703
|
+
* The `answer-ready-quiescence` reason bypasses the `durationMs===-1`
|
|
15704
|
+
* recently-streaming SUPPRESSION guard (the terminal answer text itself stamps
|
|
15705
|
+
* recentlyStreaming): quiescence IS the positive "streaming has settled" signal,
|
|
15706
|
+
* the opposite of the hung-turn backstop that guard protects.
|
|
15707
|
+
*/
|
|
15708
|
+
const answerReadyFlush = new AnswerReadyFlushController<CurrentTurn>({
|
|
15709
|
+
getCurrentTurn: () => currentTurn,
|
|
15710
|
+
getArmInput: (turn) => ({
|
|
15711
|
+
flush: {
|
|
15712
|
+
chatId: turn.sessionChatId,
|
|
15713
|
+
replyCalled: turn.replyCalled,
|
|
15714
|
+
capturedText: turn.capturedText,
|
|
15715
|
+
flushEnabled: TURN_FLUSH_SAFETY_ENABLED,
|
|
15716
|
+
},
|
|
15717
|
+
inFlightToolCount: toolFlightTracker.inFlightCount(),
|
|
15718
|
+
hasPendingAsyncDispatch: pendingProgress.hasPendingAsyncDispatch(
|
|
15719
|
+
statusKey(turn.sessionChatId, turn.sessionThreadId),
|
|
15720
|
+
),
|
|
15721
|
+
flushWindowMs: ANSWER_READY_FLUSH_MS,
|
|
15722
|
+
}),
|
|
15723
|
+
getTimerHandle: (turn) => turn.answerReadyFlushTimeoutId,
|
|
15724
|
+
setTimerHandle: (turn, handle) => {
|
|
15725
|
+
turn.answerReadyFlushTimeoutId = handle
|
|
15726
|
+
},
|
|
15727
|
+
onFlush: () =>
|
|
15728
|
+
handleSessionEvent({ kind: 'turn_end', durationMs: -1, reason: 'answer-ready-quiescence' }),
|
|
15729
|
+
log: (msg) => process.stderr.write(`telegram gateway: ${msg}\n`),
|
|
15730
|
+
})
|
|
15731
|
+
|
|
15732
|
+
/** PR A — disarm the answer-ready flush timer for a turn (thin adapter). */
|
|
15733
|
+
function clearAnswerReadyFlushTimeout(turn: CurrentTurn | null): void {
|
|
15734
|
+
answerReadyFlush.clear(turn)
|
|
15735
|
+
}
|
|
15736
|
+
|
|
15737
|
+
/** PR A — (re)arm the answer-ready flush timer for the current turn (debounce). */
|
|
15738
|
+
function resetAnswerReadyFlushTimeout(): void {
|
|
15739
|
+
answerReadyFlush.reset()
|
|
15740
|
+
}
|
|
15741
|
+
|
|
15292
15742
|
function closeActivityLane(chatId: string, threadId: number | undefined): void {
|
|
15293
15743
|
const key = chatKeyWithSuffix(chatId, threadId, 'activity')
|
|
15294
15744
|
const stream = activeDraftStreams.get(key)
|
|
@@ -15349,6 +15799,75 @@ function composeTurnActivity(turn: CurrentTurn, final = false, liveSuffix = ''):
|
|
|
15349
15799
|
return renderActivityFeedWithNested(turn.mirrorLines, childLines, final, liveSuffix, stepCount, header)
|
|
15350
15800
|
}
|
|
15351
15801
|
|
|
15802
|
+
// PENDING_NARRATIVE_FLUSH_MS is now defined in and imported from
|
|
15803
|
+
// `narrative-flush.ts` (the kernel's home) so the main-agent gateway path and
|
|
15804
|
+
// the worker/sub-agent watcher share ONE source of truth for the time-box.
|
|
15805
|
+
// The main path paints a parked block via the SAME `showNarrativeStep` path a
|
|
15806
|
+
// lookahead would, and a timer-painted block that later proves to be the reply
|
|
15807
|
+
// is deterministically retracted (the RETRACT effect wired into the controller
|
|
15808
|
+
// below).
|
|
15809
|
+
|
|
15810
|
+
/**
|
|
15811
|
+
* Retract a narration step the flush timer painted EARLY that turned out to draft
|
|
15812
|
+
* the outgoing reply — the effect half of the anti-double-print guarantee's timer
|
|
15813
|
+
* path. Splice the line out of `mirrorLines` and re-render, so neither the live
|
|
15814
|
+
* nor the finalized card surfaces the answer as a narration step. The splice runs
|
|
15815
|
+
* synchronously BEFORE the reply's `clearActivitySummary` finalize reads
|
|
15816
|
+
* `mirrorLines`, so the persisted card is clean regardless of the re-render.
|
|
15817
|
+
*/
|
|
15818
|
+
function retractNarrativeLine(turn: CurrentTurn, text: string): void {
|
|
15819
|
+
const clipped = clipNarrative(text)
|
|
15820
|
+
const idx = turn.mirrorLines.lastIndexOf(clipped)
|
|
15821
|
+
if (idx === -1) return // rolled out of the window already — nothing to retract
|
|
15822
|
+
turn.mirrorLines.splice(idx, 1)
|
|
15823
|
+
// Live re-render without the retracted line (the finalize path reads the same
|
|
15824
|
+
// spliced array; this only matters for the interim-ack case where finalize
|
|
15825
|
+
// isn't called on this reply). Guarded on a non-null render so an emptied feed
|
|
15826
|
+
// doesn't blank the card.
|
|
15827
|
+
const rerender = composeTurnActivity(turn)
|
|
15828
|
+
if (rerender == null) return
|
|
15829
|
+
turn.activityPendingRender = rerender
|
|
15830
|
+
const ea = emissionAuthorityFor(turn)
|
|
15831
|
+
cardDrainGate(turn, ea, () => {
|
|
15832
|
+
if (ea.mayDrain(turn)) {
|
|
15833
|
+
ea.openOrEditCard('narrative', () => {
|
|
15834
|
+
turn.activityInFlight = drainActivitySummary(turn, 'narrative')
|
|
15835
|
+
})
|
|
15836
|
+
}
|
|
15837
|
+
})
|
|
15838
|
+
}
|
|
15839
|
+
|
|
15840
|
+
/**
|
|
15841
|
+
* Build a per-turn narrative gate: the pure park/timer/retract state machine
|
|
15842
|
+
* (`narrative-flush.ts`) wired to THIS turn's SHOW effect (`showNarrativeStep`),
|
|
15843
|
+
* RETRACT effect (`retractNarrativeLine`), and a real `unref`'d `setTimeout`
|
|
15844
|
+
* scheduler. The scheduler captures the turn's own timer handle so a turn swap
|
|
15845
|
+
* can't mis-target it — mirrors the `noReplyDrainTimer` discipline.
|
|
15846
|
+
*/
|
|
15847
|
+
function makeNarrativeGate(turn: CurrentTurn): NarrativeFlushController {
|
|
15848
|
+
let handle: ReturnType<typeof setTimeout> | null = null
|
|
15849
|
+
return new NarrativeFlushController(
|
|
15850
|
+
{
|
|
15851
|
+
show: (text) => showNarrativeStep(turn, text),
|
|
15852
|
+
retractShown: (text) => retractNarrativeLine(turn, text),
|
|
15853
|
+
},
|
|
15854
|
+
{
|
|
15855
|
+
arm: (fn, ms) => {
|
|
15856
|
+
if (handle != null) clearTimeout(handle)
|
|
15857
|
+
handle = setTimeout(fn, ms)
|
|
15858
|
+
handle.unref?.()
|
|
15859
|
+
},
|
|
15860
|
+
disarm: () => {
|
|
15861
|
+
if (handle != null) {
|
|
15862
|
+
clearTimeout(handle)
|
|
15863
|
+
handle = null
|
|
15864
|
+
}
|
|
15865
|
+
},
|
|
15866
|
+
},
|
|
15867
|
+
PENDING_NARRATIVE_FLUSH_MS,
|
|
15868
|
+
)
|
|
15869
|
+
}
|
|
15870
|
+
|
|
15352
15871
|
/**
|
|
15353
15872
|
* Render a SHOWN narrative text block as a transient liveness step — the
|
|
15354
15873
|
* same path a tool label takes (appendActivityLabel → renderStepFeed), so
|
|
@@ -15394,27 +15913,20 @@ function resolvePendingNarrativeOnTool(
|
|
|
15394
15913
|
toolName: string,
|
|
15395
15914
|
input: Record<string, unknown> | undefined,
|
|
15396
15915
|
): void {
|
|
15397
|
-
|
|
15398
|
-
|
|
15399
|
-
|
|
15400
|
-
|
|
15401
|
-
const replyText = typeof input?.text === 'string' ? (input.text as string) : ''
|
|
15402
|
-
if (isDraftOfReply(pending.text, replyText)) return // draft of the answer → SUPPRESS
|
|
15403
|
-
}
|
|
15404
|
-
showNarrativeStep(turn, pending.text) // working preamble / post-action narration → SHOW
|
|
15916
|
+
// Delegate to the pure park/timer/retract kernel: it cancels the early-paint
|
|
15917
|
+
// timer, retracts a timer-painted block that THIS reply drafts (anti-double-
|
|
15918
|
+
// print), then SHOWs / SUPPRESSes the parked block. See narrative-flush.ts §2.
|
|
15919
|
+
turn.narrativeGate.resolveOnTool(toolName, input)
|
|
15405
15920
|
}
|
|
15406
15921
|
|
|
15407
15922
|
/**
|
|
15408
15923
|
* Narrative-dedup gate, step 1 (reducer-side): a new narrative block
|
|
15409
15924
|
* arrived. A previously-pending block had nothing reply-shaped immediately
|
|
15410
15925
|
* after it (pure narration) → flush it as SHOWN, then stage the new one for
|
|
15411
|
-
* one lookahead step. See narrative-
|
|
15926
|
+
* one lookahead step AND arm the time-boxed early paint. See narrative-flush.ts.
|
|
15412
15927
|
*/
|
|
15413
15928
|
function stagePendingNarrative(turn: CurrentTurn, text: string): void {
|
|
15414
|
-
|
|
15415
|
-
showNarrativeStep(turn, turn.pendingNarrative.text)
|
|
15416
|
-
}
|
|
15417
|
-
turn.pendingNarrative = { text }
|
|
15929
|
+
turn.narrativeGate.stage(text)
|
|
15418
15930
|
}
|
|
15419
15931
|
|
|
15420
15932
|
/**
|
|
@@ -15422,14 +15934,11 @@ function stagePendingNarrative(turn: CurrentTurn, text: string): void {
|
|
|
15422
15934
|
* trailing narrative block and nothing after it. SUPPRESS only when the turn
|
|
15423
15935
|
* already delivered its answer via reply/stream_reply and the trailing text
|
|
15424
15936
|
* is a draft of that answer; otherwise SHOW (genuine trailing narration like
|
|
15425
|
-
* "Done — all green.").
|
|
15937
|
+
* "Done — all green."). Also cancels the early-paint timer and retracts a
|
|
15938
|
+
* timer-painted draft of the delivered answer. See narrative-flush.ts §3.
|
|
15426
15939
|
*/
|
|
15427
15940
|
function flushPendingNarrativeAtTurnEnd(turn: CurrentTurn, lastReplyText: string): void {
|
|
15428
|
-
|
|
15429
|
-
if (pending == null) return
|
|
15430
|
-
turn.pendingNarrative = null
|
|
15431
|
-
if (lastReplyText.length > 0 && isDraftOfReply(pending.text, lastReplyText)) return // trailing duplicate of the answer
|
|
15432
|
-
showNarrativeStep(turn, pending.text)
|
|
15941
|
+
turn.narrativeGate.flushAtTurnEnd(lastReplyText)
|
|
15433
15942
|
}
|
|
15434
15943
|
|
|
15435
15944
|
/**
|
|
@@ -16215,9 +16724,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
16215
16724
|
// background workers keep the timer warm exactly as long as they are working.
|
|
16216
16725
|
{
|
|
16217
16726
|
const durationMs = ev.kind === 'turn_end' ? ev.durationMs : undefined
|
|
16218
|
-
|
|
16219
|
-
if (signal.activity) markIdleActivity()
|
|
16220
|
-
if (signal.turnEnded) markIdleTurnEnd()
|
|
16727
|
+
idleTracker.noteEvent(ev.kind, Date.now(), durationMs)
|
|
16221
16728
|
}
|
|
16222
16729
|
switch (ev.kind) {
|
|
16223
16730
|
case 'enqueue': {
|
|
@@ -16263,6 +16770,13 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
16263
16770
|
clearTimeout(prior.orphanedReplyTimeoutId)
|
|
16264
16771
|
prior.orphanedReplyTimeoutId = null
|
|
16265
16772
|
}
|
|
16773
|
+
// Same bounded-leak class (early-paint 250ms setTimeout): the prior
|
|
16774
|
+
// turn may have armed its narrative gate's early-paint timer before
|
|
16775
|
+
// being superseded. Left untorn, ~250ms later it fires showNarrativeStep
|
|
16776
|
+
// on the dead turn and can paint a stale narration card below the new
|
|
16777
|
+
// turn's surface. Teardown is guard-safe and idempotent (no-op when never
|
|
16778
|
+
// armed / already fired / already disarmed by the prior turn's turn_end).
|
|
16779
|
+
prior?.narrativeGate?.teardown()
|
|
16266
16780
|
// #1067: swap the entire turn atom in one assignment. Every
|
|
16267
16781
|
// handler captures `const turn = currentTurn` at entry, so a
|
|
16268
16782
|
// captured-then-awaited read can't reattribute to the new turn.
|
|
@@ -16322,6 +16836,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
16322
16836
|
silentAnchorText: '',
|
|
16323
16837
|
capturedText: [],
|
|
16324
16838
|
orphanedReplyTimeoutId: null,
|
|
16839
|
+
answerReadyFlushTimeoutId: null,
|
|
16325
16840
|
// Fresh liveness tracker: lastStreamEventAt seeded to the turn start
|
|
16326
16841
|
// so a turn that never streams still trips the fuse after windowMs.
|
|
16327
16842
|
liveness: new LivenessTracker(startedAt),
|
|
@@ -16339,7 +16854,10 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
16339
16854
|
activityEverOpened: false,
|
|
16340
16855
|
activityDrainFailures: 0,
|
|
16341
16856
|
mirrorLines: [],
|
|
16342
|
-
|
|
16857
|
+
// Assigned immediately after this literal via makeNarrativeGate(next) —
|
|
16858
|
+
// the controller's SHOW/RETRACT effects close over the turn object, which
|
|
16859
|
+
// can't reference itself inside its own initializer.
|
|
16860
|
+
narrativeGate: undefined as unknown as NarrativeFlushController,
|
|
16343
16861
|
lastReplyText: '',
|
|
16344
16862
|
foregroundSubAgents: new Map(),
|
|
16345
16863
|
answerStream: null,
|
|
@@ -16352,6 +16870,9 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
16352
16870
|
statusKey(ev.chatId, enqThreadIdNum),
|
|
16353
16871
|
),
|
|
16354
16872
|
}
|
|
16873
|
+
// Wire the per-turn narrative gate now that `next` exists (its SHOW/RETRACT
|
|
16874
|
+
// effects close over the turn). Born with this turn, torn down at turn end.
|
|
16875
|
+
next.narrativeGate = makeNarrativeGate(next)
|
|
16355
16876
|
// PR-4e — route the turn-SET through the keyed accessor: flag-OFF assigns
|
|
16356
16877
|
// the singleton (byte-identical to `currentTurn = next`); flag-ON sets the
|
|
16357
16878
|
// per-topic `byKey[statusKey]` entry AND the most-recent mirror. The key is
|
|
@@ -16520,6 +17041,11 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
16520
17041
|
case 'tool_use': {
|
|
16521
17042
|
const turn = currentTurn
|
|
16522
17043
|
if (turn == null) return
|
|
17044
|
+
// PR A — the model resumed work (surface or otherwise). Cancel any pending
|
|
17045
|
+
// answer-ready quiescence flush: the turn is no longer quiescent. (Fire-time
|
|
17046
|
+
// re-verification would also catch this, but disarming here avoids a wasted
|
|
17047
|
+
// wakeup and matches the design's disarm-on-tool requirement.)
|
|
17048
|
+
clearAnswerReadyFlushTimeout(turn)
|
|
16523
17049
|
// Narrative-dedup gate step 2 (JSONL-text-narrative primitive): a
|
|
16524
17050
|
// narrative block was pending; this tool_use is the lookahead event
|
|
16525
17051
|
// that decides it. reply/stream_reply with near-identical text ⇒
|
|
@@ -16606,6 +17132,11 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
16606
17132
|
// where the JSONL tool_use rows arrive too late.
|
|
16607
17133
|
const turn = currentTurn
|
|
16608
17134
|
if (turn == null) return
|
|
17135
|
+
// PR A — a tool_label (real-time, ~250 ms) means the model is producing
|
|
17136
|
+
// work right now: cancel any pending answer-ready quiescence flush (the
|
|
17137
|
+
// turn is not quiescent). Fires ahead of the JSONL tool_use, so it disarms
|
|
17138
|
+
// the timer at the earliest deterministic point.
|
|
17139
|
+
clearAnswerReadyFlushTimeout(turn)
|
|
16609
17140
|
// SECONDARY FIX: an active tool_label means the model is producing work
|
|
16610
17141
|
// right now — re-arm the orphaned-reply fuse so a multi-phase tool turn
|
|
16611
17142
|
// (write → compile → test → fix) that regularly emits labels doesn't let
|
|
@@ -16928,6 +17459,11 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
16928
17459
|
preambleSuppressor.onText(ev.text)
|
|
16929
17460
|
}
|
|
16930
17461
|
resetOrphanedReplyTimeout()
|
|
17462
|
+
// PR A — (re)arm the deterministic answer-ready quiescence flush. Each
|
|
17463
|
+
// text chunk debounces the timer; it fires only after ~1 s of no new
|
|
17464
|
+
// stream events, delivering a composed toolless answer without waiting on
|
|
17465
|
+
// the unreliable turn_duration signal or the ~150 s orphaned backstop.
|
|
17466
|
+
resetAnswerReadyFlushTimeout()
|
|
16931
17467
|
|
|
16932
17468
|
if (isContextExhaustionText(ev.text) && turn != null) {
|
|
16933
17469
|
const chatId = turn.sessionChatId
|
|
@@ -17017,7 +17553,14 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
17017
17553
|
// check to the full isLegitimatelyWorking predicate so detached background
|
|
17018
17554
|
// work and human-wait tools (ask_user) are also protected.
|
|
17019
17555
|
// INVARIANT: a REAL turn_end (durationMs >= 0) is NEVER suppressed.
|
|
17020
|
-
|
|
17556
|
+
// PR A carve-out: the answer-ready quiescence flush also uses
|
|
17557
|
+
// `durationMs:-1`, but it is a POSITIVE "streaming has settled" signal
|
|
17558
|
+
// fired only after ~1 s of no stream events AND no in-flight tool — the
|
|
17559
|
+
// exact opposite of a hung turn. It must NOT be suppressed by
|
|
17560
|
+
// recentlyStreaming (the terminal answer text itself stamps that window,
|
|
17561
|
+
// which is the whole bug). Its own arm/fire predicate already re-verified
|
|
17562
|
+
// quiescence, so let it through to deliver.
|
|
17563
|
+
if (ev.durationMs === -1 && ev.reason !== 'answer-ready-quiescence') {
|
|
17021
17564
|
const turn = currentTurn
|
|
17022
17565
|
const key = turn != null ? statusKey(turn.sessionChatId, turn.sessionThreadId) : ''
|
|
17023
17566
|
// Widened to also suppress while the turn is RECENTLY STREAMING — a
|
|
@@ -17085,8 +17628,8 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
17085
17628
|
// delivered reply text and SUPPRESS the duplicate; otherwise SHOW
|
|
17086
17629
|
// genuine trailing narration ("Done — all green."). Must run BEFORE
|
|
17087
17630
|
// clearActivitySummary so a SHOWN line lands in the feed's final
|
|
17088
|
-
// render. Always clears
|
|
17089
|
-
// turns.
|
|
17631
|
+
// render. Always clears the gate's parked block (and disarms its
|
|
17632
|
+
// early-paint timer) so nothing can leak across turns.
|
|
17090
17633
|
//
|
|
17091
17634
|
// NIT 2 (reply-proxy precision): use `turn.lastReplyText` (the
|
|
17092
17635
|
// most-recent reply/stream_reply input.text) rather than
|
|
@@ -17437,9 +17980,9 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
17437
17980
|
// breaks into GFM hard breaks so the Bot API 10.1 rich path doesn't
|
|
17438
17981
|
// collapse them (lists/tables/code left untouched). Runs BEFORE the
|
|
17439
17982
|
// redact/scrub below, exactly as reply orders it (repair → normalize →
|
|
17440
|
-
// redact → scrub), so masking sees the repaired text.
|
|
17441
|
-
//
|
|
17442
|
-
//
|
|
17983
|
+
// redact → scrub), so masking sees the repaired text. Paragraph gaps
|
|
17984
|
+
// are the plain `\n\n` normalizeParagraphBreaks guarantees — no spacer
|
|
17985
|
+
// pass runs on the send side any more (removed in the #2669 follow-up).
|
|
17443
17986
|
capturedText = normalizeParagraphBreaks(repairEscapedWhitespace(capturedText))
|
|
17444
17987
|
// Component 3 — origin-thread backstop. `chatId`/`threadId` are
|
|
17445
17988
|
// captured from the turn atom (turn.sessionChatId/sessionThreadId)
|
|
@@ -17499,6 +18042,17 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
17499
18042
|
// that this branch never reaches, this set is belt-and-braces —
|
|
17500
18043
|
// it keeps the captured `turn` atom internally consistent for any
|
|
17501
18044
|
// future reader.)
|
|
18045
|
+
// PR B (Fix 4 — intentional record/ledger inconsistency, out of scope).
|
|
18046
|
+
// We keep setting finalAnswerDelivered=true HERE (before the async send)
|
|
18047
|
+
// so endCurrentTurnAtomic's obligation CLOSE (decideObligationTurnEnd,
|
|
18048
|
+
// ~4818) fires unchanged at turn end. PR B only makes the turns.jsonl
|
|
18049
|
+
// *record* honest (status send_failed when the send later throws) — it
|
|
18050
|
+
// deliberately does NOT change obligation behavior. Consequence: a
|
|
18051
|
+
// send_failed turn still CLOSES its obligation, so a flood-dropped
|
|
18052
|
+
// answer is NOT re-presented — an honest record without honest recovery.
|
|
18053
|
+
// Re-delivery on send_failed (drive obligation close from real send
|
|
18054
|
+
// success, leave it open on failure) is a separate change — see the
|
|
18055
|
+
// PR-B handback FOLLOW-UP note; do NOT touch the ledger in this PR.
|
|
17502
18056
|
turn.finalAnswerDelivered = true
|
|
17503
18057
|
// Feed-reopen refinement: turn-flush delivers the model's terminal
|
|
17504
18058
|
// transcript text as the genuine answer (not an ack). Default to
|
|
@@ -17529,7 +18083,15 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
17529
18083
|
// sendMessage await for this turn will see currentTurn == null
|
|
17530
18084
|
// and bail; a new enqueue will swap in a fresh atom. The
|
|
17531
18085
|
// `backstop*` locals above hold everything the IIFE needs.
|
|
17532
|
-
|
|
18086
|
+
//
|
|
18087
|
+
// PR B — defer the turns.jsonl record write to the send IIFE below so
|
|
18088
|
+
// the recorded `status` reflects the REAL send outcome, not the
|
|
18089
|
+
// speculative `finalAnswerDelivered=true` set just above. Everything
|
|
18090
|
+
// else in endCurrentTurnAtomic (atom null, gate release, obligation
|
|
18091
|
+
// bookkeeping, purge) still runs synchronously here for the #1067 /
|
|
18092
|
+
// #1556 wedge-safety reasons. `backstopTurnEndedAt` is null iff the
|
|
18093
|
+
// atom was already torn down elsewhere (no record to emit).
|
|
18094
|
+
const backstopTurnEndedAt = endCurrentTurnAtomic(turn, { deferRecord: true })
|
|
17533
18095
|
// #549 fix — turn-flush takes ownership of the captured-text
|
|
17534
18096
|
// backup; reset the preamble buffer (its content is already in
|
|
17535
18097
|
// the captured `capturedText`, which turn-flush is about to send).
|
|
@@ -17569,6 +18131,15 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
17569
18131
|
// async IIFE ran). The old redundant purgeReactionTracking
|
|
17570
18132
|
// here re-fired on an already-cleared key WITHOUT `endingTurn`,
|
|
17571
18133
|
// emitting an inconsistent shadow trace. Removed.
|
|
18134
|
+
//
|
|
18135
|
+
// PR B — the reply tool already delivered this turn's answer, so
|
|
18136
|
+
// the flush was legitimately suppressed. Emit the deferred record
|
|
18137
|
+
// as 'suppressed' (→ `complete`, since the reply path set
|
|
18138
|
+
// finalAnswerDelivered). Not a failure.
|
|
18139
|
+
if (backstopTurnEndedAt != null) {
|
|
18140
|
+
turn.deliveryOutcome = 'suppressed'
|
|
18141
|
+
emitTurnRecord(turn, backstopTurnEndedAt)
|
|
18142
|
+
}
|
|
17572
18143
|
return
|
|
17573
18144
|
}
|
|
17574
18145
|
} catch {}
|
|
@@ -17583,16 +18154,26 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
17583
18154
|
link_preview_options: { is_disabled: true },
|
|
17584
18155
|
}
|
|
17585
18156
|
const limit = RICH_MESSAGE_MAX_CHARS
|
|
17586
|
-
//
|
|
17587
|
-
//
|
|
17588
|
-
//
|
|
17589
|
-
//
|
|
17590
|
-
// .
|
|
17591
|
-
|
|
17592
|
-
const renderedText = addParagraphSpacers(capturedText)
|
|
17593
|
-
const htmlChunks = splitMarkdownChunks(renderedText, limit)
|
|
18157
|
+
// PR B (Fix 1) — send accounting is declared OUTSIDE the try so the
|
|
18158
|
+
// single-record `finally` below can read it, and ALL setup (the chunk
|
|
18159
|
+
// split) is pulled INSIDE the try so a throw there still lands in the
|
|
18160
|
+
// finally (→ one honest `send_failed` row) rather than rejecting the
|
|
18161
|
+
// IIFE with zero turns.jsonl rows written / an unhandled rejection.
|
|
18162
|
+
let htmlChunks: string[] = []
|
|
17594
18163
|
const sentIds: number[] = []
|
|
18164
|
+
// track whether the send threw so the deferred record reflects the
|
|
18165
|
+
// real outcome (throw OR partial multi-chunk → send_failed).
|
|
18166
|
+
let sendThrew = false
|
|
17595
18167
|
try {
|
|
18168
|
+
// #2798 / #2692 — inject visible blank-line spacers into prose `\n\n`
|
|
18169
|
+
// gaps before splitting, exactly as executeReply does. The rich GFM
|
|
18170
|
+
// renderer collapses a bare `\n\n` gap TIGHT, so without this the
|
|
18171
|
+
// paragraph boundaries from the '\n\n' block join (turn-flush-safety
|
|
18172
|
+
// .ts) would still render jammed together. Mirrors reply's
|
|
18173
|
+
// `addParagraphSpacers(text)` on the non-literal path (idempotent —
|
|
18174
|
+
// exactly one U+00A0 spacer per gap, never doubled).
|
|
18175
|
+
const renderedText = addParagraphSpacers(capturedText)
|
|
18176
|
+
htmlChunks = splitMarkdownChunks(renderedText, limit)
|
|
17596
18177
|
// #654 deterministic double-message fix. If the progress
|
|
17597
18178
|
// card is on screen (60s timer fired before turn_end), edit
|
|
17598
18179
|
// it in place with the first chunk of the answer instead of
|
|
@@ -17700,10 +18281,33 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
17700
18281
|
unpinProgressCardForChat?.(backstopChatId, backstopThreadId)
|
|
17701
18282
|
}
|
|
17702
18283
|
} catch (err) {
|
|
18284
|
+
sendThrew = true
|
|
17703
18285
|
process.stderr.write(`telegram gateway: turn-flush send failed: ${(err as Error).message}\n`)
|
|
17704
18286
|
// #1713: backstop send failed — finalize as error so the
|
|
17705
18287
|
// turn ends cleanly with 😱 rather than leaving it open.
|
|
17706
18288
|
if (backstopCtrl) backstopCtrl.finalize('error')
|
|
18289
|
+
} finally {
|
|
18290
|
+
// PR B (Fix 1) — SINGLE-RECORD GUARANTEE. The send has now RESOLVED
|
|
18291
|
+
// (or thrown); this finally runs on EVERY in-process exit of the send
|
|
18292
|
+
// block — clean send, throw in setup/split, throw mid-send — so
|
|
18293
|
+
// exactly one turns.jsonl row is written, with the outcome reflecting
|
|
18294
|
+
// what actually happened. A throw or a partial multi-chunk delivery
|
|
18295
|
+
// (sentIds < htmlChunks) or an un-run/empty split → 'failed' → status
|
|
18296
|
+
// `send_failed`; a full delivery → 'delivered' → `complete`. This
|
|
18297
|
+
// replaces the false `complete` the old synchronous write produced
|
|
18298
|
+
// for a flood-dropped / errored answer the user never received, and
|
|
18299
|
+
// restores the old code's one-row-per-turn guarantee (the pre-finally
|
|
18300
|
+
// shape wrote ZERO rows if setup threw). The suppressed-reply branch
|
|
18301
|
+
// above already emitted its record and `return`ed BEFORE reaching
|
|
18302
|
+
// this try, so it can never double-write with this finally.
|
|
18303
|
+
if (backstopTurnEndedAt != null) {
|
|
18304
|
+
finalizeBackstopSend(turn, {
|
|
18305
|
+
threw: sendThrew,
|
|
18306
|
+
sentCount: sentIds.length,
|
|
18307
|
+
chunkCount: htmlChunks.length,
|
|
18308
|
+
})
|
|
18309
|
+
emitTurnRecord(turn, backstopTurnEndedAt)
|
|
18310
|
+
}
|
|
17707
18311
|
}
|
|
17708
18312
|
// #2094 cosmetic: the trailing `finally { purgeReactionTracking() }`
|
|
17709
18313
|
// was removed. endCurrentTurnAtomic already ran the canonical purge
|
|
@@ -17803,6 +18407,65 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
17803
18407
|
// is exactly `turn.finalAnswerDelivered === false` here (silent-marker
|
|
17804
18408
|
// and flush both returned earlier), delegated to the pure gate core.
|
|
17805
18409
|
if (turnEndDecision === 'reprompt') {
|
|
18410
|
+
// Option A transcript-prose bridge (#3227). Before falling through to
|
|
18411
|
+
// the re-prompt / represent safety nets, check whether the Stop hook
|
|
18412
|
+
// already isolated this turn's real answer from the transcript and
|
|
18413
|
+
// persisted it in silent-end-pending.json (`pendingText`). That file
|
|
18414
|
+
// lands BEFORE this turn_end handler runs (the hook fires upstream of
|
|
18415
|
+
// the gateway's own state write — see silent-end-interrupt-stop.mjs).
|
|
18416
|
+
// If a substantive answer is waiting, deliver it directly via the
|
|
18417
|
+
// normal send path NOW, instead of leaning on the (unreliable)
|
|
18418
|
+
// Stop-hook re-prompt or waiting ~2-5 min for the obligation
|
|
18419
|
+
// represent. The delivery closes the obligation + records dedup, so
|
|
18420
|
+
// the represent and a late reply-tool retry are both suppressed —
|
|
18421
|
+
// captured-prose delivery and represent are mutually exclusive.
|
|
18422
|
+
const proseDecision = CAPTURED_PROSE_DELIVERY_ENABLED
|
|
18423
|
+
? decideCapturedProseDelivery({
|
|
18424
|
+
turnKey: tKey,
|
|
18425
|
+
// Per-turn nonce (#3228 Finding 3) — the persisted record must
|
|
18426
|
+
// belong to THIS turn, not a stale carryover from a prior turn
|
|
18427
|
+
// on the same chat/thread (tKey is not per-turn unique).
|
|
18428
|
+
turnId: turn.turnId,
|
|
18429
|
+
minChars: CAPTURED_PROSE_MIN_CHARS,
|
|
18430
|
+
})
|
|
18431
|
+
: { deliver: false as const, reason: 'no-state' as const }
|
|
18432
|
+
if (proseDecision.deliver && proseDecision.text != null) {
|
|
18433
|
+
// Deliver the recovered answer directly. This runs async and owns
|
|
18434
|
+
// its own bookkeeping — on success it closes the obligation +
|
|
18435
|
+
// records dedup + clears the silent-end state; on send FAILURE it
|
|
18436
|
+
// arms the recovery net itself (see below) instead of leaving the
|
|
18437
|
+
// answer lost.
|
|
18438
|
+
//
|
|
18439
|
+
// We DELIBERATELY skip recordUndeliveredTurnEnd on the HAPPY path
|
|
18440
|
+
// here: re-arming the Stop-hook re-prompt for an answer that just
|
|
18441
|
+
// went out is exactly what this bridge exists to avoid.
|
|
18442
|
+
//
|
|
18443
|
+
// #3228 Finding 1 — the send-failure net can NOT rely on the shared
|
|
18444
|
+
// turn-end teardown "leaving the obligation open". That teardown
|
|
18445
|
+
// (endCurrentTurnAtomic → decideObligationTurnEnd) closes the
|
|
18446
|
+
// obligation whenever `replyCalled === true`, which is EXACTLY the
|
|
18447
|
+
// interim-ack case that reaches this branch. So a thrown send would
|
|
18448
|
+
// otherwise leave the answer permanently lost (obligation already
|
|
18449
|
+
// closed by teardown, recordUndeliveredTurnEnd skipped). The real
|
|
18450
|
+
// net lives INSIDE deliverCapturedProse's catch: it calls
|
|
18451
|
+
// recordUndeliveredTurnEnd, arming the deterministic Stop-hook
|
|
18452
|
+
// re-prompt (parity with the non-captured path below).
|
|
18453
|
+
process.stderr.write(
|
|
18454
|
+
`telegram gateway: captured-prose delivery engaged on first silent-end ` +
|
|
18455
|
+
`chat=${chatId} turnKey=${tKey} (#3227)\n`,
|
|
18456
|
+
)
|
|
18457
|
+
void deliverCapturedProse({
|
|
18458
|
+
chatId,
|
|
18459
|
+
threadId,
|
|
18460
|
+
statusKeyStr: tKey,
|
|
18461
|
+
registryKey: turn.registryKey ?? null,
|
|
18462
|
+
originTurnId: turn.turnId,
|
|
18463
|
+
text: proseDecision.text,
|
|
18464
|
+
// For the honest "(waited Ns)" clause if the exhaustion-boundary
|
|
18465
|
+
// apology fallback fires (#3228).
|
|
18466
|
+
turnDurationMs,
|
|
18467
|
+
})
|
|
18468
|
+
} else {
|
|
17806
18469
|
// PR #2892 (deterministic-turn-liveness RFC Phase 2) hardening:
|
|
17807
18470
|
// wire the represent-guard-style staleness
|
|
17808
18471
|
// check (`recordSilentTurnEnd`'s `hasOutboundDeliveredSince` dep) so
|
|
@@ -17848,6 +18511,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
17848
18511
|
)
|
|
17849
18512
|
})
|
|
17850
18513
|
}
|
|
18514
|
+
} // end else (no captured-prose to deliver)
|
|
17851
18515
|
}
|
|
17852
18516
|
signalTracker.clear(tKey)
|
|
17853
18517
|
silencePoke.endTurn(tKey)
|
|
@@ -29422,6 +30086,18 @@ void (async () => {
|
|
|
29422
30086
|
// channels.telegram.worker_feed.max_rows via the config cascade
|
|
29423
30087
|
// (scaffold emits SWITCHROOM_TG_WORKER_FEED_MAX_ROWS); unset → 8.
|
|
29424
30088
|
maxRows: workerFeedMaxRows,
|
|
30089
|
+
// Backstop TTL for the feed's stale-row reaper, DERIVED in code
|
|
30090
|
+
// from the watcher's effective in-flight terminal cap (same env /
|
|
30091
|
+
// default the watcher itself resolves) plus a margin — NOT a
|
|
30092
|
+
// hardcoded assumption about that cap's value. This keeps the
|
|
30093
|
+
// invariant "the feed must never reap a row the watcher still
|
|
30094
|
+
// considers live" enforced even if an operator raises the cap via
|
|
30095
|
+
// SWITCHROOM_SUBAGENT_INFLIGHT_TERMINAL_CAP_MS: a worker mid-very-
|
|
30096
|
+
// long tool can go silent up to the cap before the watcher
|
|
30097
|
+
// declares it terminal, so the feed waits cap + margin before
|
|
30098
|
+
// force-collapsing a row the terminal signals somehow never
|
|
30099
|
+
// removed.
|
|
30100
|
+
staleWorkerTtlMs: resolveInflightTerminalCapMs() + WORKER_FEED_STALE_TTL_MARGIN_MS,
|
|
29425
30101
|
// #3207 review: GROUP-level status pin. Workers now coalesce into
|
|
29426
30102
|
// ONE shared message, so the pin must follow the GROUP lifecycle,
|
|
29427
30103
|
// not a single worker's — otherwise a sibling's finish unpins a
|
|
@@ -29587,6 +30263,23 @@ void (async () => {
|
|
|
29587
30263
|
`telegram gateway: worker ${agentId} NAMED AS LOST — falsely finalised twice, resurrection chain bound reached (issue #3023)\n`,
|
|
29588
30264
|
)
|
|
29589
30265
|
},
|
|
30266
|
+
// Worker-feed ghost-leak fix: the watcher's AUTHORITATIVE terminal
|
|
30267
|
+
// sweep (`cleanupTerminalAgent`) fires for EVERY terminal agent,
|
|
30268
|
+
// including the JSONL-vanished and boot-orphan paths that never
|
|
30269
|
+
// reach `onFinish`. Wire feed removal here so cleanup and feed-
|
|
30270
|
+
// remove can't diverge: `terminate` is idempotent (a no-op once
|
|
30271
|
+
// `onFinish` already removed the row) and, when this was the last
|
|
30272
|
+
// live worker, collapses the shared card to its terminal summary
|
|
30273
|
+
// and unpins it — closing the immortal/unpinned/buried-card leak.
|
|
30274
|
+
onTerminalCleanup: (agentId) => {
|
|
30275
|
+
try {
|
|
30276
|
+
void workerActivityFeed?.terminate(agentId)
|
|
30277
|
+
} catch (err) {
|
|
30278
|
+
process.stderr.write(
|
|
30279
|
+
`telegram gateway: worker terminal-cleanup feed removal error agent=${agentId}: ${(err as Error).message}\n`,
|
|
30280
|
+
)
|
|
30281
|
+
}
|
|
30282
|
+
},
|
|
29590
30283
|
onFinish: ({ agentId, outcome, description, resultText, toolCount, durationMs, background: entryBackground }) => {
|
|
29591
30284
|
// Reaction promotion: if the parent turn already ended
|
|
29592
30285
|
// with this (or another) worker still running, its 👍 was
|