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.
Files changed (25) hide show
  1. package/dist/cli/switchroom.js +9977 -957
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +3 -3
  4. package/telegram-plugin/dist/gateway/gateway.js +429 -98
  5. package/telegram-plugin/edit-flood-fuse.ts +332 -14
  6. package/telegram-plugin/gateway/feed-open-gate.ts +28 -8
  7. package/telegram-plugin/gateway/feed-reopen-gate.ts +88 -6
  8. package/telegram-plugin/gateway/gateway.ts +128 -104
  9. package/telegram-plugin/gateway/narrative-lane.ts +4 -0
  10. package/telegram-plugin/gateway/progress-fallback-cap.ts +195 -0
  11. package/telegram-plugin/gateway/stream-render.ts +67 -12
  12. package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +13 -0
  13. package/telegram-plugin/gateway/subagent-origin-surface.ts +181 -0
  14. package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +7 -0
  15. package/telegram-plugin/registry/subagents-schema.ts +61 -0
  16. package/telegram-plugin/registry/turns-schema.ts +26 -0
  17. package/telegram-plugin/tests/edit-flood-fuse-cosmetic-fairness.test.ts +229 -0
  18. package/telegram-plugin/tests/feed-open-gate.test.ts +42 -0
  19. package/telegram-plugin/tests/feed-reopen-gate.test.ts +114 -0
  20. package/telegram-plugin/tests/progress-cap.test.ts +182 -0
  21. package/telegram-plugin/tests/progress-fallback-cap.test.ts +91 -0
  22. package/telegram-plugin/tests/progress-update.test.ts +108 -12
  23. package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +46 -0
  24. package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +26 -0
  25. 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
- // decideFeedReopen returns dropLabel (legacy return) or the reset
1382
- // deltas: finalAnswerDelivered→false (the turn has NOT delivered its
1383
- // final answer while still doing tool work), activityMessageId→null
1384
- // (a FRESH feed message opens below the ack), activityLastSentRender
1385
- // →null (so the drain loop's `pending !== lastSent` guard never
1386
- // mistakes the fresh render for the ack's finalized one and skips it).
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
- turn.finalAnswerDelivered = reopen.reset!.finalAnswerDelivered
1397
- turn.activityMessageId = reopen.reset!.activityMessageId
1398
- turn.activityLastSentRender = reopen.reset!.activityLastSentRender
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
+ })