switchroom 0.19.43 → 0.19.45
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/agent-scheduler/index.js +4 -1
- package/dist/auth-broker/index.js +254 -119
- package/dist/cli/notion-write-pretool.mjs +4 -1
- package/dist/cli/switchroom.js +3995 -3196
- package/dist/host-control/main.js +270 -130
- package/dist/vault/approvals/kernel-server.js +201 -66
- package/dist/vault/broker/server.js +263 -128
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +11 -4
- package/telegram-plugin/dist/gateway/gateway.js +945 -638
- package/telegram-plugin/flushed-turn-supersede.ts +190 -16
- package/telegram-plugin/gateway/cron-session.ts +31 -0
- package/telegram-plugin/gateway/gateway.ts +66 -65
- package/telegram-plugin/gateway/outbound-send-path.ts +60 -8
- package/telegram-plugin/gateway/reply-owner-wiring.ts +128 -0
- package/telegram-plugin/gateway/stream-render.ts +91 -1
- package/telegram-plugin/gateway/subagent-handback-marker.ts +49 -9
- package/telegram-plugin/gateway/subagent-reply-authority.ts +147 -0
- package/telegram-plugin/gateway/turn-end.ts +9 -1
- package/telegram-plugin/reply-owner-resolve.ts +102 -26
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +110 -8
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +2 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +297 -27
- package/telegram-plugin/tests/reply-quote-wire.test.ts +2 -0
- package/telegram-plugin/tests/send-reply-golden.test.ts +567 -5
- package/telegram-plugin/tests/stream-render-golden.test.ts +204 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +15 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_provenance_tag.py +135 -0
- package/vendor/hindsight-memory/settings.json +1 -1
|
@@ -75,9 +75,10 @@ import { decideSilentReplyAnchor } from '../silent-reply-anchor.js'
|
|
|
75
75
|
import {
|
|
76
76
|
decideSupersedeCorrection,
|
|
77
77
|
flushedAnswerMatchesReply,
|
|
78
|
-
DEFAULT_SUPERSEDE_TTL_MS,
|
|
79
78
|
type FlushedTurnSupersedeRegistry,
|
|
80
79
|
} from '../flushed-turn-supersede.js'
|
|
80
|
+
import { HANDBACK_RECENCY_WINDOW_MS } from './subagent-handback-marker.js'
|
|
81
|
+
import type { SubagentReplyAuthorityView } from './subagent-reply-authority.js'
|
|
81
82
|
import {
|
|
82
83
|
decideAnswerLatchSuppression,
|
|
83
84
|
decideContentGateBypass,
|
|
@@ -762,6 +763,16 @@ export interface SendReplyRequest {
|
|
|
762
763
|
* Late writes (finalAnswerDelivered) attribute to THIS turn even if the
|
|
763
764
|
* module-scope turn rolls over mid-call. */
|
|
764
765
|
turn: CurrentTurn | null
|
|
766
|
+
/** #4172 — true when the calling IPC client is NOT the main agent bridge
|
|
767
|
+
* (`replyCallerIsForeignSession`, cron-session.ts): a Tier-1 cheap-cron
|
|
768
|
+
* session or an unregistered client. A foreign-session reply can never be
|
|
769
|
+
* the main session's own late answer, so the flushed-turn
|
|
770
|
+
* supersede/bypass/latch block is skipped wholesale — it always sends
|
|
771
|
+
* FRESH (never edits/deletes a flushed answer, never gets latch-
|
|
772
|
+
* suppressed). Omitted/false ⇒ main-bridge caller (the gateway's
|
|
773
|
+
* `executeReply` always passes the computed value; the default only
|
|
774
|
+
* affects test harnesses). */
|
|
775
|
+
callerIsForeignSession?: boolean
|
|
765
776
|
}
|
|
766
777
|
|
|
767
778
|
/** Gateway dependencies for {@link sendReply}. Function members use method
|
|
@@ -834,6 +845,12 @@ export interface SendReplyGatewayDeps {
|
|
|
834
845
|
resolveThreadId(chatId: string, explicit?: string | number | null): number | undefined
|
|
835
846
|
getLatestInboundMessageId(chatId: string, threadId: number | null): number | null | undefined
|
|
836
847
|
getLastSubagentHandbackAt(chatId: string): number | null
|
|
848
|
+
/** #4176 — gateway-observed sub-agent liveness (`subagentReplyAuthority`,
|
|
849
|
+
* subagent-reply-authority.ts). A background `Task` sub-agent replies over
|
|
850
|
+
* THIS bridge, so neither the #4172 caller-identity gate nor the handback
|
|
851
|
+
* marker can see it; while one is live the content-gate bypass is refused so
|
|
852
|
+
* a sub-agent's reply can never edit over a flushed answer. */
|
|
853
|
+
subagentReplyAuthority: SubagentReplyAuthorityView
|
|
837
854
|
recordOutbound(rec: {
|
|
838
855
|
chat_id: string
|
|
839
856
|
thread_id: number | null
|
|
@@ -898,7 +915,7 @@ export async function sendReply(
|
|
|
898
915
|
statusKey, streamKey,
|
|
899
916
|
resolveReplyOwnerTurn, findTurnByOriginId, findTurnByQuotedMessageId,
|
|
900
917
|
resolveAnswerThreadWithLog, resolveThreadId,
|
|
901
|
-
getLatestInboundMessageId, getLastSubagentHandbackAt, recordOutbound,
|
|
918
|
+
getLatestInboundMessageId, getLastSubagentHandbackAt, subagentReplyAuthority, recordOutbound,
|
|
902
919
|
emissionAuthorityFor, clearActivitySummary,
|
|
903
920
|
startTypingLoop, stopTypingLoop, logOutbound,
|
|
904
921
|
closeObligationOnSubstantiveReply, finalizeStatusReaction,
|
|
@@ -1025,7 +1042,20 @@ export async function sendReply(
|
|
|
1025
1042
|
// when the reply fits one plain-text message, and falls back to the legacy
|
|
1026
1043
|
// delete+resend otherwise. `supersedeFlushIds` carries the ids forward.
|
|
1027
1044
|
let supersedeFlushIds: number[] = []
|
|
1028
|
-
|
|
1045
|
+
// #4172 — caller-identity gate, BEFORE any owner attribution. A reply from a
|
|
1046
|
+
// foreign session (a Tier-1 cheap-cron bridge, or an unregistered client)
|
|
1047
|
+
// can never be the main session's own late/reworded answer, so it gets NO
|
|
1048
|
+
// supersede authority (it must never edit/delete a flushed answer — the
|
|
1049
|
+
// measured #4172 double loss), NO content-gate bypass, and NO exposure to
|
|
1050
|
+
// the answer-delivered latch (which could otherwise silently swallow a cron
|
|
1051
|
+
// digest landing in the flush's pre-record race window). It simply sends
|
|
1052
|
+
// fresh below, leaving any flush record intact for the genuine own-replay.
|
|
1053
|
+
if (req.callerIsForeignSession === true) {
|
|
1054
|
+
process.stderr.write(
|
|
1055
|
+
`telegram gateway: reply: foreign-session caller — supersede/latch skipped (#4172) ` +
|
|
1056
|
+
`chatId=${chat_id}\n`,
|
|
1057
|
+
)
|
|
1058
|
+
} else {
|
|
1029
1059
|
const replyThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
1030
1060
|
// 2026-07 double-reply-on-DM fix (Part 1) — resolve the turn this reply
|
|
1031
1061
|
// belongs to by IDENTITY, via the SAME full chain the thread-router uses
|
|
@@ -1107,11 +1137,16 @@ export async function sendReply(
|
|
|
1107
1137
|
// correction only ever touches ITS OWN topic's record.
|
|
1108
1138
|
const handbackAt = getLastSubagentHandbackAt(chat_id)
|
|
1109
1139
|
const now = Date.now()
|
|
1140
|
+
// #4174 — the handback gate-hold keeps its OWN ~60 s bound
|
|
1141
|
+
// (`HANDBACK_RECENCY_WINDOW_MS`), deliberately NOT the supersede window's:
|
|
1142
|
+
// this read is CHAT-WIDE, so a longer bound would hold the content gate —
|
|
1143
|
+
// and re-open the reworded-own-answer visible-dup class — for every topic
|
|
1144
|
+
// in a delegation-heavy chat, near-continuously. Rationale on the constant.
|
|
1110
1145
|
const handbackCouldOwnReply =
|
|
1111
1146
|
handbackAt != null &&
|
|
1112
1147
|
ownerEndedAt != null &&
|
|
1113
1148
|
handbackAt > ownerEndedAt &&
|
|
1114
|
-
now - handbackAt <=
|
|
1149
|
+
now - handbackAt <= HANDBACK_RECENCY_WINDOW_MS
|
|
1115
1150
|
// MUST-FIX 1 (silent-data-loss, PROVEN by Fable 2026-07-21) + the 2026-07-27
|
|
1116
1151
|
// corroboration widening — decided by the pure `decideContentGateBypass` so
|
|
1117
1152
|
// the gateway runs the exact code the unit tests exercise. The rule, in
|
|
@@ -1135,11 +1170,27 @@ export async function sendReply(
|
|
|
1135
1170
|
// its OWN turn no longer loses the collapse it would have got
|
|
1136
1171
|
// by omitting the echo entirely (the observed 2026-07-27
|
|
1137
1172
|
// `via=origin` duplicate).
|
|
1173
|
+
//
|
|
1174
|
+
// #4176 — the SECOND ambiguity, which the marker structurally cannot see. A
|
|
1175
|
+
// background `Task` sub-agent (the `researcher`/`reviewer` types hold the
|
|
1176
|
+
// full tool set) calls `reply` over THIS bridge, mid-run: the #4172
|
|
1177
|
+
// caller-identity gate sees the MAIN agent identity, and a direct tool call
|
|
1178
|
+
// never traverses `pendingInboundBuffer.push`, so no handback marker is
|
|
1179
|
+
// stamped either. Under the #4173 OPEN window that reply resolves the
|
|
1180
|
+
// flush-ended turn at ANY age, so an unguarded bypass supersedes REGARDLESS
|
|
1181
|
+
// of content and silently edits the sub-agent's message over the user's
|
|
1182
|
+
// flushed answer (the review MAJOR on #4167; pre-existing on main at the
|
|
1183
|
+
// 60 s TTL, extended by #4173 to the OPEN cap). Gateway-observed sub-agent
|
|
1184
|
+
// liveness is the deterministic discriminator: while one is live the content
|
|
1185
|
+
// gate is KEPT, so a reply that IS the flushed answer still collapses and a
|
|
1186
|
+
// sub-agent's foreign content ships FRESH (visible, notifying).
|
|
1187
|
+
const subagentCouldOwnReply = subagentReplyAuthority.subagentCouldOwnReply()
|
|
1138
1188
|
const replyIsOwnAnswer = decideContentGateBypass({
|
|
1139
1189
|
tier: ownerTier,
|
|
1140
1190
|
resolvedTurnId,
|
|
1141
1191
|
candidates: ownerCandidates,
|
|
1142
1192
|
handbackCouldOwnReply,
|
|
1193
|
+
subagentCouldOwnReply,
|
|
1143
1194
|
})
|
|
1144
1195
|
const decision = flushedTurnSupersede.take(
|
|
1145
1196
|
chat_id,
|
|
@@ -1229,7 +1280,7 @@ export async function sendReply(
|
|
|
1229
1280
|
// 2026-07-27 duplicate looked like a pure text-matcher failure and
|
|
1230
1281
|
// took a log-archive dig to attribute to the tier restriction.
|
|
1231
1282
|
`tier=${ownerTier} latestEnded=${JSON.stringify(ownerCandidates.latestEndedTurnId)} ` +
|
|
1232
|
-
`handbackInWindow=${handbackCouldOwnReply}; sending fresh\n`,
|
|
1283
|
+
`handbackInWindow=${handbackCouldOwnReply} subagentLive=${subagentCouldOwnReply}; sending fresh\n`,
|
|
1233
1284
|
)
|
|
1234
1285
|
}
|
|
1235
1286
|
if (suppressByLatch) {
|
|
@@ -1248,9 +1299,10 @@ export async function sendReply(
|
|
|
1248
1299
|
// content and must deliver. Byte-identical replays of THIS answer are
|
|
1249
1300
|
// deduped by the content-keyed #546 cache at the top of this function.
|
|
1250
1301
|
// Honest bound: the dedup TTL (60 s) is anchored at reply RECORD time,
|
|
1251
|
-
// while the latest-ended owner tier's
|
|
1252
|
-
//
|
|
1253
|
-
//
|
|
1302
|
+
// while the latest-ended owner tier's bound is the turn-completion
|
|
1303
|
+
// window (#4173), anchored at the turn's OBSERVED real end — later by
|
|
1304
|
+
// the reply→turn_end gap. A byte-identical replay landing >60 s after
|
|
1305
|
+
// record but still inside that window is evicted from dedup yet still
|
|
1254
1306
|
// resolves this ended turn, so it now DELIVERS as a duplicate message.
|
|
1255
1307
|
// Conscious trade: a rare duplicate beats the silent handback drop the
|
|
1256
1308
|
// boolean latch caused (#3426).
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reply owner-turn resolution WIRING (#2996 anti-inflation extraction; #4173).
|
|
3
|
+
*
|
|
4
|
+
* The pure precedence/acceptance rules live in `../reply-owner-resolve.ts`;
|
|
5
|
+
* gateway.ts holds the stateful lookups (recent-turn registry, quoted-message
|
|
6
|
+
* reverse index). This module is the seam between them: it composes the four
|
|
7
|
+
* lookups into the `ReplyOwnerCandidates` set — INCLUDING the turn-completion
|
|
8
|
+
* window bounds (#4173) — exactly once, so the supersede path, the content-gate
|
|
9
|
+
* bypass, and the tier resolution can never disagree on the candidate set.
|
|
10
|
+
*
|
|
11
|
+
* 2026-07 double-reply-on-DM fix (Part 1): this composition uses the SAME full
|
|
12
|
+
* chain the thread-router uses, so the supersede resolver can never again
|
|
13
|
+
* diverge from routing (the exact bug: supersede omitted the quoted-message and
|
|
14
|
+
* latest-ended recoveries, so a DM late reply — no live turn, no
|
|
15
|
+
* `origin_turn_id` — resolved to a null owner and its flush message was never
|
|
16
|
+
* superseded → duplicate).
|
|
17
|
+
*
|
|
18
|
+
* Precedence (first non-null wins), delegated to the pure
|
|
19
|
+
* `resolveReplyOwnerTurnId` so the exact precedence is unit-tested:
|
|
20
|
+
* 1. the live `currentTurn` passed in (null once the flush nulled the atom);
|
|
21
|
+
* 2. `findTurnByOriginId(origin_turn_id)` — the model echo;
|
|
22
|
+
* 3. `findTurnByQuotedMessageId(chat_id, reply_to)` — framework-owned quote;
|
|
23
|
+
* 4. `findLatestTurnForChat(chat_id, {endedOnly:true})` — last ENDED turn.
|
|
24
|
+
* Returns the turn atom for the winning id (so callers can read its
|
|
25
|
+
* `answerDelivered` latch), or null when every lookup missed.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
resolveReplyOwnerTier,
|
|
30
|
+
resolveReplyOwnerTurnId,
|
|
31
|
+
type ReplyOwnerCandidates,
|
|
32
|
+
type ReplyOwnerTier,
|
|
33
|
+
} from '../reply-owner-resolve.js'
|
|
34
|
+
import {
|
|
35
|
+
SUPERSEDE_OPEN_WINDOW_CAP_MS,
|
|
36
|
+
SUPERSEDE_COMPLETED_GRACE_MS,
|
|
37
|
+
} from '../flushed-turn-supersede.js'
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* #4173/#4175 — the two turn-completion-window bounds, env-overridable as the
|
|
41
|
+
* operator kill-switch for the window mechanism (clamp the open cap / grace to
|
|
42
|
+
* taste; e.g. `SWITCHROOM_SUPERSEDE_OPEN_CAP_MS=60000` restores something
|
|
43
|
+
* close to the pre-#4166 60 s behaviour). Resolved HERE once — the pure
|
|
44
|
+
* modules stay env-free — and consumed by BOTH window surfaces (the gateway's
|
|
45
|
+
* `FlushedTurnSupersedeRegistry` ctor and the latest-ended owner-tier
|
|
46
|
+
* candidates below) so they can never disagree.
|
|
47
|
+
*/
|
|
48
|
+
export const SUPERSEDE_OPEN_CAP_MS = (() => {
|
|
49
|
+
const raw = Number(process.env.SWITCHROOM_SUPERSEDE_OPEN_CAP_MS)
|
|
50
|
+
return Number.isFinite(raw) && raw > 0 ? raw : SUPERSEDE_OPEN_WINDOW_CAP_MS
|
|
51
|
+
})()
|
|
52
|
+
export const SUPERSEDE_GRACE_MS = (() => {
|
|
53
|
+
const raw = Number(process.env.SWITCHROOM_SUPERSEDE_GRACE_MS)
|
|
54
|
+
return Number.isFinite(raw) && raw > 0 ? raw : SUPERSEDE_COMPLETED_GRACE_MS
|
|
55
|
+
})()
|
|
56
|
+
|
|
57
|
+
/** The minimal turn-atom shape this wiring reads. Structural on purpose so the
|
|
58
|
+
* module never imports gateway.ts (no cycle); gateway's `CurrentTurn`
|
|
59
|
+
* satisfies it. */
|
|
60
|
+
export interface OwnerTurnLike {
|
|
61
|
+
turnId: string
|
|
62
|
+
endedAt: number | null
|
|
63
|
+
realEndObservedAt: number | null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Compose the owner-resolution result from the gateway's four lookups.
|
|
68
|
+
* Generic over the concrete turn atom type so gateway.ts gets its own
|
|
69
|
+
* `CurrentTurn` back without a cast.
|
|
70
|
+
*/
|
|
71
|
+
export function resolveReplyOwnerTurnWith<T extends OwnerTurnLike>(
|
|
72
|
+
lookups: {
|
|
73
|
+
findTurnByOriginId(originTurnId: string | null | undefined): T | null
|
|
74
|
+
findTurnByQuotedMessageId(chatId: string, replyTo: unknown): T | null
|
|
75
|
+
findLatestTurnForChat(chatId: string, opts: { endedOnly: boolean }): T | null
|
|
76
|
+
now(): number
|
|
77
|
+
},
|
|
78
|
+
liveTurn: T | null,
|
|
79
|
+
chatId: string,
|
|
80
|
+
args: Record<string, unknown>,
|
|
81
|
+
): { turn: T | null; tier: ReplyOwnerTier; candidates: ReplyOwnerCandidates } {
|
|
82
|
+
const origin = lookups.findTurnByOriginId(args.origin_turn_id as string | undefined)
|
|
83
|
+
const quoted = lookups.findTurnByQuotedMessageId(chatId, args.reply_to)
|
|
84
|
+
const latestEnded = lookups.findLatestTurnForChat(chatId, { endedOnly: true })
|
|
85
|
+
const byId = new Map<string, T>()
|
|
86
|
+
// Populate lowest-precedence first so a higher tier's turn wins the id slot
|
|
87
|
+
// when two lookups resolve the same turn (they carry the same turnId anyway).
|
|
88
|
+
for (const t of [latestEnded, quoted, origin, liveTurn]) {
|
|
89
|
+
if (t != null) byId.set(t.turnId, t)
|
|
90
|
+
}
|
|
91
|
+
// F2 — bound the DESTRUCTIVE latest-ended tier to the turn-completion window
|
|
92
|
+
// (#4173) so a stale latest-ended turn can't inherit deletion authority over
|
|
93
|
+
// a newer turn's flush record. #3725: the lookup above is `endedOnly`, so
|
|
94
|
+
// `endedAt` is non-null here and the age is ALWAYS a real number — a
|
|
95
|
+
// not-yet-ended turn is no longer a candidate at all, and an explicit null
|
|
96
|
+
// age fails CLOSED downstream (as does an omitted one, since #4175).
|
|
97
|
+
const latestEndedAgeMs =
|
|
98
|
+
latestEnded?.endedAt != null ? lookups.now() - latestEnded.endedAt : null
|
|
99
|
+
// #4173 — the completion signal: null while the flush-ended turn's REAL
|
|
100
|
+
// turn_end has not been observed (window OPEN — acceptance falls to the
|
|
101
|
+
// crash-backstop cap in `latestEndedTtlMs`); ms-since-real-end once observed
|
|
102
|
+
// (window COMPLETED — only the replay grace remains). For a normally-ended
|
|
103
|
+
// turn `realEndObservedAt === endedAt`, so the tier keeps its tight bound.
|
|
104
|
+
const latestEndedRealEndAgeMs =
|
|
105
|
+
latestEnded == null
|
|
106
|
+
? null
|
|
107
|
+
: latestEnded.realEndObservedAt != null
|
|
108
|
+
? lookups.now() - latestEnded.realEndObservedAt
|
|
109
|
+
: null
|
|
110
|
+
const candidates: ReplyOwnerCandidates = {
|
|
111
|
+
liveTurnId: liveTurn?.turnId ?? null,
|
|
112
|
+
originTurnId: origin?.turnId ?? null,
|
|
113
|
+
quotedTurnId: quoted?.turnId ?? null,
|
|
114
|
+
latestEndedTurnId: latestEnded?.turnId ?? null,
|
|
115
|
+
latestEndedAgeMs,
|
|
116
|
+
latestEndedTtlMs: SUPERSEDE_OPEN_CAP_MS,
|
|
117
|
+
latestEndedRealEndAgeMs,
|
|
118
|
+
latestEndedCompletedGraceMs: SUPERSEDE_GRACE_MS,
|
|
119
|
+
}
|
|
120
|
+
// #3429 — the winning tier AND the candidate set it came from travel with the
|
|
121
|
+
// turn. Tier alone no longer decides the content-gate bypass: the
|
|
122
|
+
// model-steerable `origin`/`quoted` tiers must be CORROBORATED against the
|
|
123
|
+
// framework-derived `latestEndedTurnId` (`decideContentGateBypass`). All three
|
|
124
|
+
// derive from these SAME candidates, so they can never disagree.
|
|
125
|
+
const tier = resolveReplyOwnerTier(candidates)
|
|
126
|
+
const winnerId = resolveReplyOwnerTurnId(candidates)
|
|
127
|
+
return { turn: winnerId != null ? (byId.get(winnerId) ?? null) : null, tier, candidates }
|
|
128
|
+
}
|
|
@@ -73,6 +73,8 @@ import { logStreamingEvent } from '../streaming-metrics.js'
|
|
|
73
73
|
import { appendActivityLabel } from '../tool-activity-summary.js'
|
|
74
74
|
import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
|
|
75
75
|
import { decideTurnFlush } from '../turn-flush-safety.js'
|
|
76
|
+
import { FlushCompletionTracker } from '../flushed-turn-supersede.js'
|
|
77
|
+
import { subagentReplyAuthority } from './subagent-reply-authority.js'
|
|
76
78
|
import { decideTerminalReason, deriveTurnRole } from '../turn-liveness-floor.js'
|
|
77
79
|
import { chatKey, chatKeyWithSuffix } from './chat-key.js'
|
|
78
80
|
import { deriveTurnId } from './derive-turn-id.js'
|
|
@@ -222,6 +224,38 @@ function discardParkedTurnStart(rawContent: string): ParkedTurnStart | null {
|
|
|
222
224
|
return null
|
|
223
225
|
}
|
|
224
226
|
|
|
227
|
+
/**
|
|
228
|
+
* #4173 — the ONE turn-completion tracker for flush-ended turns (module-scope
|
|
229
|
+
* like `parkedTurnStarts`: one CLI session per gateway process). The
|
|
230
|
+
* quiescence flush ends a turn SYNTHETICALLY while the claude session is still
|
|
231
|
+
* composing; this holds that turn's atom pending until the session's REAL
|
|
232
|
+
* turn_end (or a proxy: a new turn minting, the bridge dying) is observed,
|
|
233
|
+
* then stamps `realEndObservedAt` — the signal that flips the supersede
|
|
234
|
+
* machinery from the unbounded OPEN phase to the ~60 s replay grace. See the
|
|
235
|
+
* `flushed-turn-supersede.ts` module header for the full three-phase model.
|
|
236
|
+
*/
|
|
237
|
+
export const flushCompletionTracker = new FlushCompletionTracker()
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* #4173 — a turn-completion signal was observed: close every open flush
|
|
241
|
+
* window. Stamps the pending atoms (`flushCompletionTracker`) AND starts the
|
|
242
|
+
* replay grace on the registry's records (`completeAll`) in one call, so the
|
|
243
|
+
* two surfaces (latest-ended owner tier / supersede record) can never observe
|
|
244
|
+
* different phases. Called from the real-turn_end path, the new-turn mint, and
|
|
245
|
+
* the gateway's bridge-death sweep. Returns how many atom windows closed.
|
|
246
|
+
*/
|
|
247
|
+
export function closeFlushCompletionWindows(
|
|
248
|
+
// Optional-shaped on purpose: several test harnesses drive `beginTurn` /
|
|
249
|
+
// `handleSessionEvent` with a partial deps object that carries no supersede
|
|
250
|
+
// registry. The production deps builder always injects the real one.
|
|
251
|
+
registry: { completeAll?: (now: number) => void } | null | undefined,
|
|
252
|
+
now: number,
|
|
253
|
+
): number {
|
|
254
|
+
const closed = flushCompletionTracker.closeAll(now)
|
|
255
|
+
registry?.completeAll?.(now)
|
|
256
|
+
return closed
|
|
257
|
+
}
|
|
258
|
+
|
|
225
259
|
/** Test seam: the parked store is module-scope (one CLI session, one queue), so
|
|
226
260
|
* suites that drive `handleSessionEvent` need a deterministic reset. */
|
|
227
261
|
export function __resetParkedTurnStartsForTest(): void {
|
|
@@ -558,6 +592,9 @@ function beginTurn(deps: StreamRenderDeps, ev: TurnStartEnvelope): void {
|
|
|
558
592
|
flushedAnswerText: null,
|
|
559
593
|
// 2026-07 double-reply-on-DM fix (F2) — stamped at turn end.
|
|
560
594
|
endedAt: null,
|
|
595
|
+
// #4173 — the turn-completion signal, stamped at turn end (or later, for
|
|
596
|
+
// a flush-ended turn, when the real turn_end is observed).
|
|
597
|
+
realEndObservedAt: null,
|
|
561
598
|
firstPingAt: null,
|
|
562
599
|
// Notification ownership (R8 / PR-2): no slot claimed yet, so the
|
|
563
600
|
// "claimer was substantive" flag starts false. Set atomically with
|
|
@@ -682,6 +719,12 @@ function beginTurn(deps: StreamRenderDeps, ev: TurnStartEnvelope): void {
|
|
|
682
719
|
// already the single stop-owner for ALL turns, and a start is
|
|
683
720
|
// self-healing anyway, so this cannot leak an interval.
|
|
684
721
|
startTurnTypingLoop(ev.chatId, enqThreadIdNum ?? null)
|
|
722
|
+
// #4173 — a new turn minting on the serial claude session is a completion
|
|
723
|
+
// PROXY for any still-open flush window: the session has moved on, so a
|
|
724
|
+
// prior flushed turn's own late reply can now only arrive as a bounded
|
|
725
|
+
// replay (the grace). Closing here is the SAFE direction — a window closed
|
|
726
|
+
// too early costs at most a visible duplicate, never an edit-over.
|
|
727
|
+
closeFlushCompletionWindows(deps.flushedTurnSupersede, Date.now())
|
|
685
728
|
// PR-4e — route the turn-SET through the keyed accessor: flag-OFF assigns
|
|
686
729
|
// the singleton (byte-identical to `currentTurn = next`); flag-ON sets the
|
|
687
730
|
// per-topic `byKey[statusKey]` entry AND the most-recent mirror. The key is
|
|
@@ -916,6 +959,16 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
916
959
|
const durationMs = ev.kind === 'turn_end' ? ev.durationMs : undefined
|
|
917
960
|
idleTracker.noteEvent(ev.kind, Date.now(), durationMs)
|
|
918
961
|
}
|
|
962
|
+
// #4176 — sub-agent liveness, fed from the SAME whole-stream position as the
|
|
963
|
+
// idle clock above and for the same reason: `sub_agent_*` events arrive with
|
|
964
|
+
// or without a live gateway turn, and the reply send path must know whether a
|
|
965
|
+
// background sub-agent could be the author of a decoupled reply (a sub-agent
|
|
966
|
+
// calls `reply` over the parent's OWN bridge, so neither the caller-identity
|
|
967
|
+
// gate nor the handback marker can see it). Deliberately BEFORE the switch:
|
|
968
|
+
// the `sub_agent_tool_use` case below early-returns when `getCurrentTurn()` is
|
|
969
|
+
// null, which is exactly the post-flush state this gate exists for. See
|
|
970
|
+
// gateway/subagent-reply-authority.ts.
|
|
971
|
+
subagentReplyAuthority.noteSessionEvent(ev as { kind: string; agentId?: string })
|
|
919
972
|
switch (ev.kind) {
|
|
920
973
|
case 'enqueue': {
|
|
921
974
|
// #3927 FIX A — an `enqueue` is a QUEUE event, not a turn START event
|
|
@@ -1724,6 +1777,17 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
1724
1777
|
return
|
|
1725
1778
|
}
|
|
1726
1779
|
}
|
|
1780
|
+
// #4173 — every turn_end that PROCEEDS (a real turn_end always; a
|
|
1781
|
+
// synthetic one only past the suppression guard above) is a completion
|
|
1782
|
+
// signal for any flush window still open from an EARLIER turn: the
|
|
1783
|
+
// serial session has reached a stop, so that turn's own late reply can
|
|
1784
|
+
// now only arrive as a bounded replay. Close them BEFORE this event's
|
|
1785
|
+
// own processing — the quiescence-flush branch below may OPEN a new
|
|
1786
|
+
// window for THIS turn right after. The common case this serves: the
|
|
1787
|
+
// flush's synthetic turn_end tore the atom down, so when the REAL
|
|
1788
|
+
// turn_end for that same turn lands here it finds no live atom and
|
|
1789
|
+
// otherwise no-ops — this close is how that real turn_end is observed.
|
|
1790
|
+
closeFlushCompletionWindows(flushedTurnSupersede, Date.now())
|
|
1727
1791
|
// #2094 finding 1 — turn_end gate-wedge backstop. Capture the turn
|
|
1728
1792
|
// BEFORE the body runs (the body re-reads currentTurn as `turn`, then
|
|
1729
1793
|
// nulls it via endCurrentTurnAtomic on every clean branch). The guarded
|
|
@@ -2244,6 +2308,22 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
2244
2308
|
// #1556 wedge-safety reasons. `backstopTurnEndedAt` is null iff the
|
|
2245
2309
|
// atom was already torn down elsewhere (no record to emit).
|
|
2246
2310
|
const backstopTurnEndedAt = endCurrentTurnAtomic(turn, { deferRecord: true, deferObligationClose: true })
|
|
2311
|
+
// #4173 — the ANSWER-READY QUIESCENCE variant of this branch ends the
|
|
2312
|
+
// turn SYNTHETICALLY (durationMs === -1): the claude session is still
|
|
2313
|
+
// composing and its REAL turn_end arrives later. Re-open the atom's
|
|
2314
|
+
// completion window (endCurrentTurnAtomic just stamped
|
|
2315
|
+
// `realEndObservedAt` with the default "ended = really ended" rule) and
|
|
2316
|
+
// hold it pending, so the supersede window for this flush stays
|
|
2317
|
+
// claimable until the real turn_end — or a proxy — is observed. This is
|
|
2318
|
+
// what makes a same-session reply landing after a 20-minute /compact
|
|
2319
|
+
// still collapse onto the flushed message (#4166), without holding the
|
|
2320
|
+
// window open for even a second once the session actually stops.
|
|
2321
|
+
// The TURN-END BACKSTOP variant (durationMs >= 0) fires ON the real
|
|
2322
|
+
// turn_end: the session already stopped, so its window is born
|
|
2323
|
+
// completed (grace-bounded) — it must NOT be re-opened.
|
|
2324
|
+
if (ev.durationMs === -1) {
|
|
2325
|
+
flushCompletionTracker.open(turn)
|
|
2326
|
+
}
|
|
2247
2327
|
// #549 fix — turn-flush takes ownership of the captured-text
|
|
2248
2328
|
// backup; reset the preamble buffer (its content is already in
|
|
2249
2329
|
// the captured `capturedText`, which turn-flush is about to send).
|
|
@@ -2446,7 +2526,17 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
2446
2526
|
flushedTurnSupersede.record(
|
|
2447
2527
|
backstopChatId,
|
|
2448
2528
|
backstopThreadId,
|
|
2449
|
-
|
|
2529
|
+
// #4173 — inherit the atom's completion state: the async send
|
|
2530
|
+
// above can outlast the real turn_end (a close that ran while
|
|
2531
|
+
// we awaited stamped `realEndObservedAt`), and a window closed
|
|
2532
|
+
// before its record existed must be born completed
|
|
2533
|
+
// (grace-bounded), never resurrected as open.
|
|
2534
|
+
{
|
|
2535
|
+
turnId: turn.turnId,
|
|
2536
|
+
messageIds: sentIds,
|
|
2537
|
+
text: capturedText,
|
|
2538
|
+
completedAt: turn.realEndObservedAt,
|
|
2539
|
+
},
|
|
2450
2540
|
Date.now(),
|
|
2451
2541
|
)
|
|
2452
2542
|
}
|
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
* #1177/#1182/#1201 double-sent).
|
|
19
19
|
* - CASE B — a background handback attributed to that ended turn. A
|
|
20
20
|
* `subagent_handback` WAS enqueued after the turn ended and within the
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* answer.
|
|
21
|
+
* handback recency window (`HANDBACK_RECENCY_WINDOW_MS` below, #4174), so
|
|
22
|
+
* the late reply might BE it → keep the #3429 content gate and send fresh
|
|
23
|
+
* (two messages), never silently edit/delete the flushed answer.
|
|
24
24
|
*
|
|
25
25
|
* ## Why thread-keyed (F2, dup-audit 2026-07-21)
|
|
26
26
|
*
|
|
@@ -65,12 +65,30 @@
|
|
|
65
65
|
* reply with NO live gateway turn of its own (a background worker completion),
|
|
66
66
|
* so its reply resolves a DIFFERENT, already-ended turn via the latest-ended
|
|
67
67
|
* tier. It is the F1 vector.
|
|
68
|
-
* - Every OTHER synthesized source
|
|
69
|
-
* as its OWN live inbound turn, so
|
|
70
|
-
*
|
|
71
|
-
* record (`decideSupersede`
|
|
72
|
-
* must NOT stamp — stamping
|
|
73
|
-
*
|
|
68
|
+
* - Every OTHER synthesized source that traverses THIS chokepoint (resume_*,
|
|
69
|
+
* reaction, vault_*, Tier-2 cron, …) lands as its OWN live inbound turn, so
|
|
70
|
+
* its reply resolves the `live` tier for its own turnId and structurally
|
|
71
|
+
* cannot supersede a different ended turn's flush record (`decideSupersede`
|
|
72
|
+
* requires `record.turnId === liveTurnId`). Those must NOT stamp — stamping
|
|
73
|
+
* them would needlessly hold the content gate open for an unrelated
|
|
74
|
+
* own-reply in the window (a safe but avoidable visible dup).
|
|
75
|
+
*
|
|
76
|
+
* ## What this chokepoint CANNOT see (#4172 — the caller-identity gate)
|
|
77
|
+
*
|
|
78
|
+
* The invariant above only covers sources delivered through
|
|
79
|
+
* `pendingInboundBuffer.push`. A Tier-1 CHEAP-CRON fire is delivered via
|
|
80
|
+
* `sendToAgent` to the derived `<agent>-cron` bridge and never traverses the
|
|
81
|
+
* buffer — AND its session events are dropped for the cron identity in
|
|
82
|
+
* `onSessionEvent`, so it never mints a live gateway turn either. Its `reply`
|
|
83
|
+
* therefore arrives decoupled (turn == null) with foreign content and NO
|
|
84
|
+
* marker — exactly the shape marker-absence was claimed to disprove. The same
|
|
85
|
+
* holds for any reply arriving on a connection that is not the main agent
|
|
86
|
+
* bridge. That class is closed at a DIFFERENT chokepoint: the reply send path
|
|
87
|
+
* (`outbound-send-path.ts`) refuses supersede/bypass/latch authority to any
|
|
88
|
+
* caller that is not the main agent bridge (`replyCallerIsForeignSession`,
|
|
89
|
+
* cron-session.ts) — identity, not marker stamping, is the deterministic
|
|
90
|
+
* signal there. Setting `cron: { decoupledCompletion: true }` below would NOT
|
|
91
|
+
* work: a Tier-1 fire never reaches this registry's write site.
|
|
74
92
|
*
|
|
75
93
|
* This predicate is therefore the SINGLE point of extension: any future feature
|
|
76
94
|
* that synthesizes an inbound which can land as a DECOUPLED late reply (no live
|
|
@@ -114,6 +132,12 @@ export const INBOUND_SOURCE_CLASSIFICATION: Record<string, { decoupledCompletion
|
|
|
114
132
|
// Everything below lands as its OWN live inbound turn (live tier), so its reply
|
|
115
133
|
// resolves the live tier for its own turnId and structurally cannot supersede a
|
|
116
134
|
// different ended turn's record → not a decoupled-completion vector, must not stamp.
|
|
135
|
+
//
|
|
136
|
+
// cron: `false` covers the TIER-2 (main-bridge, `context:'agent'`) fire, which
|
|
137
|
+
// does land as its own live turn through this chokepoint. The TIER-1 cheap-cron
|
|
138
|
+
// fire never traverses this chokepoint AT ALL (see "What this chokepoint
|
|
139
|
+
// CANNOT see" above) — it is closed by the caller-identity gate in the reply
|
|
140
|
+
// send path (#4172), and flipping this to `true` would not reach it.
|
|
117
141
|
cron: { decoupledCompletion: false },
|
|
118
142
|
reaction: { decoupledCompletion: false },
|
|
119
143
|
subagent_progress: { decoupledCompletion: false },
|
|
@@ -168,6 +192,22 @@ export function stampsHandbackMarker(source: string | null | undefined): boolean
|
|
|
168
192
|
return known.decoupledCompletion
|
|
169
193
|
}
|
|
170
194
|
|
|
195
|
+
/**
|
|
196
|
+
* #4174 — how long after a `subagent_handback` enqueue the content gate stays
|
|
197
|
+
* held for late replies attributed to an ended turn (`handbackCouldOwnReply`,
|
|
198
|
+
* outbound-send-path.ts). This deliberately does NOT follow the supersede
|
|
199
|
+
* window's bounds (#4173): the gate-hold is CHAT-WIDE (the latest-ended owner
|
|
200
|
+
* tier is chat-wide, so the read must be too — MUST-FIX 2 above), which means
|
|
201
|
+
* every in-window handback re-opens the reworded-own-answer visible-duplicate
|
|
202
|
+
* class for EVERY topic in the chat for the window's length. 60 s — the
|
|
203
|
+
* originally shipped bound — covers the enqueue→decoupled-reply gap the marker
|
|
204
|
+
* exists for, while keeping the chat-wide dup exposure per handback small. In
|
|
205
|
+
* a delegation-heavy agent, tying this to a minutes-long supersede bound would
|
|
206
|
+
* hold the gate almost continuously — re-opening the exact duplicate class the
|
|
207
|
+
* supersede machinery closes.
|
|
208
|
+
*/
|
|
209
|
+
export const HANDBACK_RECENCY_WINDOW_MS = 60_000
|
|
210
|
+
|
|
171
211
|
/** Sentinel thread key for the no-thread (DM / bare-chat) lane. */
|
|
172
212
|
const MAIN_THREAD_KEY = '<main>'
|
|
173
213
|
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4176 — sub-agent reply authority: the discriminator the "serial session"
|
|
3
|
+
* premise was missing.
|
|
4
|
+
*
|
|
5
|
+
* ## The premise that was false
|
|
6
|
+
*
|
|
7
|
+
* The turn-completion window (#4173, `flushed-turn-supersede.ts`) rests on this
|
|
8
|
+
* claim: a `reply` landing on the MAIN agent bridge between a flush's SYNTHETIC
|
|
9
|
+
* turn_end and the session's REAL turn_end is, by construction, that turn's own
|
|
10
|
+
* answer still being composed. The stated justification was that no other work
|
|
11
|
+
* can run on the serial claude session in that span.
|
|
12
|
+
*
|
|
13
|
+
* That is true of the session's OWN loop and false of its sub-agents. A
|
|
14
|
+
* background `Task` sub-agent runs CONCURRENTLY with the parent loop, and it
|
|
15
|
+
* calls MCP tools through the SAME plugin process and therefore the SAME IPC
|
|
16
|
+
* bridge — so `client.agentName` is the main agent and the #4172 caller-identity
|
|
17
|
+
* gate (`replyCallerIsForeignSession`) does not see it. In this fleet the
|
|
18
|
+
* `researcher` and `reviewer` sub-agent types hold the full tool set, `reply`
|
|
19
|
+
* included (`worker` holds only `progress_update`).
|
|
20
|
+
*
|
|
21
|
+
* The concrete loss that made this a MAJOR (review of #4167): the quiescence
|
|
22
|
+
* flush delivers the answer as message A and the window is OPEN; a background
|
|
23
|
+
* sub-agent calls `reply` into the same chat; owner resolution lands on the
|
|
24
|
+
* flush-ended turn (OPEN is accepted at ANY age up to the crash cap); no
|
|
25
|
+
* `subagent_handback` marker exists because a DIRECT tool call never traverses
|
|
26
|
+
* `pendingInboundBuffer.push`; `decideContentGateBypass` therefore grants
|
|
27
|
+
* `positiveAttribution`, and `decideSupersede` supersedes REGARDLESS of content
|
|
28
|
+
* — the sub-agent's message silently EDITS OVER the user's flushed answer
|
|
29
|
+
* (Telegram edits do not re-notify: both messages are lost client-side). The
|
|
30
|
+
* class pre-exists on `main` bounded by the old 60 s TTL; #4173 extends the tail
|
|
31
|
+
* to the OPEN-phase cap, which is why it is closed here rather than narrowed.
|
|
32
|
+
*
|
|
33
|
+
* ## The discriminator: gateway-observed sub-agent LIVENESS
|
|
34
|
+
*
|
|
35
|
+
* The gateway already receives the whole session-event stream, including every
|
|
36
|
+
* `sub_agent_*` kind, at `onSessionEvent` → `handleSessionEvent`. Those events
|
|
37
|
+
* are derived by the framework from the sidechain transcript
|
|
38
|
+
* (`projectSubagentLine`, session-tail.ts) — never from model discipline, never
|
|
39
|
+
* from anything a reply can steer.
|
|
40
|
+
*
|
|
41
|
+
* A sub-agent is tracked LIVE from any `sub_agent_*` event carrying its
|
|
42
|
+
* `agentId` until its `sub_agent_turn_end`. While ANY sub-agent is live, a
|
|
43
|
+
* decoupled reply on the main bridge MIGHT be that sub-agent's, so it is denied
|
|
44
|
+
* the #3429 content-gate bypass (`decideContentGateBypass`) — the reply must
|
|
45
|
+
* then BE the flushed answer to supersede it. A sub-agent's own (foreign)
|
|
46
|
+
* content therefore decides `'new-content'` and ships as a fresh, notifying
|
|
47
|
+
* message, with the flush record left intact for the turn's genuine replay.
|
|
48
|
+
*
|
|
49
|
+
* ### Why liveness and not per-reply correlation
|
|
50
|
+
*
|
|
51
|
+
* Correlating the landing MCP call with the `sub_agent_tool_use` event for that
|
|
52
|
+
* same `reply` would be exact, but the two arrive on independent transports (an
|
|
53
|
+
* MCP stdio round-trip vs. a JSONL tail), so the event can land AFTER the reply
|
|
54
|
+
* it describes — a race whose losing side is the silent edit-over. Liveness has
|
|
55
|
+
* the ordering the correlation lacks: a sub-agent cannot call a tool before it
|
|
56
|
+
* exists, and `sub_agent_started` is projected from its FIRST transcript line,
|
|
57
|
+
* which precedes any tool call it makes by a full model round-trip. The gate is
|
|
58
|
+
* therefore armed by the time the reply can exist — a structural ordering
|
|
59
|
+
* argument, not a tuned delay.
|
|
60
|
+
*
|
|
61
|
+
* ### Failure directions (all fail SAFE)
|
|
62
|
+
*
|
|
63
|
+
* - A sub-agent whose `sub_agent_turn_end` is never observed (capped, killed,
|
|
64
|
+
* crashed tail) stays live: the content gate holds, so the worst case is a
|
|
65
|
+
* REWORDED own-answer shipping as a visible duplicate. Never an edit-over.
|
|
66
|
+
* `reset()` on bridge death bounds it — the sub-agents die with the session.
|
|
67
|
+
* - A sub-agent steered after its `turn_end` (switchroom's `SendMessage`
|
|
68
|
+
* pattern) re-arms on its next `sub_agent_*` event, which again precedes any
|
|
69
|
+
* reply it makes by a model round-trip.
|
|
70
|
+
* - No sub-agent live ⇒ the premise HOLDS and the #4166 collapse is untouched;
|
|
71
|
+
* this gate costs nothing in the non-delegating case.
|
|
72
|
+
*
|
|
73
|
+
* ### Residual, stated honestly
|
|
74
|
+
*
|
|
75
|
+
* Denying the BYPASS (rather than supersede authority wholesale) is deliberate:
|
|
76
|
+
* with the gate on, a reply still supersedes when it IS the flushed answer
|
|
77
|
+
* (`flushedAnswerMatchesReply` — equality, or containment above
|
|
78
|
+
* `SUPERSEDE_MATCH_MIN_CONTAINMENT_CHARS`). Superseding on equality is
|
|
79
|
+
* content-preserving by definition. The residual is the containment arm: a
|
|
80
|
+
* sub-agent reply that is a ≥32-char verbatim substring of the flushed answer
|
|
81
|
+
* would still collapse into it. Denying supersede outright instead would, in a
|
|
82
|
+
* delegation-heavy agent where a background worker is nearly always live,
|
|
83
|
+
* re-open #4166 for every own-answer replay — a far larger, constant cost for a
|
|
84
|
+
* class that requires a sub-agent to emit a verbatim substring of the parent's
|
|
85
|
+
* answer.
|
|
86
|
+
*
|
|
87
|
+
* Pure: a set of ids, no clock reads, no I/O. The gateway wires the feed
|
|
88
|
+
* (`onSessionEvent`) and the read (the `sendReply` deps).
|
|
89
|
+
*/
|
|
90
|
+
|
|
91
|
+
/** The read surface the reply send path consumes (injected, so the golden
|
|
92
|
+
* harness drives it without touching the singleton). */
|
|
93
|
+
export interface SubagentReplyAuthorityView {
|
|
94
|
+
/** True when at least one sub-agent is live on this session and could
|
|
95
|
+
* therefore be the author of a decoupled reply landing right now. */
|
|
96
|
+
subagentCouldOwnReply(): boolean
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The minimal session-event shape this tracker reads. Structural so the module
|
|
100
|
+
* never imports the session-tail union (no cycle, trivially fake-able). */
|
|
101
|
+
export interface SubagentLifecycleEvent {
|
|
102
|
+
kind: string
|
|
103
|
+
agentId?: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const SUB_AGENT_KIND_PREFIX = 'sub_agent_'
|
|
107
|
+
|
|
108
|
+
export class SubagentReplyAuthority implements SubagentReplyAuthorityView {
|
|
109
|
+
private readonly live = new Set<string>()
|
|
110
|
+
|
|
111
|
+
/** Feed EVERY session event here. Non-`sub_agent_*` kinds are ignored, so the
|
|
112
|
+
* call site needs no filtering (and cannot drift from this one). */
|
|
113
|
+
noteSessionEvent(ev: SubagentLifecycleEvent | null | undefined): void {
|
|
114
|
+
if (ev == null) return
|
|
115
|
+
const kind = ev.kind
|
|
116
|
+
if (typeof kind !== 'string' || !kind.startsWith(SUB_AGENT_KIND_PREFIX)) return
|
|
117
|
+
const agentId = ev.agentId
|
|
118
|
+
// An id-less sub-agent event cannot be attributed to a lifecycle; ignoring
|
|
119
|
+
// it is the safe direction for `turn_end` (liveness persists → gate holds)
|
|
120
|
+
// and merely a missed arm otherwise — every other kind for a real sub-agent
|
|
121
|
+
// carries the id.
|
|
122
|
+
if (typeof agentId !== 'string' || agentId === '') return
|
|
123
|
+
if (kind === `${SUB_AGENT_KIND_PREFIX}turn_end`) this.live.delete(agentId)
|
|
124
|
+
else this.live.add(agentId)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
subagentCouldOwnReply(): boolean {
|
|
128
|
+
return this.live.size > 0
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The bridge died: the claude session and every sub-agent under it are gone.
|
|
132
|
+
* Bounds a leaked liveness entry (see "Failure directions"). */
|
|
133
|
+
reset(): void {
|
|
134
|
+
this.live.clear()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Test/diagnostics: how many sub-agents are currently tracked live. */
|
|
138
|
+
liveCount(): number {
|
|
139
|
+
return this.live.size
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The ONE live instance (one CLI session per gateway process — the same
|
|
144
|
+
* module-singleton shape as `flushCompletionTracker` in stream-render.ts).
|
|
145
|
+
* Re-`new`ing this anywhere else splits the signal and silently re-opens the
|
|
146
|
+
* edit-over hole. */
|
|
147
|
+
export const subagentReplyAuthority = new SubagentReplyAuthority()
|