switchroom 0.20.3 → 0.20.5
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 +10256 -1233
- package/dist/host-control/main.js +1 -1
- package/package.json +3 -3
- package/skills/switchroom-release/SKILL.md +3 -2
- package/telegram-plugin/dist/gateway/gateway.js +452 -99
- package/telegram-plugin/edit-flood-fuse.ts +332 -14
- package/telegram-plugin/gateway/feed-open-gate.ts +28 -8
- package/telegram-plugin/gateway/feed-reopen-gate.ts +88 -6
- package/telegram-plugin/gateway/gateway.ts +130 -104
- package/telegram-plugin/gateway/narrative-lane.ts +19 -0
- package/telegram-plugin/gateway/progress-fallback-cap.ts +195 -0
- package/telegram-plugin/gateway/stream-render.ts +67 -12
- package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +13 -0
- package/telegram-plugin/gateway/subagent-origin-surface.ts +181 -0
- package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +7 -0
- package/telegram-plugin/registry/subagents-schema.ts +61 -0
- package/telegram-plugin/registry/turns-schema.ts +26 -0
- package/telegram-plugin/silence-poke.ts +80 -0
- package/telegram-plugin/tests/edit-flood-fuse-cosmetic-fairness.test.ts +229 -0
- package/telegram-plugin/tests/feed-open-gate.test.ts +42 -0
- package/telegram-plugin/tests/feed-reopen-gate.test.ts +114 -0
- package/telegram-plugin/tests/progress-cap.test.ts +182 -0
- package/telegram-plugin/tests/progress-fallback-cap.test.ts +91 -0
- package/telegram-plugin/tests/progress-update.test.ts +108 -12
- package/telegram-plugin/tests/silence-poke-card-render.test.ts +300 -0
- package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +46 -0
- package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +26 -0
- package/telegram-plugin/tests/worker-origin-gap-dispatch.test.ts +304 -0
|
@@ -64,6 +64,7 @@ import { backstopAlreadyDelivered } from '../outbox.js'
|
|
|
64
64
|
import { journalExternalDelivery } from './outbox-sweep.js'
|
|
65
65
|
import { NarrativeFlushController } from '../narrative-flush.js'
|
|
66
66
|
import { recordTurnEnd, recordTurnStart } from '../registry/turns-schema.js'
|
|
67
|
+
import { stampSubagentDispatchTurn } from '../registry/subagents-schema.js'
|
|
67
68
|
import { retryWithThreadFallback } from '../retry-api-call.js'
|
|
68
69
|
import { richMessage } from '../rich-send.js'
|
|
69
70
|
import { emitRuntimeMetric } from '../runtime-metrics.js'
|
|
@@ -604,6 +605,10 @@ function beginTurn(deps: StreamRenderDeps, ev: TurnStartEnvelope): void {
|
|
|
604
605
|
replyCalled: false,
|
|
605
606
|
finalAnswerDelivered: false,
|
|
606
607
|
finalAnswerSubstantive: false,
|
|
608
|
+
// Post-substantive feed-reopen counter — reset each turn.
|
|
609
|
+
postSubstantiveToolLabelCount: 0,
|
|
610
|
+
// Post-substantive lever-1 lift latch — reset each turn.
|
|
611
|
+
postAnswerMainActivity: false,
|
|
607
612
|
// Sticky latch — reset ONLY here (turn start), never by reopen.
|
|
608
613
|
finalAnswerEverDelivered: false,
|
|
609
614
|
// 2026-07 double-reply-on-DM fix (Part 2) — answer-delivered race
|
|
@@ -879,6 +884,7 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
879
884
|
CONTEXT_EXHAUSTION_COOLDOWN_MS,
|
|
880
885
|
DELIVERY_CONFIRM_ENABLED,
|
|
881
886
|
FEED_REOPEN_AFTER_ACK_ENABLED,
|
|
887
|
+
FEED_REOPEN_AFTER_SUBSTANTIVE_ENABLED,
|
|
882
888
|
HANDBACK_PRETURN_ENABLED,
|
|
883
889
|
HISTORY_ENABLED,
|
|
884
890
|
LIVENESS_TERMINAL_HONESTY,
|
|
@@ -1250,6 +1256,35 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
1250
1256
|
// failure mode #116 originally tracked) emit no more tool_use
|
|
1251
1257
|
// events, so the marker mtime stops advancing → watchdog acts.
|
|
1252
1258
|
touchTurnActiveMarker(STATE_DIR)
|
|
1259
|
+
// Dispatch-time parent_turn_key stamp from the GATEWAY's live turn
|
|
1260
|
+
// context (Telegram msg 6897 misroute, 2026-08-04). The pretool hook's
|
|
1261
|
+
// #2085 stamp reads the turn-active marker FILE, and the watcher's
|
|
1262
|
+
// backfill needs a turns row whose window contains the dispatch — both
|
|
1263
|
+
// can miss at once (marker swept, hook write lost to SQLITE_BUSY, a
|
|
1264
|
+
// turn whose surface registration failed). The gateway observing this
|
|
1265
|
+
// Agent/Task tool_use inside a live turn KNOWS the turn key directly:
|
|
1266
|
+
// stamp it marker-free. COALESCE inside the helper — a hook-stamped
|
|
1267
|
+
// value is never overwritten, so normal in-turn dispatch is unchanged.
|
|
1268
|
+
if (
|
|
1269
|
+
(ev.toolName === 'Agent' || ev.toolName === 'Task') &&
|
|
1270
|
+
ev.toolUseId != null && ev.toolUseId.length > 0 &&
|
|
1271
|
+
turn.registryKey != null && turnsDb != null
|
|
1272
|
+
) {
|
|
1273
|
+
try {
|
|
1274
|
+
stampSubagentDispatchTurn(turnsDb, {
|
|
1275
|
+
toolUseId: ev.toolUseId,
|
|
1276
|
+
parentTurnKey: turn.registryKey,
|
|
1277
|
+
agentType: typeof ev.input?.subagent_type === 'string' ? ev.input.subagent_type : null,
|
|
1278
|
+
description: typeof ev.input?.description === 'string' ? ev.input.description : null,
|
|
1279
|
+
background: ev.input?.run_in_background === true,
|
|
1280
|
+
now: Date.now(),
|
|
1281
|
+
})
|
|
1282
|
+
} catch (err) {
|
|
1283
|
+
process.stderr.write(
|
|
1284
|
+
`telegram gateway: dispatch-time parent_turn_key stamp failed toolUseId=${ev.toolUseId}: ${(err as Error).message}\n`,
|
|
1285
|
+
)
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1253
1288
|
// #549 fix: a tool_use immediately following text events makes
|
|
1254
1289
|
// those texts "preamble" — the progress card already captured
|
|
1255
1290
|
// them as a narrative for this tool. Drop the pending answer-
|
|
@@ -1377,25 +1412,45 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
1377
1412
|
// liveness is the bounded no-reply timer's job) and lets the silent-end
|
|
1378
1413
|
// re-prompt fire if the turn ends on only an ack.
|
|
1379
1414
|
// Kill switch SWITCHROOM_FEED_REOPEN_AFTER_ACK=0 → legacy `return`.
|
|
1415
|
+
//
|
|
1416
|
+
// POST-SUBSTANTIVE reopen: a turn that delivered a real answer and then
|
|
1417
|
+
// kept doing tool work reopens the feed once >= SUBSTANTIVE_REOPEN_MIN_
|
|
1418
|
+
// LABELS post-answer labels arrive — WITHOUT clearing finalAnswerDelivered
|
|
1419
|
+
// (that would trip the turn-end re-prompt → duplicate answer). Instead the
|
|
1420
|
+
// decision returns liftLeverOne, and the drain below opens the fresh card
|
|
1421
|
+
// below the reply via `postAnswerMainActivity` (the foreground sibling of
|
|
1422
|
+
// the sub-agent post-answer liveness exemption). Kill switch
|
|
1423
|
+
// SWITCHROOM_FEED_REOPEN_AFTER_SUBSTANTIVE=0 → post-substantive labels stay
|
|
1424
|
+
// dropped (legacy).
|
|
1380
1425
|
if (turn.finalAnswerDelivered) {
|
|
1381
|
-
//
|
|
1382
|
-
//
|
|
1383
|
-
|
|
1384
|
-
// (
|
|
1385
|
-
//
|
|
1386
|
-
//
|
|
1426
|
+
// Count post-substantive-answer labels so the reopen fires only once the
|
|
1427
|
+
// turn is plainly still working (>=2), not on a stray housekeeping tool.
|
|
1428
|
+
if (turn.finalAnswerSubstantive) turn.postSubstantiveToolLabelCount++
|
|
1429
|
+
// decideFeedReopen returns dropLabel (legacy return), or — on the ACK
|
|
1430
|
+
// path — the reset deltas (finalAnswerDelivered→false, a FRESH feed
|
|
1431
|
+
// message, last-sent cleared), or — on the SUBSTANTIVE path — no reset
|
|
1432
|
+
// but liftLeverOne=true (keep finalAnswerDelivered true, open the card
|
|
1433
|
+
// below the reply).
|
|
1387
1434
|
const reopen = decideFeedReopen({
|
|
1388
1435
|
finalAnswerDelivered: turn.finalAnswerDelivered,
|
|
1389
|
-
// ACK-ONLY: reopen only when the prior final was a short ack, not a
|
|
1390
|
-
// substantive answer — otherwise post-answer housekeeping would
|
|
1391
|
-
// reset finalAnswerDelivered and trip the silent-end re-prompt.
|
|
1392
1436
|
finalAnswerSubstantive: turn.finalAnswerSubstantive,
|
|
1393
1437
|
enabled: FEED_REOPEN_AFTER_ACK_ENABLED,
|
|
1438
|
+
reopenAfterSubstantiveEnabled: FEED_REOPEN_AFTER_SUBSTANTIVE_ENABLED,
|
|
1439
|
+
postSubstantiveToolLabelCount: turn.postSubstantiveToolLabelCount,
|
|
1394
1440
|
})
|
|
1395
1441
|
if (reopen.dropLabel) return
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1442
|
+
// ACK reopen carries a reset; the substantive reopen deliberately does
|
|
1443
|
+
// not (finalAnswerDelivered stays true, activityMessageId is already
|
|
1444
|
+
// null from the answer's clearActivitySummary).
|
|
1445
|
+
if (reopen.reset != null) {
|
|
1446
|
+
turn.finalAnswerDelivered = reopen.reset.finalAnswerDelivered
|
|
1447
|
+
turn.activityMessageId = reopen.reset.activityMessageId
|
|
1448
|
+
turn.activityLastSentRender = reopen.reset.activityLastSentRender
|
|
1449
|
+
}
|
|
1450
|
+
// Post-substantive reopen: latch the lever-1 lift so the drain (which
|
|
1451
|
+
// reads this off `turn`) opens the fresh card below the delivered reply.
|
|
1452
|
+
// Sticky for the rest of the turn — the model stays "still working".
|
|
1453
|
+
if (reopen.liftLeverOne) turn.postAnswerMainActivity = true
|
|
1399
1454
|
}
|
|
1400
1455
|
const rendered = appendActivityLabel(turn.mirrorLines, ev.label)
|
|
1401
1456
|
if (rendered != null) {
|
|
@@ -115,6 +115,19 @@ export function buildSubagentHandbackInbound(opts: {
|
|
|
115
115
|
meta: {
|
|
116
116
|
source: 'subagent_handback',
|
|
117
117
|
outcome: opts.ctx.outcome,
|
|
118
|
+
// Carry the originating chat as a model-visible channel attribute
|
|
119
|
+
// (mirrors the real-inbound + resume_interrupted shapes — see
|
|
120
|
+
// inbound-router.ts:buildInboundEnvelope and resume-inbound-builder.ts).
|
|
121
|
+
// LOAD-BEARING for turn registration: the gateway's enqueue handler
|
|
122
|
+
// (`beginTurn`, stream-render.ts) gates the ENTIRE turn-atom mint on
|
|
123
|
+
// `ev.chatId`, which is parsed from the channel XML's `chat_id`
|
|
124
|
+
// attribute — rendered ONLY from meta. Without it a handback turn gets
|
|
125
|
+
// NO `turns` row and NO `turn-active.json` marker, so a worker
|
|
126
|
+
// dispatched from inside that turn cannot be stamped with a
|
|
127
|
+
// `parent_turn_key` at dispatch (#2085) nor attributed by the
|
|
128
|
+
// started_at window backfill — its card + handback then misroute to
|
|
129
|
+
// the owner DM (Telegram msg 6897 incident, 2026-08-04).
|
|
130
|
+
chat_id: opts.ctx.chatId,
|
|
118
131
|
// #3268 — round-trip the fabricated `ts` through `meta.message_id` so it
|
|
119
132
|
// survives to enqueue. `ev.messageId` at enqueue is parsed from the
|
|
120
133
|
// channel envelope's `message_id` attribute, which is rendered ONLY from
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-agent surface-chat resolution — the DB-backed seam behind the
|
|
3
|
+
* gateway's `resolveSubagentOriginChat` / `resolveWorkerFeedChat` and the
|
|
4
|
+
* handback/progress destination inputs.
|
|
5
|
+
*
|
|
6
|
+
* Extracted (Telegram msg 6897 misroute, 2026-08-04) so the FULL resolution
|
|
7
|
+
* precedence — including the new most-recent-turn floor — is unit-testable
|
|
8
|
+
* against a real registry DB (gateway.ts is not importable in tests; the
|
|
9
|
+
* repo's `decideWorkerFeedDestination` pattern).
|
|
10
|
+
*
|
|
11
|
+
* Precedence (resolveWorkerSurfaceChat):
|
|
12
|
+
* 1. `origin` — the dispatch-attributed turn: jsonl_agent_id →
|
|
13
|
+
* subagents.parent_turn_key (walking the nested-parent chain) →
|
|
14
|
+
* turns.chat_id/thread_id. The correct answer whenever the row was
|
|
15
|
+
* stamped (pretool-hook marker stamp #2085, gateway dispatch stamp, or
|
|
16
|
+
* the watcher's started_at window backfill).
|
|
17
|
+
* 2. `fleet` — the configured fleet chat (permanently '' today).
|
|
18
|
+
* 3. `recent-turn` — NEW safe-degradation floor: the chat+topic of the
|
|
19
|
+
* most recent turn AT OR BEFORE the worker's dispatch time. A
|
|
20
|
+
* gap-dispatched worker (no turns row open at its started_at, stamp
|
|
21
|
+
* missed) lands NEAR the work — in the topic the operator drove last
|
|
22
|
+
* before dispatching — instead of the owner DM with the thread
|
|
23
|
+
* stripped. Bounded by the dispatch time so a turn the operator drives
|
|
24
|
+
* LATER in an unrelated (possibly shared) chat can never capture the
|
|
25
|
+
* worker's surface; with no pre-dispatch turn (or no subagents row to
|
|
26
|
+
* date the dispatch from) the floor declines and resolution falls to
|
|
27
|
+
* the owner DM.
|
|
28
|
+
* 4. `owner-dm` — the durable last resort ("wrong chat" beats
|
|
29
|
+
* "no card").
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
getTurnByKey,
|
|
34
|
+
findMostRecentTurn,
|
|
35
|
+
type Turn,
|
|
36
|
+
} from '../registry/turns-schema.js'
|
|
37
|
+
import {
|
|
38
|
+
resolveSubagentOriginTurnKey,
|
|
39
|
+
getSubagentByJsonlId,
|
|
40
|
+
} from '../registry/subagents-schema.js'
|
|
41
|
+
|
|
42
|
+
/** Structural sqlite shape — byte-matches the registry modules' own
|
|
43
|
+
* bun:sqlite structural type, so handles flow both ways without casts. */
|
|
44
|
+
type SqliteDatabase = {
|
|
45
|
+
exec(sql: string): void
|
|
46
|
+
prepare(sql: string): {
|
|
47
|
+
run(...params: unknown[]): unknown
|
|
48
|
+
all(...params: unknown[]): unknown[]
|
|
49
|
+
get(...params: unknown[]): unknown
|
|
50
|
+
}
|
|
51
|
+
transaction(fn: (...args: unknown[]) => unknown): (...args: unknown[]) => unknown
|
|
52
|
+
close(): void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SubagentSurfaceChat {
|
|
56
|
+
chatId: string
|
|
57
|
+
threadId?: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type SubagentSurfaceVia = 'origin' | 'fleet' | 'recent-turn' | 'owner-dm' | 'none'
|
|
61
|
+
|
|
62
|
+
/** Map a turns row to a routable {chatId, threadId}. Null when the row has
|
|
63
|
+
* no usable chat. thread_id is stored as TEXT — parse defensively. */
|
|
64
|
+
function turnToSurfaceChat(turn: Pick<Turn, 'chat_id' | 'thread_id'> | null): SubagentSurfaceChat | null {
|
|
65
|
+
if (turn == null || turn.chat_id.length === 0) return null
|
|
66
|
+
const threadNum =
|
|
67
|
+
turn.thread_id != null && turn.thread_id.length > 0 ? Number(turn.thread_id) : NaN
|
|
68
|
+
return {
|
|
69
|
+
chatId: turn.chat_id,
|
|
70
|
+
...(Number.isFinite(threadNum) ? { threadId: threadNum } : {}),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The dispatch-attributed origin chat for a worker (jsonl stem →
|
|
76
|
+
* parent_turn_key chain → turns row). Null on any miss; never throws.
|
|
77
|
+
* The DB-parameterized body of the gateway's `resolveSubagentOriginChat`.
|
|
78
|
+
*/
|
|
79
|
+
export function resolveSubagentOriginChatDb(
|
|
80
|
+
db: SqliteDatabase,
|
|
81
|
+
jsonlAgentId: string,
|
|
82
|
+
): SubagentSurfaceChat | null {
|
|
83
|
+
try {
|
|
84
|
+
const originKey = resolveSubagentOriginTurnKey(db, jsonlAgentId)
|
|
85
|
+
if (originKey == null) return null
|
|
86
|
+
return turnToSurfaceChat(getTurnByKey(db, originKey))
|
|
87
|
+
} catch {
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The recent-turn routing floor: chat+topic of the newest turns row
|
|
94
|
+
* (running or ended) started AT OR BEFORE the worker's own dispatch time
|
|
95
|
+
* (`subagents.started_at`, looked up by jsonl stem). The bound is
|
|
96
|
+
* load-bearing (see `findMostRecentTurn`): a turn newer than the dispatch
|
|
97
|
+
* can never be the dispatching context, so flooring to it would leak the
|
|
98
|
+
* worker's surface into whatever unrelated chat the operator drove LAST.
|
|
99
|
+
* Null when the worker has no subagents row (no dispatch time to bound by —
|
|
100
|
+
* declining beats guessing), no pre-dispatch turn exists, or the row is
|
|
101
|
+
* unusable; never throws.
|
|
102
|
+
*/
|
|
103
|
+
export function resolveRecentTurnFallbackChat(
|
|
104
|
+
db: SqliteDatabase,
|
|
105
|
+
jsonlAgentId: string,
|
|
106
|
+
): SubagentSurfaceChat | null {
|
|
107
|
+
try {
|
|
108
|
+
const worker = getSubagentByJsonlId(db, jsonlAgentId)
|
|
109
|
+
if (worker == null) return null
|
|
110
|
+
return turnToSurfaceChat(findMostRecentTurn(db, worker.started_at))
|
|
111
|
+
} catch {
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Once-per-agent recent-turn-floor audit line (msg-6897 hardening). The floor
|
|
118
|
+
* firing is deliberate safe degradation, but it must never be silent — an
|
|
119
|
+
* operator diagnosing a mis-landed worker surface needs the routing decision
|
|
120
|
+
* on stderr, exactly like the gateway's owner-DM fallback log. Bounded FIFO
|
|
121
|
+
* set (a late duplicate line is harmless; the cap bounds a long gateway
|
|
122
|
+
* lifetime); a gateway restart clears it. `log` injectable for tests.
|
|
123
|
+
*/
|
|
124
|
+
const RECENT_TURN_FLOOR_LOG_CAP = 256
|
|
125
|
+
const recentTurnFloorLogged = new Set<string>()
|
|
126
|
+
export function noteWorkerRecentTurnFloor(
|
|
127
|
+
agentId: string,
|
|
128
|
+
dest: SubagentSurfaceChat,
|
|
129
|
+
log: (line: string) => void = (line) => process.stderr.write(line),
|
|
130
|
+
): void {
|
|
131
|
+
if (recentTurnFloorLogged.has(agentId)) return
|
|
132
|
+
recentTurnFloorLogged.add(agentId)
|
|
133
|
+
if (recentTurnFloorLogged.size > RECENT_TURN_FLOOR_LOG_CAP) {
|
|
134
|
+
const oldest = recentTurnFloorLogged.values().next().value
|
|
135
|
+
if (oldest != null) recentTurnFloorLogged.delete(oldest)
|
|
136
|
+
}
|
|
137
|
+
log(
|
|
138
|
+
`telegram gateway: worker origin unresolved agent=${agentId} — flooring to pre-dispatch recent turn chat=${dest.chatId}${dest.threadId != null ? ` thread=${dest.threadId}` : ''}\n`,
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The full ladder resolved into the SHAPE the handback/progress deciders
|
|
144
|
+
* take (`decideSubagentHandback` / `decideSubagentProgress`): the resolved
|
|
145
|
+
* surface chat in their `fleetChatId` slot ('' on owner-dm/none so each
|
|
146
|
+
* decider's own `ownerChatId` floor stays the last resort) and the resolved
|
|
147
|
+
* topic in `originThreadId` (omitted when none). Audits the recent-turn
|
|
148
|
+
* floor when it fires. One helper so the gateway call sites are a spread,
|
|
149
|
+
* not a third and fourth hand-rolled copy of the precedence.
|
|
150
|
+
*/
|
|
151
|
+
export function resolveWorkerSurfaceForDecider(
|
|
152
|
+
db: SqliteDatabase | null,
|
|
153
|
+
jsonlAgentId: string,
|
|
154
|
+
opts: { fleetChatId: string; ownerDm: string },
|
|
155
|
+
): { fleetChatId: string; originThreadId?: number } {
|
|
156
|
+
const dest = resolveWorkerSurfaceChat(db, jsonlAgentId, opts)
|
|
157
|
+
if (dest.via === 'recent-turn') noteWorkerRecentTurnFloor(jsonlAgentId, dest)
|
|
158
|
+
return {
|
|
159
|
+
fleetChatId: dest.via === 'owner-dm' || dest.via === 'none' ? '' : dest.chatId,
|
|
160
|
+
...(dest.threadId != null ? { originThreadId: dest.threadId } : {}),
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Full surface-chat precedence for a worker's card/handback/progress
|
|
166
|
+
* destination. `db` may be null (turn registry disabled) — resolution then
|
|
167
|
+
* degrades to fleet → owner DM exactly as before the recent-turn floor.
|
|
168
|
+
*/
|
|
169
|
+
export function resolveWorkerSurfaceChat(
|
|
170
|
+
db: SqliteDatabase | null,
|
|
171
|
+
jsonlAgentId: string,
|
|
172
|
+
opts: { fleetChatId: string; ownerDm: string },
|
|
173
|
+
): { chatId: string; threadId?: number; via: SubagentSurfaceVia } {
|
|
174
|
+
const origin = db != null ? resolveSubagentOriginChatDb(db, jsonlAgentId) : null
|
|
175
|
+
if (origin != null && origin.chatId.length > 0) return { ...origin, via: 'origin' }
|
|
176
|
+
if (opts.fleetChatId.length > 0) return { chatId: opts.fleetChatId, via: 'fleet' }
|
|
177
|
+
const recent = db != null ? resolveRecentTurnFallbackChat(db, jsonlAgentId) : null
|
|
178
|
+
if (recent != null) return { ...recent, via: 'recent-turn' }
|
|
179
|
+
if (opts.ownerDm.length > 0) return { chatId: opts.ownerDm, via: 'owner-dm' }
|
|
180
|
+
return { chatId: '', via: 'none' }
|
|
181
|
+
}
|
|
@@ -137,6 +137,13 @@ export function buildSubagentProgressInbound(opts: {
|
|
|
137
137
|
text,
|
|
138
138
|
meta: {
|
|
139
139
|
source: 'subagent_progress',
|
|
140
|
+
// Originating chat as a model-visible channel attribute — same
|
|
141
|
+
// load-bearing turn-registration role as the handback builder's
|
|
142
|
+
// meta.chat_id (see subagent-handback-inbound-builder.ts): without it
|
|
143
|
+
// the progress turn mints no turn atom (no `turns` row, no
|
|
144
|
+
// `turn-active.json`), so a worker dispatched from inside it can never
|
|
145
|
+
// be attributed to this chat/topic and its surfaces fall to the DM.
|
|
146
|
+
chat_id: opts.ctx.chatId,
|
|
140
147
|
...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
|
|
141
148
|
subagent_jsonl_id: opts.ctx.subagentJsonlId,
|
|
142
149
|
bucket_idx: String(opts.ctx.bucketIdx),
|
|
@@ -784,6 +784,67 @@ export function recordNestedSubagentDispatch(
|
|
|
784
784
|
`).run(args.parentJsonlAgentId, args.parentJsonlAgentId, args.toolUseId)
|
|
785
785
|
}
|
|
786
786
|
|
|
787
|
+
export interface StampSubagentDispatchTurnArgs {
|
|
788
|
+
/** tool_use id of the Agent/Task dispatch — the subagents PK the pretool
|
|
789
|
+
* hook inserts (or will insert) the row under. */
|
|
790
|
+
toolUseId: string
|
|
791
|
+
/** The live turn's registry key (`turns.turn_key`) at dispatch time. */
|
|
792
|
+
parentTurnKey: string
|
|
793
|
+
agentType?: string | null
|
|
794
|
+
description?: string | null
|
|
795
|
+
/** `run_in_background` from the dispatch's tool_input. */
|
|
796
|
+
background: boolean
|
|
797
|
+
now: number
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Stamp `parent_turn_key` on a depth-1 sub-agent row at DISPATCH time, from
|
|
802
|
+
* the gateway's own live turn context (the `tool_use` session event for an
|
|
803
|
+
* Agent/Task dispatch observed while a turn atom is open).
|
|
804
|
+
*
|
|
805
|
+
* Why this exists (Telegram msg 6897 misroute, 2026-08-04): the pretool
|
|
806
|
+
* hook's dispatch-time stamp (#2085) sources the turn key from the
|
|
807
|
+
* `turn-active.json` marker file, and the watcher's backfill
|
|
808
|
+
* (subagent-watcher.ts) sources it from a `turns` row whose
|
|
809
|
+
* [started_at, ended_at] window contains the dispatch. Both sources can be
|
|
810
|
+
* missing at once — a synthesized turn that never registered its surface, a
|
|
811
|
+
* swept/corrupted marker, a hook write lost to SQLITE_BUSY — leaving
|
|
812
|
+
* `parent_turn_key` NULL and the worker's card + handback falling back to
|
|
813
|
+
* the owner DM with the thread stripped. The gateway, however, KNOWS the
|
|
814
|
+
* live turn key when it observes the dispatch: this helper writes it
|
|
815
|
+
* directly, marker-free and window-free.
|
|
816
|
+
*
|
|
817
|
+
* Behaviour (idempotent, mirrors recordNestedSubagentDispatch):
|
|
818
|
+
* - INSERT OR IGNORE a row keyed on the dispatch tool_use_id (harmless
|
|
819
|
+
* no-op when the pretool hook's row already landed — the common case).
|
|
820
|
+
* - UPDATE `parent_turn_key` only when NULL (COALESCE), so a value the
|
|
821
|
+
* hook already stamped from the live marker is never overwritten.
|
|
822
|
+
*/
|
|
823
|
+
export function stampSubagentDispatchTurn(
|
|
824
|
+
db: SqliteDatabase,
|
|
825
|
+
args: StampSubagentDispatchTurnArgs,
|
|
826
|
+
): void {
|
|
827
|
+
db.prepare(`
|
|
828
|
+
INSERT OR IGNORE INTO subagents
|
|
829
|
+
(id, parent_session_id, parent_turn_key, agent_type, description,
|
|
830
|
+
background, started_at, last_activity_at, status)
|
|
831
|
+
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, 'running')
|
|
832
|
+
`).run(
|
|
833
|
+
args.toolUseId,
|
|
834
|
+
args.parentTurnKey,
|
|
835
|
+
args.agentType ?? null,
|
|
836
|
+
args.description ?? null,
|
|
837
|
+
args.background ? 1 : 0,
|
|
838
|
+
args.now,
|
|
839
|
+
args.now,
|
|
840
|
+
)
|
|
841
|
+
db.prepare(`
|
|
842
|
+
UPDATE subagents
|
|
843
|
+
SET parent_turn_key = COALESCE(parent_turn_key, ?)
|
|
844
|
+
WHERE id = ?
|
|
845
|
+
`).run(args.parentTurnKey, args.toolUseId)
|
|
846
|
+
}
|
|
847
|
+
|
|
787
848
|
/**
|
|
788
849
|
* Resolve the ORIGIN turn key for a worker, walking the nested-parent chain.
|
|
789
850
|
*
|
|
@@ -673,6 +673,32 @@ export function findRecentTurnsForChat(
|
|
|
673
673
|
return rows.map(mapRow)
|
|
674
674
|
}
|
|
675
675
|
|
|
676
|
+
/**
|
|
677
|
+
* Return the most recent turn across ALL chats (any state — running or
|
|
678
|
+
* ended) whose `started_at` is at or before `beforeOrAtMs`, or null when no
|
|
679
|
+
* such turn exists. The last-resort routing floor for an unattributable
|
|
680
|
+
* sub-agent's surfaces (worker card / handback): when origin resolution
|
|
681
|
+
* misses (parent_turn_key never stamped), the last turn BEFORE the worker's
|
|
682
|
+
* dispatch is where the operator dispatched it from — a strictly better
|
|
683
|
+
* landing spot than the owner DM with the thread stripped.
|
|
684
|
+
*
|
|
685
|
+
* The bound is load-bearing, not an optimisation: an UNBOUNDED "newest turn"
|
|
686
|
+
* floor routes a worker's private result to wherever the operator chatted
|
|
687
|
+
* LAST — possibly an unrelated, shared group entered after the dispatch. A
|
|
688
|
+
* turn newer than the dispatch can never be the dispatching context, so it
|
|
689
|
+
* is never a valid floor; callers with no pre-dispatch turn fall through to
|
|
690
|
+
* the owner-DM last resort instead.
|
|
691
|
+
*/
|
|
692
|
+
export function findMostRecentTurn(db: SqliteDatabase, beforeOrAtMs: number): Turn | null {
|
|
693
|
+
const row = db.prepare(`
|
|
694
|
+
SELECT * FROM turns
|
|
695
|
+
WHERE started_at <= ?
|
|
696
|
+
ORDER BY started_at DESC
|
|
697
|
+
LIMIT 1
|
|
698
|
+
`).get(beforeOrAtMs) as RawTurnRow | undefined
|
|
699
|
+
return row ? mapRow(row) : null
|
|
700
|
+
}
|
|
701
|
+
|
|
676
702
|
/**
|
|
677
703
|
* Return the most recent N turns across all chats for an agent, ordered by
|
|
678
704
|
* started_at DESC. Intended for the REST API endpoint
|
|
@@ -28,6 +28,14 @@
|
|
|
28
28
|
* is visibly running; nulling `currentTurn` there would darken the very feed the
|
|
29
29
|
* user is watching. A turn with no in-flight tool is unaffected.
|
|
30
30
|
*
|
|
31
|
+
* #4330 caveat: an activity-card render that LANDED on Telegram within
|
|
32
|
+
* `CARD_RENDER_FRESH_MS` (stamped via `noteCardRender` from the card-drain
|
|
33
|
+
* transport) likewise DEFERS the terminal unwedge, bounded by the same hard
|
|
34
|
+
* ceiling — a visibly-updating pinned "→ Working…" card is user-visible
|
|
35
|
+
* activity, and killing the turn under it is a false positive. It never
|
|
36
|
+
* resets the clock: the framework heartbeat climbs the card on pure wall
|
|
37
|
+
* clock even on a hung turn, so a reset would pin a dead turn forever.
|
|
38
|
+
*
|
|
31
39
|
* Terminal action, once per turn:
|
|
32
40
|
*
|
|
33
41
|
* t=0 startTurn() — silence clock starts at turnStartedAt
|
|
@@ -110,6 +118,21 @@ export interface SilencePokeState {
|
|
|
110
118
|
* silence clock — a real reply / feed edit still does that; this only
|
|
111
119
|
* gates the terminal teardown. */
|
|
112
120
|
sawBashThisTurn: boolean
|
|
121
|
+
/**
|
|
122
|
+
* #4330: wall-clock ms of the last activity-card render (open or in-place
|
|
123
|
+
* edit) that actually LANDED on Telegram for this turn — the pinned
|
|
124
|
+
* "→ Working… · Nm · N tools" status card the user is watching. Stamped by
|
|
125
|
+
* `noteCardRender` from the card-drain transport site
|
|
126
|
+
* (`gateway/narrative-lane.ts` drainActivitySummary), which covers every
|
|
127
|
+
* producer: tool labels, narrative SHOWs, the liveness open, and the
|
|
128
|
+
* framework heartbeat climb. Deliberately NOT a clock reset — the heartbeat
|
|
129
|
+
* climb re-renders on pure wall clock, so a hung turn's card keeps
|
|
130
|
+
* "updating" and an unbounded reset would pin a dead turn forever (the
|
|
131
|
+
* #1556 class). Instead the tick() DEFERS the terminal fallback while the
|
|
132
|
+
* card moved within `CARD_RENDER_FRESH_MS`, bounded by
|
|
133
|
+
* `fallbackHardCeiling` exactly like the in-flight-tool / compaction
|
|
134
|
+
* defers. null until the first card render of the turn. */
|
|
135
|
+
lastCardRenderAt: number | null
|
|
113
136
|
/**
|
|
114
137
|
* #3519 sharpen: claude-CLI background shells PROVEN alive right now. A
|
|
115
138
|
* shell is added on its launch marker (structured `backgroundTaskId`, via
|
|
@@ -153,6 +176,16 @@ export const DEFAULT_THRESHOLDS: ThresholdsMs = {
|
|
|
153
176
|
|
|
154
177
|
export const DEFAULT_POLL_INTERVAL_MS = 5_000
|
|
155
178
|
|
|
179
|
+
/**
|
|
180
|
+
* #4330 — how recent the last landed activity-card render must be for the
|
|
181
|
+
* card to count as "actively updating in front of the user". The framework
|
|
182
|
+
* heartbeat edits the card every ~6s (FEED_HEARTBEAT_MIN_STALE_MS); 30s
|
|
183
|
+
* tolerates several shed/coalesced cosmetic edits under flood-control
|
|
184
|
+
* backoff while still lapsing quickly once the card genuinely stops moving
|
|
185
|
+
* (turn ended → card cleared, or the drain wedged).
|
|
186
|
+
*/
|
|
187
|
+
export const CARD_RENDER_FRESH_MS = 30_000
|
|
188
|
+
|
|
156
189
|
export interface FrameworkFallbackContext {
|
|
157
190
|
key: string
|
|
158
191
|
chatId: string
|
|
@@ -326,6 +359,7 @@ export function startTurn(key: string, now: number): void {
|
|
|
326
359
|
fallbackFired: false,
|
|
327
360
|
floorFired: false,
|
|
328
361
|
inFlightTools: new Map(),
|
|
362
|
+
lastCardRenderAt: null,
|
|
329
363
|
sawBashThisTurn: false,
|
|
330
364
|
aliveShells: new Set(),
|
|
331
365
|
})
|
|
@@ -397,6 +431,36 @@ export function noteProduction(key: string, now: number): void {
|
|
|
397
431
|
s.fallbackFired = false
|
|
398
432
|
}
|
|
399
433
|
|
|
434
|
+
/**
|
|
435
|
+
* #4330: record an activity-card render (open or in-place edit) that actually
|
|
436
|
+
* LANDED on Telegram for `key` — the pinned "→ Working…" status card visibly
|
|
437
|
+
* moved. Called from the card-drain transport site
|
|
438
|
+
* (`gateway/narrative-lane.ts` drainActivitySummary) after a successful
|
|
439
|
+
* sendRichMessage / non-shed editMessageText, so it covers every card
|
|
440
|
+
* producer — tool labels, narrative SHOWs, the liveness open, AND the
|
|
441
|
+
* framework heartbeat climb (`feedHeartbeatTick`), which is exactly the path
|
|
442
|
+
* that has no `noteProduction` site.
|
|
443
|
+
*
|
|
444
|
+
* Deliberately does NOT reset the silence clock (`lastOutboundAt` untouched,
|
|
445
|
+
* `fallbackFired` untouched): the heartbeat climb renders on pure wall clock
|
|
446
|
+
* (`now - turn.startedAt`), so a genuinely hung turn's card keeps climbing
|
|
447
|
+
* forever — counting that as production would keep a dead turn alive
|
|
448
|
+
* indefinitely (the #1556 dangling-turn class the stream-render tool-label
|
|
449
|
+
* comment warns about). Instead, tick() DEFERS the terminal fallback while
|
|
450
|
+
* `now - lastCardRenderAt < CARD_RENDER_FRESH_MS`, bounded by
|
|
451
|
+
* `fallbackHardCeiling` — same semantics as the #1292/#3519/#4058 defers.
|
|
452
|
+
* Model-driven card progress (a NEW tool label, a foreground sub-agent nested
|
|
453
|
+
* render, a draft update) additionally resets the clock via the existing
|
|
454
|
+
* `noteProduction` sites; this stamp is the floor beneath them.
|
|
455
|
+
*
|
|
456
|
+
* No-op when the kill switch is on or the key has no live turn.
|
|
457
|
+
*/
|
|
458
|
+
export function noteCardRender(key: string, now: number): void {
|
|
459
|
+
const s = state.get(key)
|
|
460
|
+
if (s == null) return
|
|
461
|
+
s.lastCardRenderAt = now
|
|
462
|
+
}
|
|
463
|
+
|
|
400
464
|
/**
|
|
401
465
|
* Record a `thinking` session event. Used to pick "still thinking…" vs
|
|
402
466
|
* "still working…" wording for the 300s framework fallback.
|
|
@@ -801,6 +865,22 @@ function tick(now: number): void {
|
|
|
801
865
|
// gated by SWITCHROOM_SILENCE_DEFER_INFLIGHT_TOOLS=0 — that flag
|
|
802
866
|
// scopes the TOOL defers; compaction has no tool in flight.
|
|
803
867
|
if (activeDeps.isCompactionInFlight?.(key) === true) continue
|
|
868
|
+
// #4330 — the pinned activity card is visibly updating. A card render
|
|
869
|
+
// (open or edit) landed on Telegram within CARD_RENDER_FRESH_MS, so
|
|
870
|
+
// the user is watching a live "→ Working… · Nm · N tools" surface —
|
|
871
|
+
// the "silent to the user" premise does not hold, and tearing the
|
|
872
|
+
// turn down would kill the very card they are watching, then re-ask
|
|
873
|
+
// their message under it. DEFER (clock untouched, fallbackFired
|
|
874
|
+
// unset, re-checked next tick), bounded by the enclosing
|
|
875
|
+
// `underCeiling` guard: the heartbeat climb keeps a hung turn's card
|
|
876
|
+
// moving on pure wall clock, so a truly wedged turn still unwedges at
|
|
877
|
+
// the hard ceiling. Like the #4058 compaction defer, deliberately NOT
|
|
878
|
+
// gated by SWITCHROOM_SILENCE_DEFER_INFLIGHT_TOOLS=0 — that flag
|
|
879
|
+
// scopes the TOOL defers; a card render is not a tool signal.
|
|
880
|
+
if (
|
|
881
|
+
s.lastCardRenderAt != null &&
|
|
882
|
+
now - s.lastCardRenderAt < CARD_RENDER_FRESH_MS
|
|
883
|
+
) continue
|
|
804
884
|
const forceDisable = process.env.SWITCHROOM_SILENCE_DEFER_INFLIGHT_TOOLS === '0'
|
|
805
885
|
if (!forceDisable && activeDeps.isLegitimatelyWorking != null) {
|
|
806
886
|
if (activeDeps.isLegitimatelyWorking(key)) continue
|