switchroom 0.20.2 → 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/bin/handoff-briefing.sh +41 -1
- package/dist/cli/switchroom.js +10259 -1232
- package/dist/host-control/main.js +1 -1
- package/package.json +3 -3
- package/profiles/default/CLAUDE.md.hbs +13 -11
- 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
|
@@ -754,6 +754,7 @@ import { shadowEmit, isMachineInTurn } from './inbound-delivery-machine-shadow.j
|
|
|
754
754
|
// #2996 P8 PR-B — the extracted turn-end funnel (state stays here; logic moved).
|
|
755
755
|
import { createTurnEndFunnel, type TurnEndReason } from './turn-end.js'
|
|
756
756
|
import { createTurnStartSurfaces } from './turn-start-surfaces.js'
|
|
757
|
+
import { sendWithProgressCap } from './progress-fallback-cap.js'
|
|
757
758
|
// #2996 P8 PR-C1 — the extracted delivery-confirm sweep wiring.
|
|
758
759
|
import { createDeliveryConfirmWiring } from './delivery-confirm-wiring.js'
|
|
759
760
|
// #2996 P8 PR-C2 — the extracted obligation wiring.
|
|
@@ -972,7 +973,8 @@ import {
|
|
|
972
973
|
DEFAULT_BRIDGE_DEAD_GRACE_MS,
|
|
973
974
|
} from './bridge-dead-watchdog.js'
|
|
974
975
|
import { findAgentProcessInContainer } from './boot-probes.js'
|
|
975
|
-
import { applySubagentsSchema, getSubagentByJsonlId,
|
|
976
|
+
import { applySubagentsSchema, getSubagentByJsonlId, listNonTerminalSubagentsForTurn } from '../registry/subagents-schema.js'
|
|
977
|
+
import { resolveSubagentOriginChatDb, resolveRecentTurnFallbackChat, resolveWorkerSurfaceChat, resolveWorkerSurfaceForDecider, noteWorkerRecentTurnFloor } from './subagent-origin-surface.js'
|
|
976
978
|
import type { InterruptedSubagent } from './resume-inbound-builder.js'
|
|
977
979
|
import { resolveWorkerFeedDispatch, decideWorkerFeedDestination, handleWorkerResume, type WorkerFeedDispatch } from './worker-feed-dispatch.js'
|
|
978
980
|
import {
|
|
@@ -1960,28 +1962,23 @@ function resolveSubagentOriginChat(
|
|
|
1960
1962
|
agentId: string,
|
|
1961
1963
|
): { chatId: string; threadId?: number } | null {
|
|
1962
1964
|
if (turnsDb == null) return null
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
threadId: Number.isFinite(threadNum) ? threadNum : undefined,
|
|
1981
|
-
}
|
|
1982
|
-
} catch {
|
|
1983
|
-
return null
|
|
1984
|
-
}
|
|
1965
|
+
// Transitive walk (jsonl stem → parent_turn_key chain → turns row) —
|
|
1966
|
+
// extracted to subagent-origin-surface.ts so the resolution precedence is
|
|
1967
|
+
// unit-testable against a real registry DB. Null on any miss; the callers
|
|
1968
|
+
// keep their fallback ladders (now with the recent-turn floor).
|
|
1969
|
+
return resolveSubagentOriginChatDb(turnsDb, agentId)
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
/**
|
|
1973
|
+
* Recent-turn routing floor (msg-6897 misroute fix): the newest turn AT OR
|
|
1974
|
+
* BEFORE the worker's dispatch time — where the operator dispatched it from.
|
|
1975
|
+
* Used ONLY after origin resolution has missed; bounded by the dispatch time
|
|
1976
|
+
* so a later turn in an unrelated (possibly shared) chat can never capture
|
|
1977
|
+
* the worker's surface. Null-safe (no pre-dispatch turn → owner-DM fallback).
|
|
1978
|
+
*/
|
|
1979
|
+
function recentTurnFallbackChat(agentId: string): { chatId: string; threadId?: number } | null {
|
|
1980
|
+
if (turnsDb == null) return null
|
|
1981
|
+
return resolveRecentTurnFallbackChat(turnsDb, agentId)
|
|
1985
1982
|
}
|
|
1986
1983
|
|
|
1987
1984
|
/**
|
|
@@ -2062,41 +2059,26 @@ const workerFeedOriginDeferrals = new Map<string, number>()
|
|
|
2062
2059
|
const WORKER_FEED_ORIGIN_DEFER_MAX = 10
|
|
2063
2060
|
|
|
2064
2061
|
/**
|
|
2065
|
-
* Resolve a worker-feed destination chat with a guaranteed last resort
|
|
2066
|
-
*
|
|
2067
|
-
*
|
|
2068
|
-
*
|
|
2069
|
-
*
|
|
2070
|
-
*
|
|
2071
|
-
*
|
|
2072
|
-
*
|
|
2073
|
-
* empty too, hit the `chatId.length === 0` skip in `workerActivityFeed.update`
|
|
2074
|
-
* — painting NOTHING, silently. That is the one path most likely to violate
|
|
2075
|
-
* "any and all active work has a card."
|
|
2076
|
-
*
|
|
2077
|
-
* This never returns `''`: the precedence is origin chat → fleet chat →
|
|
2078
|
-
* first allowed chat (the owner DM). The owner DM is the durable floor —
|
|
2079
|
-
* "wrong chat" beats "no card." A one-line routing-decision log flags the
|
|
2080
|
-
* fallback so an operator can see the misroute without it reading as an
|
|
2081
|
-
* error (it isn't one — the card surfaced).
|
|
2062
|
+
* Resolve a worker-feed destination chat with a guaranteed last resort
|
|
2063
|
+
* (universal-liveness: "any active work has a card" — the owner DM is the
|
|
2064
|
+
* durable floor, "wrong chat" beats "no card"). The ladder itself
|
|
2065
|
+
* (origin → fleet → recent-turn → owner DM) lives in
|
|
2066
|
+
* `resolveWorkerSurfaceChat` (subagent-origin-surface.ts) — the ONE tested
|
|
2067
|
+
* implementation, shared with the handback/progress deciders below so the
|
|
2068
|
+
* precedence cannot diverge per call site again; this wrapper adds only the
|
|
2069
|
+
* gateway's audit logs.
|
|
2082
2070
|
*/
|
|
2083
2071
|
function resolveWorkerFeedChat(
|
|
2084
2072
|
agentId: string,
|
|
2085
2073
|
fleetChatId: string,
|
|
2086
|
-
// Origin sources threadId; when origin is null the caller can pass the
|
|
2087
|
-
// fallback chat's sibling forum-topic id so a misrouted card still lands
|
|
2088
|
-
// in the origin topic instead of General (#3458 exhausted-defer paint).
|
|
2089
|
-
fallbackThreadId?: number,
|
|
2090
2074
|
): { chatId: string; threadId?: number } {
|
|
2091
|
-
const
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
if (
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
}
|
|
2099
|
-
return { chatId: ownerDm, threadId: origin?.threadId ?? fallbackThreadId }
|
|
2075
|
+
const dest = resolveWorkerSurfaceChat(turnsDb, agentId, {
|
|
2076
|
+
fleetChatId,
|
|
2077
|
+
ownerDm: loadAccess().allowFrom[0] ?? '',
|
|
2078
|
+
})
|
|
2079
|
+
if (dest.via === 'recent-turn') noteWorkerRecentTurnFloor(agentId, dest)
|
|
2080
|
+
if (dest.via === 'owner-dm') noteWorkerFeedOwnerDmFallback(agentId)
|
|
2081
|
+
return { chatId: dest.chatId, ...(dest.threadId != null ? { threadId: dest.threadId } : {}) }
|
|
2100
2082
|
}
|
|
2101
2083
|
|
|
2102
2084
|
// ─── Periodic history reaper (#1073) ──────────────────────────────────────
|
|
@@ -2798,6 +2780,13 @@ const MIDFLIGHT_BUSY_ACK_ENABLED =
|
|
|
2798
2780
|
// the finalAnswerDelivered-consumer interactions.
|
|
2799
2781
|
const FEED_REOPEN_AFTER_ACK_ENABLED =
|
|
2800
2782
|
process.env.SWITCHROOM_FEED_REOPEN_AFTER_ACK !== '0'
|
|
2783
|
+
// Feed-reopen-after-SUBSTANTIVE. Distinct from the ack kill switch above: a
|
|
2784
|
+
// turn that delivered a real answer EARLY and then kept doing tool work had
|
|
2785
|
+
// its feed go dark for the rest of the turn. Lets the feed RE-OPEN after >=2
|
|
2786
|
+
// post-answer labels WITHOUT clearing finalAnswerDelivered. Off (=0) → legacy.
|
|
2787
|
+
// See `feed-reopen-gate.ts` for the full rationale.
|
|
2788
|
+
const FEED_REOPEN_AFTER_SUBSTANTIVE_ENABLED =
|
|
2789
|
+
process.env.SWITCHROOM_FEED_REOPEN_AFTER_SUBSTANTIVE !== '0'
|
|
2801
2790
|
|
|
2802
2791
|
// Activity-feed heartbeat (PR1). The feed is pull-only — it only re-renders on
|
|
2803
2792
|
// a tool_label event, so a long single step that emits no new label leaves the
|
|
@@ -3291,6 +3280,18 @@ export type CurrentTurn = {
|
|
|
3291
3280
|
// Reset to false on every fresh-turn enqueue alongside
|
|
3292
3281
|
// `finalAnswerDelivered`.
|
|
3293
3282
|
finalAnswerSubstantive: boolean
|
|
3283
|
+
// Post-SUBSTANTIVE feed-reopen counter — incremented on each tool label that
|
|
3284
|
+
// arrives while `finalAnswerDelivered && finalAnswerSubstantive` (a real
|
|
3285
|
+
// answer landed early, model still working). At `SUBSTANTIVE_REOPEN_MIN_LABELS`
|
|
3286
|
+
// (>=2) the tool_label handler reopens the feed (see `feed-reopen-gate.ts`).
|
|
3287
|
+
// Reset to 0 on every fresh-turn enqueue.
|
|
3288
|
+
postSubstantiveToolLabelCount: number
|
|
3289
|
+
// Post-SUBSTANTIVE feed-reopen lever-1 lift latch. Set true by the tool_label
|
|
3290
|
+
// handler when the long-turn reopen fires (>=2 post-answer labels). The drain
|
|
3291
|
+
// reads it as `mayOpenActivityCard`'s `postAnswerMainActivity` so the fresh
|
|
3292
|
+
// card may OPEN below the already-delivered reply despite the sticky lever-1
|
|
3293
|
+
// latch. Sticky for the rest of the turn; reset to false on fresh-turn enqueue.
|
|
3294
|
+
postAnswerMainActivity: boolean
|
|
3294
3295
|
// Sticky "a substantive final answer has been delivered this turn" latch
|
|
3295
3296
|
// (design `docs/message-emission-determinism.md` §9 preamble / R0). Distinct
|
|
3296
3297
|
// from the MUTABLE `finalAnswerDelivered`, which the ack-reopen path clears
|
|
@@ -12491,22 +12492,15 @@ async function executeProgressUpdate(args: Record<string, unknown>): Promise<unk
|
|
|
12491
12492
|
}
|
|
12492
12493
|
}
|
|
12493
12494
|
|
|
12494
|
-
//
|
|
12495
|
+
// Attention cap: max 5 DELIVERIES per turn when a turn atom exists, else a
|
|
12496
|
+
// rolling 15-min/chat fallback when the inbound minted none (handback /
|
|
12497
|
+
// progress-inbound turns) so a worker can't send at the 20s floor forever
|
|
12498
|
+
// and defeat the documented per-turn cap. Both count deliveries only. The
|
|
12499
|
+
// slot is RESERVED before the send (via sendWithProgressCap below) so two
|
|
12500
|
+
// truly-concurrent same-key calls can't both pass the check and overshoot
|
|
12501
|
+
// the cap, and it is RELEASED if the send throws — so a thrown send never
|
|
12502
|
+
// burns a slot even under concurrency (#4328 Fix 2 preserved).
|
|
12495
12503
|
const turnStart = activeTurnStartedAt.get(key)
|
|
12496
|
-
if (turnStart != null) {
|
|
12497
|
-
const currentCount = progressUpdateTurnCount.get(key) ?? 0
|
|
12498
|
-
if (currentCount >= 5) {
|
|
12499
|
-
return {
|
|
12500
|
-
content: [
|
|
12501
|
-
{
|
|
12502
|
-
type: 'text',
|
|
12503
|
-
text: JSON.stringify({ ok: false, reason: 'turn_limit' }),
|
|
12504
|
-
},
|
|
12505
|
-
],
|
|
12506
|
-
}
|
|
12507
|
-
}
|
|
12508
|
-
progressUpdateTurnCount.set(key, currentCount + 1)
|
|
12509
|
-
}
|
|
12510
12504
|
|
|
12511
12505
|
// Issue #305 Option A — the card-injection path (route a sub-agent's
|
|
12512
12506
|
// `progress_update` narrative into its row on the parent's pinned card)
|
|
@@ -12527,14 +12521,33 @@ async function executeProgressUpdate(args: Record<string, unknown>): Promise<unk
|
|
|
12527
12521
|
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
12528
12522
|
}
|
|
12529
12523
|
|
|
12530
|
-
|
|
12524
|
+
// Reserve the attention-cap slot, send, and release the slot on a throw. A
|
|
12525
|
+
// successful send KEEPS the reservation (it IS the delivery record), so no
|
|
12526
|
+
// extra counting runs after the send. `capped` short-circuits when the
|
|
12527
|
+
// reservation is refused.
|
|
12528
|
+
const capped = await sendWithProgressCap(
|
|
12529
|
+
{ key, now, turnStart, turnCount: progressUpdateTurnCount },
|
|
12531
12530
|
(): Promise<{ message_id: number }> =>
|
|
12532
|
-
|
|
12533
|
-
|
|
12534
|
-
|
|
12535
|
-
|
|
12536
|
-
|
|
12531
|
+
robustApiCall(
|
|
12532
|
+
(): Promise<{ message_id: number }> =>
|
|
12533
|
+
literalText
|
|
12534
|
+
// allow-raw-bot-api: literal progress-update send routed through robustApiCall
|
|
12535
|
+
? lockedBot.api.sendMessage(chat_id, text, sendOpts as never)
|
|
12536
|
+
: lockedBot.api.sendRichMessage(chat_id, richMessage(text), sendOpts as never),
|
|
12537
|
+
{ verb: 'sendMessage', chat_id, threadId },
|
|
12538
|
+
),
|
|
12537
12539
|
)
|
|
12540
|
+
if (capped.capped) {
|
|
12541
|
+
return {
|
|
12542
|
+
content: [
|
|
12543
|
+
{
|
|
12544
|
+
type: 'text',
|
|
12545
|
+
text: JSON.stringify({ ok: false, reason: 'turn_limit' }),
|
|
12546
|
+
},
|
|
12547
|
+
],
|
|
12548
|
+
}
|
|
12549
|
+
}
|
|
12550
|
+
const sent = capped.result
|
|
12538
12551
|
|
|
12539
12552
|
// Record in sent-message history. RecordOutboundArgs uses `texts`
|
|
12540
12553
|
// (parallel array to message_ids), not `text` — the singular-name
|
|
@@ -12551,6 +12564,10 @@ async function executeProgressUpdate(args: Record<string, unknown>): Promise<unk
|
|
|
12551
12564
|
|
|
12552
12565
|
progressUpdateLastSent.set(key, now)
|
|
12553
12566
|
|
|
12567
|
+
// The delivery slot was already reserved before the send (sendWithProgressCap
|
|
12568
|
+
// above), so there is nothing to count here — a successful send keeps its
|
|
12569
|
+
// reservation and a thrown send released it.
|
|
12570
|
+
|
|
12554
12571
|
// Issue #203: progress_update is a user-visible signal — tick the
|
|
12555
12572
|
// silent-gap tracker so it doesn't count as silent time.
|
|
12556
12573
|
try {
|
|
@@ -13921,6 +13938,7 @@ function gatewayStreamRenderDeps() {
|
|
|
13921
13938
|
CONTEXT_EXHAUSTION_COOLDOWN_MS,
|
|
13922
13939
|
DELIVERY_CONFIRM_ENABLED,
|
|
13923
13940
|
FEED_REOPEN_AFTER_ACK_ENABLED,
|
|
13941
|
+
FEED_REOPEN_AFTER_SUBSTANTIVE_ENABLED,
|
|
13924
13942
|
HANDBACK_PRETURN_ENABLED,
|
|
13925
13943
|
HISTORY_ENABLED,
|
|
13926
13944
|
LIVENESS_TERMINAL_HONESTY,
|
|
@@ -24335,28 +24353,19 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
|
|
|
24335
24353
|
// when this group's LAST worker finishes; no per-worker unpin.
|
|
24336
24354
|
}
|
|
24337
24355
|
|
|
24338
|
-
|
|
24356
|
+
// ONE ladder — origin → fleet → recent-turn floor → owner DM
|
|
24357
|
+
// (resolveWorkerSurfaceForDecider: the tested precedence,
|
|
24358
|
+
// shaped into the decider's fleetChatId/originThreadId slots,
|
|
24359
|
+
// floor audited — msg-6897 misroute fix).
|
|
24360
|
+
const hbOwnerDm = loadAccess().allowFrom[0] ?? ''
|
|
24339
24361
|
const decision = decideSubagentHandback({
|
|
24340
24362
|
handbackEnvValue: process.env.SWITCHROOM_SUBAGENT_HANDBACK,
|
|
24341
24363
|
outcome,
|
|
24342
24364
|
isBackground,
|
|
24343
|
-
|
|
24344
|
-
// turn) back to the conversation the Task was dispatched
|
|
24345
|
-
// from, so the result lands where the user asked — not the
|
|
24346
|
-
// agent's DM. Falls back to fleetChatId/ownerChatId.
|
|
24347
|
-
fleetChatId: handbackOrigin?.chatId || fleetChatId,
|
|
24348
|
-
// Supergroup topic the Task was dispatched from. Plumbed
|
|
24349
|
-
// through so the handback turn (and the model's in-voice
|
|
24350
|
-
// "here's what the worker found" reply) land in the
|
|
24351
|
-
// originating topic — not the chat's last-seen topic.
|
|
24352
|
-
// Applied only when the origin chat resolved (DM fallback
|
|
24353
|
-
// is topic-less).
|
|
24354
|
-
...(handbackOrigin?.threadId != null
|
|
24355
|
-
? { originThreadId: handbackOrigin.threadId }
|
|
24356
|
-
: {}),
|
|
24365
|
+
...resolveWorkerSurfaceForDecider(turnsDb, agentId, { fleetChatId, ownerDm: hbOwnerDm }),
|
|
24357
24366
|
// Owner-chat fallback: if the parent-turn chat can't be
|
|
24358
24367
|
// resolved, route to the owner chat.
|
|
24359
|
-
ownerChatId:
|
|
24368
|
+
ownerChatId: hbOwnerDm,
|
|
24360
24369
|
taskDescription: description,
|
|
24361
24370
|
resultText,
|
|
24362
24371
|
// Plumb the JSONL agent id so the spool can mint a
|
|
@@ -24650,14 +24659,26 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
|
|
|
24650
24659
|
// chat/thread resolution `resolveWorkerFeedChat` did) is now one
|
|
24651
24660
|
// pure, unit-tested function; the gateway keeps only the defer-map
|
|
24652
24661
|
// bookkeeping, the two audit logs, and the feed.update.
|
|
24662
|
+
const wfOrigin = resolveSubagentOriginChat(agentId)
|
|
24663
|
+
// msg-6897 misroute fix: with origin unresolved AND no live
|
|
24664
|
+
// turn to stamp from, the exhausted-defer paint previously
|
|
24665
|
+
// fell to the owner DM. Feed the most-recent turn's
|
|
24666
|
+
// chat/topic through the SAME stamp-fallback slot (#3458)
|
|
24667
|
+
// so the paint lands near the work instead.
|
|
24668
|
+
const wfStamp =
|
|
24669
|
+
stampTurn != null
|
|
24670
|
+
? { chatId: stampTurn.sessionChatId, threadId: stampTurn.sessionThreadId }
|
|
24671
|
+
: wfOrigin == null
|
|
24672
|
+
? recentTurnFallbackChat(agentId)
|
|
24673
|
+
: null
|
|
24653
24674
|
const dest = decideWorkerFeedDestination({
|
|
24654
|
-
origin:
|
|
24675
|
+
origin: wfOrigin,
|
|
24655
24676
|
cardExists: workerActivityFeed?.has(agentId) === true,
|
|
24656
24677
|
priorDeferrals: workerFeedOriginDeferrals.get(agentId) ?? 0,
|
|
24657
24678
|
maxDeferrals: WORKER_FEED_ORIGIN_DEFER_MAX,
|
|
24658
24679
|
fleetChatId,
|
|
24659
|
-
stampChatId:
|
|
24660
|
-
stampThreadId:
|
|
24680
|
+
stampChatId: wfStamp?.chatId,
|
|
24681
|
+
stampThreadId: wfStamp?.threadId,
|
|
24661
24682
|
ownerDm: loadAccess().allowFrom[0] ?? '',
|
|
24662
24683
|
})
|
|
24663
24684
|
if (dest.action === 'defer') {
|
|
@@ -24678,6 +24699,10 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
|
|
|
24678
24699
|
}
|
|
24679
24700
|
// Card fell to the owner DM — log the misroute once per agent.
|
|
24680
24701
|
if (dest.ownerDmFallback) noteWorkerFeedOwnerDmFallback(agentId)
|
|
24702
|
+
// Recent-turn floor fed the stamp slot and won — audit once (msg-6897).
|
|
24703
|
+
if (stampTurn == null && wfOrigin == null && wfStamp != null && dest.chatId === wfStamp.chatId) {
|
|
24704
|
+
noteWorkerRecentTurnFloor(agentId, wfStamp)
|
|
24705
|
+
}
|
|
24681
24706
|
void workerActivityFeed?.update(
|
|
24682
24707
|
agentId,
|
|
24683
24708
|
dest.chatId,
|
|
@@ -24710,20 +24735,19 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
|
|
|
24710
24735
|
// the pure decision (gate 1b → 'skeleton-liveness'), which drops
|
|
24711
24736
|
// it deterministically (controls-in-code, unit-tested) rather
|
|
24712
24737
|
// than an opaque inline return here.
|
|
24713
|
-
|
|
24738
|
+
// Same single ladder as the handback above
|
|
24739
|
+
// (resolveWorkerSurfaceForDecider — msg-6897 misroute fix).
|
|
24740
|
+
const pgOwnerDm = loadAccess().allowFrom[0] ?? ''
|
|
24741
|
+
const progressSurface = resolveWorkerSurfaceForDecider(turnsDb, agentId, {
|
|
24742
|
+
fleetChatId,
|
|
24743
|
+
ownerDm: pgOwnerDm,
|
|
24744
|
+
})
|
|
24714
24745
|
const decision = decideSubagentProgress({
|
|
24715
24746
|
skeleton: skeleton === true,
|
|
24716
24747
|
disableEnvValue: process.env.SWITCHROOM_DISABLE_SUBAGENT_PROGRESS,
|
|
24717
24748
|
isBackground,
|
|
24718
|
-
|
|
24719
|
-
|
|
24720
|
-
fleetChatId: progressOrigin?.chatId || fleetChatId,
|
|
24721
|
-
// Carry the dispatching topic so the progress wake lands in
|
|
24722
|
-
// it (applied only when the origin chat resolved).
|
|
24723
|
-
...(progressOrigin?.threadId != null
|
|
24724
|
-
? { originThreadId: progressOrigin.threadId }
|
|
24725
|
-
: {}),
|
|
24726
|
-
ownerChatId: loadAccess().allowFrom[0] ?? '',
|
|
24749
|
+
...progressSurface,
|
|
24750
|
+
ownerChatId: pgOwnerDm,
|
|
24727
24751
|
subagentJsonlId: agentId,
|
|
24728
24752
|
taskDescription: description,
|
|
24729
24753
|
latestSummary,
|
|
@@ -24744,7 +24768,7 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
|
|
|
24744
24768
|
// in (origin thread) so the right lane is yielded in a
|
|
24745
24769
|
// supergroup; chat-level for DM-shaped agents.
|
|
24746
24770
|
pendingProgress.clearPending(
|
|
24747
|
-
statusKey(decision.chatId,
|
|
24771
|
+
statusKey(decision.chatId, progressSurface.originThreadId),
|
|
24748
24772
|
'progress',
|
|
24749
24773
|
)
|
|
24750
24774
|
process.stderr.write(
|
|
@@ -374,6 +374,10 @@ export function createNarrativeLane(deps: NarrativeLaneDeps) {
|
|
|
374
374
|
labeledToolCount: turn.labeledToolCount,
|
|
375
375
|
crossTurnAnswerDelivered,
|
|
376
376
|
postAnswerSubagentActivity: openFlags?.postAnswerSubagentActivity,
|
|
377
|
+
// Post-SUBSTANTIVE reopen: the tool_label handler latches this sticky
|
|
378
|
+
// on `turn` when the long-turn reopen fires, so the drain lifts lever 1
|
|
379
|
+
// and the fresh card opens BELOW the already-delivered substantive reply.
|
|
380
|
+
postAnswerMainActivity: turn.postAnswerMainActivity,
|
|
377
381
|
})
|
|
378
382
|
) {
|
|
379
383
|
break
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fallback attention cap for `progress_update` when the inbound minted no turn
|
|
3
|
+
* atom.
|
|
4
|
+
*
|
|
5
|
+
* The documented ceiling for `progress_update` is "at most 5 per turn" — but
|
|
6
|
+
* that cap is enforced only when a turn atom exists (`activeTurnStartedAt` set
|
|
7
|
+
* by `turn-start-surfaces.ts`). Some inbounds mint no turn atom at all —
|
|
8
|
+
* handback turns and the synthesized progress-inbound turns — so on those a
|
|
9
|
+
* worker could call `progress_update` at the unconditional 20s floor forever
|
|
10
|
+
* (~3/min, ~180/hr into one chat), which defeats the documented cap and pings
|
|
11
|
+
* the operator's phone indefinitely.
|
|
12
|
+
*
|
|
13
|
+
* This module restores the attention cap on that path with a rolling window:
|
|
14
|
+
* at most {@link PROGRESS_FALLBACK_MAX} DELIVERIES per
|
|
15
|
+
* {@link PROGRESS_FALLBACK_WINDOW_MS} per chat/topic key. It is not a ban
|
|
16
|
+
* defence — the 20s floor and the edit-flood fuse still pace sends; this only
|
|
17
|
+
* bounds how many phone-pinging progress messages a turn-less path can emit.
|
|
18
|
+
*
|
|
19
|
+
* State is a per-key list of delivery timestamps, pruned in place on every
|
|
20
|
+
* access; a key whose window empties is dropped from the map so it cannot grow
|
|
21
|
+
* unbounded across chats that have gone quiet. Because each window holds at
|
|
22
|
+
* most {@link PROGRESS_FALLBACK_MAX} entries, the per-key array is O(1).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const recentSends = new Map<string, number[]>();
|
|
26
|
+
|
|
27
|
+
/** Rolling window length. */
|
|
28
|
+
export const PROGRESS_FALLBACK_WINDOW_MS = 15 * 60_000;
|
|
29
|
+
/** Max deliveries per window per key — mirrors the 5-per-turn cap. */
|
|
30
|
+
export const PROGRESS_FALLBACK_MAX = 5;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Prune timestamps older than the window for `key`, persisting the result.
|
|
34
|
+
* Drops the key entirely when its window is empty so the map stays bounded.
|
|
35
|
+
*/
|
|
36
|
+
function prune(key: string, now: number): number[] {
|
|
37
|
+
const cutoff = now - PROGRESS_FALLBACK_WINDOW_MS;
|
|
38
|
+
const recent = (recentSends.get(key) ?? []).filter((ts) => ts > cutoff);
|
|
39
|
+
if (recent.length === 0) recentSends.delete(key);
|
|
40
|
+
else recentSends.set(key, recent);
|
|
41
|
+
return recent;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* True when the rolling window for `key` is already at capacity — the caller
|
|
46
|
+
* should refuse the send with `turn_limit`. Read-only w.r.t. the delivery
|
|
47
|
+
* count (it only prunes aged-out entries); record a delivery separately via
|
|
48
|
+
* {@link recordProgressFallbackSend} AFTER the send lands.
|
|
49
|
+
*/
|
|
50
|
+
export function progressFallbackAtCap(key: string, now: number): boolean {
|
|
51
|
+
return prune(key, now).length >= PROGRESS_FALLBACK_MAX;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Record one successful fallback-path delivery. Call ONLY after the send has
|
|
56
|
+
* actually landed, so a thrown send never consumes a window slot.
|
|
57
|
+
*
|
|
58
|
+
* NOTE: this is the check-then-record shape (`progressFallbackAtCap` then
|
|
59
|
+
* `recordProgressFallbackSend`) which is NOT safe against two truly-concurrent
|
|
60
|
+
* same-key calls — both can pass the cap check before either records. The
|
|
61
|
+
* production path uses {@link reserveProgressSlot} / {@link sendWithProgressCap}
|
|
62
|
+
* instead, which reserve the slot BEFORE the await so the count is visible to a
|
|
63
|
+
* concurrent caller. These two remain for the direct unit tests of the window.
|
|
64
|
+
*/
|
|
65
|
+
export function recordProgressFallbackSend(key: string, now: number): void {
|
|
66
|
+
const recent = prune(key, now);
|
|
67
|
+
recent.push(now);
|
|
68
|
+
recentSends.set(key, recent);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A reserved cap slot; call {@link release} to hand it back (idempotent). */
|
|
72
|
+
export interface ProgressSlotReservation {
|
|
73
|
+
release: () => void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Reserve one fallback-window slot for `key` at `now`, or refuse when the
|
|
78
|
+
* window is already at capacity.
|
|
79
|
+
*
|
|
80
|
+
* Unlike {@link progressFallbackAtCap} + {@link recordProgressFallbackSend},
|
|
81
|
+
* this records the timestamp AT CHECK TIME (before the caller's `await`), so a
|
|
82
|
+
* second concurrent same-key call sees the reservation and cannot overshoot the
|
|
83
|
+
* cap. On send failure the caller must {@link ProgressSlotReservation.release |
|
|
84
|
+
* release} it, which removes exactly the one timestamp this reservation added —
|
|
85
|
+
* so a throw still burns no slot (#4328 Fix 2 preserved under concurrency).
|
|
86
|
+
*
|
|
87
|
+
* Returns `null` when the window is full (caller refuses with `turn_limit`).
|
|
88
|
+
*/
|
|
89
|
+
export function reserveProgressFallbackSlot(
|
|
90
|
+
key: string,
|
|
91
|
+
now: number,
|
|
92
|
+
): ProgressSlotReservation | null {
|
|
93
|
+
const recent = prune(key, now);
|
|
94
|
+
if (recent.length >= PROGRESS_FALLBACK_MAX) return null;
|
|
95
|
+
recent.push(now);
|
|
96
|
+
recentSends.set(key, recent);
|
|
97
|
+
|
|
98
|
+
let released = false;
|
|
99
|
+
return {
|
|
100
|
+
release: () => {
|
|
101
|
+
if (released) return;
|
|
102
|
+
released = true;
|
|
103
|
+
const arr = recentSends.get(key);
|
|
104
|
+
if (!arr) return;
|
|
105
|
+
// Remove exactly ONE occurrence of the reserved timestamp — concurrent
|
|
106
|
+
// reservations may share the same `now`, and they are interchangeable.
|
|
107
|
+
const idx = arr.indexOf(now);
|
|
108
|
+
if (idx >= 0) arr.splice(idx, 1);
|
|
109
|
+
if (arr.length === 0) recentSends.delete(key);
|
|
110
|
+
else recentSends.set(key, arr);
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Max `progress_update` deliveries per turn atom — mirrors the fallback cap. */
|
|
116
|
+
export const PROGRESS_TURN_MAX = 5;
|
|
117
|
+
|
|
118
|
+
/** Dependencies the unified reservation needs, threaded from the gateway. */
|
|
119
|
+
export interface ProgressCapDeps {
|
|
120
|
+
/** The chat/topic status key. */
|
|
121
|
+
key: string;
|
|
122
|
+
/** `Date.now()` captured once by the caller. */
|
|
123
|
+
now: number;
|
|
124
|
+
/**
|
|
125
|
+
* `activeTurnStartedAt.get(key)` — presence selects the turn-scoped counter
|
|
126
|
+
* path; absence selects the rolling fallback window.
|
|
127
|
+
*/
|
|
128
|
+
turnStart: number | undefined;
|
|
129
|
+
/** The live per-turn counter (`progressUpdateTurnCount`). */
|
|
130
|
+
turnCount: Map<string, number>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Reserve one attention-cap slot BEFORE the send, race-safely, on whichever
|
|
135
|
+
* path applies:
|
|
136
|
+
*
|
|
137
|
+
* - turn atom present → increment `progressUpdateTurnCount` now (bounded at
|
|
138
|
+
* {@link PROGRESS_TURN_MAX}); release decrements it.
|
|
139
|
+
* - turn atom absent → reserve a rolling-window slot via
|
|
140
|
+
* {@link reserveProgressFallbackSlot}.
|
|
141
|
+
*
|
|
142
|
+
* Because the reservation mutates the shared count synchronously (no `await`
|
|
143
|
+
* between the read and the write), a second concurrent same-key call sees it
|
|
144
|
+
* and cannot push the count past the cap. Returns `null` when already at cap.
|
|
145
|
+
*/
|
|
146
|
+
export function reserveProgressSlot(
|
|
147
|
+
deps: ProgressCapDeps,
|
|
148
|
+
): ProgressSlotReservation | null {
|
|
149
|
+
const { key, now, turnStart, turnCount } = deps;
|
|
150
|
+
if (turnStart != null) {
|
|
151
|
+
const current = turnCount.get(key) ?? 0;
|
|
152
|
+
if (current >= PROGRESS_TURN_MAX) return null;
|
|
153
|
+
turnCount.set(key, current + 1);
|
|
154
|
+
let released = false;
|
|
155
|
+
return {
|
|
156
|
+
release: () => {
|
|
157
|
+
if (released) return;
|
|
158
|
+
released = true;
|
|
159
|
+
turnCount.set(key, Math.max(0, (turnCount.get(key) ?? 0) - 1));
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return reserveProgressFallbackSlot(key, now);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The reserve-then-confirm wrapper the gateway's `executeProgressUpdate` uses
|
|
168
|
+
* around its send. Reserves a cap slot (race-safe, before the await), runs
|
|
169
|
+
* `send`, and RELEASES the slot if `send` throws — so a failed delivery never
|
|
170
|
+
* consumes a slot even under concurrency.
|
|
171
|
+
*
|
|
172
|
+
* Returns `{ capped: true }` when already at cap (no send attempted), else
|
|
173
|
+
* `{ capped: false, result }` with the successful send's result. A successful
|
|
174
|
+
* send KEEPS the reservation (it is the delivery record); the caller records
|
|
175
|
+
* nothing extra.
|
|
176
|
+
*/
|
|
177
|
+
export async function sendWithProgressCap<T>(
|
|
178
|
+
deps: ProgressCapDeps,
|
|
179
|
+
send: () => Promise<T>,
|
|
180
|
+
): Promise<{ capped: true } | { capped: false; result: T }> {
|
|
181
|
+
const reservation = reserveProgressSlot(deps);
|
|
182
|
+
if (reservation === null) return { capped: true };
|
|
183
|
+
try {
|
|
184
|
+
const result = await send();
|
|
185
|
+
return { capped: false, result };
|
|
186
|
+
} catch (err) {
|
|
187
|
+
reservation.release();
|
|
188
|
+
throw err;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Test-only: clear all fallback-window state. */
|
|
193
|
+
export function _resetProgressFallbackCap(): void {
|
|
194
|
+
recentSends.clear();
|
|
195
|
+
}
|