switchroom 0.20.3 → 0.20.4
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 +9977 -957
- package/dist/host-control/main.js +1 -1
- package/package.json +3 -3
- package/telegram-plugin/dist/gateway/gateway.js +429 -98
- 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 +128 -104
- package/telegram-plugin/gateway/narrative-lane.ts +4 -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/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/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
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-`message_id` cosmetic fair-share (#4300).
|
|
3
|
+
*
|
|
4
|
+
* The bug: `cosmeticPerChatMaxPerWindow` (6/60s) is ONE bucket shared across
|
|
5
|
+
* every cosmetic surface in a chat — the primary activity card, a worker /
|
|
6
|
+
* sub-agent card, and answer-stream draft edits. Under intra-cosmetic
|
|
7
|
+
* contention (two live cards) — and especially once a 429 tightens that bucket
|
|
8
|
+
* — the surface the user is actually watching (the primary activity card) could
|
|
9
|
+
* be starved well past 60s between frames while the OTHER surface kept
|
|
10
|
+
* repainting. The fix carves a small AIMD-immune per-message floor OUT OF the
|
|
11
|
+
* same pool (no new wire rate, so 429 risk is unchanged) so no cosmetic surface
|
|
12
|
+
* can starve another, and a watched card keeps a minimum cadence under flood.
|
|
13
|
+
*
|
|
14
|
+
* These tests assert OUTCOMES (frames that actually reached the API within a
|
|
15
|
+
* window), not code paths, and pin the behaviour against the kill-switch so a
|
|
16
|
+
* revert of the core change fails at least one of them.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, it, expect } from 'vitest'
|
|
19
|
+
import { createEditFloodFuse, EDIT_FLOOD_FUSE_DEFAULTS } from '../edit-flood-fuse.js'
|
|
20
|
+
import type { Clock } from '../send-gate.js'
|
|
21
|
+
|
|
22
|
+
const CHAT = '5005'
|
|
23
|
+
const PRIMARY = 1 // the activity card the user watches
|
|
24
|
+
const WORKER = 2 // a second live cosmetic surface (sub-agent card / draft)
|
|
25
|
+
|
|
26
|
+
const D = EDIT_FLOOD_FUSE_DEFAULTS
|
|
27
|
+
|
|
28
|
+
class FakeClock implements Clock {
|
|
29
|
+
private cur = 0
|
|
30
|
+
private seq = 0
|
|
31
|
+
private timers: { at: number; id: number; resolve: () => void }[] = []
|
|
32
|
+
|
|
33
|
+
now(): number { return this.cur }
|
|
34
|
+
|
|
35
|
+
sleep(ms: number): Promise<void> {
|
|
36
|
+
return new Promise<void>((resolve) => {
|
|
37
|
+
this.timers.push({ at: this.cur + ms, id: this.seq++, resolve })
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async advance(ms: number): Promise<void> {
|
|
42
|
+
const target = this.cur + ms
|
|
43
|
+
for (;;) {
|
|
44
|
+
await flush()
|
|
45
|
+
const due = this.timers.filter((t) => t.at <= target).sort((a, b) => a.at - b.at || a.id - b.id)
|
|
46
|
+
if (due.length === 0) break
|
|
47
|
+
const t = due[0]!
|
|
48
|
+
this.timers = this.timers.filter((x) => x !== t)
|
|
49
|
+
this.cur = t.at
|
|
50
|
+
t.resolve()
|
|
51
|
+
await flush()
|
|
52
|
+
}
|
|
53
|
+
this.cur = target
|
|
54
|
+
await flush()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function flush(): Promise<void> {
|
|
59
|
+
return new Promise((r) => setImmediate(r))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const flood = () => Object.assign(
|
|
63
|
+
new Error('Too Many Requests: retry after 3'),
|
|
64
|
+
{ error_code: 429, parameters: { retry_after: 3 } },
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Drive `n` observed 429s so the ceilings tighten by `n` AIMD levels. The
|
|
69
|
+
* probes go to a SEPARATE chat: tightening is global, but a probe still charges
|
|
70
|
+
* the target chat's shared total window, and we don't want those synthetic
|
|
71
|
+
* sends to eat into CHAT's tightened cosmetic-total budget under test.
|
|
72
|
+
*/
|
|
73
|
+
async function tighten(fuse: ReturnType<typeof createEditFloodFuse>, n: number): Promise<void> {
|
|
74
|
+
for (let i = 0; i < n; i++) {
|
|
75
|
+
await fuse.apply('sendMessage', { chat_id: '9009', text: 'probe' }, async () => { throw flood() })
|
|
76
|
+
.then(() => { /* unreachable */ }, () => { /* the 429 is re-thrown; swallow */ })
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Run a contention scenario: WORKER repaints densely (every `workerEveryMs`)
|
|
82
|
+
* and PRIMARY repaints at a heartbeat cadence (every `primaryEveryMs`), both
|
|
83
|
+
* for `spanMs`. Returns how many frames of each landed strictly inside the
|
|
84
|
+
* first `perChatWindowMs` window.
|
|
85
|
+
*/
|
|
86
|
+
async function contend(
|
|
87
|
+
fuse: ReturnType<typeof createEditFloodFuse>, clock: FakeClock,
|
|
88
|
+
opts: { spanMs: number; workerEveryMs: number; primaryEveryMs: number },
|
|
89
|
+
): Promise<{ primary: number; worker: number }> {
|
|
90
|
+
const landed: { primary: number; worker: number } = { primary: 0, worker: 0 }
|
|
91
|
+
const inflight: Promise<unknown>[] = []
|
|
92
|
+
let nextWorker = 0
|
|
93
|
+
let nextPrimary = 3_000 // let the worker grab the pool first, as in the real stall
|
|
94
|
+
const step = 500
|
|
95
|
+
for (let t = 0; t <= opts.spanMs; t += step) {
|
|
96
|
+
if (t >= nextWorker) {
|
|
97
|
+
inflight.push(fuse.apply(
|
|
98
|
+
'editMessageText', { chat_id: CHAT, message_id: WORKER, text: `w${t}` },
|
|
99
|
+
async () => { if (clock.now() < D.perChatWindowMs) landed.worker++; return true },
|
|
100
|
+
))
|
|
101
|
+
nextWorker += opts.workerEveryMs
|
|
102
|
+
}
|
|
103
|
+
if (t >= nextPrimary) {
|
|
104
|
+
inflight.push(fuse.apply(
|
|
105
|
+
'editMessageText', { chat_id: CHAT, message_id: PRIMARY, text: `p${t}` },
|
|
106
|
+
async () => { if (clock.now() < D.perChatWindowMs) landed.primary++; return true },
|
|
107
|
+
))
|
|
108
|
+
nextPrimary += opts.primaryEveryMs
|
|
109
|
+
}
|
|
110
|
+
await clock.advance(step)
|
|
111
|
+
}
|
|
112
|
+
// Drain every still-deferred frame so no promise is left dangling.
|
|
113
|
+
await clock.advance(D.maxDeferMs + 1_000)
|
|
114
|
+
await Promise.all(inflight)
|
|
115
|
+
return landed
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
describe('#4300 cosmetic fair-share — a watched card is not starved by a second surface', () => {
|
|
119
|
+
it('(a) under flood tightening the primary card keeps its per-message floor while a worker card repaints', async () => {
|
|
120
|
+
// One 429 → AIMD level 1: the shared cosmetic pool ceiling(6) shrinks to 3,
|
|
121
|
+
// which two live cosmetic surfaces would otherwise fight over.
|
|
122
|
+
const clockOn = new FakeClock()
|
|
123
|
+
const fuseOn = createEditFloodFuse({ clock: clockOn })
|
|
124
|
+
await tighten(fuseOn, 1)
|
|
125
|
+
expect(fuseOn.stats().tightenLevel).toBeGreaterThanOrEqual(1)
|
|
126
|
+
const on = await contend(fuseOn, clockOn, { spanMs: 58_000, workerEveryMs: 1_000, primaryEveryMs: 6_000 })
|
|
127
|
+
|
|
128
|
+
// The invariant: the watched card still repaints at least its guaranteed
|
|
129
|
+
// floor (cosmeticFloorPerWindow = 2/60s = 1 edit/30s), AIMD-immune, even
|
|
130
|
+
// though the worker is hammering the same pool.
|
|
131
|
+
expect(on.primary).toBeGreaterThanOrEqual(D.cosmeticFloorPerWindow)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('(d) mutation guard — with fair-share OFF the SAME scenario starves the primary below the floor', async () => {
|
|
135
|
+
// Kill-switch OFF is the pre-fix single shared bucket. Reverting the core
|
|
136
|
+
// change is behaviourally identical to this, so if the fix did nothing the
|
|
137
|
+
// (a) assertion above would already hold here — it must NOT.
|
|
138
|
+
const clockOff = new FakeClock()
|
|
139
|
+
const fuseOff = createEditFloodFuse({ clock: clockOff, cosmeticFairShareEnabled: false })
|
|
140
|
+
await tighten(fuseOff, 1)
|
|
141
|
+
const off = await contend(fuseOff, clockOff, { spanMs: 58_000, workerEveryMs: 1_000, primaryEveryMs: 6_000 })
|
|
142
|
+
|
|
143
|
+
// No per-message floor: the dense worker wins the shared pool and the
|
|
144
|
+
// watched card is starved below the floor the fix guarantees.
|
|
145
|
+
expect(off.primary).toBeLessThan(D.cosmeticFloorPerWindow)
|
|
146
|
+
expect(fuseOff.stats().cosmeticFairShareEnabled).toBe(false)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('(b) a heavily-tightened pool still lets a lone watched card repaint ≥ 1 edit / 30s (AIMD floor)', async () => {
|
|
150
|
+
const clock = new FakeClock()
|
|
151
|
+
const fuse = createEditFloodFuse({ clock })
|
|
152
|
+
// Two 429s → level 2: ceiling(6) → 1, so WITHOUT the floor the whole chat's
|
|
153
|
+
// cosmetic budget would be a single frame per 60s.
|
|
154
|
+
await tighten(fuse, 2)
|
|
155
|
+
expect(fuse.stats().tightenLevel).toBeGreaterThanOrEqual(2)
|
|
156
|
+
|
|
157
|
+
const landed: number[] = []
|
|
158
|
+
const inflight: Promise<unknown>[] = []
|
|
159
|
+
// A watched card's heartbeat: an edit every 5s across a 60s window.
|
|
160
|
+
for (let i = 0; i < 12; i++) {
|
|
161
|
+
inflight.push(fuse.apply(
|
|
162
|
+
'editMessageText', { chat_id: CHAT, message_id: PRIMARY, text: `hb${i}` },
|
|
163
|
+
async () => { if (clock.now() < D.perChatWindowMs) landed.push(i); return true },
|
|
164
|
+
))
|
|
165
|
+
await clock.advance(5_000)
|
|
166
|
+
}
|
|
167
|
+
await clock.advance(1_000)
|
|
168
|
+
const inWindow = landed.length
|
|
169
|
+
await clock.advance(D.maxDeferMs + 1_000)
|
|
170
|
+
await Promise.all(inflight)
|
|
171
|
+
|
|
172
|
+
// ≥ 1 edit / 30s = ≥ cosmeticFloorPerWindow (2) per 60s window, guaranteed
|
|
173
|
+
// no matter how many 429s tighten the pool.
|
|
174
|
+
expect(inWindow).toBeGreaterThanOrEqual(D.cosmeticFloorPerWindow)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('(c) fair-share OFF is the old single shared bucket — two surfaces share exactly cosmeticPerChatMax, byte-for-byte', async () => {
|
|
178
|
+
// No tightening: the pre-fix behaviour is that ALL cosmetic surfaces in a
|
|
179
|
+
// chat draw from one `cosmeticPerChatMaxPerWindow` bucket with no
|
|
180
|
+
// per-message reservation. Two saturating surfaces therefore land, in
|
|
181
|
+
// aggregate, exactly the pool — never more (the fix does not raise the wire
|
|
182
|
+
// rate) and, with the flag off, with no floor keeping either one alive.
|
|
183
|
+
const clock = new FakeClock()
|
|
184
|
+
const fuse = createEditFloodFuse({ clock, cosmeticFairShareEnabled: false })
|
|
185
|
+
let a = 0
|
|
186
|
+
let b = 0
|
|
187
|
+
const inflight: Promise<unknown>[] = []
|
|
188
|
+
for (let t = 0; t < 58_000; t += 500) {
|
|
189
|
+
inflight.push(fuse.apply('editMessageText', { chat_id: CHAT, message_id: PRIMARY, text: `a${t}` },
|
|
190
|
+
async () => { if (clock.now() < D.perChatWindowMs) a++; return true }))
|
|
191
|
+
inflight.push(fuse.apply('editMessageText', { chat_id: CHAT, message_id: WORKER, text: `b${t}` },
|
|
192
|
+
async () => { if (clock.now() < D.perChatWindowMs) b++; return true }))
|
|
193
|
+
await clock.advance(500)
|
|
194
|
+
}
|
|
195
|
+
await clock.advance(D.maxDeferMs + 1_000)
|
|
196
|
+
await Promise.all(inflight)
|
|
197
|
+
|
|
198
|
+
// The single shared bucket: aggregate cosmetic frames == the pool, and the
|
|
199
|
+
// fair-share machinery is provably not in play.
|
|
200
|
+
expect(a + b).toBeLessThanOrEqual(D.cosmeticPerChatMaxPerWindow)
|
|
201
|
+
expect(a + b).toBeGreaterThan(0)
|
|
202
|
+
expect(fuse.stats().cosmeticFairShareEnabled).toBe(false)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('raises a visible `throttled` signal when a cosmetic edit is deferred past throttleNoticeMs', async () => {
|
|
206
|
+
const clock = new FakeClock()
|
|
207
|
+
const actions: string[] = []
|
|
208
|
+
// Saturate the per-chat cosmetic pool with a DIFFERENT card so the target
|
|
209
|
+
// edit has to wait; a long defer window lets it cross the notice threshold.
|
|
210
|
+
const fuse = createEditFloodFuse({
|
|
211
|
+
clock,
|
|
212
|
+
cosmeticPerChatMaxPerWindow: 1, cosmeticFloorPerWindow: 0,
|
|
213
|
+
perChatWindowMs: 600_000, maxDeferMs: 120_000, throttleNoticeMs: 45_000,
|
|
214
|
+
onTrip: (i) => { actions.push(i.action) },
|
|
215
|
+
})
|
|
216
|
+
// Burn the pool.
|
|
217
|
+
await fuse.apply('editMessageText', { chat_id: CHAT, message_id: WORKER, text: 'hog' },
|
|
218
|
+
async () => true)
|
|
219
|
+
// The watched card's frame cannot get in; it sits deferred.
|
|
220
|
+
const stuck = fuse.apply('editMessageText', { chat_id: CHAT, message_id: PRIMARY, text: 'watched' },
|
|
221
|
+
async () => true)
|
|
222
|
+
await clock.advance(46_000)
|
|
223
|
+
expect(fuse.stats().throttled).toBeGreaterThan(0)
|
|
224
|
+
expect(actions).toContain('throttled')
|
|
225
|
+
// Drain.
|
|
226
|
+
await clock.advance(120_000)
|
|
227
|
+
await stuck
|
|
228
|
+
})
|
|
229
|
+
})
|