switchroom 0.13.13 → 0.13.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/switchroom.js +2 -2
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +261 -86
- package/telegram-plugin/gateway/gateway.ts +106 -0
- package/telegram-plugin/pending-work-progress.ts +377 -0
- package/telegram-plugin/runtime-metrics.ts +20 -0
- package/telegram-plugin/tests/pending-work-progress.test.ts +354 -0
- package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +237 -0
|
@@ -76,6 +76,7 @@ import {
|
|
|
76
76
|
import { emitRuntimeMetric } from '../runtime-metrics.js'
|
|
77
77
|
import { classifyInbound } from '../inbound-classifier.js'
|
|
78
78
|
import * as silencePoke from '../silence-poke.js'
|
|
79
|
+
import * as pendingProgress from '../pending-work-progress.js'
|
|
79
80
|
import { writeSilentEndState, clearSilentEndState, recordUndeliveredTurnEnd } from '../silent-end.js'
|
|
80
81
|
import { isFinalAnswerReply } from '../final-answer-detect.js'
|
|
81
82
|
import { createAnswerStream, type AnswerStreamHandle } from '../answer-stream.js'
|
|
@@ -3149,6 +3150,7 @@ silencePoke.startTimer({
|
|
|
3149
3150
|
// Drop silence-poke state and clear turn-active so the next inbound
|
|
3150
3151
|
// for this chat starts a fresh turn instead of queueing forever.
|
|
3151
3152
|
silencePoke.endTurn(fbKey)
|
|
3153
|
+
pendingProgress.noteTurnEnd(fbKey)
|
|
3152
3154
|
purgeReactionTracking(fbKey)
|
|
3153
3155
|
// Defense-in-depth: the fallback's purgeReactionTracking above
|
|
3154
3156
|
// clears the canonical statusKey(chatId, threadId) for fbKey
|
|
@@ -3206,6 +3208,34 @@ silencePoke.startTimer({
|
|
|
3206
3208
|
},
|
|
3207
3209
|
})
|
|
3208
3210
|
|
|
3211
|
+
// #1445 cross-turn pending-async ambient. When a turn ends after the
|
|
3212
|
+
// model dispatched background async work (Agent / Task / Bash run-in-
|
|
3213
|
+
// background) and the model has stopped speaking, keep editing the
|
|
3214
|
+
// model's last reply in place at 60s intervals so the user sees
|
|
3215
|
+
// ambient liveness during the wait. Edits are silent, never spawn a
|
|
3216
|
+
// new pinged message, and stop the moment the user re-engages or the
|
|
3217
|
+
// model synthesises a handback. The full design rationale lives in
|
|
3218
|
+
// `pending-work-progress.ts`'s header docblock. Kill switch:
|
|
3219
|
+
// `SWITCHROOM_DISABLE_PENDING_PROGRESS=1`.
|
|
3220
|
+
pendingProgress.startTimer({
|
|
3221
|
+
editMessage: async (ctx) => {
|
|
3222
|
+
await swallowingApiCall(
|
|
3223
|
+
() =>
|
|
3224
|
+
lockedBot.api.editMessageText(
|
|
3225
|
+
ctx.chatId,
|
|
3226
|
+
ctx.messageId,
|
|
3227
|
+
ctx.newText,
|
|
3228
|
+
),
|
|
3229
|
+
{
|
|
3230
|
+
chat_id: ctx.chatId,
|
|
3231
|
+
verb: 'pending-progress-edit',
|
|
3232
|
+
...(ctx.threadId != null ? { threadId: ctx.threadId } : {}),
|
|
3233
|
+
},
|
|
3234
|
+
)
|
|
3235
|
+
},
|
|
3236
|
+
emitMetric: (event) => emitRuntimeMetric(event),
|
|
3237
|
+
})
|
|
3238
|
+
|
|
3209
3239
|
// Per-agent buffer for synthetic inbounds the gateway couldn't deliver
|
|
3210
3240
|
// because the bridge wasn't connected at send-time. Drained on
|
|
3211
3241
|
// bridge-register so a fresh client picks up missed wake-ups before
|
|
@@ -3578,6 +3608,22 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
3578
3608
|
label.length > 0 ? label : null,
|
|
3579
3609
|
Date.now(),
|
|
3580
3610
|
)
|
|
3611
|
+
// #1445 cross-turn pending-async ambient. Mark the chat as
|
|
3612
|
+
// having dispatched background work this turn so a turn_end
|
|
3613
|
+
// that follows activates the edit-in-place ambient line.
|
|
3614
|
+
// Covers `Agent` / `Task` (the harness-managed async path
|
|
3615
|
+
// — handback channel turn clears it) and `Bash` with
|
|
3616
|
+
// run_in_background:true (model is expected to poll
|
|
3617
|
+
// BashOutput; the ambient ticks until next inbound or the
|
|
3618
|
+
// 30-min budget cap).
|
|
3619
|
+
const evInput = ev.input as { run_in_background?: boolean } | undefined
|
|
3620
|
+
if (
|
|
3621
|
+
ev.toolName === 'Agent'
|
|
3622
|
+
|| ev.toolName === 'Task'
|
|
3623
|
+
|| (ev.toolName === 'Bash' && evInput?.run_in_background === true)
|
|
3624
|
+
) {
|
|
3625
|
+
pendingProgress.noteAsyncDispatch(key)
|
|
3626
|
+
}
|
|
3581
3627
|
}
|
|
3582
3628
|
} else if (ev.kind === 'tool_result') {
|
|
3583
3629
|
// #1292: drain the in-flight entry. Idempotent on unknown ids
|
|
@@ -4391,6 +4437,22 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
4391
4437
|
}
|
|
4392
4438
|
}
|
|
4393
4439
|
|
|
4440
|
+
// #1445 cross-turn pending-async ambient. Capture the last text
|
|
4441
|
+
// chunk as the anchor — if this turn ends with a pending async
|
|
4442
|
+
// dispatch, the framework edits THIS message in place every 60s
|
|
4443
|
+
// with a `— still working (Nm)` suffix until the user re-engages.
|
|
4444
|
+
// Multi-chunk replies: anchor is the LAST chunk (edits append to
|
|
4445
|
+
// the visually-trailing message; earlier chunks are left intact).
|
|
4446
|
+
if (sentIds.length === chunks.length && chunks.length > 0) {
|
|
4447
|
+
const anchorMsgId = sentIds[chunks.length - 1]
|
|
4448
|
+
if (typeof anchorMsgId === 'number') {
|
|
4449
|
+
pendingProgress.noteOutbound(statusKey(chat_id, threadId), {
|
|
4450
|
+
messageId: anchorMsgId,
|
|
4451
|
+
text: chunks[chunks.length - 1],
|
|
4452
|
+
})
|
|
4453
|
+
}
|
|
4454
|
+
}
|
|
4455
|
+
|
|
4394
4456
|
// #273: when files is 2-10 photos, batch them into a single
|
|
4395
4457
|
// sendMediaGroup album rather than N separate sendPhoto calls. The
|
|
4396
4458
|
// user's device fires one notification for the album instead of N
|
|
@@ -4715,6 +4777,15 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
4715
4777
|
const sChatId = args.chat_id as string
|
|
4716
4778
|
const sThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
4717
4779
|
outboundDedup.record(sChatId, sThreadId, args.text as string, Date.now())
|
|
4780
|
+
// #1445 cross-turn pending-async ambient. The terminal stream_reply
|
|
4781
|
+
// (done=true) is the user-visible anchor for any cross-turn wait
|
|
4782
|
+
// that follows. Capture it so if this turn ends with a pending
|
|
4783
|
+
// async dispatch, the framework edits THIS message in place at
|
|
4784
|
+
// intervals.
|
|
4785
|
+
pendingProgress.noteOutbound(statusKey(sChatId, sThreadId), {
|
|
4786
|
+
messageId: result.messageId,
|
|
4787
|
+
text: args.text as string,
|
|
4788
|
+
})
|
|
4718
4789
|
}
|
|
4719
4790
|
// #1664 — mark the turn's final answer as delivered. For stream_reply a
|
|
4720
4791
|
// call with done=true IS the final answer by definition (the model
|
|
@@ -5728,6 +5799,25 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
5728
5799
|
// Drain any orphaned typing-wrap entries left over from a crashed
|
|
5729
5800
|
// prior turn before resetting focus.
|
|
5730
5801
|
typingWrapper.drainAll()
|
|
5802
|
+
if (ev.chatId) {
|
|
5803
|
+
// #1445 cross-turn pending-async ambient — backstop for the
|
|
5804
|
+
// `handleInbound` path's `clearPending('inbound')`. The
|
|
5805
|
+
// inbound path covers real user messages, but synthesised
|
|
5806
|
+
// wakes (subagent-handback channel turn, cron fires, vault
|
|
5807
|
+
// grant resumes, restart markers) push directly to
|
|
5808
|
+
// `pendingInboundBuffer` and bypass `handleInbound`. The
|
|
5809
|
+
// `enqueue` session-event fires for EVERY fresh turn atom
|
|
5810
|
+
// regardless of source — clearing here drops any prior turn's
|
|
5811
|
+
// ambient before the new turn's `noteOutbound` lands. The
|
|
5812
|
+
// call is idempotent so it's safe to fire in addition to the
|
|
5813
|
+
// inbound-path clear (for the real-inbound case, this is a
|
|
5814
|
+
// no-op because state was already deleted by then).
|
|
5815
|
+
const enqThreadId = ev.threadId != null ? Number(ev.threadId) : undefined
|
|
5816
|
+
pendingProgress.clearPending(
|
|
5817
|
+
statusKey(ev.chatId, enqThreadId),
|
|
5818
|
+
'handback',
|
|
5819
|
+
)
|
|
5820
|
+
}
|
|
5731
5821
|
if (ev.chatId) {
|
|
5732
5822
|
// Issue #195: if a previous turn left an answer-lane stream open
|
|
5733
5823
|
// (rapid steer/queue), force it to a new generation so its in-flight
|
|
@@ -6045,6 +6135,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6045
6135
|
// full message above). Match the pattern used at the regular
|
|
6046
6136
|
// turn-end path (line ~5039) and the wedged-turn path (~5290).
|
|
6047
6137
|
silencePoke.endTurn(ceKey)
|
|
6138
|
+
pendingProgress.noteTurnEnd(ceKey)
|
|
6048
6139
|
// Issue #195: tear down the answer-lane stream on context-exhaustion
|
|
6049
6140
|
// bail-out. The user is being told the session needs /restart, so any
|
|
6050
6141
|
// partially-streamed answer would be misleading.
|
|
@@ -6230,6 +6321,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6230
6321
|
try { removeTurnActiveMarker(STATE_DIR) } catch { /* best-effort */ }
|
|
6231
6322
|
signalTracker.clear(tKey)
|
|
6232
6323
|
silencePoke.endTurn(tKey)
|
|
6324
|
+
pendingProgress.noteTurnEnd(tKey)
|
|
6233
6325
|
}
|
|
6234
6326
|
lastPtyPreviewByChat.delete(statusKey(chatId, threadId))
|
|
6235
6327
|
pendingPtyPartial = null
|
|
@@ -6304,6 +6396,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6304
6396
|
const tKey = statusKey(chatId, threadId)
|
|
6305
6397
|
signalTracker.clear(tKey)
|
|
6306
6398
|
silencePoke.endTurn(tKey)
|
|
6399
|
+
pendingProgress.noteTurnEnd(tKey)
|
|
6307
6400
|
}
|
|
6308
6401
|
|
|
6309
6402
|
void (async () => {
|
|
@@ -6550,6 +6643,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6550
6643
|
}
|
|
6551
6644
|
signalTracker.clear(tKey)
|
|
6552
6645
|
silencePoke.endTurn(tKey)
|
|
6646
|
+
pendingProgress.noteTurnEnd(tKey)
|
|
6553
6647
|
}
|
|
6554
6648
|
lastPtyPreviewByChat.delete(statusKey(chatId, threadId))
|
|
6555
6649
|
pendingPtyPartial = null
|
|
@@ -7772,6 +7866,18 @@ async function handleInbound(
|
|
|
7772
7866
|
// the framework can nudge the model if it goes quiet past the
|
|
7773
7867
|
// soft / firm thresholds.
|
|
7774
7868
|
silencePoke.startTurn(statusKey(chat_id, messageThreadId), Date.now())
|
|
7869
|
+
// #1445 cross-turn pending-async ambient. A new turn starting
|
|
7870
|
+
// (user inbound, synthesised wake, or handback channel) is the
|
|
7871
|
+
// signal that the model is about to re-engage — clear any
|
|
7872
|
+
// pending-progress edits anchored to the *prior* turn's
|
|
7873
|
+
// outbound so the framework stops talking over the new turn.
|
|
7874
|
+
// clearPending drops the per-key state outright, so the new
|
|
7875
|
+
// turn's `tool_use(Agent|Task|Bash bg)` + outbound capture
|
|
7876
|
+
// afresh via `noteAsyncDispatch` / `noteOutbound`.
|
|
7877
|
+
pendingProgress.clearPending(
|
|
7878
|
+
statusKey(chat_id, messageThreadId),
|
|
7879
|
+
'inbound',
|
|
7880
|
+
)
|
|
7775
7881
|
// Human-feel UX: hold a continuous `typing…` indicator for the
|
|
7776
7882
|
// WHOLE turn, not just the split-second a reply is transmitted.
|
|
7777
7883
|
// A person you message shows as typing the entire time they
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-turn pending-async progress — issue #1445.
|
|
3
|
+
*
|
|
4
|
+
* When a turn ends with pending background async work (the model
|
|
5
|
+
* dispatched `Agent` / `Task` and ended its turn before the worker
|
|
6
|
+
* returned), keep editing the model's last reply *in place* at
|
|
7
|
+
* intervals so the user sees ambient liveness during the wait — without
|
|
8
|
+
* any new pinged messages and without re-introducing the retired
|
|
9
|
+
* progress card.
|
|
10
|
+
*
|
|
11
|
+
* Background data justifying this module (2026-05-23 forensic + UAT):
|
|
12
|
+
*
|
|
13
|
+
* - silence-poke success rate is 0–7% across hundreds of fires
|
|
14
|
+
* (finn: 0/78, clerk: 6/91, klanker: 5/158) — the polite levels
|
|
15
|
+
* reach the model as `<system-reminder>`s piggybacked on the next
|
|
16
|
+
* tool result, so they (a) only land if the model is actively
|
|
17
|
+
* cycling tools, (b) compete with hundreds of other tokens, and (c)
|
|
18
|
+
* only ever exist while the turn is open. The 300s framework
|
|
19
|
+
* fallback is the only user-visible silence-poke output, and its
|
|
20
|
+
* first job is to *kill the wedged turn*.
|
|
21
|
+
*
|
|
22
|
+
* - The dominant user-visible failure mode (issue #1445) is in fact
|
|
23
|
+
* cross-turn: the model calls `Agent` (or `Bash` with
|
|
24
|
+
* `run_in_background:true`), sends one ack reply that pings, then
|
|
25
|
+
* ends the turn. The silence-poke ladder is *gone* the moment
|
|
26
|
+
* endTurn() fires. The user then sees nothing for 10–30+ minutes
|
|
27
|
+
* until the worker returns. A live UAT confirmed: a deliberate
|
|
28
|
+
* `sleep 350` prompt produced one `[PING] Background sleep running;
|
|
29
|
+
* awaiting completion notification.` at +19s and the turn ended.
|
|
30
|
+
*
|
|
31
|
+
* Mechanism:
|
|
32
|
+
*
|
|
33
|
+
* tool_use(Agent|Task) → mark chat key `pending=true`
|
|
34
|
+
* outbound reply → capture anchor (messageId, text)
|
|
35
|
+
* turn_end with pending+anchor → activate the timer for the key
|
|
36
|
+
* tick (every 5s, edit every → editMessageText against the anchor
|
|
37
|
+
* EDIT_INTERVAL_MS) appending/refreshing the suffix
|
|
38
|
+
* " — still working (Nm)"
|
|
39
|
+
* inbound user message → clear (user re-engaged or moved on)
|
|
40
|
+
* subagent_handback inject → clear (model about to re-engage)
|
|
41
|
+
* MAX_LIFETIME_MS budget cap → clear (give up; 30 min default)
|
|
42
|
+
*
|
|
43
|
+
* Single shared timer for the whole gateway — like silence-poke's
|
|
44
|
+
* `tick()`, the per-key cost is O(map size) per poll. The poll
|
|
45
|
+
* interval is short (5s) but edits are spaced at EDIT_INTERVAL_MS so
|
|
46
|
+
* the Telegram bot.api editMessageText rate stays well under limits.
|
|
47
|
+
*
|
|
48
|
+
* Edits are plain text (no parseMode). The suffix is appended to the
|
|
49
|
+
* model's authored text; on subsequent edits the prior suffix is
|
|
50
|
+
* stripped before re-appending so the message never accumulates
|
|
51
|
+
* duplicate suffixes.
|
|
52
|
+
*
|
|
53
|
+
* Kill switch: `SWITCHROOM_DISABLE_PENDING_PROGRESS=1` disables the
|
|
54
|
+
* whole subsystem. The conversational-pacing prompt is unaffected.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
export const EDIT_INTERVAL_MS = 60_000
|
|
58
|
+
export const POLL_INTERVAL_MS = 5_000
|
|
59
|
+
export const MAX_LIFETIME_MS = 30 * 60_000
|
|
60
|
+
/** Telegram message length limit is 4096; budget headroom for the
|
|
61
|
+
* suffix and any escape expansion. If the anchor text plus suffix
|
|
62
|
+
* would exceed this, we skip the edit (the user still sees the
|
|
63
|
+
* original) rather than truncate the model's authored prose. */
|
|
64
|
+
export const TELEGRAM_MSG_CAP = 4000
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Regex matching the suffix we append. Used to strip a prior suffix
|
|
68
|
+
* before appending the next one. The (\d+) covers "1m" / "12m" / etc.
|
|
69
|
+
* Kept anchored to end-of-string so it only matches OUR suffix, not
|
|
70
|
+
* something the model happened to write.
|
|
71
|
+
*/
|
|
72
|
+
const SUFFIX_RE = /\n\n— still working \(\d+m\)$/
|
|
73
|
+
|
|
74
|
+
export interface PendingProgressEditCtx {
|
|
75
|
+
chatId: string
|
|
76
|
+
threadId: number | null
|
|
77
|
+
messageId: number
|
|
78
|
+
newText: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Discriminated union — kept structurally identical to the
|
|
83
|
+
* `pending_progress_*` variants in `runtime-metrics.ts:RuntimeMetricEvent`
|
|
84
|
+
* so the gateway's `emitMetric: emitRuntimeMetric` wire-up typechecks
|
|
85
|
+
* cleanly with no cast. `started` carries only the chat key; `edited`
|
|
86
|
+
* always carries the cumulative elapsed time; `cleared` carries an
|
|
87
|
+
* optional elapsed + the reason (`inbound` | `handback` | `timeout` |
|
|
88
|
+
* `manual`).
|
|
89
|
+
*/
|
|
90
|
+
export type PendingProgressMetric =
|
|
91
|
+
| { kind: 'pending_progress_started'; chatKey: string }
|
|
92
|
+
| { kind: 'pending_progress_edited'; chatKey: string; elapsedMs: number }
|
|
93
|
+
| {
|
|
94
|
+
kind: 'pending_progress_cleared'
|
|
95
|
+
chatKey: string
|
|
96
|
+
elapsedMs?: number
|
|
97
|
+
reason?: string
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface PendingProgressDeps {
|
|
101
|
+
editMessage: (ctx: PendingProgressEditCtx) => Promise<void>
|
|
102
|
+
emitMetric?: (event: PendingProgressMetric) => void
|
|
103
|
+
/** Optional clock override for tests. */
|
|
104
|
+
nowMs?: () => number
|
|
105
|
+
/** Optional poll interval override for tests. */
|
|
106
|
+
pollIntervalMs?: number
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface State {
|
|
110
|
+
/** True after a `tool_use(Agent|Task)` was observed for this key in
|
|
111
|
+
* the current turn. Cleared on next turn start. */
|
|
112
|
+
pending: boolean
|
|
113
|
+
/** The captured anchor — last outbound reply message_id for this
|
|
114
|
+
* key. */
|
|
115
|
+
anchorMessageId: number | null
|
|
116
|
+
/** The captured anchor text — what the model wrote, *minus* any
|
|
117
|
+
* prior pending-progress suffix. Used as the base for every edit. */
|
|
118
|
+
anchorOriginalText: string
|
|
119
|
+
/** Wall-clock ms when the cross-turn ambient state was *activated*
|
|
120
|
+
* (at turn_end with pending+anchor). null before activation. */
|
|
121
|
+
activatedAt: number | null
|
|
122
|
+
/** Wall-clock ms of last edit fire — gates the EDIT_INTERVAL_MS
|
|
123
|
+
* cadence. null until first edit fires. */
|
|
124
|
+
lastEditAt: number | null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const stateByKey = new Map<string, State>()
|
|
128
|
+
let timer: ReturnType<typeof setInterval> | null = null
|
|
129
|
+
let activeDeps: PendingProgressDeps | null = null
|
|
130
|
+
|
|
131
|
+
function enabled(): boolean {
|
|
132
|
+
const v = process.env.SWITCHROOM_DISABLE_PENDING_PROGRESS
|
|
133
|
+
return !(v === '1' || v === 'true')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function nowMs(): number {
|
|
137
|
+
return activeDeps?.nowMs ? activeDeps.nowMs() : Date.now()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function ensure(key: string): State {
|
|
141
|
+
let s = stateByKey.get(key)
|
|
142
|
+
if (!s) {
|
|
143
|
+
s = {
|
|
144
|
+
pending: false,
|
|
145
|
+
anchorMessageId: null,
|
|
146
|
+
anchorOriginalText: '',
|
|
147
|
+
activatedAt: null,
|
|
148
|
+
lastEditAt: null,
|
|
149
|
+
}
|
|
150
|
+
stateByKey.set(key, s)
|
|
151
|
+
}
|
|
152
|
+
return s
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Fresh turn — reset the per-turn `pending` flag and the per-turn
|
|
157
|
+
* anchor. The cross-turn `activated` state is per-PRIOR-turn and is
|
|
158
|
+
* cleared by the explicit clear paths (`clearPending` with reason
|
|
159
|
+
* `inbound` / `handback` / `timeout`), not by a new turn. The gateway
|
|
160
|
+
* wires those clears at TWO sites for full coverage:
|
|
161
|
+
*
|
|
162
|
+
* 1. `handleInbound` (real user message) → `clearPending('inbound')`
|
|
163
|
+
* — the fast path; fires the moment the gateway sees an inbound,
|
|
164
|
+
* before the new turn atom is even built.
|
|
165
|
+
* 2. `handleSessionEvent` `enqueue` case (every fresh turn atom)
|
|
166
|
+
* → `clearPending('handback')` — the backstop covering
|
|
167
|
+
* synthesised wakes (subagent-handback, cron, vault grant,
|
|
168
|
+
* restart marker) that push directly to `pendingInboundBuffer`
|
|
169
|
+
* and bypass `handleInbound`. Idempotent w/r/t the first clear.
|
|
170
|
+
*
|
|
171
|
+
* `startTurn` itself only matters if the state map already has an
|
|
172
|
+
* entry for `key` — which post-fix is impossible (the clears
|
|
173
|
+
* delete it). Kept for test ergonomics and as defence-in-depth.
|
|
174
|
+
*/
|
|
175
|
+
export function startTurn(key: string): void {
|
|
176
|
+
if (!enabled()) return
|
|
177
|
+
const s = stateByKey.get(key)
|
|
178
|
+
if (s == null) return
|
|
179
|
+
// Only the per-turn fields reset. activatedAt/lastEditAt belong to
|
|
180
|
+
// the prior turn's pending-progress and are cleared separately.
|
|
181
|
+
s.pending = false
|
|
182
|
+
s.anchorMessageId = null
|
|
183
|
+
s.anchorOriginalText = ''
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Mark this chat as having dispatched async background work in the
|
|
188
|
+
* current turn. Idempotent. Called when the gateway sees a `tool_use`
|
|
189
|
+
* for `Agent` or `Task`.
|
|
190
|
+
*/
|
|
191
|
+
export function noteAsyncDispatch(key: string): void {
|
|
192
|
+
if (!enabled()) return
|
|
193
|
+
ensure(key).pending = true
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Capture an outbound reply as a candidate anchor for cross-turn
|
|
198
|
+
* editing. Called on every successful bot reply send. If a prior
|
|
199
|
+
* pending-progress suffix is present in the text (rare — should only
|
|
200
|
+
* happen if we sent something to ourselves), strip it before storing
|
|
201
|
+
* so subsequent edits don't double-suffix.
|
|
202
|
+
*/
|
|
203
|
+
export function noteOutbound(
|
|
204
|
+
key: string,
|
|
205
|
+
opts: { messageId: number; text: string },
|
|
206
|
+
): void {
|
|
207
|
+
if (!enabled()) return
|
|
208
|
+
const s = ensure(key)
|
|
209
|
+
s.anchorMessageId = opts.messageId
|
|
210
|
+
s.anchorOriginalText = opts.text.replace(SUFFIX_RE, '')
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Called at turn_end. If the turn had a pending async dispatch AND
|
|
215
|
+
* captured an anchor, activate the cross-turn ambient state — the
|
|
216
|
+
* timer will start editing.
|
|
217
|
+
*
|
|
218
|
+
* If pending=false OR no anchor was captured, drop the state entry
|
|
219
|
+
* entirely (nothing for us to do).
|
|
220
|
+
*/
|
|
221
|
+
export function noteTurnEnd(key: string): void {
|
|
222
|
+
if (!enabled()) return
|
|
223
|
+
const s = stateByKey.get(key)
|
|
224
|
+
if (s == null) return
|
|
225
|
+
if (s.pending && s.anchorMessageId != null) {
|
|
226
|
+
s.activatedAt = nowMs()
|
|
227
|
+
// lastEditAt is null so the first edit fires after one full
|
|
228
|
+
// EDIT_INTERVAL_MS from activation — not immediately.
|
|
229
|
+
s.lastEditAt = s.activatedAt
|
|
230
|
+
activeDeps?.emitMetric?.({
|
|
231
|
+
kind: 'pending_progress_started',
|
|
232
|
+
chatKey: key,
|
|
233
|
+
})
|
|
234
|
+
} else {
|
|
235
|
+
stateByKey.delete(key)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Clear pending-progress for a chat — reasons:
|
|
241
|
+
* 'inbound' — user sent a new message, they're re-engaged
|
|
242
|
+
* 'handback' — switchroom injected a subagent_handback channel turn
|
|
243
|
+
* 'timeout' — exceeded MAX_LIFETIME_MS
|
|
244
|
+
* 'manual' — test / debug
|
|
245
|
+
*/
|
|
246
|
+
export function clearPending(
|
|
247
|
+
key: string,
|
|
248
|
+
reason: 'inbound' | 'handback' | 'timeout' | 'manual',
|
|
249
|
+
): void {
|
|
250
|
+
if (!stateByKey.has(key)) return
|
|
251
|
+
const s = stateByKey.get(key)!
|
|
252
|
+
const elapsed = s.activatedAt != null ? nowMs() - s.activatedAt : 0
|
|
253
|
+
stateByKey.delete(key)
|
|
254
|
+
activeDeps?.emitMetric?.({
|
|
255
|
+
kind: 'pending_progress_cleared',
|
|
256
|
+
chatKey: key,
|
|
257
|
+
elapsedMs: elapsed,
|
|
258
|
+
reason,
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Start the shared interval timer. Idempotent. Honours the kill
|
|
264
|
+
* switch — no-op when disabled.
|
|
265
|
+
*/
|
|
266
|
+
export function startTimer(deps: PendingProgressDeps): void {
|
|
267
|
+
if (!enabled()) return
|
|
268
|
+
if (timer != null) return
|
|
269
|
+
activeDeps = deps
|
|
270
|
+
const interval = deps.pollIntervalMs ?? POLL_INTERVAL_MS
|
|
271
|
+
timer = setInterval(() => tick(nowMs()), interval)
|
|
272
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Stop the timer. Idempotent. */
|
|
276
|
+
export function stopTimer(): void {
|
|
277
|
+
if (timer != null) {
|
|
278
|
+
clearInterval(timer)
|
|
279
|
+
timer = null
|
|
280
|
+
}
|
|
281
|
+
activeDeps = null
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Parse `<chatId>:<threadIdOrEmpty>` back into structured fields,
|
|
286
|
+
* matching the `statusKey` shape used throughout the gateway.
|
|
287
|
+
*/
|
|
288
|
+
function parseKey(key: string): { chatId: string; threadId: number | null } {
|
|
289
|
+
const idx = key.indexOf(':')
|
|
290
|
+
if (idx < 0) return { chatId: key, threadId: null }
|
|
291
|
+
const chatId = key.slice(0, idx)
|
|
292
|
+
const tail = key.slice(idx + 1)
|
|
293
|
+
if (tail === '' || tail === 'undefined') return { chatId, threadId: null }
|
|
294
|
+
const n = Number(tail)
|
|
295
|
+
return { chatId, threadId: Number.isFinite(n) ? n : null }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function tick(now: number): void {
|
|
299
|
+
if (activeDeps == null) return
|
|
300
|
+
for (const [key, s] of stateByKey.entries()) {
|
|
301
|
+
if (s.activatedAt == null || s.anchorMessageId == null) continue
|
|
302
|
+
|
|
303
|
+
const elapsed = now - s.activatedAt
|
|
304
|
+
if (elapsed >= MAX_LIFETIME_MS) {
|
|
305
|
+
clearPending(key, 'timeout')
|
|
306
|
+
continue
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const sinceEdit = s.lastEditAt == null ? 0 : now - s.lastEditAt
|
|
310
|
+
if (sinceEdit < EDIT_INTERVAL_MS) continue
|
|
311
|
+
|
|
312
|
+
// Build suffix from elapsed wall-clock. Always at least 1m so the
|
|
313
|
+
// user-visible counter reads honestly (we only edit at intervals
|
|
314
|
+
// ≥ EDIT_INTERVAL_MS = 60s).
|
|
315
|
+
const minutes = Math.max(1, Math.round(elapsed / 60_000))
|
|
316
|
+
const suffix = `\n\n— still working (${minutes}m)`
|
|
317
|
+
const newText = s.anchorOriginalText + suffix
|
|
318
|
+
|
|
319
|
+
if (newText.length > TELEGRAM_MSG_CAP) {
|
|
320
|
+
// Don't truncate the model's prose — just skip this edit.
|
|
321
|
+
// The previous edit (or the original) is still visible.
|
|
322
|
+
s.lastEditAt = now
|
|
323
|
+
continue
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const { chatId, threadId } = parseKey(key)
|
|
327
|
+
s.lastEditAt = now
|
|
328
|
+
|
|
329
|
+
const editCtx: PendingProgressEditCtx = {
|
|
330
|
+
chatId,
|
|
331
|
+
threadId,
|
|
332
|
+
messageId: s.anchorMessageId,
|
|
333
|
+
newText,
|
|
334
|
+
}
|
|
335
|
+
// Fire-and-forget so a slow edit doesn't block the tick loop.
|
|
336
|
+
// Errors are logged but never bubble (a 429 / "message not modified"
|
|
337
|
+
// / chat-deleted is a soft failure).
|
|
338
|
+
void Promise.resolve()
|
|
339
|
+
.then(() => activeDeps!.editMessage(editCtx))
|
|
340
|
+
.then(() => {
|
|
341
|
+
activeDeps!.emitMetric?.({
|
|
342
|
+
kind: 'pending_progress_edited',
|
|
343
|
+
chatKey: key,
|
|
344
|
+
elapsedMs: elapsed,
|
|
345
|
+
})
|
|
346
|
+
})
|
|
347
|
+
.catch((err) => {
|
|
348
|
+
process.stderr.write(
|
|
349
|
+
`pending-work-progress: edit failed key=${key} ` +
|
|
350
|
+
`msg=${editCtx.messageId}: ${(err as Error).message}\n`,
|
|
351
|
+
)
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ─── Test helpers ─────────────────────────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
/** Test-only: drive one tick deterministically. */
|
|
359
|
+
export function __tickForTests(now: number): void {
|
|
360
|
+
tick(now)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Test-only: install deps without starting the real timer. */
|
|
364
|
+
export function __setDepsForTests(deps: PendingProgressDeps | null): void {
|
|
365
|
+
activeDeps = deps
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Test-only: peek at per-key state. */
|
|
369
|
+
export function __getStateForTests(key: string): State | undefined {
|
|
370
|
+
return stateByKey.get(key)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Test-only: full reset. */
|
|
374
|
+
export function __resetAllForTests(): void {
|
|
375
|
+
stateByKey.clear()
|
|
376
|
+
stopTimer()
|
|
377
|
+
}
|
|
@@ -104,6 +104,26 @@ export type RuntimeMetricEvent =
|
|
|
104
104
|
fallback_kind: 'working' | 'thinking'
|
|
105
105
|
silence_ms: number
|
|
106
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* #1445 cross-turn pending-async ambient lifecycle. `started` fires
|
|
109
|
+
* when a turn ends with a captured anchor AND a pending Agent/Task/
|
|
110
|
+
* Bash-background dispatch — i.e. the framework will now edit the
|
|
111
|
+
* model's last reply in place every ~60s until cleared. `edited`
|
|
112
|
+
* fires on each successful in-place edit; `elapsed_ms` is how long
|
|
113
|
+
* ambient has been running for this chat. `cleared` fires when
|
|
114
|
+
* ambient stops — `reason` says why (inbound / handback / timeout).
|
|
115
|
+
* Targets: edited/started ratio is the "still alive minutes per
|
|
116
|
+
* activation" health proxy; cleared.reason='inbound' should
|
|
117
|
+
* dominate (model + user resolving naturally).
|
|
118
|
+
*/
|
|
119
|
+
| { kind: 'pending_progress_started'; chatKey: string }
|
|
120
|
+
| { kind: 'pending_progress_edited'; chatKey: string; elapsedMs: number }
|
|
121
|
+
| {
|
|
122
|
+
kind: 'pending_progress_cleared'
|
|
123
|
+
chatKey: string
|
|
124
|
+
elapsedMs?: number
|
|
125
|
+
reason?: string
|
|
126
|
+
}
|
|
107
127
|
|
|
108
128
|
/**
|
|
109
129
|
* The JSONL sink lives under the runtime state dir so it's per-agent
|