switchroom 0.18.19 → 0.18.21
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 +59 -6
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_shared/delegation-golden-rule.md.hbs +9 -0
- package/profiles/_shared/dev-protocol.md.hbs +2 -0
- package/profiles/_shared/execution-discipline.md.hbs +2 -2
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/answer-ready-flush.ts +187 -0
- package/telegram-plugin/dist/gateway/gateway.js +1114 -184
- package/telegram-plugin/format.ts +179 -20
- package/telegram-plugin/gateway/cron-session.ts +32 -0
- package/telegram-plugin/gateway/gateway.ts +794 -106
- 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 +9 -9
- package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +17 -0
- 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/narrative-flush.ts +181 -0
- package/telegram-plugin/pending-work-progress.ts +65 -1
- package/telegram-plugin/registry/subagents-schema.ts +6 -0
- package/telegram-plugin/session-tail.ts +6 -1
- package/telegram-plugin/silent-end.ts +182 -0
- package/telegram-plugin/stream-reply-handler.ts +14 -5
- package/telegram-plugin/subagent-watcher.ts +330 -82
- 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 +54 -34
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +6 -5
- package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
- package/telegram-plugin/tests/idle-clear.test.ts +315 -37
- 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/nested-worker-visibility-harness.test.ts +20 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +5 -4
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +100 -42
- 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/stream-reply-handler.test.ts +12 -9
- package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +30 -0
- package/telegram-plugin/tests/subagent-watcher-first-paint-independence.test.ts +171 -0
- package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +220 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +13 -12
- package/telegram-plugin/tests/telegram-format.test.ts +36 -23
- package/telegram-plugin/tests/turn-flush-safety.test.ts +21 -17
- 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 +165 -0
- package/telegram-plugin/tool-activity-summary.ts +78 -16
- package/telegram-plugin/turn-flush-safety.ts +4 -4
- 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'
|
|
@@ -348,7 +349,7 @@ const REPLY_TO_TEXT_MAX = 200
|
|
|
348
349
|
// #1161 silent-end fallback text now lives in ../silent-end.ts
|
|
349
350
|
// (`silentEndFallbackText`, imported above) so the transport-boundary
|
|
350
351
|
// tests exercise the real string — see PR #2892.
|
|
351
|
-
import { splitMarkdownChunks, repairEscapedWhitespace, normalizeParagraphBreaks, normalizePunctuation, stripExcessBold, escapeMarkdown, hardenCardBreaks, RICH_MESSAGE_MAX_CHARS } from '../format.js'
|
|
352
|
+
import { splitMarkdownChunks, repairEscapedWhitespace, normalizeParagraphBreaks, addParagraphSpacers, normalizePunctuation, stripExcessBold, escapeMarkdown, hardenCardBreaks, RICH_MESSAGE_MAX_CHARS } from '../format.js'
|
|
352
353
|
import { richMessage } from '../rich-send.js'
|
|
353
354
|
import { scrubVoice } from '../text-voice-scrub.js'
|
|
354
355
|
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
|
/**
|
|
@@ -8125,6 +8223,10 @@ const STREAM_THROTTLE_MS_OVERRIDE: number | undefined = (() => {
|
|
|
8125
8223
|
})()
|
|
8126
8224
|
const TURN_FLUSH_SAFETY_ENABLED = isTurnFlushSafetyEnabled()
|
|
8127
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
|
+
|
|
8128
8230
|
// When SET, the answer-lane stream (telegram-plugin/answer-stream.ts) renders
|
|
8129
8231
|
// the model's transcript text as a USER-VISIBLE edit-in-place message. Default
|
|
8130
8232
|
// OFF: the lane stays dormant and the reply tool is the single canonical
|
|
@@ -9177,6 +9279,17 @@ const SILENCE_FLOOR_MS = parsePositiveMsEnv('SWITCHROOM_SILENCE_FLOOR_MS', 45_00
|
|
|
9177
9279
|
// #2527 — role-aware terminal reaction honesty (the "thumbs-up false done"
|
|
9178
9280
|
// fix). Default ON; SWITCHROOM_TG_TERMINAL_HONESTY=0 reverts to always-👍.
|
|
9179
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'
|
|
9180
9293
|
// SILENCE_DEFER_INFLIGHT_TOOLS: previously an opt-in (=1). The new
|
|
9181
9294
|
// isLegitimatelyWorking callback supersedes this — defer is now the DEFAULT
|
|
9182
9295
|
// when the callback is wired. The legacy flag is kept so `=0` still lets
|
|
@@ -9989,6 +10102,250 @@ function agentHasInFlightBackgroundWork(now: number): boolean {
|
|
|
9989
10102
|
// Throttle for the background-work defer diagnostic (the 5s sweep would otherwise
|
|
9990
10103
|
// log every tick across a multi-minute research window).
|
|
9991
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
|
+
|
|
9992
10349
|
function obligationSweep(): void {
|
|
9993
10350
|
if (!OBLIGATION_LEDGER_ENABLED) return
|
|
9994
10351
|
if (!obligationLedger.hasOpen()) return
|
|
@@ -11460,10 +11817,18 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
11460
11817
|
},
|
|
11461
11818
|
|
|
11462
11819
|
onInjectInbound(_client: IpcClient, msg: InjectInboundMessage) {
|
|
11463
|
-
//
|
|
11464
|
-
//
|
|
11465
|
-
//
|
|
11466
|
-
|
|
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()
|
|
11467
11832
|
const promptKey = typeof msg.inbound.meta?.prompt_key === 'string'
|
|
11468
11833
|
? msg.inbound.meta.prompt_key
|
|
11469
11834
|
: 'unknown'
|
|
@@ -15021,11 +15386,10 @@ async function executeEditMessage(args: Record<string, unknown>): Promise<unknow
|
|
|
15021
15386
|
// secret into a live bubble or the history row. Mask before scrub/send.
|
|
15022
15387
|
editRawText = redactOutboundText(editRawText, 'edit_message')
|
|
15023
15388
|
// Fleet-wide consistent formatting (same order as the reply path: redact
|
|
15024
|
-
// first so secrets are matched literally, then normalize
|
|
15025
|
-
//
|
|
15026
|
-
//
|
|
15027
|
-
|
|
15028
|
-
if (!editLiteralText) editRawText = stripExcessBold(normalizePunctuation(editRawText))
|
|
15389
|
+
// first so secrets are matched literally, then normalize, then spacers on
|
|
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).
|
|
15392
|
+
if (!editLiteralText) editRawText = addParagraphSpacers(stripExcessBold(normalizePunctuation(editRawText)))
|
|
15029
15393
|
// Voice scrub (#1683): same em-dash scrub as the reply path. Edits
|
|
15030
15394
|
// are how silent-anchor and progress-update mutate already-sent
|
|
15031
15395
|
// bubbles, so without this an edit can re-introduce dashes the
|
|
@@ -15313,6 +15677,68 @@ function resetOrphanedReplyTimeout(): void {
|
|
|
15313
15677
|
}
|
|
15314
15678
|
}
|
|
15315
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
|
+
|
|
15316
15742
|
function closeActivityLane(chatId: string, threadId: number | undefined): void {
|
|
15317
15743
|
const key = chatKeyWithSuffix(chatId, threadId, 'activity')
|
|
15318
15744
|
const stream = activeDraftStreams.get(key)
|
|
@@ -15373,6 +15799,75 @@ function composeTurnActivity(turn: CurrentTurn, final = false, liveSuffix = ''):
|
|
|
15373
15799
|
return renderActivityFeedWithNested(turn.mirrorLines, childLines, final, liveSuffix, stepCount, header)
|
|
15374
15800
|
}
|
|
15375
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
|
+
|
|
15376
15871
|
/**
|
|
15377
15872
|
* Render a SHOWN narrative text block as a transient liveness step — the
|
|
15378
15873
|
* same path a tool label takes (appendActivityLabel → renderStepFeed), so
|
|
@@ -15418,27 +15913,20 @@ function resolvePendingNarrativeOnTool(
|
|
|
15418
15913
|
toolName: string,
|
|
15419
15914
|
input: Record<string, unknown> | undefined,
|
|
15420
15915
|
): void {
|
|
15421
|
-
|
|
15422
|
-
|
|
15423
|
-
|
|
15424
|
-
|
|
15425
|
-
const replyText = typeof input?.text === 'string' ? (input.text as string) : ''
|
|
15426
|
-
if (isDraftOfReply(pending.text, replyText)) return // draft of the answer → SUPPRESS
|
|
15427
|
-
}
|
|
15428
|
-
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)
|
|
15429
15920
|
}
|
|
15430
15921
|
|
|
15431
15922
|
/**
|
|
15432
15923
|
* Narrative-dedup gate, step 1 (reducer-side): a new narrative block
|
|
15433
15924
|
* arrived. A previously-pending block had nothing reply-shaped immediately
|
|
15434
15925
|
* after it (pure narration) → flush it as SHOWN, then stage the new one for
|
|
15435
|
-
* one lookahead step. See narrative-
|
|
15926
|
+
* one lookahead step AND arm the time-boxed early paint. See narrative-flush.ts.
|
|
15436
15927
|
*/
|
|
15437
15928
|
function stagePendingNarrative(turn: CurrentTurn, text: string): void {
|
|
15438
|
-
|
|
15439
|
-
showNarrativeStep(turn, turn.pendingNarrative.text)
|
|
15440
|
-
}
|
|
15441
|
-
turn.pendingNarrative = { text }
|
|
15929
|
+
turn.narrativeGate.stage(text)
|
|
15442
15930
|
}
|
|
15443
15931
|
|
|
15444
15932
|
/**
|
|
@@ -15446,14 +15934,11 @@ function stagePendingNarrative(turn: CurrentTurn, text: string): void {
|
|
|
15446
15934
|
* trailing narrative block and nothing after it. SUPPRESS only when the turn
|
|
15447
15935
|
* already delivered its answer via reply/stream_reply and the trailing text
|
|
15448
15936
|
* is a draft of that answer; otherwise SHOW (genuine trailing narration like
|
|
15449
|
-
* "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.
|
|
15450
15939
|
*/
|
|
15451
15940
|
function flushPendingNarrativeAtTurnEnd(turn: CurrentTurn, lastReplyText: string): void {
|
|
15452
|
-
|
|
15453
|
-
if (pending == null) return
|
|
15454
|
-
turn.pendingNarrative = null
|
|
15455
|
-
if (lastReplyText.length > 0 && isDraftOfReply(pending.text, lastReplyText)) return // trailing duplicate of the answer
|
|
15456
|
-
showNarrativeStep(turn, pending.text)
|
|
15941
|
+
turn.narrativeGate.flushAtTurnEnd(lastReplyText)
|
|
15457
15942
|
}
|
|
15458
15943
|
|
|
15459
15944
|
/**
|
|
@@ -16239,9 +16724,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
16239
16724
|
// background workers keep the timer warm exactly as long as they are working.
|
|
16240
16725
|
{
|
|
16241
16726
|
const durationMs = ev.kind === 'turn_end' ? ev.durationMs : undefined
|
|
16242
|
-
|
|
16243
|
-
if (signal.activity) markIdleActivity()
|
|
16244
|
-
if (signal.turnEnded) markIdleTurnEnd()
|
|
16727
|
+
idleTracker.noteEvent(ev.kind, Date.now(), durationMs)
|
|
16245
16728
|
}
|
|
16246
16729
|
switch (ev.kind) {
|
|
16247
16730
|
case 'enqueue': {
|
|
@@ -16287,6 +16770,13 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
16287
16770
|
clearTimeout(prior.orphanedReplyTimeoutId)
|
|
16288
16771
|
prior.orphanedReplyTimeoutId = null
|
|
16289
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()
|
|
16290
16780
|
// #1067: swap the entire turn atom in one assignment. Every
|
|
16291
16781
|
// handler captures `const turn = currentTurn` at entry, so a
|
|
16292
16782
|
// captured-then-awaited read can't reattribute to the new turn.
|
|
@@ -16346,6 +16836,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
16346
16836
|
silentAnchorText: '',
|
|
16347
16837
|
capturedText: [],
|
|
16348
16838
|
orphanedReplyTimeoutId: null,
|
|
16839
|
+
answerReadyFlushTimeoutId: null,
|
|
16349
16840
|
// Fresh liveness tracker: lastStreamEventAt seeded to the turn start
|
|
16350
16841
|
// so a turn that never streams still trips the fuse after windowMs.
|
|
16351
16842
|
liveness: new LivenessTracker(startedAt),
|
|
@@ -16363,7 +16854,10 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
16363
16854
|
activityEverOpened: false,
|
|
16364
16855
|
activityDrainFailures: 0,
|
|
16365
16856
|
mirrorLines: [],
|
|
16366
|
-
|
|
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,
|
|
16367
16861
|
lastReplyText: '',
|
|
16368
16862
|
foregroundSubAgents: new Map(),
|
|
16369
16863
|
answerStream: null,
|
|
@@ -16376,6 +16870,9 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
16376
16870
|
statusKey(ev.chatId, enqThreadIdNum),
|
|
16377
16871
|
),
|
|
16378
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)
|
|
16379
16876
|
// PR-4e — route the turn-SET through the keyed accessor: flag-OFF assigns
|
|
16380
16877
|
// the singleton (byte-identical to `currentTurn = next`); flag-ON sets the
|
|
16381
16878
|
// per-topic `byKey[statusKey]` entry AND the most-recent mirror. The key is
|
|
@@ -16544,6 +17041,11 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
16544
17041
|
case 'tool_use': {
|
|
16545
17042
|
const turn = currentTurn
|
|
16546
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)
|
|
16547
17049
|
// Narrative-dedup gate step 2 (JSONL-text-narrative primitive): a
|
|
16548
17050
|
// narrative block was pending; this tool_use is the lookahead event
|
|
16549
17051
|
// that decides it. reply/stream_reply with near-identical text ⇒
|
|
@@ -16630,6 +17132,11 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
16630
17132
|
// where the JSONL tool_use rows arrive too late.
|
|
16631
17133
|
const turn = currentTurn
|
|
16632
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)
|
|
16633
17140
|
// SECONDARY FIX: an active tool_label means the model is producing work
|
|
16634
17141
|
// right now — re-arm the orphaned-reply fuse so a multi-phase tool turn
|
|
16635
17142
|
// (write → compile → test → fix) that regularly emits labels doesn't let
|
|
@@ -16952,6 +17459,11 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
16952
17459
|
preambleSuppressor.onText(ev.text)
|
|
16953
17460
|
}
|
|
16954
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()
|
|
16955
17467
|
|
|
16956
17468
|
if (isContextExhaustionText(ev.text) && turn != null) {
|
|
16957
17469
|
const chatId = turn.sessionChatId
|
|
@@ -17041,7 +17553,14 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
17041
17553
|
// check to the full isLegitimatelyWorking predicate so detached background
|
|
17042
17554
|
// work and human-wait tools (ask_user) are also protected.
|
|
17043
17555
|
// INVARIANT: a REAL turn_end (durationMs >= 0) is NEVER suppressed.
|
|
17044
|
-
|
|
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') {
|
|
17045
17564
|
const turn = currentTurn
|
|
17046
17565
|
const key = turn != null ? statusKey(turn.sessionChatId, turn.sessionThreadId) : ''
|
|
17047
17566
|
// Widened to also suppress while the turn is RECENTLY STREAMING — a
|
|
@@ -17109,8 +17628,8 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
17109
17628
|
// delivered reply text and SUPPRESS the duplicate; otherwise SHOW
|
|
17110
17629
|
// genuine trailing narration ("Done — all green."). Must run BEFORE
|
|
17111
17630
|
// clearActivitySummary so a SHOWN line lands in the feed's final
|
|
17112
|
-
// render. Always clears
|
|
17113
|
-
// turns.
|
|
17631
|
+
// render. Always clears the gate's parked block (and disarms its
|
|
17632
|
+
// early-paint timer) so nothing can leak across turns.
|
|
17114
17633
|
//
|
|
17115
17634
|
// NIT 2 (reply-proxy precision): use `turn.lastReplyText` (the
|
|
17116
17635
|
// most-recent reply/stream_reply input.text) rather than
|
|
@@ -17523,6 +18042,17 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
17523
18042
|
// that this branch never reaches, this set is belt-and-braces —
|
|
17524
18043
|
// it keeps the captured `turn` atom internally consistent for any
|
|
17525
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.
|
|
17526
18056
|
turn.finalAnswerDelivered = true
|
|
17527
18057
|
// Feed-reopen refinement: turn-flush delivers the model's terminal
|
|
17528
18058
|
// transcript text as the genuine answer (not an ack). Default to
|
|
@@ -17553,7 +18083,15 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
17553
18083
|
// sendMessage await for this turn will see currentTurn == null
|
|
17554
18084
|
// and bail; a new enqueue will swap in a fresh atom. The
|
|
17555
18085
|
// `backstop*` locals above hold everything the IIFE needs.
|
|
17556
|
-
|
|
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 })
|
|
17557
18095
|
// #549 fix — turn-flush takes ownership of the captured-text
|
|
17558
18096
|
// backup; reset the preamble buffer (its content is already in
|
|
17559
18097
|
// the captured `capturedText`, which turn-flush is about to send).
|
|
@@ -17593,6 +18131,15 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
17593
18131
|
// async IIFE ran). The old redundant purgeReactionTracking
|
|
17594
18132
|
// here re-fired on an already-cleared key WITHOUT `endingTurn`,
|
|
17595
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
|
+
}
|
|
17596
18143
|
return
|
|
17597
18144
|
}
|
|
17598
18145
|
} catch {}
|
|
@@ -17607,15 +18154,26 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
17607
18154
|
link_preview_options: { is_disabled: true },
|
|
17608
18155
|
}
|
|
17609
18156
|
const limit = RICH_MESSAGE_MAX_CHARS
|
|
17610
|
-
//
|
|
17611
|
-
// single
|
|
17612
|
-
//
|
|
17613
|
-
//
|
|
17614
|
-
//
|
|
17615
|
-
|
|
17616
|
-
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[] = []
|
|
17617
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
|
|
17618
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)
|
|
17619
18177
|
// #654 deterministic double-message fix. If the progress
|
|
17620
18178
|
// card is on screen (60s timer fired before turn_end), edit
|
|
17621
18179
|
// it in place with the first chunk of the answer instead of
|
|
@@ -17723,10 +18281,33 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
17723
18281
|
unpinProgressCardForChat?.(backstopChatId, backstopThreadId)
|
|
17724
18282
|
}
|
|
17725
18283
|
} catch (err) {
|
|
18284
|
+
sendThrew = true
|
|
17726
18285
|
process.stderr.write(`telegram gateway: turn-flush send failed: ${(err as Error).message}\n`)
|
|
17727
18286
|
// #1713: backstop send failed — finalize as error so the
|
|
17728
18287
|
// turn ends cleanly with 😱 rather than leaving it open.
|
|
17729
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
|
+
}
|
|
17730
18311
|
}
|
|
17731
18312
|
// #2094 cosmetic: the trailing `finally { purgeReactionTracking() }`
|
|
17732
18313
|
// was removed. endCurrentTurnAtomic already ran the canonical purge
|
|
@@ -17826,6 +18407,65 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
17826
18407
|
// is exactly `turn.finalAnswerDelivered === false` here (silent-marker
|
|
17827
18408
|
// and flush both returned earlier), delegated to the pure gate core.
|
|
17828
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 {
|
|
17829
18469
|
// PR #2892 (deterministic-turn-liveness RFC Phase 2) hardening:
|
|
17830
18470
|
// wire the represent-guard-style staleness
|
|
17831
18471
|
// check (`recordSilentTurnEnd`'s `hasOutboundDeliveredSince` dep) so
|
|
@@ -17871,6 +18511,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
17871
18511
|
)
|
|
17872
18512
|
})
|
|
17873
18513
|
}
|
|
18514
|
+
} // end else (no captured-prose to deliver)
|
|
17874
18515
|
}
|
|
17875
18516
|
signalTracker.clear(tKey)
|
|
17876
18517
|
silencePoke.endTurn(tKey)
|
|
@@ -29445,6 +30086,18 @@ void (async () => {
|
|
|
29445
30086
|
// channels.telegram.worker_feed.max_rows via the config cascade
|
|
29446
30087
|
// (scaffold emits SWITCHROOM_TG_WORKER_FEED_MAX_ROWS); unset → 8.
|
|
29447
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,
|
|
29448
30101
|
// #3207 review: GROUP-level status pin. Workers now coalesce into
|
|
29449
30102
|
// ONE shared message, so the pin must follow the GROUP lifecycle,
|
|
29450
30103
|
// not a single worker's — otherwise a sibling's finish unpins a
|
|
@@ -29610,6 +30263,23 @@ void (async () => {
|
|
|
29610
30263
|
`telegram gateway: worker ${agentId} NAMED AS LOST — falsely finalised twice, resurrection chain bound reached (issue #3023)\n`,
|
|
29611
30264
|
)
|
|
29612
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
|
+
},
|
|
29613
30283
|
onFinish: ({ agentId, outcome, description, resultText, toolCount, durationMs, background: entryBackground }) => {
|
|
29614
30284
|
// Reaction promotion: if the parent turn already ended
|
|
29615
30285
|
// with this (or another) worker still running, its 👍 was
|
|
@@ -29872,7 +30542,7 @@ void (async () => {
|
|
|
29872
30542
|
// suppresses stale-after-restart delivery (a 4-h-old
|
|
29873
30543
|
// "still working (5m)" would be a lie). Sweep on handback
|
|
29874
30544
|
// lives in the `onFinish` block just above.
|
|
29875
|
-
onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine, model }) => {
|
|
30545
|
+
onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine, model, skeleton }) => {
|
|
29876
30546
|
let fleetChatId = ''
|
|
29877
30547
|
try {
|
|
29878
30548
|
const fleets = progressDriver?.peekAllFleets() ?? []
|
|
@@ -29962,6 +30632,15 @@ void (async () => {
|
|
|
29962
30632
|
return
|
|
29963
30633
|
}
|
|
29964
30634
|
if (surface !== 'nest') return // 'skip' — orphan-status off
|
|
30635
|
+
// #3233: a skeleton liveness cue carries NO step content by
|
|
30636
|
+
// construction (empty latestSummary/progressLine) — it exists
|
|
30637
|
+
// ONLY to create/keep-alive the orphan worker-feed row handled
|
|
30638
|
+
// just above. Branch EXPLICITLY on the `skeleton` discriminator
|
|
30639
|
+
// rather than inferring "no content" from an empty step line:
|
|
30640
|
+
// a skeleton cue must never nest into the parent's live turn
|
|
30641
|
+
// card (there is nothing to render, and the parent's own card
|
|
30642
|
+
// already owns the turn). Deterministic, controls-in-code.
|
|
30643
|
+
if (skeleton) return
|
|
29965
30644
|
const turn = currentTurn
|
|
29966
30645
|
if (turn == null) return // defensive: 'nest' implies a live turn
|
|
29967
30646
|
// Render regardless of `replyCalled` — a foreground Task
|
|
@@ -30107,8 +30786,17 @@ void (async () => {
|
|
|
30107
30786
|
return
|
|
30108
30787
|
}
|
|
30109
30788
|
|
|
30789
|
+
// #3233: with the worker feed DISABLED, the legacy bucket relay
|
|
30790
|
+
// below injects a synthesized "still working" inbound turn. A
|
|
30791
|
+
// skeleton liveness cue carries an EMPTY latestSummary, so
|
|
30792
|
+
// letting it reach the relay would queue a blank/contentless
|
|
30793
|
+
// progress card. The `skeleton` discriminator is threaded into
|
|
30794
|
+
// the pure decision (gate 1b → 'skeleton-liveness'), which drops
|
|
30795
|
+
// it deterministically (controls-in-code, unit-tested) rather
|
|
30796
|
+
// than an opaque inline return here.
|
|
30110
30797
|
const progressOrigin = resolveSubagentOriginChat(agentId)
|
|
30111
30798
|
const decision = decideSubagentProgress({
|
|
30799
|
+
skeleton: skeleton === true,
|
|
30112
30800
|
disableEnvValue: process.env.SWITCHROOM_DISABLE_SUBAGENT_PROGRESS,
|
|
30113
30801
|
isBackground,
|
|
30114
30802
|
// Prefer the conversation the Task was dispatched from over
|