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,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-demand voice ('🔊 Listen') callback family (`voice:<token>`) extracted
|
|
3
|
+
* verbatim from the gateway's `callback_query:data` dispatcher
|
|
4
|
+
* (switchroom#2996 P6 dispatcher drain).
|
|
5
|
+
*
|
|
6
|
+
* Handles the tap INTERNALLY — synthesizes the cached reply text and sends it
|
|
7
|
+
* as a native voice note. MUST run before the agent-callback routing in the
|
|
8
|
+
* dispatcher so a tap never reaches the agent as an inbound message.
|
|
9
|
+
*
|
|
10
|
+
* Returns true when the callback was a voice-on-demand tap (handled —
|
|
11
|
+
* dispatcher must `return`), false otherwise (dispatcher falls through).
|
|
12
|
+
*
|
|
13
|
+
* DI note (check-bot-api-wrapping, cluster-F pattern): the raw
|
|
14
|
+
* `bot.api.sendVoice` / `bot.api.editMessageReplyMarkup` calls are NOT made
|
|
15
|
+
* here — they are injected as `sendVoiceByFileId` / `sendVoiceByUpload` /
|
|
16
|
+
* `stripListenKeyboard`, bound in gateway.ts inside `robustApiCall(...)` with
|
|
17
|
+
* their existing allow-raw-bot-api markers. This module has no raw Bot API
|
|
18
|
+
* site.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readFileSync } from 'node:fs'
|
|
22
|
+
import type { Context } from 'grammy'
|
|
23
|
+
import {
|
|
24
|
+
isVoiceOnDemandCallback,
|
|
25
|
+
parseVoiceOnDemandToken,
|
|
26
|
+
type VoiceOnDemandPayload,
|
|
27
|
+
} from '../voice-ondemand.js'
|
|
28
|
+
import { sendVoiceReusingFileId, type SentVoiceMessage } from '../voice-send.js'
|
|
29
|
+
import { synthesizeViaSidecar } from '../voice-synthesize-sidecar.js'
|
|
30
|
+
import { normalizeForTts } from '../tts-normalize.js'
|
|
31
|
+
import { materializeSidecarToken } from '../../src/telegram/materialize-sidecar-token.js'
|
|
32
|
+
|
|
33
|
+
export interface VoiceSendOpts {
|
|
34
|
+
reply_parameters?: { message_id: number }
|
|
35
|
+
message_thread_id?: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface VoiceOnDemandCallbackDeps {
|
|
39
|
+
loadAccess: () => { allowFrom: string[] }
|
|
40
|
+
voiceOnDemandCache: {
|
|
41
|
+
get: (token: string) => VoiceOnDemandPayload | null
|
|
42
|
+
setTelegramFileId: (token: string, fileId: string) => void
|
|
43
|
+
}
|
|
44
|
+
/** robustApiCall(() => bot.api.sendVoice(chatId, fileId, opts), verbOpts) — bound in gateway.ts. */
|
|
45
|
+
sendVoiceByFileId: (
|
|
46
|
+
chatId: string,
|
|
47
|
+
fileId: string,
|
|
48
|
+
sendOpts: VoiceSendOpts,
|
|
49
|
+
threadId: number | undefined,
|
|
50
|
+
) => Promise<SentVoiceMessage>
|
|
51
|
+
/** robustApiCall(() => bot.api.sendVoice(chatId, new InputFile(audio), opts), verbOpts) — bound in gateway.ts. */
|
|
52
|
+
sendVoiceByUpload: (
|
|
53
|
+
chatId: string,
|
|
54
|
+
audio: Uint8Array,
|
|
55
|
+
sendOpts: VoiceSendOpts,
|
|
56
|
+
threadId: number | undefined,
|
|
57
|
+
) => Promise<SentVoiceMessage>
|
|
58
|
+
/** robustApiCall(() => bot.api.editMessageReplyMarkup(...)) — bound in gateway.ts; swallows failure. */
|
|
59
|
+
stripListenKeyboard: (
|
|
60
|
+
chatId: string,
|
|
61
|
+
messageId: number,
|
|
62
|
+
threadId: number | undefined,
|
|
63
|
+
) => Promise<void>
|
|
64
|
+
/** stderr log sink. */
|
|
65
|
+
log: (line: string) => void
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Handle a `voice:` callback. Returns false if `data` is not one. */
|
|
69
|
+
export async function handleVoiceOnDemandCallback(
|
|
70
|
+
ctx: Context,
|
|
71
|
+
data: string,
|
|
72
|
+
deps: VoiceOnDemandCallbackDeps,
|
|
73
|
+
): Promise<boolean> {
|
|
74
|
+
if (!isVoiceOnDemandCallback(data)) return false
|
|
75
|
+
const access = deps.loadAccess()
|
|
76
|
+
const senderId = String(ctx.from!.id)
|
|
77
|
+
if (!access.allowFrom.includes(senderId)) {
|
|
78
|
+
await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
|
|
79
|
+
return true
|
|
80
|
+
}
|
|
81
|
+
const token = parseVoiceOnDemandToken(data)
|
|
82
|
+
const entry = token != null ? deps.voiceOnDemandCache.get(token) : null
|
|
83
|
+
if (entry == null) {
|
|
84
|
+
await ctx
|
|
85
|
+
.answerCallbackQuery({ text: 'Voice expired — send again to hear it.' })
|
|
86
|
+
.catch(() => {})
|
|
87
|
+
return true
|
|
88
|
+
}
|
|
89
|
+
const cbChatId = String(ctx.chat?.id ?? ctx.from!.id)
|
|
90
|
+
const cbMessageId = ctx.callbackQuery?.message?.message_id
|
|
91
|
+
const cbThreadId = (() => {
|
|
92
|
+
const msg = ctx.callbackQuery?.message
|
|
93
|
+
if (msg && 'is_topic_message' in msg && msg.is_topic_message && 'message_thread_id' in msg) {
|
|
94
|
+
const tid = (msg as { message_thread_id?: number }).message_thread_id
|
|
95
|
+
return typeof tid === 'number' ? tid : undefined
|
|
96
|
+
}
|
|
97
|
+
return undefined
|
|
98
|
+
})()
|
|
99
|
+
const tok = token as string
|
|
100
|
+
const sendOpts: VoiceSendOpts = {
|
|
101
|
+
...(cbMessageId != null ? { reply_parameters: { message_id: cbMessageId } } : {}),
|
|
102
|
+
...(cbThreadId != null ? { message_thread_id: cbThreadId } : {}),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// #2763 attach-on-tap: prefer the eagerly pre-synthesized file (written by
|
|
106
|
+
// the pre-synth queue at reply time). If it's on disk, attach it
|
|
107
|
+
// immediately — no GPU wait. Missing/unreadable file (expired + swept,
|
|
108
|
+
// crash, pre-feature entry, kill-switched gateway) falls back transparently
|
|
109
|
+
// to the lazy synth path. Only invoked when there's no reusable file_id (or
|
|
110
|
+
// a stored id was rejected as stale) — see sendVoiceReusingFileId.
|
|
111
|
+
const loadAudio = async (): Promise<Uint8Array | null> => {
|
|
112
|
+
let audio: Uint8Array | null = null
|
|
113
|
+
if (entry.filePath != null) {
|
|
114
|
+
try {
|
|
115
|
+
audio = readFileSync(entry.filePath)
|
|
116
|
+
} catch {
|
|
117
|
+
audio = null // swept/missing — lazy fallback
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (audio != null) {
|
|
121
|
+
await ctx.answerCallbackQuery({ text: '🔊' }).catch(() => {})
|
|
122
|
+
return audio
|
|
123
|
+
}
|
|
124
|
+
await ctx.answerCallbackQuery({ text: '🔊 Synthesizing…' }).catch(() => {})
|
|
125
|
+
// Local sidecar (kokoro) synthesis — same helper the immediate voice-out
|
|
126
|
+
// path uses. On-demand is a local-engine feature; the cache is only
|
|
127
|
+
// populated when resolveVoiceOutPlan gated the local verdict.
|
|
128
|
+
const sidecarToken = await materializeSidecarToken()
|
|
129
|
+
if (!sidecarToken) {
|
|
130
|
+
await ctx
|
|
131
|
+
.answerCallbackQuery({ text: 'Voice sidecar unavailable — try again later.' })
|
|
132
|
+
.catch(() => {})
|
|
133
|
+
return null
|
|
134
|
+
}
|
|
135
|
+
const result = await synthesizeViaSidecar({
|
|
136
|
+
token: sidecarToken,
|
|
137
|
+
// #2760 Phase 1: same deterministic normalization as the immediate
|
|
138
|
+
// voice-out path — the Listen lazy path builds its own /tts body from
|
|
139
|
+
// the persisted cache, so it must normalize independently (cache
|
|
140
|
+
// entries may predate the flag flip).
|
|
141
|
+
text: normalizeForTts(entry.text),
|
|
142
|
+
voice: entry.voice,
|
|
143
|
+
speed: entry.speed,
|
|
144
|
+
})
|
|
145
|
+
if (!result.ok) {
|
|
146
|
+
deps.log(
|
|
147
|
+
`telegram gateway: voice-out on-demand: synthesis failed reason=${result.reason}\n`,
|
|
148
|
+
)
|
|
149
|
+
await ctx
|
|
150
|
+
.answerCallbackQuery({ text: `Voice failed: ${result.reason}` })
|
|
151
|
+
.catch(() => {})
|
|
152
|
+
return null
|
|
153
|
+
}
|
|
154
|
+
return result.audio
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Fast path: if we already captured a reusable file_id, ack instantly and
|
|
158
|
+
// send BY the id — no disk read, no re-upload. First tap (no id yet) and a
|
|
159
|
+
// stale-id fallback both go through loadAudio + InputFile below and refresh
|
|
160
|
+
// the stored id from the returned message.
|
|
161
|
+
if (entry.telegramFileId != null) {
|
|
162
|
+
await ctx.answerCallbackQuery({ text: '🔊' }).catch(() => {})
|
|
163
|
+
}
|
|
164
|
+
const sendResult = await sendVoiceReusingFileId({
|
|
165
|
+
fileId: entry.telegramFileId ?? null,
|
|
166
|
+
sendByFileId: (fid) => deps.sendVoiceByFileId(cbChatId, fid, sendOpts, cbThreadId),
|
|
167
|
+
loadAudio,
|
|
168
|
+
sendByUpload: (audioOut) => deps.sendVoiceByUpload(cbChatId, audioOut, sendOpts, cbThreadId),
|
|
169
|
+
onFileId: (fid) => deps.voiceOnDemandCache.setTelegramFileId(tok, fid),
|
|
170
|
+
log: (line) => deps.log(line),
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
if (!sendResult.ok) {
|
|
174
|
+
// no-audio already surfaced a toast inside loadAudio; send-failed is
|
|
175
|
+
// logged non-fatally — the '🔊 Listen' keyboard is left intact so the
|
|
176
|
+
// user can retry (only a successful send strips it below).
|
|
177
|
+
if (sendResult.reason === 'send-failed') {
|
|
178
|
+
const err = sendResult.error
|
|
179
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
180
|
+
deps.log(
|
|
181
|
+
`telegram gateway: voice-out on-demand: sendVoice failed (non-fatal): ${msg}\n`,
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
return true
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
// Single-use on SUCCESS: strip the '🔊 Listen' keyboard so the button
|
|
188
|
+
// can't be re-tapped now that the audio has been delivered. Mirrors the
|
|
189
|
+
// agent-button single_use strip (keyboardIsSingleUse) house style.
|
|
190
|
+
// Best-effort + non-fatal — a failed strip only leaves a replayable
|
|
191
|
+
// button, never drops the delivered audio. Only reached on a successful
|
|
192
|
+
// sendVoice; expiry / synth-failure / sidecar-unavailable all return
|
|
193
|
+
// earlier WITHOUT stripping, so the user can retry those.
|
|
194
|
+
if (cbMessageId != null) {
|
|
195
|
+
await deps.stripListenKeyboard(cbChatId, cbMessageId, cbThreadId).catch(() => {})
|
|
196
|
+
}
|
|
197
|
+
} catch (err) {
|
|
198
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
199
|
+
deps.log(
|
|
200
|
+
`telegram gateway: voice-out on-demand: strip-listen-keyboard failed (non-fatal): ${msg}\n`,
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
return true
|
|
204
|
+
}
|
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
import type { Subagent } from '../registry/subagents-schema.js'
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* The narrow slice of the worker-activity feed `handleWorkerResume` needs —
|
|
5
|
+
* structurally satisfied by `WorkerActivityFeed` (worker-activity-feed.ts)
|
|
6
|
+
* without importing it (keeps this module dependency-light and the seam
|
|
7
|
+
* trivially mockable in tests).
|
|
8
|
+
*/
|
|
9
|
+
export interface WorkerFeedForResume {
|
|
10
|
+
resurrect(agentId: string): void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Issue #3373 (SendMessage-resume feed re-surface) — the gateway's `onResume`
|
|
15
|
+
* handler body, extracted so the seam is unit-testable and gateway.ts keeps
|
|
16
|
+
* only a thin delegation (the line-ratchet drains inline bodies).
|
|
17
|
+
*
|
|
18
|
+
* A worker with a GENUINE terminal completion that is resumed via SendMessage
|
|
19
|
+
* grows its jsonl past the terminal boundary and is re-registered live by the
|
|
20
|
+
* watcher (#3315). This clears the feed's terminal `finalized` latch
|
|
21
|
+
* (`feed.resurrect`) so the resumed worker's next onProgress cue repaints a
|
|
22
|
+
* fresh live row — without it the latch swallows every resumed cue and the
|
|
23
|
+
* worker reads as silence. Distinct from the #3023 `onResurrect` path: no
|
|
24
|
+
* bounded-chain budget (a worker may be stopped/resumed any number of times).
|
|
25
|
+
*
|
|
26
|
+
* Best-effort: a resurrect failure is logged, never thrown back into the
|
|
27
|
+
* watcher's poll loop. `feed` may be null/undefined (feed disabled) — the
|
|
28
|
+
* re-surface log line is still emitted for the audit trail.
|
|
29
|
+
*/
|
|
30
|
+
export function handleWorkerResume(
|
|
31
|
+
feed: WorkerFeedForResume | null | undefined,
|
|
32
|
+
agentId: string,
|
|
33
|
+
log: (msg: string) => void,
|
|
34
|
+
): void {
|
|
35
|
+
try {
|
|
36
|
+
feed?.resurrect(agentId)
|
|
37
|
+
} catch (err) {
|
|
38
|
+
log(`telegram gateway: worker resume feed re-surface error agent=${agentId}: ${(err as Error).message}`)
|
|
39
|
+
}
|
|
40
|
+
log(`telegram gateway: worker ${agentId} card RE-SURFACED — resumed via SendMessage after a genuine terminal (issue #3373)`)
|
|
41
|
+
}
|
|
42
|
+
|
|
3
43
|
export interface WorkerFeedDispatch {
|
|
4
44
|
/** True when the sub-agent was dispatched with `run_in_background: true`. */
|
|
5
45
|
isBackground: boolean
|
|
@@ -25,9 +25,32 @@
|
|
|
25
25
|
* message.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
/**
|
|
28
|
+
/** Tool suffixes whose `input.text` IS the canonical answer surface. */
|
|
29
29
|
export const REPLY_TOOLS = new Set(['reply', 'stream_reply'])
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Strip this plugin's `mcp__<server-key>__telegram__` prefix, matched by the
|
|
33
|
+
* same key-agnostic regex `computeLabel` / `isTelegramReplyTool` use so renames
|
|
34
|
+
* and forks (`clerk-telegram`, `switchroom-telegram`, …) all resolve. A bare
|
|
35
|
+
* name (no prefix) is returned unchanged.
|
|
36
|
+
*/
|
|
37
|
+
const TELEGRAM_TOOL_PREFIX_RE = /^mcp__[^_].*?telegram__/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Prefix-aware membership test for {@link REPLY_TOOLS}.
|
|
41
|
+
*
|
|
42
|
+
* Production jsonl carries the fully-qualified MCP tool name
|
|
43
|
+
* (`mcp__switchroom-telegram__stream_reply`), so a bare `REPLY_TOOLS.has(name)`
|
|
44
|
+
* is always false in prod and the draft-then-send suppression path silently
|
|
45
|
+
* goes inert (#3231). Bare names ('reply' / 'stream_reply') can still appear
|
|
46
|
+
* from non-MCP sources, so both wire shapes must match: strip the telegram MCP
|
|
47
|
+
* prefix (a no-op for bare names) then test the suffix against REPLY_TOOLS.
|
|
48
|
+
*/
|
|
49
|
+
export function isReplyTool(toolName: string | null | undefined): boolean {
|
|
50
|
+
if (typeof toolName !== 'string') return false
|
|
51
|
+
return REPLY_TOOLS.has(toolName.replace(TELEGRAM_TOOL_PREFIX_RE, ''))
|
|
52
|
+
}
|
|
53
|
+
|
|
31
54
|
/**
|
|
32
55
|
* Normalize for prefix comparison: strip markdown/HTML-ish emphasis,
|
|
33
56
|
* heading and quote marks, collapse whitespace, lowercase. Mirrors the
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
* effects and the per-turn lifetime.
|
|
40
40
|
*/
|
|
41
41
|
|
|
42
|
-
import {
|
|
42
|
+
import { isReplyTool, isDraftOfReply } from './narrative-dedup.js'
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
45
|
* Time-box for the parked-narrative early-paint (the kernel's home for the
|
|
@@ -139,7 +139,7 @@ export class NarrativeFlushController {
|
|
|
139
139
|
resolveOnTool(toolName: string, input: Record<string, unknown> | undefined): void {
|
|
140
140
|
this.scheduler.disarm()
|
|
141
141
|
const replyText =
|
|
142
|
-
|
|
142
|
+
isReplyTool(toolName) && typeof input?.text === 'string' ? (input.text as string) : null
|
|
143
143
|
if (replyText != null) this.maybeRetract(replyText)
|
|
144
144
|
const pending = this.pending
|
|
145
145
|
if (pending == null) return
|
|
@@ -203,10 +203,34 @@ function renderTable(node: TableNode): string {
|
|
|
203
203
|
return [headerLine, sepLine, ...bodyLines].join("\n");
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Escape a line-leading `#` in rendered PROSE so Telegram cannot promote the
|
|
208
|
+
* line to a heading (#3304 regression).
|
|
209
|
+
*
|
|
210
|
+
* Per CommonMark/GFM, `#` opens an ATX heading only when followed by a space,
|
|
211
|
+
* another `#`, or end-of-line — so micromark correctly parses `#3304 is
|
|
212
|
+
* merged.` as a plain paragraph, and this renderer re-emits it verbatim
|
|
213
|
+
* (`escapeMarkdown` deliberately leaves `#` alone). But Telegram's Bot API
|
|
214
|
+
* 10.1 server-side rich-markdown parser is NON-spec here: any line-leading
|
|
215
|
+
* `#` run is read as a heading, so an issue reference opening a line renders
|
|
216
|
+
* the whole paragraph as huge heading text. Backslash-escaping the first `#`
|
|
217
|
+
* of each such line keeps it literal on Telegram while remaining a no-op
|
|
218
|
+
* visually (Telegram honours backslash escapes, same mechanism the link-href
|
|
219
|
+
* `)` escape relies on).
|
|
220
|
+
*
|
|
221
|
+
* Applied ONLY to paragraph output (including paragraphs nested in
|
|
222
|
+
* blockquotes / list items via their renderBlock recursion), never to
|
|
223
|
+
* heading, code-block, or table nodes — so a genuine `# Title` heading node
|
|
224
|
+
* still renders as `# Title`.
|
|
225
|
+
*/
|
|
226
|
+
function escapeLineLeadingHash(text: string): string {
|
|
227
|
+
return text.replace(/^([ \t]{0,3})#/gm, "$1\\#");
|
|
228
|
+
}
|
|
229
|
+
|
|
206
230
|
function renderBlock(node: Block): string {
|
|
207
231
|
switch (node.type) {
|
|
208
232
|
case "paragraph":
|
|
209
|
-
return renderInlineChildren(node.children);
|
|
233
|
+
return escapeLineLeadingHash(renderInlineChildren(node.children));
|
|
210
234
|
case "heading":
|
|
211
235
|
return `${"#".repeat(node.level)} ${renderInlineChildren(node.children)}`;
|
|
212
236
|
case "blockquote":
|
|
@@ -28,6 +28,19 @@ import { RICH_MESSAGE_MAX_CHARS } from './format.js'
|
|
|
28
28
|
*/
|
|
29
29
|
export const STATUS_ROLLING_LINES = 5
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Ceiling on the per-worker recent-step history RETAINED in `row.narrative`
|
|
33
|
+
* (worker-activity-feed.ts) and the deepest trail the worker cards will render.
|
|
34
|
+
*
|
|
35
|
+
* Distinct from `STATUS_ROLLING_LINES` (which governs the 🤖 agent card's
|
|
36
|
+
* window and stays at 5) so raising the worker trail never widens the agent
|
|
37
|
+
* card. Set to 6 because the deterministic per-worker depth curve
|
|
38
|
+
* (`workerHistoryDepth`, tool-activity-summary.ts) peaks at 6 for a lone
|
|
39
|
+
* worker — `max(3, 7 − workerCount)` at `w = 1`. The buffer must retain at
|
|
40
|
+
* least this many lines or the renderer would ask for 6 and only find 5.
|
|
41
|
+
*/
|
|
42
|
+
export const WORKER_HISTORY_MAX = 6
|
|
43
|
+
|
|
31
44
|
/**
|
|
32
45
|
* Per-line character cap, applied to every step + child step on BOTH
|
|
33
46
|
* surfaces before HTML-escaping (clip raw → escape last). A line longer
|
|
@@ -46,7 +46,7 @@ import { homedir } from 'os'
|
|
|
46
46
|
import { projectSubagentLine, sanitizeCwdToProjectName, detectErrorInTranscriptLine } from './session-tail.js'
|
|
47
47
|
import { sanitiseToolArg } from './fleet-state.js'
|
|
48
48
|
import { clipNarrative, describeToolUse } from './tool-activity-summary.js'
|
|
49
|
-
import {
|
|
49
|
+
import { isReplyTool } from './narrative-dedup.js'
|
|
50
50
|
import { NarrativeFlushController, PENDING_NARRATIVE_FLUSH_MS } from './narrative-flush.js'
|
|
51
51
|
import { truncate } from './card-format.js'
|
|
52
52
|
import { bumpSubagentActivity, recordSubagentStall, recordSubagentResume, recordSubagentEnd, reapStuckRunningRows, countRunningBackgroundSubagents, recordNestedSubagentDispatch, recordSubagentModel } from './registry/subagents-schema.js'
|
|
@@ -551,6 +551,31 @@ export interface SubagentWatcherConfig {
|
|
|
551
551
|
* that the chain is bounded, not that a lost worker gets a fresh card.
|
|
552
552
|
*/
|
|
553
553
|
onWorkerLost?: (agentId: string, description: string) => void
|
|
554
|
+
/**
|
|
555
|
+
* Issue #3373 (SendMessage-resume feed re-surface). Fires when a worker that
|
|
556
|
+
* had a GENUINE terminal completion (a real `sub_agent_turn_end`, then swept
|
|
557
|
+
* by `cleanupTerminalAgent`) is RESUMED via SendMessage — its
|
|
558
|
+
* `agent-<id>.jsonl` grows PAST the byte size recorded at terminal cleanup,
|
|
559
|
+
* and `scanSubagentsDir` re-registers it as a fresh live entry (#3315).
|
|
560
|
+
*
|
|
561
|
+
* This is DISTINCT from `onResurrect` (#3023), which reverses a FALSE finish
|
|
562
|
+
* from silent-stall synthesis and carries bounded-chain semantics. A
|
|
563
|
+
* SendMessage resume is a real, legitimately-unbounded lifecycle event (a
|
|
564
|
+
* worker may be stopped and resumed many times), so it does NOT route through
|
|
565
|
+
* the resurrection budget — it fires here every time growth-past-terminal is
|
|
566
|
+
* observed.
|
|
567
|
+
*
|
|
568
|
+
* Why it exists: the live worker-activity feed latches a terminal `finalized`
|
|
569
|
+
* gate per agentId (worker-activity-feed.ts) so a late zombie tick can't
|
|
570
|
+
* repaint a done worker. The #3315 re-registration re-arms the watcher and
|
|
571
|
+
* re-fires `onProgress`, but those cues are SWALLOWED by that latch — so a
|
|
572
|
+
* resumed long-running worker reads as silence with no card. The gateway
|
|
573
|
+
* wires this to `feed.resurrect(agentId)` (clear the latch) so the subsequent
|
|
574
|
+
* `onProgress` cue repaints a fresh live row. Deterministic: gated solely on
|
|
575
|
+
* the file growing past the recorded terminal boundary, never on model
|
|
576
|
+
* discipline. Best-effort; the callback is expected to be idempotent.
|
|
577
|
+
*/
|
|
578
|
+
onResume?: (agentId: string, description: string) => void
|
|
554
579
|
/**
|
|
555
580
|
* Called exactly once per sub-agent when its watcher observes a terminal
|
|
556
581
|
* transition (`done` or `failed`). Mirrors the existing `sub_agent_started`
|
|
@@ -846,6 +871,17 @@ const DEFAULT_REAPER_INTERVAL_MS = 15 * 60_000 // 15 minutes
|
|
|
846
871
|
*/
|
|
847
872
|
const TERMINAL_CLEANUP_GRACE_MS = 30_000
|
|
848
873
|
|
|
874
|
+
/**
|
|
875
|
+
* Bounded tail window (bytes) the boot-reconcile fast-path reads to decide
|
|
876
|
+
* whether a stale historical worker already reached `turn_end` before this
|
|
877
|
+
* boot (see `probeReachedTurnEndAtBoot`). Large enough to contain any realistic
|
|
878
|
+
* final line (a `turn_duration` line is ~100 bytes; an `end_turn` assistant
|
|
879
|
+
* answer line is well under this), yet a tiny fraction of the multi-MB
|
|
880
|
+
* transcripts a long-running worker accumulates — so the boot read is O(window)
|
|
881
|
+
* per record instead of O(filesize). 512 KiB.
|
|
882
|
+
*/
|
|
883
|
+
const BOOT_TERMINAL_PROBE_WINDOW_BYTES = 512 * 1024
|
|
884
|
+
|
|
849
885
|
/**
|
|
850
886
|
* Throttle for the liveness-path retry of `backfillJsonlAgentId` (see
|
|
851
887
|
* WorkerEntry.lastBackfillAttemptAt). Cheap (one meta.json read + two
|
|
@@ -1551,8 +1587,8 @@ export function readSubTail(
|
|
|
1551
1587
|
const narrativeJustFired = resolvePendingSubNarrative(ev.toolName, ev.input)
|
|
1552
1588
|
// NIT 3: capture a foreground sub-agent's actual reply text so the
|
|
1553
1589
|
// turn_end path can suppress a trailing draft of it (see
|
|
1554
|
-
// resolvePendingSubNarrative). Only
|
|
1555
|
-
if (
|
|
1590
|
+
// resolvePendingSubNarrative). Only reply tools carry the answer.
|
|
1591
|
+
if (isReplyTool(ev.toolName) && typeof ev.input?.text === 'string') {
|
|
1556
1592
|
entry.lastReplyText = ev.input.text as string
|
|
1557
1593
|
}
|
|
1558
1594
|
entry.toolCount++
|
|
@@ -1923,6 +1959,58 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1923
1959
|
|
|
1924
1960
|
let stopped = false
|
|
1925
1961
|
|
|
1962
|
+
/**
|
|
1963
|
+
* Cheap boot-terminal probe. Reads only a BOUNDED TAIL of a JSONL (not the
|
|
1964
|
+
* whole multi-MB transcript) and runs the canonical `projectSubagentLine`
|
|
1965
|
+
* projector over its complete lines to decide whether the worker had already
|
|
1966
|
+
* reached `sub_agent_turn_end` before this boot. Used by the boot-reconcile
|
|
1967
|
+
* fast-path to tell a done-at-boot worker (which still needs terminal
|
|
1968
|
+
* cleanup — feed-row sweep, card collapse/unpin) apart from a running-at-boot
|
|
1969
|
+
* dead prior-session worker (which needs nothing) WITHOUT the expensive full
|
|
1970
|
+
* read. `done` is the only terminal boot state (`WorkerState` never flips to
|
|
1971
|
+
* `'failed'` at boot — see its doc), and its signal (`system/turn_duration`
|
|
1972
|
+
* or an `end_turn` assistant line) is always the tail of the file, so a
|
|
1973
|
+
* bounded window catches it. Conservative on error: an unreadable/oversized
|
|
1974
|
+
* final line returns `false`, so the entry falls through to the running-stale
|
|
1975
|
+
* skip rather than being mis-swept.
|
|
1976
|
+
*/
|
|
1977
|
+
function probeReachedTurnEndAtBoot(filePath: string, size: number): boolean {
|
|
1978
|
+
if (size <= 0) return false
|
|
1979
|
+
const start = Math.max(0, size - BOOT_TERMINAL_PROBE_WINDOW_BYTES)
|
|
1980
|
+
let raw: string
|
|
1981
|
+
try {
|
|
1982
|
+
const len = size - start
|
|
1983
|
+
const buf = Buffer.alloc(len)
|
|
1984
|
+
const fd = fs.openSync(filePath, 'r')
|
|
1985
|
+
try {
|
|
1986
|
+
fs.readSync(fd, buf, 0, len, start)
|
|
1987
|
+
} finally {
|
|
1988
|
+
fs.closeSync(fd)
|
|
1989
|
+
}
|
|
1990
|
+
raw = buf.toString('utf-8')
|
|
1991
|
+
} catch {
|
|
1992
|
+
return false
|
|
1993
|
+
}
|
|
1994
|
+
const lines = raw.split('\n')
|
|
1995
|
+
// If we started mid-file, the first fragment is a partial line — drop it
|
|
1996
|
+
// (projectSubagentLine would JSON-parse-fail on it anyway, but be explicit).
|
|
1997
|
+
if (start > 0) lines.shift()
|
|
1998
|
+
const probeState = { hasEmittedStart: true } // suppress start events; irrelevant here
|
|
1999
|
+
for (const line of lines) {
|
|
2000
|
+
if (!line) continue
|
|
2001
|
+
let events
|
|
2002
|
+
try {
|
|
2003
|
+
events = projectSubagentLine(line, 'probe', probeState)
|
|
2004
|
+
} catch {
|
|
2005
|
+
continue
|
|
2006
|
+
}
|
|
2007
|
+
for (const ev of events) {
|
|
2008
|
+
if (ev.kind === 'sub_agent_turn_end') return true
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
return false
|
|
2012
|
+
}
|
|
2013
|
+
|
|
1926
2014
|
// ─── Per-agent registration ─────────────────────────────────────────────
|
|
1927
2015
|
|
|
1928
2016
|
/**
|
|
@@ -1969,6 +2057,85 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1969
2057
|
}
|
|
1970
2058
|
registry.set(agentId, entry)
|
|
1971
2059
|
|
|
2060
|
+
// Boot reconcile fast-path (perf): a historical boot entry whose file's
|
|
2061
|
+
// LAST WRITE already exceeds the in-flight promotion freshness window is a
|
|
2062
|
+
// provably-dead prior-session worker. The freshness gate below reaches the
|
|
2063
|
+
// exact same verdict ("stale — leaving historical, no live transition to
|
|
2064
|
+
// observe"), but only AFTER the full initial `readSubTail` (a whole-
|
|
2065
|
+
// transcript parse plus per-record sqlite liveness/backfill round-trips).
|
|
2066
|
+
// On a busy 24/7 agent, hundreds of these dead JSONLs accumulate, and doing
|
|
2067
|
+
// that O(n) work synchronously at boot delayed the gateway answering
|
|
2068
|
+
// Telegram by minutes after every restart — the agent looked dead. Since
|
|
2069
|
+
// the age check ALONE decides "dead prior-session worker" (identical
|
|
2070
|
+
// threshold `inflightPromoteMaxAgeMs`), compute it up front and short-
|
|
2071
|
+
// circuit: register a minimal historical entry, skip the read, the
|
|
2072
|
+
// promotion attempt, and the FSWatcher. A resume re-registration (#3315)
|
|
2073
|
+
// passes isHistorical=false (historicalFiles.delete precedes it) and a
|
|
2074
|
+
// genuinely fresh in-flight-at-boot worker fails the age check, so both
|
|
2075
|
+
// still take the full path below.
|
|
2076
|
+
if (isHistorical) {
|
|
2077
|
+
let mtimeMs: number | null = null
|
|
2078
|
+
let fileSize = 0
|
|
2079
|
+
try {
|
|
2080
|
+
const st = fs.statSync(filePath)
|
|
2081
|
+
if (typeof st.size === 'number') fileSize = st.size
|
|
2082
|
+
if (typeof st.mtimeMs === 'number') mtimeMs = st.mtimeMs
|
|
2083
|
+
} catch {
|
|
2084
|
+
/* unreadable → mtimeMs stays null → conservative full path below */
|
|
2085
|
+
}
|
|
2086
|
+
// Only fast-path when the mtime is KNOWN and already past the freshness
|
|
2087
|
+
// window — a confidently-dead prior-session worker. A missing/unreadable
|
|
2088
|
+
// mtime is NOT treated as dead here (unlike the freshness gate's
|
|
2089
|
+
// Infinity-is-stale rule): we conservatively fall through to the full
|
|
2090
|
+
// path so the read still happens, since the whole optimisation targets
|
|
2091
|
+
// real on-disk files (which always carry an mtime) and the boot cost only
|
|
2092
|
+
// bites at fleet scale where mtimes are present.
|
|
2093
|
+
if (mtimeMs != null && n - mtimeMs > inflightPromoteMaxAgeMs) {
|
|
2094
|
+
const fileAgeMs = n - mtimeMs
|
|
2095
|
+
// The expensive boot work was the full initial `readSubTail` (whole-
|
|
2096
|
+
// transcript parse + per-record sqlite liveness/backfill round-trips)
|
|
2097
|
+
// done for EVERY historical `running` JSONL, including the hundreds a
|
|
2098
|
+
// busy 24/7 agent hoards that were last written days-to-weeks ago. It
|
|
2099
|
+
// produced nothing actionable for a worker the freshness gate was about
|
|
2100
|
+
// to leave historical anyway, yet its O(filesize) disk reads (×~1177 on
|
|
2101
|
+
// the incident agent) delayed the gateway answering Telegram by minutes
|
|
2102
|
+
// after every restart. Replace it with a BOUNDED tail probe.
|
|
2103
|
+
//
|
|
2104
|
+
// The probe distinguishes the two stale-at-boot populations WITHOUT the
|
|
2105
|
+
// full read, so we don't over-skip: a *done*-at-boot worker (its JSONL
|
|
2106
|
+
// already reached `turn_end`) must still reach `scheduleTerminalCleanup`
|
|
2107
|
+
// — otherwise its worker-feed row leaks as a ghost (card never
|
|
2108
|
+
// collapsed/unpinned; #<worker-feed ghost-leak>). A *running*-at-boot
|
|
2109
|
+
// dead prior-session worker (never wrote a terminal `turn_end` — the
|
|
2110
|
+
// incident population) needs nothing: no read, no backfill, no watcher,
|
|
2111
|
+
// no cleanup.
|
|
2112
|
+
const doneAtBoot = probeReachedTurnEndAtBoot(filePath, fileSize)
|
|
2113
|
+
// Park the tail cursor at the boot-time EOF either way. A historical
|
|
2114
|
+
// entry whose cursor sits at EOF is a cheap no-op in the poll loop
|
|
2115
|
+
// (readSubTail statSyncs, sees `size === cursor`, returns before any
|
|
2116
|
+
// openSync/sqlite — the skeleton-cue path is gated on `!historical`), so
|
|
2117
|
+
// no poll-loop special-casing is needed; a genuine post-boot resume that
|
|
2118
|
+
// grows the file PAST this EOF is still read on the next poll. No
|
|
2119
|
+
// FSWatcher: a dead prior-session worker has no live transition to
|
|
2120
|
+
// observe. And a stale worker's pre-existing `turn_end` sits BEFORE the
|
|
2121
|
+
// parked cursor, so it is never replayed as a spurious completion (the
|
|
2122
|
+
// v0.14.23 stale-handback regression stays fixed).
|
|
2123
|
+
tails.set(agentId, { cursor: fileSize, pendingPartial: '', hasEmittedStart: false, watcher: null })
|
|
2124
|
+
if (doneAtBoot) {
|
|
2125
|
+
// Mirror the done-at-boot branch of the full path (below), minus the
|
|
2126
|
+
// whole-transcript parse: suppress a spurious completion notification
|
|
2127
|
+
// and schedule the terminal cleanup so the feed row is swept.
|
|
2128
|
+
entry.state = 'done'
|
|
2129
|
+
entry.completionNotified = true
|
|
2130
|
+
log?.(`subagent-watcher: ${agentId} done at boot but stale (last write ${Math.round(fileAgeMs / 1000)}s ago > ${Math.round(inflightPromoteMaxAgeMs / 1000)}s) — historical; scheduling terminal cleanup (bounded tail probe, no whole-transcript parse, no watcher)`)
|
|
2131
|
+
scheduleTerminalCleanup(agentId)
|
|
2132
|
+
} else {
|
|
2133
|
+
log?.(`subagent-watcher: ${agentId} running at boot but stale (last write ${Math.round(fileAgeMs / 1000)}s ago > ${Math.round(inflightPromoteMaxAgeMs / 1000)}s) — leaving historical, skipping reconcile read (dead prior-session worker, not in-flight; no whole-transcript parse, no backfill, no watcher)`)
|
|
2134
|
+
}
|
|
2135
|
+
return
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
|
|
1972
2139
|
// Backfill jsonl_agent_id linkage. The PreToolUse hook inserts the row
|
|
1973
2140
|
// keyed on tool_use_id and doesn't know the JSONL stem yet (the JSONL
|
|
1974
2141
|
// doesn't exist when PreToolUse fires). We bridge that gap here: read
|
|
@@ -2950,6 +3117,22 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
2950
3117
|
terminatedAgentIds.delete(agentId)
|
|
2951
3118
|
historicalFiles.delete(filePath)
|
|
2952
3119
|
knownFiles.add(filePath)
|
|
3120
|
+
// Issue #3373: the worker-activity feed latched a terminal `finalized`
|
|
3121
|
+
// gate for this agentId at its genuine completion, so the `onProgress`
|
|
3122
|
+
// cues the re-registration is about to re-fire would be SWALLOWED and no
|
|
3123
|
+
// card would re-surface. Fire onResume FIRST (before registerAgent, whose
|
|
3124
|
+
// initial read can synchronously re-emit onProgress) so the gateway clears
|
|
3125
|
+
// that gate; the subsequent live cue then repaints a fresh row. Fired only
|
|
3126
|
+
// on growth-past-terminal — never for a static leftover (the
|
|
3127
|
+
// `currentSize <= terminalSize` continue above), so the anti-zombie latch
|
|
3128
|
+
// still blocks genuinely-finished workers.
|
|
3129
|
+
if (config.onResume != null) {
|
|
3130
|
+
try {
|
|
3131
|
+
config.onResume(agentId, registry.get(agentId)?.description ?? 'sub-agent')
|
|
3132
|
+
} catch (cbErr) {
|
|
3133
|
+
log?.(`subagent-watcher: onResume callback error ${agentId}: ${(cbErr as Error).message}`)
|
|
3134
|
+
}
|
|
3135
|
+
}
|
|
2953
3136
|
registerAgent(filePath, agentId, terminalSize)
|
|
2954
3137
|
continue
|
|
2955
3138
|
}
|
|
@@ -20,6 +20,11 @@ import { readFileSync } from 'node:fs'
|
|
|
20
20
|
import { resolve } from 'node:path'
|
|
21
21
|
|
|
22
22
|
const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
|
|
23
|
+
// #2996 P4-B: drainActivitySummary (the card-OPEN persist site) and
|
|
24
|
+
// clearActivitySummary (the normal-CLOSE clear site) moved VERBATIM into
|
|
25
|
+
// narrative-lane.ts (bodies indented +2 inside the factory — multi-line
|
|
26
|
+
// markers below use the lane spelling).
|
|
27
|
+
const laneSrc = readFileSync(resolve(__dirname, '..', 'gateway', 'narrative-lane.ts'), 'utf-8')
|
|
23
28
|
|
|
24
29
|
function between(src: string, startMarker: string, endMarker: string): string {
|
|
25
30
|
const after = src.split(startMarker)[1] ?? ''
|
|
@@ -30,7 +35,7 @@ describe('activity-card durability wiring', () => {
|
|
|
30
35
|
it('the card-OPEN path persists the durable handle (writeActivityCardRecord)', () => {
|
|
31
36
|
// The open branch runs when activityMessageId transitions null → set.
|
|
32
37
|
const openBranch = between(
|
|
33
|
-
|
|
38
|
+
laneSrc,
|
|
34
39
|
'if (turn.activityMessageId == null) {',
|
|
35
40
|
'turn.activityLastSentRender = target',
|
|
36
41
|
)
|
|
@@ -55,8 +60,8 @@ describe('activity-card durability wiring', () => {
|
|
|
55
60
|
|
|
56
61
|
it('the normal-CLOSE path clears the durable handle, id-scoped (reap-race guard)', () => {
|
|
57
62
|
const closeBody = between(
|
|
58
|
-
|
|
59
|
-
'const id = turn.activityMessageId\n
|
|
63
|
+
laneSrc,
|
|
64
|
+
'const id = turn.activityMessageId\n turn.activityMessageId = null',
|
|
60
65
|
'if (CLEAR_STATUS_ON_COMPLETION)',
|
|
61
66
|
)
|
|
62
67
|
expect(closeBody.length).toBeGreaterThan(50)
|