switchroom 0.18.32 → 0.19.0
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/auth-broker/index.js +17 -1
- package/dist/cli/switchroom.js +847 -729
- package/dist/host-control/main.js +18 -2
- package/dist/vault/approvals/kernel-server.js +17 -1
- package/dist/vault/broker/server.js +44 -2
- package/package.json +2 -2
- package/profiles/_base/start.sh.hbs +105 -18
- package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
- package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
- package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
- package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
- package/telegram-plugin/gateway/boot-card.ts +169 -1
- package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
- package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
- package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
- package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
- package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
- package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
- package/telegram-plugin/gateway/gateway.ts +1347 -6758
- package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
- package/telegram-plugin/gateway/inbound-router.ts +400 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
- package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
- package/telegram-plugin/gateway/model-command.ts +23 -0
- package/telegram-plugin/gateway/narrative-lane.ts +865 -0
- package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
- package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
- package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
- package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
- package/telegram-plugin/gateway/secret-request-card.ts +45 -0
- package/telegram-plugin/gateway/stream-render.ts +2166 -0
- package/telegram-plugin/gateway/turn-end.ts +606 -0
- package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
- package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
- package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
- package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
- package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
- package/telegram-plugin/narrative-dedup.ts +24 -1
- package/telegram-plugin/narrative-flush.ts +2 -2
- package/telegram-plugin/render/render.ts +25 -1
- package/telegram-plugin/status-no-truncate.ts +13 -0
- package/telegram-plugin/subagent-watcher.ts +186 -3
- package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
- package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
- package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
- package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
- package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
- package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
- package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +15 -6
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
- package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
- package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
- package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +47 -10
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
- package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
- package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +14 -3
- package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
- package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
- package/telegram-plugin/tests/model-command.test.ts +30 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +27 -9
- package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
- package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
- package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +16 -7
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
- package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
- package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
- package/telegram-plugin/tests/render/render.test.ts +42 -0
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +28 -18
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
- package/telegram-plugin/tests/stop-command.test.ts +22 -12
- package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
- package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
- package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +14 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
- package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
- package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
- package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
- package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
- package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +110 -20
- package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
- package/telegram-plugin/tool-activity-summary.ts +83 -35
- package/telegram-plugin/turn-flush-safety.ts +80 -14
- package/telegram-plugin/uat/restart-capability.ts +76 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
- package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
- package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
- package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
- package/telegram-plugin/worker-activity-feed.ts +10 -4
|
@@ -0,0 +1,865 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// Narrative / activity lane — `composeTurnActivity`, the narrative gate
|
|
3
|
+
// (`makeNarrativeGate` + SHOW/RETRACT/stage/resolve effects,
|
|
4
|
+
// `flushPendingNarrativeAtTurnEnd`), and the activity-feed drain/liveness/
|
|
5
|
+
// heartbeat/clear cluster, relocated VERBATIM from gateway.ts
|
|
6
|
+
// (switchroom#2996 P4-B, plan Amendments 1/5/9/10).
|
|
7
|
+
//
|
|
8
|
+
// SHAPE
|
|
9
|
+
// -----
|
|
10
|
+
// One factory, `createNarrativeLane(deps)`, destructures the injected gateway
|
|
11
|
+
// deps ONCE and returns the 15 lane functions with their original signatures.
|
|
12
|
+
// Intra-lane calls (e.g. the gate's SHOW effect -> `showNarrativeStep` ->
|
|
13
|
+
// `drainActivitySummary`) resolve inside the factory scope, so every body is
|
|
14
|
+
// byte-identical to the pre-move gateway.ts inline body except the enumerated
|
|
15
|
+
// spelling below. gateway.ts holds thin hoisted wrappers that build the lane
|
|
16
|
+
// LAZILY on first call (post-boot, so the captured `bot` handle is the live
|
|
17
|
+
// one) and keep every existing call site — including the stream-render (P4-A)
|
|
18
|
+
// deps builder — unchanged.
|
|
19
|
+
//
|
|
20
|
+
// DI CONTRACT (read before editing)
|
|
21
|
+
// ---------------------------------
|
|
22
|
+
// - Every injected dep except `getCurrentTurn` is a STABLE gateway const /
|
|
23
|
+
// function-decl / singleton instance (verified at extraction: `bot` is
|
|
24
|
+
// assigned exactly once inside the isGatewayMain boot, before any lane
|
|
25
|
+
// call can occur — the lazy first-call capture is therefore live).
|
|
26
|
+
// `earlyLivenessOpenTimers` and the config consts stay declared in
|
|
27
|
+
// gateway.ts (they have non-lane readers: the disconnect teardown drains
|
|
28
|
+
// the timer map).
|
|
29
|
+
// - TURN HANDLE (Amendment 9 / #1664): the lane NEVER reads the
|
|
30
|
+
// `currentTurn` module global — the single live re-read (the heartbeat's
|
|
31
|
+
// chat-root fallback) routes through the injected `getCurrentTurn()`
|
|
32
|
+
// accessor. Per-topic resolution injects the live `currentTurnMap`
|
|
33
|
+
// instance. This is the ONLY body deviation from byte-identical.
|
|
34
|
+
// - SHARED SINGLETONS (Amendment 1): this lane has ZERO
|
|
35
|
+
// `outboundDedup` / `backstopDeliveryLedger` sites (verified: the
|
|
36
|
+
// answer-stream dedup sites are P4-A's stream-render surface, already
|
|
37
|
+
// extracted + golden-tested). The module constructs NO OutboundDedupCache
|
|
38
|
+
// — pinned structurally by stream-render-golden alongside the P2/P4-A
|
|
39
|
+
// modules.
|
|
40
|
+
//
|
|
41
|
+
// ORACLE (Amendment 10): extracted-module golden harness
|
|
42
|
+
// (tests/narrative-lane-golden.test.ts) drives these functions directly with
|
|
43
|
+
// a fake bot recorder + real leaf modules, per the outbound-send-path test
|
|
44
|
+
// precedent; the cross-surface stream-then-reply dedup proof spans the P2 +
|
|
45
|
+
// P4 modules in stream-render-golden.test.ts with the REAL lane flush wired
|
|
46
|
+
// into the stream deps.
|
|
47
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
import { runSilentTurnHeartbeatTick } from '../feed-heartbeat-climb.js'
|
|
50
|
+
import { NarrativeFlushController, PENDING_NARRATIVE_FLUSH_MS } from '../narrative-flush.js'
|
|
51
|
+
import { richMessage } from '../rich-send.js'
|
|
52
|
+
import {
|
|
53
|
+
appendActivityLabel, clipNarrative, formatStepSuffix, renderActivityFeedWithNested,
|
|
54
|
+
} from '../tool-activity-summary.js'
|
|
55
|
+
import { evaluatePostAnswerLiveness } from '../turn-liveness-floor.js'
|
|
56
|
+
import { clearActivityCardRecord, writeActivityCardRecord } from './activity-card-store.js'
|
|
57
|
+
import { chatKeyWithSuffix } from './chat-key.js'
|
|
58
|
+
import {
|
|
59
|
+
computeCrossTurnAnswerDelivered, mayOpenActivityCard, shouldEarlyOpenLiveness,
|
|
60
|
+
type FeedOpenProducer,
|
|
61
|
+
} from './feed-open-gate.js'
|
|
62
|
+
import type { SessionActivityHeader } from '../tool-activity-summary.js'
|
|
63
|
+
import type { CurrentTurn, NarrativeLaneDeps } from './gateway.js'
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build the narrative/activity lane over the injected gateway deps. Called
|
|
67
|
+
* lazily by gateway.ts on first use (see the section comment for why the
|
|
68
|
+
* one-shot capture is safe) and directly by the golden harness with fakes.
|
|
69
|
+
*/
|
|
70
|
+
export function createNarrativeLane(deps: NarrativeLaneDeps) {
|
|
71
|
+
const {
|
|
72
|
+
ACTIVITY_CARD_STORE_PATH, CLEAR_STATUS_ON_COMPLETION, FEED_HEARTBEAT_ENABLED,
|
|
73
|
+
FEED_HEARTBEAT_MIN_STALE_MS, FEED_LIVENESS_OPEN_ENABLED, FEED_LIVENESS_OPEN_MS,
|
|
74
|
+
PIN_STATUS_WHILE_WORKING, POST_ANSWER_LIVENESS_STALE_MS, STATIC,
|
|
75
|
+
activeDraftStreams, activityCardPersistEnabled, activityCardStoreFs, bot,
|
|
76
|
+
cardDrainGate, currentTurnMap, earlyLivenessOpenTimers, emissionAuthorityFor,
|
|
77
|
+
feedOpenGateDeps, getCurrentTurn, reconcileStatusPin, robustApiCall, statusKey,
|
|
78
|
+
} = deps
|
|
79
|
+
|
|
80
|
+
function closeActivityLane(chatId: string, threadId: number | undefined): void {
|
|
81
|
+
const key = chatKeyWithSuffix(chatId, threadId, 'activity')
|
|
82
|
+
const stream = activeDraftStreams.get(key)
|
|
83
|
+
if (stream == null) return
|
|
84
|
+
activeDraftStreams.delete(key)
|
|
85
|
+
void stream.finalize().catch(() => {})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function closeProgressLane(chatId: string, threadId: number | undefined): void {
|
|
89
|
+
// Progress-card streams include a turnKey suffix in their key
|
|
90
|
+
// (e.g. "chatId:_:progress:chatId:1"). Iterate and match by prefix
|
|
91
|
+
// so the backstop actually finds the stream.
|
|
92
|
+
const prefix = chatKeyWithSuffix(chatId, threadId, 'progress')
|
|
93
|
+
for (const [key, stream] of activeDraftStreams) {
|
|
94
|
+
if (key.startsWith(prefix)) {
|
|
95
|
+
activeDraftStreams.delete(key)
|
|
96
|
+
void stream.finalize().catch(() => {})
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Render this turn's activity feed, nesting any active foreground sub-agent's
|
|
103
|
+
* narrative beneath the parent's own steps (Model A). With no active
|
|
104
|
+
* foreground sub-agent this is exactly the flat feed. Multiple concurrent
|
|
105
|
+
* foreground sub-agents (rare — parallel Task dispatch) flatten in insertion
|
|
106
|
+
* order; the single-sub-agent common case nests precisely under its
|
|
107
|
+
* Delegating line.
|
|
108
|
+
*
|
|
109
|
+
* The header (elapsed + tool count) is now threaded into the render so the
|
|
110
|
+
* main-session card matches the worker card's two-line header style. This
|
|
111
|
+
* fixes the missing header regression where the worker card showed elapsed/
|
|
112
|
+
* tool-count metadata but the main-session card rendered step-lines only.
|
|
113
|
+
*/
|
|
114
|
+
function composeTurnActivity(turn: CurrentTurn, final = false, liveSuffix = ''): string | null {
|
|
115
|
+
const childLines: string[] = []
|
|
116
|
+
for (const narrative of turn.foregroundSubAgents.values()) {
|
|
117
|
+
childLines.push(...narrative)
|
|
118
|
+
}
|
|
119
|
+
// Pass labeledToolCount as stepCount only on the terminal (final) render so
|
|
120
|
+
// the persisted feed record shows a `✓ N steps` total. The live in-progress
|
|
121
|
+
// feed omits it (stepCount undefined) to stay clean and minimal.
|
|
122
|
+
const stepCount = final ? turn.labeledToolCount : undefined
|
|
123
|
+
// Build the session header so the main-session card renders the same two-line
|
|
124
|
+
// elapsed/tool-count header as the worker card.
|
|
125
|
+
const header: SessionActivityHeader = {
|
|
126
|
+
label: 'Agent',
|
|
127
|
+
elapsedMs: turn.startedAt > 0 ? Date.now() - turn.startedAt : 0,
|
|
128
|
+
toolCount: turn.labeledToolCount,
|
|
129
|
+
state: final ? 'done' : 'running',
|
|
130
|
+
model: turn.currentModel,
|
|
131
|
+
// The parent's OWN running token total → `· N tok` on the metrics line.
|
|
132
|
+
// 0 → tokenSegment omits it (clean, same as the worker feed).
|
|
133
|
+
totalTokens: turn.totalTokens,
|
|
134
|
+
}
|
|
135
|
+
return renderActivityFeedWithNested(turn.mirrorLines, childLines, final, liveSuffix, stepCount, header)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// PENDING_NARRATIVE_FLUSH_MS is now defined in and imported from
|
|
139
|
+
// `narrative-flush.ts` (the kernel's home) so the main-agent gateway path and
|
|
140
|
+
// the worker/sub-agent watcher share ONE source of truth for the time-box.
|
|
141
|
+
// The main path paints a parked block via the SAME `showNarrativeStep` path a
|
|
142
|
+
// lookahead would, and a timer-painted block that later proves to be the reply
|
|
143
|
+
// is deterministically retracted (the RETRACT effect wired into the controller
|
|
144
|
+
// below).
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Retract a narration step the flush timer painted EARLY that turned out to draft
|
|
148
|
+
* the outgoing reply — the effect half of the anti-double-print guarantee's timer
|
|
149
|
+
* path. Splice the line out of `mirrorLines` and re-render, so neither the live
|
|
150
|
+
* nor the finalized card surfaces the answer as a narration step. The splice runs
|
|
151
|
+
* synchronously BEFORE the reply's `clearActivitySummary` finalize reads
|
|
152
|
+
* `mirrorLines`, so the persisted card is clean regardless of the re-render.
|
|
153
|
+
*/
|
|
154
|
+
function retractNarrativeLine(turn: CurrentTurn, text: string): void {
|
|
155
|
+
const clipped = clipNarrative(text)
|
|
156
|
+
const idx = turn.mirrorLines.lastIndexOf(clipped)
|
|
157
|
+
if (idx === -1) return // rolled out of the window already — nothing to retract
|
|
158
|
+
turn.mirrorLines.splice(idx, 1)
|
|
159
|
+
// Live re-render without the retracted line (the finalize path reads the same
|
|
160
|
+
// spliced array; this only matters for the interim-ack case where finalize
|
|
161
|
+
// isn't called on this reply). Guarded on a non-null render so an emptied feed
|
|
162
|
+
// doesn't blank the card.
|
|
163
|
+
const rerender = composeTurnActivity(turn)
|
|
164
|
+
if (rerender == null) return
|
|
165
|
+
turn.activityPendingRender = rerender
|
|
166
|
+
const ea = emissionAuthorityFor(turn)
|
|
167
|
+
cardDrainGate(turn, ea, () => {
|
|
168
|
+
if (ea.mayDrain(turn)) {
|
|
169
|
+
ea.openOrEditCard('narrative', () => {
|
|
170
|
+
turn.activityInFlight = drainActivitySummary(turn, 'narrative')
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Build a per-turn narrative gate: the pure park/timer/retract state machine
|
|
178
|
+
* (`narrative-flush.ts`) wired to THIS turn's SHOW effect (`showNarrativeStep`),
|
|
179
|
+
* RETRACT effect (`retractNarrativeLine`), and a real `unref`'d `setTimeout`
|
|
180
|
+
* scheduler. The scheduler captures the turn's own timer handle so a turn swap
|
|
181
|
+
* can't mis-target it — mirrors the `noReplyDrainTimer` discipline.
|
|
182
|
+
*/
|
|
183
|
+
function makeNarrativeGate(turn: CurrentTurn): NarrativeFlushController {
|
|
184
|
+
let handle: ReturnType<typeof setTimeout> | null = null
|
|
185
|
+
return new NarrativeFlushController(
|
|
186
|
+
{
|
|
187
|
+
show: (text) => showNarrativeStep(turn, text),
|
|
188
|
+
retractShown: (text) => retractNarrativeLine(turn, text),
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
arm: (fn, ms) => {
|
|
192
|
+
if (handle != null) clearTimeout(handle)
|
|
193
|
+
handle = setTimeout(fn, ms)
|
|
194
|
+
handle.unref?.()
|
|
195
|
+
},
|
|
196
|
+
disarm: () => {
|
|
197
|
+
if (handle != null) {
|
|
198
|
+
clearTimeout(handle)
|
|
199
|
+
handle = null
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
PENDING_NARRATIVE_FLUSH_MS,
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Render a SHOWN narrative text block as a transient liveness step — the
|
|
209
|
+
* same path a tool label takes (appendActivityLabel → renderStepFeed), so
|
|
210
|
+
* the narrative line is rolling-window-clipped and replaced by the next
|
|
211
|
+
* event exactly like a tool step. NOT a new message, NOT persisted as a
|
|
212
|
+
* parallel mirror (invariant `chat-is-the-single-source-of-truth`,
|
|
213
|
+
* reference/invariants.md). Clipped to a single 120-char line via
|
|
214
|
+
* clipNarrative so it reads as a step, not a paragraph.
|
|
215
|
+
*/
|
|
216
|
+
function showNarrativeStep(turn: CurrentTurn, text: string): void {
|
|
217
|
+
const rendered = appendActivityLabel(turn.mirrorLines, clipNarrative(text))
|
|
218
|
+
if (rendered == null) return
|
|
219
|
+
turn.activityPendingRender = composeTurnActivity(turn) ?? rendered
|
|
220
|
+
const ea = emissionAuthorityFor(turn)
|
|
221
|
+
// PR-4d: route the deliver-before-drain decision through the centralized
|
|
222
|
+
// card-drain gate (chatLock-serialized under the flag; verbatim block OFF).
|
|
223
|
+
cardDrainGate(turn, ea, () => {
|
|
224
|
+
if (ea.mayDrain(turn)) {
|
|
225
|
+
// Producer A (narrative SHOW): pre-answer narrative may now OPEN a card,
|
|
226
|
+
// not just EDIT one — lever 5 is INERT (see feed-open-gate.ts), and
|
|
227
|
+
// Lever 2 / clearActivitySummary guarantees reply-is-last ordering instead.
|
|
228
|
+
// Accumulation into mirrorLines still happens, so any narration staged
|
|
229
|
+
// before the card opened renders on the first OPEN (whichever producer
|
|
230
|
+
// wins the race — narrative here, or the enqueue/liveness timer).
|
|
231
|
+
// PR-4a: routed through the emission-authority façade (no-op delegate).
|
|
232
|
+
ea.openOrEditCard('narrative', () => {
|
|
233
|
+
turn.activityInFlight = drainActivitySummary(turn, 'narrative')
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Narrative-dedup gate, step 2 (reducer-side): a tool_use just arrived while
|
|
241
|
+
* a narrative block was pending. Decide SHOW vs SUPPRESS and clear the
|
|
242
|
+
* pending slot. SUPPRESS only when the tool is reply/stream_reply AND the
|
|
243
|
+
* pending text is a draft-then-send of that reply's `input.text`. Everything
|
|
244
|
+
* else (a working tool, or a reply whose text differs — post-action
|
|
245
|
+
* narration) is SHOWN. See narrative-dedup.ts §2b.
|
|
246
|
+
*/
|
|
247
|
+
function resolvePendingNarrativeOnTool(
|
|
248
|
+
turn: CurrentTurn,
|
|
249
|
+
toolName: string,
|
|
250
|
+
input: Record<string, unknown> | undefined,
|
|
251
|
+
): void {
|
|
252
|
+
// Delegate to the pure park/timer/retract kernel: it cancels the early-paint
|
|
253
|
+
// timer, retracts a timer-painted block that THIS reply drafts (anti-double-
|
|
254
|
+
// print), then SHOWs / SUPPRESSes the parked block. See narrative-flush.ts §2.
|
|
255
|
+
turn.narrativeGate.resolveOnTool(toolName, input)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Narrative-dedup gate, step 1 (reducer-side): a new narrative block
|
|
260
|
+
* arrived. A previously-pending block had nothing reply-shaped immediately
|
|
261
|
+
* after it (pure narration) → flush it as SHOWN, then stage the new one for
|
|
262
|
+
* one lookahead step AND arm the time-boxed early paint. See narrative-flush.ts.
|
|
263
|
+
*/
|
|
264
|
+
function stagePendingNarrative(turn: CurrentTurn, text: string): void {
|
|
265
|
+
turn.narrativeGate.stage(text)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Narrative-dedup gate, step 3 (reducer-side): the turn is ending with a
|
|
270
|
+
* trailing narrative block and nothing after it. SUPPRESS only when the turn
|
|
271
|
+
* already delivered its answer via reply/stream_reply and the trailing text
|
|
272
|
+
* is a draft of that answer; otherwise SHOW (genuine trailing narration like
|
|
273
|
+
* "Done — all green."). Also cancels the early-paint timer and retracts a
|
|
274
|
+
* timer-painted draft of the delivered answer. See narrative-flush.ts §3.
|
|
275
|
+
*/
|
|
276
|
+
function flushPendingNarrativeAtTurnEnd(turn: CurrentTurn, lastReplyText: string): void {
|
|
277
|
+
turn.narrativeGate.flushAtTurnEnd(lastReplyText)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Drain the tool-activity summary's pending render queue. Single-flight
|
|
282
|
+
* by construction (caller assigns the returned promise to
|
|
283
|
+
* `turn.activityInFlight`; while set, new tool_uses only update
|
|
284
|
+
* `turn.activityPendingRender` and return).
|
|
285
|
+
*
|
|
286
|
+
* Transport: a single in-place edited message. The first render does
|
|
287
|
+
* `sendMessage` (capturing `turn.activityMessageId`); subsequent renders
|
|
288
|
+
* `editMessageText` that id, so the summary accumulates in place without
|
|
289
|
+
* retyping the whole block. `clearActivitySummary` deletes the message
|
|
290
|
+
* when the reply tool takes over. Works in DMs, groups, and forum topics
|
|
291
|
+
* alike (forum topics pass message_thread_id).
|
|
292
|
+
*
|
|
293
|
+
* The drain holds a reference to `turn`, so a turn-swap mid-drain
|
|
294
|
+
* doesn't corrupt the next turn's atom — late writes land on the
|
|
295
|
+
* captured `turn` (already-completed turn, harmless).
|
|
296
|
+
*/
|
|
297
|
+
async function drainActivitySummary(
|
|
298
|
+
turn: CurrentTurn,
|
|
299
|
+
// Which producer triggered this drain (design §9 levers 1 + 5). Gates the
|
|
300
|
+
// OPEN (first sendMessage) branch via `mayOpenActivityCard`; EDITs of an
|
|
301
|
+
// already-open card are never gated. Defaults to 'tool' — the historically
|
|
302
|
+
// unconditional OPEN behaviour — so any caller that does not opt into the
|
|
303
|
+
// gate is unaffected. Narrative-SHOW and liveness callers pass their producer
|
|
304
|
+
// explicitly.
|
|
305
|
+
producer: FeedOpenProducer = 'tool',
|
|
306
|
+
// Optional flags forwarded to `mayOpenActivityCard`.
|
|
307
|
+
openFlags?: { postAnswerSubagentActivity?: boolean },
|
|
308
|
+
): Promise<void> {
|
|
309
|
+
try {
|
|
310
|
+
while (turn.activityPendingRender !== turn.activityLastSentRender) {
|
|
311
|
+
const target = turn.activityPendingRender
|
|
312
|
+
if (target == null) break
|
|
313
|
+
// OPEN gate (design §9 levers 1 + 5): when this drain would OPEN a fresh
|
|
314
|
+
// card (activityMessageId == null), consult the pure gate. Refusing an
|
|
315
|
+
// OPEN must NOT advance activityLastSentRender — the accumulated render
|
|
316
|
+
// stays pending so a later OPEN-eligible producer (a tool label, or
|
|
317
|
+
// liveness) renders it. An EDIT (activityMessageId != null) is never
|
|
318
|
+
// gated. Enforced HERE so it covers BOTH the inline producers AND the
|
|
319
|
+
// detached heartbeat setInterval drain (R7/concurrency). The gate guards
|
|
320
|
+
// gate EVALUATION, not an in-flight send: it is not a hard mutex — a send
|
|
321
|
+
// already PAST this check and suspended at its `await robustApiCall(
|
|
322
|
+
// sendMessage)` when a substantive final lands still completes and opens a
|
|
323
|
+
// card; that residual is reconciled by lever-2's `clearActivitySummary`
|
|
324
|
+
// chaining its finalize onto `turn.activityInFlight` (the suspended drain)
|
|
325
|
+
// and editing the card in place, not by this gate blocking it.
|
|
326
|
+
// Lever 4 (cross-turn / race C/D): a synthetic represent/owed-reply turn
|
|
327
|
+
// (and the liveness/heartbeat timer firing on it) starts with a CLEARED
|
|
328
|
+
// per-turn `finalAnswerEverDelivered` latch even when a substantive answer
|
|
329
|
+
// already reached the user in an EARLIER turn — so without this its first
|
|
330
|
+
// drain opens a card BELOW that prior reply. Only such a turn carries
|
|
331
|
+
// `crossTurnGate`; reuse the represent guard's delivered-since check
|
|
332
|
+
// (`hasOutboundDeliveredSince`) with the obligation's `openedAt` cutoff and
|
|
333
|
+
// the SUBSTANTIVE 200-char threshold (so an ack never trips it → #2141
|
|
334
|
+
// stays green). Computed ONLY when about to OPEN (activityMessageId ==
|
|
335
|
+
// null) AND only for a turn with a cross-turn gate — no history query on
|
|
336
|
+
// the common foreground path. Scoped to the synthetic surface by the
|
|
337
|
+
// presence of `crossTurnGate`, so it can never fire on a foreground turn.
|
|
338
|
+
// PR-4b: the cross-turn predicate is now the PURE, shared helper extracted
|
|
339
|
+
// into feed-open-gate.ts (body lifted verbatim) — the SAME function the
|
|
340
|
+
// emission-authority façade calls in its enabled branch, so flag-ON and
|
|
341
|
+
// flag-OFF compute an identical verdict. History deps injected (the module
|
|
342
|
+
// stays sqlite-free). The pure-gate consult + the `break` below stay
|
|
343
|
+
// LITERALLY in the drain (disabled-path byte-identity).
|
|
344
|
+
const crossTurnAnswerDelivered = computeCrossTurnAnswerDelivered(
|
|
345
|
+
turn,
|
|
346
|
+
feedOpenGateDeps(),
|
|
347
|
+
)
|
|
348
|
+
if (
|
|
349
|
+
turn.activityMessageId == null
|
|
350
|
+
&& !mayOpenActivityCard({
|
|
351
|
+
producer,
|
|
352
|
+
finalAnswerEverDelivered: turn.finalAnswerEverDelivered,
|
|
353
|
+
labeledToolCount: turn.labeledToolCount,
|
|
354
|
+
crossTurnAnswerDelivered,
|
|
355
|
+
postAnswerSubagentActivity: openFlags?.postAnswerSubagentActivity,
|
|
356
|
+
})
|
|
357
|
+
) {
|
|
358
|
+
break
|
|
359
|
+
}
|
|
360
|
+
// `renderActivityFeed` already emitted ready Telegram HTML with per-line
|
|
361
|
+
// markup (**→ current** / _✓ done_) and escaped each label's
|
|
362
|
+
// <,>,& itself (#1942 class) — send verbatim, do NOT re-escape or
|
|
363
|
+
// re-wrap (double-escaping would surface literal tags).
|
|
364
|
+
const html = target
|
|
365
|
+
const chat = turn.sessionChatId
|
|
366
|
+
const thread = turn.sessionThreadId
|
|
367
|
+
// Native reply-quote: anchor the feed message to the user's question so
|
|
368
|
+
// it renders as a quoted header (reply_parameters renders on a real
|
|
369
|
+
// message; edits preserve it). allow_sending_without_reply so a deleted
|
|
370
|
+
// source can't drop the send.
|
|
371
|
+
const replyAnchor = turn.sourceMessageId != null
|
|
372
|
+
? { reply_parameters: { message_id: turn.sourceMessageId, allow_sending_without_reply: true } }
|
|
373
|
+
: {}
|
|
374
|
+
try {
|
|
375
|
+
if (turn.activityMessageId == null) {
|
|
376
|
+
const sent = await robustApiCall(
|
|
377
|
+
// allow-raw-bot-api: sendRichMessage routed through robustApiCall (not in the THREAD_NOT_FOUND blast pattern)
|
|
378
|
+
() => bot.api.sendRichMessage(chat, richMessage(html), {
|
|
379
|
+
...(thread != null ? { message_thread_id: thread } : {}),
|
|
380
|
+
disable_notification: true,
|
|
381
|
+
...replyAnchor,
|
|
382
|
+
}),
|
|
383
|
+
{ chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.send' },
|
|
384
|
+
)
|
|
385
|
+
turn.activityMessageId = sent.message_id
|
|
386
|
+
turn.activityEverOpened = true
|
|
387
|
+
// Known Gap 1 (deterministic-turn-liveness.md) — persist the
|
|
388
|
+
// minimal card handle the moment it opens, so a gateway restart
|
|
389
|
+
// mid-turn has something to finalize on next boot instead of
|
|
390
|
+
// leaving this card frozen forever. Fire-and-forget/best-effort:
|
|
391
|
+
// a failed persist degrades to the pre-fix (in-memory-only)
|
|
392
|
+
// behaviour, never blocks the card opening.
|
|
393
|
+
if (activityCardPersistEnabled) {
|
|
394
|
+
writeActivityCardRecord(ACTIVITY_CARD_STORE_PATH, activityCardStoreFs, {
|
|
395
|
+
turnKey: statusKey(chat, thread),
|
|
396
|
+
chatId: chat,
|
|
397
|
+
threadId: thread ?? null,
|
|
398
|
+
activityMessageId: sent.message_id,
|
|
399
|
+
startedAt: turn.startedAt,
|
|
400
|
+
// Mirror the ACTUAL pin decision, not an unconditional `true`:
|
|
401
|
+
// the OPEN below silently-pins the fresh card only when
|
|
402
|
+
// `PIN_STATUS_WHILE_WORKING` is on (`reconcileStatusPin` no-ops
|
|
403
|
+
// when it's off, and can also fail on missing supergroup
|
|
404
|
+
// rights). Persisting `pinned: true` regardless would make the
|
|
405
|
+
// boot reaper attempt an unpin on a card that was never pinned.
|
|
406
|
+
// The reaper's unpin is defense-in-depth anyway
|
|
407
|
+
// (`statusPinBootCleanup` owns the primary unpin), so tracking
|
|
408
|
+
// the flag honestly is what matters here.
|
|
409
|
+
pinned: PIN_STATUS_WHILE_WORKING,
|
|
410
|
+
})
|
|
411
|
+
}
|
|
412
|
+
// Status-pin: the per-turn status message just opened — it's the
|
|
413
|
+
// in-flight "what it's doing" surface. Silently pin it so the turn
|
|
414
|
+
// stays in view when the feed scrolls past. Keyed to the same
|
|
415
|
+
// status-key the canonical turn-end (purgeReactionTracking) unpins.
|
|
416
|
+
// Fire-and-forget; the single-owner reconcile keeps state consistent.
|
|
417
|
+
void reconcileStatusPin(
|
|
418
|
+
`fg:${statusKey(chat, thread)}`,
|
|
419
|
+
chat,
|
|
420
|
+
{ pinned: true, messageId: sent.message_id },
|
|
421
|
+
)
|
|
422
|
+
} else {
|
|
423
|
+
const id = turn.activityMessageId
|
|
424
|
+
await robustApiCall(
|
|
425
|
+
() => bot.api.editMessageText(chat, id, richMessage(html), {}),
|
|
426
|
+
{ chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.edit' },
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
turn.activityLastSentRender = target
|
|
430
|
+
} catch (err) {
|
|
431
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
432
|
+
const low = msg.toLowerCase()
|
|
433
|
+
// Transport-class failures (429, message gone, "not modified") must
|
|
434
|
+
// NOT inflate `activityDrainFailures` — that counter flags a turn as
|
|
435
|
+
// DEGRADED on turn-end, and a transient rate-limit or an
|
|
436
|
+
// already-deleted message is not a logic defect. Counting them would
|
|
437
|
+
// false-flag a healthy turn. "not modified" is success; gone/429 are
|
|
438
|
+
// retried or harmless. Only a genuine send/open failure counts.
|
|
439
|
+
const isTransport =
|
|
440
|
+
low.includes('not modified') ||
|
|
441
|
+
low.includes('not found') ||
|
|
442
|
+
low.includes("can't be edited") ||
|
|
443
|
+
low.includes('cannot be edited') ||
|
|
444
|
+
low.includes('not enough rights') ||
|
|
445
|
+
low.includes('429') ||
|
|
446
|
+
low.includes('retry after')
|
|
447
|
+
if (!isTransport) {
|
|
448
|
+
turn.activityDrainFailures += 1
|
|
449
|
+
// Surface the failing anchor + topic: the resume-400 bug fed a
|
|
450
|
+
// fabricated 13-digit message_id as the reply anchor here, so every
|
|
451
|
+
// send 400'd and the feed never opened. Logging the anchor +
|
|
452
|
+
// everOpened makes a feed-blanking send self-explanatory (and the
|
|
453
|
+
// turn-end DEGRADED line aggregates it).
|
|
454
|
+
process.stderr.write(
|
|
455
|
+
`telegram gateway: activity-summary drain failed: ${msg} ` +
|
|
456
|
+
`(chat=${chat} thread=${thread ?? '-'} ` +
|
|
457
|
+
`replyAnchor=${turn.sourceMessageId ?? 'none'} ` +
|
|
458
|
+
`everOpened=${turn.activityEverOpened} failures=${turn.activityDrainFailures})\n`,
|
|
459
|
+
)
|
|
460
|
+
}
|
|
461
|
+
// Mark as sent so we don't infinite-loop on a stuck render.
|
|
462
|
+
turn.activityLastSentRender = target
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
} finally {
|
|
466
|
+
turn.activityInFlight = null
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* OPEN the minimal "Working…" liveness card for a 0-label turn once it has been
|
|
472
|
+
* alive >= FEED_LIVENESS_OPEN_MS. The ONE place the liveness card may OPEN — both
|
|
473
|
+
* the enqueue-time early-open timer (`scheduleEarlyLivenessOpen`) and the 6 s
|
|
474
|
+
* heartbeat call through here, so a card opened by one caller is a clean no-op
|
|
475
|
+
* for the other. This function OPENS only; it does NOT climb an already-open card
|
|
476
|
+
* (its WHEN-gate `shouldEarlyOpenLiveness` returns false once `activityMessageId`
|
|
477
|
+
* is set). The 0-label CLIMB of an already-open card lives at the heartbeat call
|
|
478
|
+
* site via `silentTurnClimbRender` (deterministic-turn-liveness.md Phase 1) — so
|
|
479
|
+
* BOTH the labelled and the 0-label branches now keep the card visibly climbing.
|
|
480
|
+
* - `drainActivitySummary` OPENs when `activityMessageId == null` and EDITs
|
|
481
|
+
* once it is set, so a second call after an open just maintains the card;
|
|
482
|
+
* - the `mirrorLines.length === 0` guard at the heartbeat call site (and the
|
|
483
|
+
* drain's own gate) means once a real tool label lands this path is skipped
|
|
484
|
+
* and the labelled-feed heartbeat takes over;
|
|
485
|
+
* - the OPEN itself is still gated by `mayOpenActivityCard` (lever 1 / 4) via
|
|
486
|
+
* `ea.openOrEditCard('liveness', …)`, so a card never opens below a
|
|
487
|
+
* delivered answer or on a cross-turn synthetic surface.
|
|
488
|
+
*
|
|
489
|
+
* Renders the turn's accumulated narration when present (the §3 case: narration
|
|
490
|
+
* staged before the first tool via `mirrorLines`) so the early open is not a
|
|
491
|
+
* bare placeholder when there is real text to show; falls back to "Working…"
|
|
492
|
+
* for a genuinely silent thinking turn.
|
|
493
|
+
*/
|
|
494
|
+
function openLivenessFeedIfDue(turn: CurrentTurn): void {
|
|
495
|
+
const age = Date.now() - turn.startedAt
|
|
496
|
+
// The WHEN decision (pure, `feed-open-gate.ts`): feature on, target chat,
|
|
497
|
+
// past threshold, no card already open. Returns false once a card is open
|
|
498
|
+
// (the drain EDITs instead) so the two callers can never double-open.
|
|
499
|
+
if (!shouldEarlyOpenLiveness({
|
|
500
|
+
enabled: FEED_LIVENESS_OPEN_ENABLED,
|
|
501
|
+
ageMs: age,
|
|
502
|
+
thresholdMs: FEED_LIVENESS_OPEN_MS,
|
|
503
|
+
mirrorLineCount: turn.mirrorLines.length,
|
|
504
|
+
activityMessageId: turn.activityMessageId,
|
|
505
|
+
sessionChatId: turn.sessionChatId,
|
|
506
|
+
})) return
|
|
507
|
+
const lines = turn.mirrorLines.length > 0 ? turn.mirrorLines : ['Working…']
|
|
508
|
+
const livenessHeader: SessionActivityHeader = {
|
|
509
|
+
label: 'Agent', elapsedMs: age, toolCount: turn.labeledToolCount, state: 'running',
|
|
510
|
+
model: turn.currentModel,
|
|
511
|
+
}
|
|
512
|
+
// Liveness card is a single "step" whose start is the turn start, so `age`
|
|
513
|
+
// IS the step's own elapsed. formatStepSuffix keeps the `→` line timer-free
|
|
514
|
+
// until the step has run ≥ STEP_TIMER_MIN_MS (header total still shows).
|
|
515
|
+
const rendered = renderActivityFeedWithNested(lines, [], false, formatStepSuffix(age), undefined, livenessHeader)
|
|
516
|
+
if (rendered == null) return
|
|
517
|
+
turn.activityPendingRender = rendered
|
|
518
|
+
const ea = emissionAuthorityFor(turn)
|
|
519
|
+
// PR-4d: route through the centralized chatLock-serialized card-drain gate.
|
|
520
|
+
cardDrainGate(turn, ea, () => {
|
|
521
|
+
if (ea.mayDrain(turn)) {
|
|
522
|
+
// Producer C (liveness timer): the thinking-gap / early-open. Now that
|
|
523
|
+
// Lever 5 is inert (narrative may open pre-answer — #2588), liveness
|
|
524
|
+
// remains the natural open for 0-tool pre-answer turns that are silent.
|
|
525
|
+
// The sticky-latch (lever 1) still gates it in the drain.
|
|
526
|
+
// PR-4a: routed through the emission-authority façade (no-op delegate).
|
|
527
|
+
ea.openOrEditCard('liveness', () => {
|
|
528
|
+
turn.activityInFlight = drainActivitySummary(turn, 'liveness')
|
|
529
|
+
})
|
|
530
|
+
}
|
|
531
|
+
})
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Schedule the enqueue-time early-open of the "Working…" liveness card. Called
|
|
536
|
+
* once per fresh turn at the `enqueue` lifecycle event (the single chokepoint
|
|
537
|
+
* every real turn atom passes through — inbound, cron, subagent-handback,
|
|
538
|
+
* vault-resume, restart-marker; anonymous one-shot hook clients never emit
|
|
539
|
+
* `enqueue`, so they are excluded by construction). Fires `openLivenessFeedIfDue`
|
|
540
|
+
* once at `FEED_LIVENESS_OPEN_MS` after turn start so narration / thinking that
|
|
541
|
+
* happens BEFORE the first tool surfaces a card within ~a second — no more dead
|
|
542
|
+
* air until a tool label or the old 12 s threshold. A no-op if a tool/narrative
|
|
543
|
+
* already opened the card (the helper's own guards). The 6 s heartbeat remains
|
|
544
|
+
* the backstop + the climb.
|
|
545
|
+
*/
|
|
546
|
+
function scheduleEarlyLivenessOpen(turn: CurrentTurn): void {
|
|
547
|
+
if (STATIC || !FEED_HEARTBEAT_ENABLED || !FEED_LIVENESS_OPEN_ENABLED) return
|
|
548
|
+
if (turn.sessionChatId == null) return
|
|
549
|
+
const key = statusKey(turn.sessionChatId, turn.sessionThreadId)
|
|
550
|
+
stopEarlyLivenessOpen(key)
|
|
551
|
+
const t = setTimeout(() => {
|
|
552
|
+
earlyLivenessOpenTimers.delete(key)
|
|
553
|
+
// Re-resolve the live turn for this key: only open if THIS turn is still the
|
|
554
|
+
// live one for its topic (a successor turn would carry its own timer). Under
|
|
555
|
+
// flag OFF `get(key)` returns the singleton — same turn unless a successor
|
|
556
|
+
// already replaced it, which the turnId match below also guards.
|
|
557
|
+
const live = currentTurnMap.get(key)
|
|
558
|
+
if (live == null || live.turnId !== turn.turnId) return
|
|
559
|
+
openLivenessFeedIfDue(live)
|
|
560
|
+
}, FEED_LIVENESS_OPEN_MS)
|
|
561
|
+
t.unref?.()
|
|
562
|
+
earlyLivenessOpenTimers.set(key, t)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Cancel the enqueue-time early-open timer for a status-key (turn-end teardown
|
|
566
|
+
* + re-arm guard). Idempotent. */
|
|
567
|
+
function stopEarlyLivenessOpen(key: string): void {
|
|
568
|
+
const t = earlyLivenessOpenTimers.get(key)
|
|
569
|
+
if (t != null) { clearTimeout(t); earlyLivenessOpenTimers.delete(key) }
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Heartbeat tick (PR1): keep the live activity feed visibly advancing during a
|
|
574
|
+
* long single step that emits no new tool_label. Re-renders the feed with a
|
|
575
|
+
* climbing " · Ns" elapsed on the in-progress line through the SAME single-flight
|
|
576
|
+
* drain path the tool_label handler uses (no separate transport, no race). Pure
|
|
577
|
+
* no-op unless there is a live in-flight feed whose newest step has gone stale.
|
|
578
|
+
* Skips once the final answer landed (the feed is handing off) and after the
|
|
579
|
+
* turn ends (activityMessageId nulled by clearActivitySummary). Deterministic +
|
|
580
|
+
* framework-owned — never depends on the model.
|
|
581
|
+
*/
|
|
582
|
+
function feedHeartbeatTick(): void {
|
|
583
|
+
const turn = getCurrentTurn()
|
|
584
|
+
if (turn == null) return
|
|
585
|
+
if (turn.finalAnswerDelivered) {
|
|
586
|
+
// Fix 2: post-answer background-agent liveness. When the sub-agent/workflow
|
|
587
|
+
// watcher has surfaced a new step AFTER the substantive final answer, drive
|
|
588
|
+
// a liveness card so the operator can see "background agent still working".
|
|
589
|
+
//
|
|
590
|
+
// Gate: `turn.subagentActivityAt` must be set (watcher fired) AND it must
|
|
591
|
+
// exceed `turn.finalAnswerDeliveredAt` (the watcher advanced AFTER the answer
|
|
592
|
+
// was delivered — not just any pre-answer label). This is the key fix:
|
|
593
|
+
// #2587 read `lastToolLabelAt`, which is frozen by the drop-guard after a
|
|
594
|
+
// substantive answer and therefore never crosses the threshold. `subagentActivityAt`
|
|
595
|
+
// is written by the watcher's onProgress callback INDEPENDENTLY of the
|
|
596
|
+
// tool_label / drop-guard path, so it correctly advances post-answer.
|
|
597
|
+
//
|
|
598
|
+
// Idle-gap suppression + staleness cap (concern 3) — the single pure decision
|
|
599
|
+
// `evaluatePostAnswerLiveness`:
|
|
600
|
+
// - 'idle' → no watcher activity after the answer (`subagentActivityAt`
|
|
601
|
+
// undefined or ≤ finalAnswerDeliveredAt). Stay silent; the
|
|
602
|
+
// reply-is-last invariant is fully preserved for idle turns.
|
|
603
|
+
// - 'stale' → the worker's last advance is older than POST_ANSWER_LIVENESS_STALE_MS
|
|
604
|
+
// (its `onFinish` froze `subagentActivityAt` and no new step has
|
|
605
|
+
// arrived). STOP re-rendering so the card doesn't climb `running`
|
|
606
|
+
// forever — mirrors the pre-answer FEED_LIVENESS_OPEN_MS cap. The
|
|
607
|
+
// worker's own terminal card (workerActivityFeed.finish) is the
|
|
608
|
+
// durable record once it completes.
|
|
609
|
+
// - 'emit' → genuine in-flight post-answer activity; render the card below.
|
|
610
|
+
const subagentAt = turn.subagentActivityAt
|
|
611
|
+
// Fix 3 (sub-agent-delegation freeze): a foreground `Task`/`Agent` still
|
|
612
|
+
// tracked in `turn.foregroundSubAgents` is POSITIVE evidence the worker
|
|
613
|
+
// has not reported finished — see the doc comment on
|
|
614
|
+
// `PostAnswerLivenessInput.stillDispatched` in turn-liveness-floor.ts for
|
|
615
|
+
// why this must bypass the staleness cap rather than let a single long
|
|
616
|
+
// silent step freeze the card mid-delegation.
|
|
617
|
+
const stillDispatched = turn.foregroundSubAgents.size > 0
|
|
618
|
+
const livenessVerdict = evaluatePostAnswerLiveness({
|
|
619
|
+
subagentActivityAt: subagentAt,
|
|
620
|
+
finalAnswerDeliveredAt: turn.finalAnswerDeliveredAt,
|
|
621
|
+
now: Date.now(),
|
|
622
|
+
staleCapMs: POST_ANSWER_LIVENESS_STALE_MS,
|
|
623
|
+
stillDispatched,
|
|
624
|
+
})
|
|
625
|
+
if (livenessVerdict !== 'emit' || subagentAt == null) return // idle gap or stale worker → stay silent (the `== null` also narrows subagentAt for the elapsed below)
|
|
626
|
+
// A background worker is genuinely active after the answer. Open or maintain
|
|
627
|
+
// a liveness card below the reply. Route through `mayOpenActivityCard` with
|
|
628
|
+
// `postAnswerSubagentActivity:true` so Lever 1 is lifted for 'tool' producer
|
|
629
|
+
// (Fix 2's Lever 1 exception in feed-open-gate.ts). The card renders the
|
|
630
|
+
// turn's accumulated mirrorLines (which may be empty — in that case the drain
|
|
631
|
+
// opens a "Working…" placeholder matching the pre-answer liveness path).
|
|
632
|
+
if (turn.sessionChatId == null) return
|
|
633
|
+
const age = Date.now() - turn.startedAt
|
|
634
|
+
const livenessHeader: SessionActivityHeader = {
|
|
635
|
+
label: 'Agent', elapsedMs: age, toolCount: turn.labeledToolCount, state: 'running',
|
|
636
|
+
model: turn.currentModel,
|
|
637
|
+
}
|
|
638
|
+
const lines = turn.mirrorLines.length > 0 ? turn.mirrorLines : ['Working in background…']
|
|
639
|
+
// `subagentAt` is the worker's last ADVANCE — the current step's start —
|
|
640
|
+
// so this suffix is already per-step. formatStepSuffix adds the 10 s gate.
|
|
641
|
+
const elapsed = Date.now() - subagentAt
|
|
642
|
+
const rendered = renderActivityFeedWithNested(lines, [], false, formatStepSuffix(elapsed), undefined, livenessHeader)
|
|
643
|
+
if (rendered == null) return
|
|
644
|
+
turn.activityPendingRender = rendered
|
|
645
|
+
const ea = emissionAuthorityFor(turn)
|
|
646
|
+
cardDrainGate(turn, ea, () => {
|
|
647
|
+
if (ea.mayDrain(turn)) {
|
|
648
|
+
// Producer 'tool' with postAnswerSubagentActivity=true: the Lever 1
|
|
649
|
+
// exception allows this OPEN. Lever 4 (cross-turn) and idle-liveness
|
|
650
|
+
// blocks are still respected by the drain. The card surfaces BELOW the
|
|
651
|
+
// reply showing the background agent's live activity.
|
|
652
|
+
ea.openOrEditCard('tool', () => {
|
|
653
|
+
turn.activityInFlight = drainActivitySummary(turn, 'tool', { postAnswerSubagentActivity: true })
|
|
654
|
+
})
|
|
655
|
+
}
|
|
656
|
+
})
|
|
657
|
+
return
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Liveness feed (open + maintain). `mirrorLines.length === 0` means no tool
|
|
661
|
+
// has ever produced a label this turn — pure thinking, or only suppressed
|
|
662
|
+
// tools. Open a minimal "Working…" feed once the turn passes the threshold,
|
|
663
|
+
// and keep its elapsed climbing until a real label arrives. The first label
|
|
664
|
+
// makes mirrorLines non-empty, so the labelled-feed heartbeat below takes
|
|
665
|
+
// over and its edit cleanly replaces the placeholder. drainActivitySummary
|
|
666
|
+
// sends (opens) when activityMessageId is null and edits (maintains) once set
|
|
667
|
+
// — so this one branch handles both the open and the climb.
|
|
668
|
+
//
|
|
669
|
+
// The OPEN logic lives in ONE place (`openLivenessFeedIfDue`) so the
|
|
670
|
+
// enqueue-time early-open timer (`scheduleEarlyLivenessOpen`) and this 6 s
|
|
671
|
+
// heartbeat both reach the same drain — there is exactly one path that can
|
|
672
|
+
// OPEN the liveness card, so the two callers can never double-open or race.
|
|
673
|
+
//
|
|
674
|
+
// Phase 1 climb (deterministic-turn-liveness.md): once the card IS open, the
|
|
675
|
+
// OPEN path no-ops (its WHEN-gate `shouldEarlyOpenLiveness` returns false for
|
|
676
|
+
// an already-open card) — which is exactly how the 0-label card used to FREEZE
|
|
677
|
+
// during a long silent tool. So split the two cases: OPEN when no card exists,
|
|
678
|
+
// and otherwise re-render the "Working…" card with a fresh wall-clock elapsed
|
|
679
|
+
// through the SAME cardDrainGate / mayDrain / liveness EDIT path the labelled
|
|
680
|
+
// branch below uses. Model-independent (reads only `now - startedAt`), so a
|
|
681
|
+
// blocked tool call can't starve it; edit-only, so it never push-notifies.
|
|
682
|
+
//
|
|
683
|
+
// The tick BODY lives in feed-heartbeat-climb.ts (`runSilentTurnHeartbeatTick`)
|
|
684
|
+
// so the shipped decision logic is directly under the outcome-based regression
|
|
685
|
+
// test (tests/silent-turn-climb-transport.test.ts) — this gateway IIFE cannot
|
|
686
|
+
// be imported in-process. This call site only wires the REAL deps; the wiring
|
|
687
|
+
// shape is pinned structurally by tests/feed-heartbeat-liveness-open.test.ts.
|
|
688
|
+
{
|
|
689
|
+
const ea = emissionAuthorityFor(turn)
|
|
690
|
+
const handled = runSilentTurnHeartbeatTick(
|
|
691
|
+
{
|
|
692
|
+
mirrorLineCount: turn.mirrorLines.length,
|
|
693
|
+
activityMessageId: turn.activityMessageId,
|
|
694
|
+
labeledToolCount: turn.labeledToolCount,
|
|
695
|
+
ageMs: Date.now() - turn.startedAt,
|
|
696
|
+
minStaleMs: FEED_HEARTBEAT_MIN_STALE_MS,
|
|
697
|
+
},
|
|
698
|
+
{
|
|
699
|
+
openLivenessFeedIfDue: () => openLivenessFeedIfDue(turn),
|
|
700
|
+
setPendingRender: (rendered) => { turn.activityPendingRender = rendered },
|
|
701
|
+
cardDrainGate: (run) => cardDrainGate(turn, ea, run),
|
|
702
|
+
mayDrain: () => ea.mayDrain(turn),
|
|
703
|
+
openOrEditCard: (apply) => ea.openOrEditCard('liveness', apply),
|
|
704
|
+
drain: () => { turn.activityInFlight = drainActivitySummary(turn, 'liveness') },
|
|
705
|
+
},
|
|
706
|
+
)
|
|
707
|
+
if (handled) return
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Labelled-feed heartbeat: keep a stale in-progress step visibly advancing.
|
|
711
|
+
if (turn.activityMessageId == null) return // no live feed yet / already cleared
|
|
712
|
+
if (turn.lastToolLabelAt == null) return // feed not driven by a labelled step
|
|
713
|
+
const elapsed = Date.now() - turn.lastToolLabelAt
|
|
714
|
+
if (elapsed < FEED_HEARTBEAT_MIN_STALE_MS) return // step is fresh; feed advancing normally
|
|
715
|
+
// `lastToolLabelAt` resets on every new tool label, so `elapsed` is the
|
|
716
|
+
// CURRENT step's own run time. formatStepSuffix holds the timer back until
|
|
717
|
+
// the step passes STEP_TIMER_MIN_MS (10 s) — header total is unaffected.
|
|
718
|
+
const rendered = composeTurnActivity(turn, false, formatStepSuffix(elapsed))
|
|
719
|
+
if (rendered == null) return
|
|
720
|
+
turn.activityPendingRender = rendered
|
|
721
|
+
const ea = emissionAuthorityFor(turn)
|
|
722
|
+
// PR-4d: route through the centralized chatLock-serialized card-drain gate.
|
|
723
|
+
cardDrainGate(turn, ea, () => {
|
|
724
|
+
if (ea.mayDrain(turn)) {
|
|
725
|
+
// Maintains an already-open card (guarded above on activityMessageId !=
|
|
726
|
+
// null) → only ever EDITs. 'liveness' is correct either way.
|
|
727
|
+
// PR-4a: routed through the emission-authority façade (no-op delegate).
|
|
728
|
+
ea.openOrEditCard('liveness', () => {
|
|
729
|
+
turn.activityInFlight = drainActivitySummary(turn, 'liveness')
|
|
730
|
+
})
|
|
731
|
+
}
|
|
732
|
+
})
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Reconcile the activity summary when the model's reply tool takes over as the
|
|
737
|
+
* authoritative surface. Awaits any in-flight render so we don't race a stale
|
|
738
|
+
* write, then EITHER:
|
|
739
|
+
* - FINALIZE (default, CLEAR_STATUS_ON_COMPLETION=false): edit the message to
|
|
740
|
+
* a terminal all-done render (no "→ in-progress" line) and stop tracking it
|
|
741
|
+
* — the status stays in the chat as a record beside the reply. No delete.
|
|
742
|
+
* - DELETE (CLEAR_STATUS_ON_COMPLETION=true, opt-in via
|
|
743
|
+
* channels.telegram.clear_status_on_completion): remove the message so only
|
|
744
|
+
* the reply remains (the pre-2026-06 behaviour).
|
|
745
|
+
* Idempotent + best-effort — failures stderr-log but don't block.
|
|
746
|
+
*
|
|
747
|
+
* Called on the first reply (hand-off) and again at turn_end (no-reply safety
|
|
748
|
+
* net); finalize edits are idempotent (a 'message is not modified' on the
|
|
749
|
+
* second call is swallowed).
|
|
750
|
+
*
|
|
751
|
+
* `finalHtmlOverride` (finalize path only): a render captured by the caller
|
|
752
|
+
* BEFORE it tore down turn state the finalize render depends on. The
|
|
753
|
+
* foreground handoff-clear path passes this — it deletes the just-finished
|
|
754
|
+
* sub-agent's narrative right after this call, so the async
|
|
755
|
+
* `composeTurnActivity(turn, true)` below would see an emptied feed (and, on
|
|
756
|
+
* ack-first turns, empty `mirrorLines`), render null, and skip the finalize —
|
|
757
|
+
* freezing the last live "→ in-progress" line. The captured render keeps the
|
|
758
|
+
* persisted record reading done (✓). Omitted → compute it here (the common
|
|
759
|
+
* reply/turn_end callers, where state is stable).
|
|
760
|
+
*/
|
|
761
|
+
function clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | null): void {
|
|
762
|
+
const chat = turn.sessionChatId
|
|
763
|
+
const thread = turn.sessionThreadId
|
|
764
|
+
const inFlight = turn.activityInFlight ?? Promise.resolve()
|
|
765
|
+
void inFlight.then(async () => {
|
|
766
|
+
if (turn.activityMessageId == null) return
|
|
767
|
+
const id = turn.activityMessageId
|
|
768
|
+
turn.activityMessageId = null
|
|
769
|
+
// Known Gap 1 (deterministic-turn-liveness.md) — the card is closing
|
|
770
|
+
// normally (about to be deleted or finalized below), so drop its durable
|
|
771
|
+
// handle too: a normal close must never leave a stale record for the
|
|
772
|
+
// boot reaper to "finalize" a message that's already been handled.
|
|
773
|
+
if (activityCardPersistEnabled) {
|
|
774
|
+
clearActivityCardRecord(
|
|
775
|
+
ACTIVITY_CARD_STORE_PATH,
|
|
776
|
+
activityCardStoreFs,
|
|
777
|
+
statusKey(chat, thread),
|
|
778
|
+
// Scope to this card's exact id (reap-race guard): only drop the row
|
|
779
|
+
// for the card THIS turn is closing, never a fresher card another
|
|
780
|
+
// turn may have already upserted under the same topic key.
|
|
781
|
+
id,
|
|
782
|
+
)
|
|
783
|
+
}
|
|
784
|
+
if (CLEAR_STATUS_ON_COMPLETION) {
|
|
785
|
+
try {
|
|
786
|
+
await robustApiCall(
|
|
787
|
+
() => bot.api.deleteMessage(chat, id),
|
|
788
|
+
{ chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.delete' },
|
|
789
|
+
)
|
|
790
|
+
} catch (err) {
|
|
791
|
+
// Best-effort teardown of a status card. A transport-class failure
|
|
792
|
+
// (message already deleted, chat gone, 429) is not a liveness-logic
|
|
793
|
+
// error — "message to delete not found" is the desired end state
|
|
794
|
+
// here, and a 429 on a teardown delete is retried by robustApiCall.
|
|
795
|
+
// Stay silent on those; warn only on a genuinely unexpected error.
|
|
796
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
797
|
+
const low = msg.toLowerCase()
|
|
798
|
+
if (
|
|
799
|
+
low.includes('not modified') ||
|
|
800
|
+
low.includes('not found') ||
|
|
801
|
+
low.includes('not enough rights') ||
|
|
802
|
+
low.includes('429') ||
|
|
803
|
+
low.includes('retry after')
|
|
804
|
+
) {
|
|
805
|
+
return
|
|
806
|
+
}
|
|
807
|
+
process.stderr.write(`telegram gateway: activity-summary delete failed: ${msg}\n`)
|
|
808
|
+
}
|
|
809
|
+
return
|
|
810
|
+
}
|
|
811
|
+
// Default: leave the status message as a record, edited to a terminal
|
|
812
|
+
// all-done state so it doesn't freeze on a misleading "→ in-progress" line.
|
|
813
|
+
let finalHtml =
|
|
814
|
+
finalHtmlOverride !== undefined ? finalHtmlOverride : composeTurnActivity(turn, true)
|
|
815
|
+
// Liveness-only feed: opened on the timer for a turn that never labelled a
|
|
816
|
+
// tool (pure thinking / suppressed tools), so mirrorLines is empty and the
|
|
817
|
+
// terminal render is null. Finalize to a done "✓ Working…" record instead
|
|
818
|
+
// of leaving the message frozen on the live "→ Working…" line.
|
|
819
|
+
if (finalHtml == null && turn.mirrorLines.length === 0 && turn.activityEverOpened) {
|
|
820
|
+
const livenessElapsed = turn.startedAt > 0 ? Date.now() - turn.startedAt : 0
|
|
821
|
+
const livenessHeader: SessionActivityHeader = {
|
|
822
|
+
label: 'Agent', elapsedMs: livenessElapsed, toolCount: turn.labeledToolCount, state: 'done',
|
|
823
|
+
model: turn.currentModel,
|
|
824
|
+
}
|
|
825
|
+
finalHtml = renderActivityFeedWithNested(['Working…'], [], true, '', undefined, livenessHeader)
|
|
826
|
+
}
|
|
827
|
+
if (finalHtml == null) return
|
|
828
|
+
try {
|
|
829
|
+
await robustApiCall(
|
|
830
|
+
() => bot.api.editMessageText(chat, id, richMessage(finalHtml), {}),
|
|
831
|
+
{ chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.finalize' },
|
|
832
|
+
)
|
|
833
|
+
} catch (err) {
|
|
834
|
+
// Same transport-class discipline as the delete path: the card
|
|
835
|
+
// finalize is a best-effort liveness edit. "not modified" = the card
|
|
836
|
+
// already shows the finalized body (success); "not found" / "not
|
|
837
|
+
// enough rights" = the message is gone (nothing to finalize); 429 is
|
|
838
|
+
// retried by robustApiCall. None of those are liveness-logic errors
|
|
839
|
+
// and none warrant a stderr warning. Warn only on the unexpected.
|
|
840
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
841
|
+
const low = msg.toLowerCase()
|
|
842
|
+
if (
|
|
843
|
+
low.includes('not modified') ||
|
|
844
|
+
low.includes('not found') ||
|
|
845
|
+
low.includes("can't be edited") ||
|
|
846
|
+
low.includes('cannot be edited') ||
|
|
847
|
+
low.includes('not enough rights') ||
|
|
848
|
+
low.includes('429') ||
|
|
849
|
+
low.includes('retry after')
|
|
850
|
+
) {
|
|
851
|
+
return
|
|
852
|
+
}
|
|
853
|
+
process.stderr.write(`telegram gateway: activity-summary finalize failed: ${msg}\n`)
|
|
854
|
+
}
|
|
855
|
+
})
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
return {
|
|
859
|
+
closeActivityLane, closeProgressLane, composeTurnActivity, retractNarrativeLine,
|
|
860
|
+
makeNarrativeGate, showNarrativeStep, resolvePendingNarrativeOnTool,
|
|
861
|
+
stagePendingNarrative, flushPendingNarrativeAtTurnEnd, drainActivitySummary,
|
|
862
|
+
openLivenessFeedIfDue, scheduleEarlyLivenessOpen, stopEarlyLivenessOpen,
|
|
863
|
+
feedHeartbeatTick, clearActivitySummary,
|
|
864
|
+
}
|
|
865
|
+
}
|