switchroom 0.18.20 → 0.18.22
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 +24 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_shared/delegation-golden-rule.md.hbs +9 -0
- package/profiles/_shared/dev-protocol.md.hbs +2 -0
- package/profiles/_shared/execution-discipline.md.hbs +2 -2
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +268 -61
- package/telegram-plugin/flushed-turn-supersede.ts +230 -0
- package/telegram-plugin/gateway/gateway.ts +88 -1
- package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +17 -0
- package/telegram-plugin/registry/subagents-schema.ts +6 -0
- package/telegram-plugin/subagent-watcher.ts +86 -1
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +206 -0
- package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +20 -0
- package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +30 -0
- package/telegram-plugin/tests/subagent-watcher-first-paint-independence.test.ts +171 -0
- package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +7 -5
- package/telegram-plugin/tests/subagent-watcher.test.ts +13 -12
- package/telegram-plugin/tests/turn-flush-safety.test.ts +71 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +13 -8
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +196 -0
- package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +40 -0
- package/telegram-plugin/turn-flush-safety.ts +74 -1
- package/telegram-plugin/worker-activity-feed.ts +155 -45
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn-flush supersede registry (2026-07 duplicate-reply fix).
|
|
3
|
+
*
|
|
4
|
+
* The duplicate-reply class this closes:
|
|
5
|
+
*
|
|
6
|
+
* 1. A turn-flush (the answer-ready quiescence flush OR the older turn-end
|
|
7
|
+
* backstop) posts the model's terminal text as a Telegram message, because
|
|
8
|
+
* the turn appeared to end without a `reply` tool call.
|
|
9
|
+
* 2. ~10 s later the model's REAL `reply` tool call lands (it was still
|
|
10
|
+
* composing it when the flush fired, or claude-code replayed an un-acked
|
|
11
|
+
* tool_call after a bridge reconnect).
|
|
12
|
+
* 3. The gateway sends that as a SECOND message → the user sees a duplicate.
|
|
13
|
+
*
|
|
14
|
+
* The pre-existing `OutboundDedupCache` did NOT catch this because it matches on
|
|
15
|
+
* EXACT normalised-text equality: a flush that dumped `narration\n\nanswer`
|
|
16
|
+
* never equals the clean `answer`-only reply, so the containment case slipped
|
|
17
|
+
* through (216 occurrences in older logs via the turn-end backstop alone).
|
|
18
|
+
*
|
|
19
|
+
* This registry SUBSTANTIALLY REDUCES the class, keyed on the turn IDENTITY
|
|
20
|
+
* (the per-turn `turnId` nonce) rather than on text. When a flush sends, it
|
|
21
|
+
* records `{ turnId, messageIds }` for the chat/thread. When a `reply` for the
|
|
22
|
+
* SAME turn later lands, the gateway SUPERSEDES the flushed message(s) — deletes
|
|
23
|
+
* them and lets the canonical reply send path deliver exactly one clean message
|
|
24
|
+
* (delete+resend; the caller may also edit-in-place). A reply for a DIFFERENT
|
|
25
|
+
* (newer) turn never supersedes — that turn owns its own message.
|
|
26
|
+
*
|
|
27
|
+
* NOT fully deterministic — a residual race remains. The flush→reply direction
|
|
28
|
+
* this registry covers, plus the controller's fire-time recount for the
|
|
29
|
+
* reply→flush direction, close the COMMON ~10 s replay-gap case; but if a reply
|
|
30
|
+
* and a flush interleave so the reply's `take()` runs BEFORE the flush's
|
|
31
|
+
* `record()` (a much smaller window), `take` finds no record and the duplicate
|
|
32
|
+
* can still slip through. We deliberately trade that residual window for the
|
|
33
|
+
* safety guarantee that we NEVER delete a message we cannot positively attribute
|
|
34
|
+
* to the reply's own turn (identity-only supersede — see `decideSupersede`).
|
|
35
|
+
*
|
|
36
|
+
* Pure module: no I/O, no globals, no clock reads beyond the caller-supplied
|
|
37
|
+
* `now`. Fully unit-testable; the gateway wires the actual delete/send.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** TTL after which a recorded flush is forgotten. 60 s comfortably spans the
|
|
41
|
+
* observed ~10 s flush→reply replay gap with margin, and matches the outbound
|
|
42
|
+
* dedup window so the two mechanisms age out together. */
|
|
43
|
+
export const DEFAULT_SUPERSEDE_TTL_MS = 60_000
|
|
44
|
+
|
|
45
|
+
export interface FlushedTurnRecord {
|
|
46
|
+
/** The per-turn `turnId` nonce (`deriveTurnId` shape) of the flushed turn.
|
|
47
|
+
* Keying on this — NOT the stable `chatId:threadId` statusKey — is what makes
|
|
48
|
+
* the supersede turn-identity-scoped: a later, unrelated turn on the same
|
|
49
|
+
* chat has a different `turnId` and must NOT clobber this record's message. */
|
|
50
|
+
turnId: string | null
|
|
51
|
+
/** The Telegram message id(s) the flush posted (edit target + any extra
|
|
52
|
+
* chunk messages). Superseding deletes all of them. */
|
|
53
|
+
messageIds: number[]
|
|
54
|
+
/** The text the flush delivered — retained for diagnostics/logging only. The
|
|
55
|
+
* supersede decision NEVER compares text (that is the whole point: it fires
|
|
56
|
+
* even when the flushed text differs from the reply text). */
|
|
57
|
+
text: string
|
|
58
|
+
/** Wall-clock ms when recorded. */
|
|
59
|
+
ts: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The supersede decision for a landing reply. Pure so the gateway runs the
|
|
64
|
+
* exact code the regression tests exercise (`gateway.ts` is not importable in
|
|
65
|
+
* tests — the repo's `decideTurnFlush` / `decideCapturedProseDelivery` pattern).
|
|
66
|
+
*/
|
|
67
|
+
export interface SupersedeDecision {
|
|
68
|
+
/** True → the reply belongs to an already-flushed turn; the gateway must
|
|
69
|
+
* delete `deleteMessageIds` and deliver the reply as the single message. */
|
|
70
|
+
supersede: boolean
|
|
71
|
+
/** Message ids the gateway must delete before/instead of the fresh send.
|
|
72
|
+
* Empty when `supersede` is false. */
|
|
73
|
+
deleteMessageIds: number[]
|
|
74
|
+
/** Machine-readable reason (for logs / tests). */
|
|
75
|
+
reason: 'supersede' | 'no-record' | 'expired' | 'different-turn'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Decide whether a landing reply supersedes a recorded flush.
|
|
80
|
+
*
|
|
81
|
+
* Supersede IFF the record is present, fresh (within TTL), AND positively
|
|
82
|
+
* attributable to the reply's turn by IDENTITY:
|
|
83
|
+
* - a turnId-bearing record is superseded ONLY by a reply whose resolved
|
|
84
|
+
* `liveTurnId` equals `record.turnId`;
|
|
85
|
+
* - a null-turnId record (a synthetic/no-nonce flush) is superseded ONLY by a
|
|
86
|
+
* reply that ALSO has no resolvable turn (`liveTurnId == null`).
|
|
87
|
+
*
|
|
88
|
+
* Crucially, a reply with `liveTurnId == null` does NOT supersede a
|
|
89
|
+
* turnId-bearing record: we never delete a message we cannot positively
|
|
90
|
+
* attribute to the reply's own turn. (The earlier revision superseded ANY
|
|
91
|
+
* record on a null live turn, which — combined with a single overwriting lane
|
|
92
|
+
* slot — could late-delete a DIFFERENT turn's legitimate message. The gateway
|
|
93
|
+
* now resolves a last-known turnId for the reply before calling in, so the
|
|
94
|
+
* common late-replay case still matches by identity rather than relying on the
|
|
95
|
+
* promiscuous null branch.)
|
|
96
|
+
*/
|
|
97
|
+
export function decideSupersede(
|
|
98
|
+
record: FlushedTurnRecord | undefined,
|
|
99
|
+
args: { liveTurnId: string | null; now: number; ttlMs?: number },
|
|
100
|
+
): SupersedeDecision {
|
|
101
|
+
const ttlMs = args.ttlMs ?? DEFAULT_SUPERSEDE_TTL_MS
|
|
102
|
+
if (record == null) return { supersede: false, deleteMessageIds: [], reason: 'no-record' }
|
|
103
|
+
if (args.now - record.ts > ttlMs) {
|
|
104
|
+
return { supersede: false, deleteMessageIds: [], reason: 'expired' }
|
|
105
|
+
}
|
|
106
|
+
const sameTurn =
|
|
107
|
+
record.turnId != null
|
|
108
|
+
? record.turnId === args.liveTurnId
|
|
109
|
+
: args.liveTurnId == null
|
|
110
|
+
if (!sameTurn) {
|
|
111
|
+
return { supersede: false, deleteMessageIds: [], reason: 'different-turn' }
|
|
112
|
+
}
|
|
113
|
+
return { supersede: true, deleteMessageIds: [...record.messageIds], reason: 'supersede' }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Sentinel key for records whose flush carried no turnId nonce. */
|
|
117
|
+
const NULL_TURN_KEY = '<<null-turn>>'
|
|
118
|
+
|
|
119
|
+
function turnKey(turnId: string | null): string {
|
|
120
|
+
return turnId == null ? NULL_TURN_KEY : turnId
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* In-memory registry of recently-flushed turns, keyed by `chatId|threadId` and
|
|
125
|
+
* then by the flush's `turnId` nonce WITHIN that lane. Holding a per-turnId map
|
|
126
|
+
* (rather than a single overwriting slot) is what makes the identity guarantee
|
|
127
|
+
* airtight: two turns can each flush a message on the same lane, and each turn's
|
|
128
|
+
* later reply supersedes ONLY its own flushed message — turn B's reply can never
|
|
129
|
+
* reach turn A's record, and a late turn-A reply can never reach turn B's.
|
|
130
|
+
*
|
|
131
|
+
* Bounded by TTL eviction (swept on every `record`); chat count per gateway is
|
|
132
|
+
* small and a lane holds at most a handful of concurrent-turn records.
|
|
133
|
+
*/
|
|
134
|
+
export class FlushedTurnSupersedeRegistry {
|
|
135
|
+
private readonly entries = new Map<string, Map<string, FlushedTurnRecord>>()
|
|
136
|
+
private readonly ttlMs: number
|
|
137
|
+
|
|
138
|
+
constructor(opts: { ttlMs?: number } = {}) {
|
|
139
|
+
this.ttlMs = opts.ttlMs ?? DEFAULT_SUPERSEDE_TTL_MS
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Record a flush's posted message(s) so a later same-turn reply supersedes
|
|
143
|
+
* them, keyed on the flush's `turnId` within the chat/thread lane. No record
|
|
144
|
+
* is kept when the flush posted zero messages. Sweeps expired records first
|
|
145
|
+
* (lightweight active GC — LOW-2) so orphan records never accumulate. */
|
|
146
|
+
record(
|
|
147
|
+
chatId: string,
|
|
148
|
+
threadId: number | undefined,
|
|
149
|
+
rec: { turnId: string | null; messageIds: number[]; text: string },
|
|
150
|
+
now: number,
|
|
151
|
+
): void {
|
|
152
|
+
if (rec.messageIds.length === 0) return
|
|
153
|
+
this.sweep(now)
|
|
154
|
+
const lane = makeKey(chatId, threadId)
|
|
155
|
+
let laneMap = this.entries.get(lane)
|
|
156
|
+
if (laneMap == null) {
|
|
157
|
+
laneMap = new Map<string, FlushedTurnRecord>()
|
|
158
|
+
this.entries.set(lane, laneMap)
|
|
159
|
+
}
|
|
160
|
+
laneMap.set(turnKey(rec.turnId), {
|
|
161
|
+
turnId: rec.turnId,
|
|
162
|
+
messageIds: [...rec.messageIds],
|
|
163
|
+
text: rec.text,
|
|
164
|
+
ts: now,
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Decide supersede for a landing reply WITHOUT consuming the record. Selects
|
|
169
|
+
* the record whose turnId matches the reply's resolved `liveTurnId` (or the
|
|
170
|
+
* null-turnId record when `liveTurnId == null`). */
|
|
171
|
+
peek(
|
|
172
|
+
chatId: string,
|
|
173
|
+
threadId: number | undefined,
|
|
174
|
+
args: { liveTurnId: string | null; now: number },
|
|
175
|
+
): SupersedeDecision {
|
|
176
|
+
const rec = this.entries.get(makeKey(chatId, threadId))?.get(turnKey(args.liveTurnId))
|
|
177
|
+
return decideSupersede(rec, { liveTurnId: args.liveTurnId, now: args.now, ttlMs: this.ttlMs })
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Decide supersede AND, on a supersede, consume the matched record (so a
|
|
181
|
+
* second replay of the same reply doesn't try to delete the same — now gone —
|
|
182
|
+
* messages again). Returns the same decision `peek` would. */
|
|
183
|
+
take(
|
|
184
|
+
chatId: string,
|
|
185
|
+
threadId: number | undefined,
|
|
186
|
+
args: { liveTurnId: string | null; now: number },
|
|
187
|
+
): SupersedeDecision {
|
|
188
|
+
const lane = makeKey(chatId, threadId)
|
|
189
|
+
const decision = this.peek(chatId, threadId, args)
|
|
190
|
+
if (decision.supersede) {
|
|
191
|
+
const laneMap = this.entries.get(lane)
|
|
192
|
+
laneMap?.delete(turnKey(args.liveTurnId))
|
|
193
|
+
if (laneMap != null && laneMap.size === 0) this.entries.delete(lane)
|
|
194
|
+
}
|
|
195
|
+
return decision
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Drop all records for a chat/thread (e.g. after the flushed message was
|
|
199
|
+
* deleted through another path). Idempotent. */
|
|
200
|
+
forget(chatId: string, threadId: number | undefined): void {
|
|
201
|
+
this.entries.delete(makeKey(chatId, threadId))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Test-only: clear all entries. */
|
|
205
|
+
clear(): void {
|
|
206
|
+
this.entries.clear()
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Evict every record past its TTL and prune emptied lanes. */
|
|
210
|
+
private sweep(now: number): void {
|
|
211
|
+
for (const [lane, laneMap] of this.entries) {
|
|
212
|
+
for (const [tk, rec] of laneMap) {
|
|
213
|
+
if (now - rec.ts > this.ttlMs) laneMap.delete(tk)
|
|
214
|
+
}
|
|
215
|
+
if (laneMap.size === 0) this.entries.delete(lane)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Test-only: live record count after TTL eviction. */
|
|
220
|
+
size(now: number): number {
|
|
221
|
+
this.sweep(now)
|
|
222
|
+
let total = 0
|
|
223
|
+
for (const laneMap of this.entries.values()) total += laneMap.size
|
|
224
|
+
return total
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function makeKey(chatId: string, threadId: number | undefined): string {
|
|
229
|
+
return threadId == null ? chatId : `${chatId}|${threadId}`
|
|
230
|
+
}
|
|
@@ -88,6 +88,7 @@ import {
|
|
|
88
88
|
type TelegraphAccount,
|
|
89
89
|
} from '../telegraph.js'
|
|
90
90
|
import { OutboundDedupCache } from '../recent-outbound-dedup.js'
|
|
91
|
+
import { FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
|
|
91
92
|
import { createInboundCoalescer, inboundCoalesceKey } from './inbound-coalesce.js'
|
|
92
93
|
import {
|
|
93
94
|
splitCoalescedAttachments,
|
|
@@ -2220,6 +2221,14 @@ const deferredDoneReactions = new DeferredDoneReactions<StatusReactionController
|
|
|
2220
2221
|
// path threw `outboundDedup is not defined` at runtime, blocking ALL outbound
|
|
2221
2222
|
// from the agent. Restore the module-level singleton here.
|
|
2222
2223
|
const outboundDedup = new OutboundDedupCache()
|
|
2224
|
+
// 2026-07 duplicate-reply fix — turnId-keyed supersede. When a turn-flush
|
|
2225
|
+
// (answer-ready quiescence OR the turn-end backstop) posts the model's terminal
|
|
2226
|
+
// text and the model's REAL `reply` for the SAME turn lands later, the reply
|
|
2227
|
+
// SUPERSEDES the flushed message (delete + canonical resend) instead of shipping
|
|
2228
|
+
// a second message. Keyed on the per-turn `turnId` nonce, NOT on text — so it
|
|
2229
|
+
// catches the containment case the exact-text `outboundDedup` misses (a
|
|
2230
|
+
// `narration\n\nanswer` flush never equals the clean `answer`-only reply).
|
|
2231
|
+
const flushedTurnSupersede = new FlushedTurnSupersedeRegistry()
|
|
2223
2232
|
/**
|
|
2224
2233
|
* Per-chat cache of `available_reactions` from `getChat`. Populated lazily —
|
|
2225
2234
|
* the FIRST message in a chat creates a controller without the filter (null
|
|
@@ -12902,6 +12911,50 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
12902
12911
|
}
|
|
12903
12912
|
}
|
|
12904
12913
|
|
|
12914
|
+
// 2026-07 duplicate-reply fix — turnId-keyed supersede (consumption side).
|
|
12915
|
+
// If an earlier turn-flush (answer-ready quiescence OR the turn-end backstop)
|
|
12916
|
+
// already posted THIS turn's terminal text and the model's REAL `reply` for
|
|
12917
|
+
// the same turn is landing now (it was still composing the tool call when the
|
|
12918
|
+
// flush fired, or claude-code replayed the tool_call after a bridge
|
|
12919
|
+
// reconnect), delete the flushed message(s) so the canonical reply below
|
|
12920
|
+
// delivers exactly one clean message instead of a second one. Keyed on the
|
|
12921
|
+
// per-turn `turnId` nonce, so it fires even when the flushed narration+answer
|
|
12922
|
+
// blob differs from the clean answer-only reply — the containment case the
|
|
12923
|
+
// exact-text `outboundDedup` above structurally cannot catch. A reply for a
|
|
12924
|
+
// DIFFERENT newer live turn never supersedes (decideSupersede → different-turn),
|
|
12925
|
+
// so a fresh turn's answer is never clobbered.
|
|
12926
|
+
{
|
|
12927
|
+
const replyThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
12928
|
+
// Resolve the turnId this reply belongs to by IDENTITY, not just the live
|
|
12929
|
+
// `currentTurn`. A late reply lands with `currentTurn == null` (silence poke
|
|
12930
|
+
// cleared it — Bug D) or with a transiently-cleared currentTurn while its own
|
|
12931
|
+
// turn is really still the owner (LOW-1); in both cases the turn is still
|
|
12932
|
+
// resolvable from the `origin_turn_id` nonce the model echoes back (Tier 2 —
|
|
12933
|
+
// the same last-known-turn resolver the chat-routing / obligation code uses).
|
|
12934
|
+
// Passing this resolved turnId means supersede matches the flushed record by
|
|
12935
|
+
// identity instead of falling back to a null-liveTurnId branch that would
|
|
12936
|
+
// otherwise be free to delete a DIFFERENT turn's legitimate message.
|
|
12937
|
+
const resolvedTurnId =
|
|
12938
|
+
turn?.turnId ?? findTurnByOriginId(args.origin_turn_id as string | undefined)?.turnId ?? null
|
|
12939
|
+
const decision = flushedTurnSupersede.take(
|
|
12940
|
+
chat_id,
|
|
12941
|
+
replyThreadId,
|
|
12942
|
+
{ liveTurnId: resolvedTurnId, now: Date.now() },
|
|
12943
|
+
)
|
|
12944
|
+
if (decision.supersede) {
|
|
12945
|
+
process.stderr.write(
|
|
12946
|
+
`telegram gateway: reply: superseding flushed turn message(s) ` +
|
|
12947
|
+
`chatId=${chat_id} ids=${JSON.stringify(decision.deleteMessageIds)}\n`,
|
|
12948
|
+
)
|
|
12949
|
+
for (const id of decision.deleteMessageIds) {
|
|
12950
|
+
await swallowingApiCall(
|
|
12951
|
+
() => lockedBot.api.deleteMessage(chat_id, id),
|
|
12952
|
+
{ chat_id, verb: 'reply.supersedeFlushed' },
|
|
12953
|
+
)
|
|
12954
|
+
}
|
|
12955
|
+
}
|
|
12956
|
+
}
|
|
12957
|
+
|
|
12905
12958
|
const files = (args.files as string[] | undefined) ?? []
|
|
12906
12959
|
const quoteOptIn = args.quote !== false
|
|
12907
12960
|
let reply_to = args.reply_to != null ? Number(args.reply_to) : undefined
|
|
@@ -18264,6 +18317,22 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
18264
18317
|
Date.now(),
|
|
18265
18318
|
currentTurn?.registryKey ?? null,
|
|
18266
18319
|
)
|
|
18320
|
+
// 2026-07 duplicate-reply fix — record the flushed message id(s)
|
|
18321
|
+
// keyed on THIS turn's `turnId` nonce so a late `reply` for the same
|
|
18322
|
+
// turn supersedes them (delete + canonical resend) instead of
|
|
18323
|
+
// shipping a duplicate. `turn` is the ending turn atom captured at
|
|
18324
|
+
// the top of this branch (endCurrentTurnAtomic nulled currentTurn,
|
|
18325
|
+
// but the captured `turn` still carries the honest turnId). Covers
|
|
18326
|
+
// BOTH flush paths — answer-ready quiescence and the turn-end
|
|
18327
|
+
// backstop both funnel through this single IIFE.
|
|
18328
|
+
if (sentIds.length > 0) {
|
|
18329
|
+
flushedTurnSupersede.record(
|
|
18330
|
+
backstopChatId,
|
|
18331
|
+
backstopThreadId,
|
|
18332
|
+
{ turnId: turn.turnId, messageIds: sentIds, text: capturedText },
|
|
18333
|
+
Date.now(),
|
|
18334
|
+
)
|
|
18335
|
+
}
|
|
18267
18336
|
// #1713: route the backstop terminal through finalize() —
|
|
18268
18337
|
// single terminal path keeps the controller contract clean.
|
|
18269
18338
|
if (backstopCtrl) backstopCtrl.finalize('done')
|
|
@@ -30542,7 +30611,7 @@ void (async () => {
|
|
|
30542
30611
|
// suppresses stale-after-restart delivery (a 4-h-old
|
|
30543
30612
|
// "still working (5m)" would be a lie). Sweep on handback
|
|
30544
30613
|
// lives in the `onFinish` block just above.
|
|
30545
|
-
onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine, model }) => {
|
|
30614
|
+
onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine, model, skeleton }) => {
|
|
30546
30615
|
let fleetChatId = ''
|
|
30547
30616
|
try {
|
|
30548
30617
|
const fleets = progressDriver?.peekAllFleets() ?? []
|
|
@@ -30632,6 +30701,15 @@ void (async () => {
|
|
|
30632
30701
|
return
|
|
30633
30702
|
}
|
|
30634
30703
|
if (surface !== 'nest') return // 'skip' — orphan-status off
|
|
30704
|
+
// #3233: a skeleton liveness cue carries NO step content by
|
|
30705
|
+
// construction (empty latestSummary/progressLine) — it exists
|
|
30706
|
+
// ONLY to create/keep-alive the orphan worker-feed row handled
|
|
30707
|
+
// just above. Branch EXPLICITLY on the `skeleton` discriminator
|
|
30708
|
+
// rather than inferring "no content" from an empty step line:
|
|
30709
|
+
// a skeleton cue must never nest into the parent's live turn
|
|
30710
|
+
// card (there is nothing to render, and the parent's own card
|
|
30711
|
+
// already owns the turn). Deterministic, controls-in-code.
|
|
30712
|
+
if (skeleton) return
|
|
30635
30713
|
const turn = currentTurn
|
|
30636
30714
|
if (turn == null) return // defensive: 'nest' implies a live turn
|
|
30637
30715
|
// Render regardless of `replyCalled` — a foreground Task
|
|
@@ -30777,8 +30855,17 @@ void (async () => {
|
|
|
30777
30855
|
return
|
|
30778
30856
|
}
|
|
30779
30857
|
|
|
30858
|
+
// #3233: with the worker feed DISABLED, the legacy bucket relay
|
|
30859
|
+
// below injects a synthesized "still working" inbound turn. A
|
|
30860
|
+
// skeleton liveness cue carries an EMPTY latestSummary, so
|
|
30861
|
+
// letting it reach the relay would queue a blank/contentless
|
|
30862
|
+
// progress card. The `skeleton` discriminator is threaded into
|
|
30863
|
+
// the pure decision (gate 1b → 'skeleton-liveness'), which drops
|
|
30864
|
+
// it deterministically (controls-in-code, unit-tested) rather
|
|
30865
|
+
// than an opaque inline return here.
|
|
30780
30866
|
const progressOrigin = resolveSubagentOriginChat(agentId)
|
|
30781
30867
|
const decision = decideSubagentProgress({
|
|
30868
|
+
skeleton: skeleton === true,
|
|
30782
30869
|
disableEnvValue: process.env.SWITCHROOM_DISABLE_SUBAGENT_PROGRESS,
|
|
30783
30870
|
isBackground,
|
|
30784
30871
|
// Prefer the conversation the Task was dispatched from over
|
|
@@ -176,12 +176,20 @@ export interface SubagentProgressDecisionInput {
|
|
|
176
176
|
* passes it in; the decision returns the new bucket idx on
|
|
177
177
|
* `deliver: true` so the caller can update its tracker. */
|
|
178
178
|
lastBucketIdx: number | null
|
|
179
|
+
/** #3233: true for a growth-independent SKELETON liveness cue (empty
|
|
180
|
+
* `latestSummary`, no step content). It exists ONLY to first-paint /
|
|
181
|
+
* keep-alive the in-message worker-feed row; the legacy bucket relay would
|
|
182
|
+
* turn it into a synthesized "still working" inbound with no content — a
|
|
183
|
+
* blank card. Suppressed deterministically here so the worker-feed-DISABLED
|
|
184
|
+
* path degrades to a no-op rather than a blank envelope. */
|
|
185
|
+
skeleton?: boolean
|
|
179
186
|
/** Deterministic clock for tests. */
|
|
180
187
|
nowMs?: number
|
|
181
188
|
}
|
|
182
189
|
|
|
183
190
|
export type SubagentProgressSkipReason =
|
|
184
191
|
| 'env-disabled'
|
|
192
|
+
| 'skeleton-liveness'
|
|
185
193
|
| 'foreground'
|
|
186
194
|
| 'no-chat'
|
|
187
195
|
| 'bucket-already-fired'
|
|
@@ -199,6 +207,8 @@ export type SubagentProgressDecision =
|
|
|
199
207
|
*
|
|
200
208
|
* Gates, in order:
|
|
201
209
|
* 1. kill-switch — `SWITCHROOM_DISABLE_SUBAGENT_PROGRESS=1` disables.
|
|
210
|
+
* 1b. skeleton-liveness (#3233) — a contentless skeleton cue is never
|
|
211
|
+
* relayed as a synthesized inbound (worker-feed row only).
|
|
202
212
|
* 2. foreground — foreground sub-agents stream natively.
|
|
203
213
|
* 3. no-chat — nowhere to deliver.
|
|
204
214
|
* 4. missing-jsonl-id — the dedup key. Without it we'd lose
|
|
@@ -233,6 +243,13 @@ export function decideSubagentProgress(
|
|
|
233
243
|
if (isEnvFlagOn(input.disableEnvValue)) {
|
|
234
244
|
return { deliver: false, reason: 'env-disabled' }
|
|
235
245
|
}
|
|
246
|
+
// #3233: a skeleton liveness cue carries no step content — never relay it as
|
|
247
|
+
// a synthesized progress inbound (that would be a blank card). Its whole job
|
|
248
|
+
// is the in-message worker-feed row; when that surface is off, degrade to a
|
|
249
|
+
// no-op. Checked before bucketing so it can never advance the bucket tracker.
|
|
250
|
+
if (input.skeleton === true) {
|
|
251
|
+
return { deliver: false, reason: 'skeleton-liveness' }
|
|
252
|
+
}
|
|
236
253
|
if (!input.isBackground) {
|
|
237
254
|
return { deliver: false, reason: 'foreground' }
|
|
238
255
|
}
|
|
@@ -277,6 +277,12 @@ export function applySubagentsSchema(db: SqliteDatabase): void {
|
|
|
277
277
|
// column is guaranteed to exist (either created with the table or added by
|
|
278
278
|
// the migration above).
|
|
279
279
|
db.exec('CREATE INDEX IF NOT EXISTS subagents_jsonl_id ON subagents(jsonl_agent_id)')
|
|
280
|
+
// Same deferred-index rationale as jsonl_agent_id above: parent_agent_id is
|
|
281
|
+
// added by the ALTER migration for pre-existing tables, so its index must be
|
|
282
|
+
// created here (after the column is guaranteed to exist), not in the base SQL.
|
|
283
|
+
// Backs the per-poll child-existence probe in subagent-watcher.ts
|
|
284
|
+
// (`SELECT 1 FROM subagents WHERE parent_agent_id = ? LIMIT 1`).
|
|
285
|
+
db.exec('CREATE INDEX IF NOT EXISTS subagents_parent_agent ON subagents(parent_agent_id)')
|
|
280
286
|
}
|
|
281
287
|
|
|
282
288
|
// ---------------------------------------------------------------------------
|
|
@@ -628,6 +628,12 @@ export interface SubagentWatcherConfig {
|
|
|
628
628
|
* assistant line — the gateway then falls back to the registry's
|
|
629
629
|
* dispatch-time model. */
|
|
630
630
|
model?: string
|
|
631
|
+
/** True for a growth-INDEPENDENT skeleton liveness cue (#3231): fired on a
|
|
632
|
+
* no-growth poll for a running entry so the card can first-paint / stay
|
|
633
|
+
* alive without waiting for JSONL growth. Carries the entry's real state
|
|
634
|
+
* but an EMPTY `latestSummary`/`progressLine` — never fabricated content.
|
|
635
|
+
* Consumers that count real narrative/tool cues must exclude it. */
|
|
636
|
+
skeleton?: boolean
|
|
631
637
|
}) => void
|
|
632
638
|
/** `Date.now` override for tests. */
|
|
633
639
|
now?: () => number
|
|
@@ -1088,6 +1094,9 @@ export function readSubTail(
|
|
|
1088
1094
|
progressLine?: string
|
|
1089
1095
|
/** Live model this worker is running (see SubagentWatcherConfig.onProgress). */
|
|
1090
1096
|
model?: string
|
|
1097
|
+
/** Growth-independent skeleton liveness cue (#3231). See the identically
|
|
1098
|
+
* named field on SubagentWatcherConfig.onProgress. */
|
|
1099
|
+
skeleton?: boolean
|
|
1091
1100
|
}) => void,
|
|
1092
1101
|
): void {
|
|
1093
1102
|
try {
|
|
@@ -1103,7 +1112,83 @@ export function readSubTail(
|
|
|
1103
1112
|
tail.cursor = 0
|
|
1104
1113
|
tail.pendingPartial = ''
|
|
1105
1114
|
}
|
|
1106
|
-
if (stat.size === tail.cursor)
|
|
1115
|
+
if (stat.size === tail.cursor) {
|
|
1116
|
+
// First-paint independence (#3231): the worker card is otherwise driven
|
|
1117
|
+
// ONLY by growth-triggered progress cues below, so a running worker whose
|
|
1118
|
+
// JSONL is not currently growing surfaces NOTHING. That is the ~90-205s
|
|
1119
|
+
// invisible-card bug observed live (a57fbf, 2026-07-13): an async
|
|
1120
|
+
// foreground sub-agent did two Bash calls, then its first tool BLOCKED for
|
|
1121
|
+
// ~99s (no JSONL growth → no cue), and — because its spawning turn had
|
|
1122
|
+
// already ended — no nest and no worker-feed row existed to paint. Its
|
|
1123
|
+
// card did not appear until 205s after registration, on the next growth
|
|
1124
|
+
// event that happened to be classified to the feed. Fire a growth-INDEPENDENT
|
|
1125
|
+
// skeleton liveness cue on every no-growth poll for a live entry so the
|
|
1126
|
+
// gateway can paint (and keep alive) the card from registration onward,
|
|
1127
|
+
// uniformly across ALL spawn origins/nesting levels. The cue carries the
|
|
1128
|
+
// entry's REAL current state (lastTool/toolCount/model) but an EMPTY step
|
|
1129
|
+
// line — no fabricated content: it is inert on the foreground-nest path
|
|
1130
|
+
// (empty child → no-op, the parent's own card owns the live turn) and
|
|
1131
|
+
// creates/refreshes the orphan/background worker-feed row (→ "starting…",
|
|
1132
|
+
// whose first paint the feed's own firstPaintMin + heartbeat then owns).
|
|
1133
|
+
if (onProgress != null && entry.state === 'running' && !entry.historical) {
|
|
1134
|
+
// Child-aware suppression (#3233): the skeleton cue exists to paint a
|
|
1135
|
+
// LEAF worker whose card would otherwise be invisible (the 205s
|
|
1136
|
+
// blackout). A pure-ORCHESTRATOR parent — one that has dispatched a
|
|
1137
|
+
// descendant of its own — must NOT earn a redundant "starting…"
|
|
1138
|
+
// liveness row: the child surfaces its own live row in the same worker
|
|
1139
|
+
// feed, so an extra skeleton row for the parent is pure feed clutter
|
|
1140
|
+
// (fails the no-noise / never-storm bar). The discriminator is
|
|
1141
|
+
// deliberately NOT "0 own tools" — a leaf that registers and BLOCKS on
|
|
1142
|
+
// its very first tool has 0 completed tools and MUST still paint.
|
|
1143
|
+
// Instead, suppress when THIS entry has EVER dispatched a child (any
|
|
1144
|
+
// child registry row keyed by parent_agent_id = this entry's jsonl
|
|
1145
|
+
// agentId; recordNestedSubagentDispatch stamps it). "Ever", not "a
|
|
1146
|
+
// currently-running child": the skeleton cue is only the NO-GROWTH
|
|
1147
|
+
// fallback, so suppressing it for an orchestrator never hides real
|
|
1148
|
+
// work — if the parent does its own tools, those fire real growth
|
|
1149
|
+
// cues and paint the row; if it only orchestrates, its children carry
|
|
1150
|
+
// the liveness. Using "currently running" instead would re-paint a
|
|
1151
|
+
// spurious orchestrator "starting…" the moment its child finished. A
|
|
1152
|
+
// genuine leaf has no child row at all, so it keeps firing the
|
|
1153
|
+
// skeleton cue and paints promptly — the 205s-blackout class is intact.
|
|
1154
|
+
let hasChild = false
|
|
1155
|
+
if (db != null) {
|
|
1156
|
+
try {
|
|
1157
|
+
const kid = db
|
|
1158
|
+
.prepare(
|
|
1159
|
+
'SELECT 1 FROM subagents WHERE parent_agent_id = ? LIMIT 1',
|
|
1160
|
+
)
|
|
1161
|
+
.get(entry.agentId)
|
|
1162
|
+
hasChild = kid != null
|
|
1163
|
+
} catch (kidErr) {
|
|
1164
|
+
// Best-effort: an absent/failed linkage read is treated as "leaf"
|
|
1165
|
+
// so we never suppress a genuine blackout paint on a DB hiccup.
|
|
1166
|
+
log?.(`subagent-watcher: skeleton child-check error ${entry.agentId}: ${(kidErr as Error).message}`)
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
if (!hasChild) {
|
|
1170
|
+
try {
|
|
1171
|
+
onProgress({
|
|
1172
|
+
agentId: entry.agentId,
|
|
1173
|
+
description: entry.description,
|
|
1174
|
+
latestSummary: '',
|
|
1175
|
+
elapsedMs: now - entry.dispatchedAt,
|
|
1176
|
+
prevBucketIdx: entry.lastProgressBucketIdx,
|
|
1177
|
+
setBucketIdx: (b: number) => {
|
|
1178
|
+
entry.lastProgressBucketIdx = b
|
|
1179
|
+
},
|
|
1180
|
+
lastTool: entry.lastTool,
|
|
1181
|
+
toolCount: entry.toolCount,
|
|
1182
|
+
model: entry.currentModel,
|
|
1183
|
+
skeleton: true,
|
|
1184
|
+
})
|
|
1185
|
+
} catch (cbErr) {
|
|
1186
|
+
log?.(`subagent-watcher: onProgress (skeleton) callback error ${entry.agentId}: ${(cbErr as Error).message}`)
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
return
|
|
1191
|
+
}
|
|
1107
1192
|
|
|
1108
1193
|
const buf = Buffer.alloc(stat.size - tail.cursor)
|
|
1109
1194
|
const fd = fs.openSync(entry.filePath, 'r')
|