switchroom 0.19.44 → 0.19.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/switchroom.js +848 -826
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +48 -4
- package/telegram-plugin/dist/gateway/gateway.js +499 -322
- package/telegram-plugin/flushed-turn-supersede.ts +190 -16
- package/telegram-plugin/gateway/auth-command.ts +7 -4
- package/telegram-plugin/gateway/auth-loopback-relay.ts +39 -20
- package/telegram-plugin/gateway/cron-session.ts +31 -0
- package/telegram-plugin/gateway/gateway.ts +67 -66
- 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/auth-loopback-relay.test.ts +58 -0
- 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
|
@@ -85,7 +85,9 @@ import {
|
|
|
85
85
|
type TelegraphAccount,
|
|
86
86
|
} from '../telegraph.js'
|
|
87
87
|
import { OutboundDedupCache } from '../recent-outbound-dedup.js'
|
|
88
|
-
import { FlushedTurnSupersedeRegistry
|
|
88
|
+
import { FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
|
|
89
|
+
import { resolveReplyOwnerTurnWith, SUPERSEDE_OPEN_CAP_MS, SUPERSEDE_GRACE_MS } from './reply-owner-wiring.js'
|
|
90
|
+
import { subagentReplyAuthority } from './subagent-reply-authority.js'
|
|
89
91
|
import { createInboundCoalescer, inboundCoalesceKey } from './inbound-coalesce.js'
|
|
90
92
|
import {
|
|
91
93
|
splitCoalescedAttachments,
|
|
@@ -455,7 +457,7 @@ import {
|
|
|
455
457
|
type SendReplyGatewayDeps,
|
|
456
458
|
type DeliverCapturedProseDeps,
|
|
457
459
|
} from './outbound-send-path.js'
|
|
458
|
-
import { handleSessionEvent as handleSessionEventCore, drainParkedTurnStartsForChat } from './stream-render.js'
|
|
460
|
+
import { handleSessionEvent as handleSessionEventCore, drainParkedTurnStartsForChat, closeFlushCompletionWindows } from './stream-render.js'
|
|
459
461
|
import { createNarrativeLane } from './narrative-lane.js'
|
|
460
462
|
import {
|
|
461
463
|
parseAgentCallback,
|
|
@@ -488,8 +490,6 @@ import {
|
|
|
488
490
|
FLUSH_SUBSTANTIVE_MIN_CHARS,
|
|
489
491
|
} from '../turn-flush-safety.js'
|
|
490
492
|
import {
|
|
491
|
-
resolveReplyOwnerTurnId,
|
|
492
|
-
resolveReplyOwnerTier,
|
|
493
493
|
type ReplyOwnerTier, type ReplyOwnerCandidates,
|
|
494
494
|
type AnswerDeliveredLatch,
|
|
495
495
|
} from '../reply-owner-resolve.js'
|
|
@@ -657,7 +657,7 @@ import { handleRequestDriveApproval } from './drive-write-approval.js'
|
|
|
657
657
|
import { handleRequestMs365Approval } from './ms365-write-approval.js'
|
|
658
658
|
import { buildDiffPreviewCard } from './diff-preview-card.js'
|
|
659
659
|
import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick } from './pending-inbound-buffer.js'
|
|
660
|
-
import { isCronIdentity, isCronInjectFire, deliverInjectWithFallback } from './cron-session.js'
|
|
660
|
+
import { isCronIdentity, isCronInjectFire, deliverInjectWithFallback, replyCallerIsForeignSession } from './cron-session.js'
|
|
661
661
|
import {
|
|
662
662
|
ObligationLedger,
|
|
663
663
|
obligationEscalationText,
|
|
@@ -2377,7 +2377,12 @@ const outboundDedup = new OutboundDedupCache()
|
|
|
2377
2377
|
// a second message. Keyed on the per-turn `turnId` nonce, NOT on text — so it
|
|
2378
2378
|
// catches the containment case the exact-text `outboundDedup` misses (a
|
|
2379
2379
|
// `narration\n\nanswer` flush never equals the clean `answer`-only reply).
|
|
2380
|
-
|
|
2380
|
+
// #4173/#4175 — window bounds (env kill-switch + rationale) live in
|
|
2381
|
+
// reply-owner-wiring.ts so this ctor and the owner-tier candidates share them.
|
|
2382
|
+
const flushedTurnSupersede = new FlushedTurnSupersedeRegistry({
|
|
2383
|
+
openCapMs: SUPERSEDE_OPEN_CAP_MS,
|
|
2384
|
+
completedGraceMs: SUPERSEDE_GRACE_MS,
|
|
2385
|
+
})
|
|
2381
2386
|
// #3276 — the turn-flush backstop's per-turn delivery latch + per-chunk
|
|
2382
2387
|
// idempotency ledger. The latch (keyed on `turnId`) is the deterministic
|
|
2383
2388
|
// arbiter of backstop-vs-reply; the chunk ledger lets a retry after a partial
|
|
@@ -3457,12 +3462,18 @@ export type CurrentTurn = {
|
|
|
3457
3462
|
// The `latest-ended` supersede tier carries DESTRUCTIVE authority (it drives
|
|
3458
3463
|
// message deletion), so `resolveReplyOwnerTurn` only honours a latest-ended
|
|
3459
3464
|
// turn whose `endedAt` is non-null (#3725 — the registry is populated at turn
|
|
3460
|
-
// START, so the tail entry may still be RUNNING) AND within the
|
|
3461
|
-
// otherwise a late reply belonging to an
|
|
3462
|
-
// a NEWER turn at the registry tail and
|
|
3463
|
-
//
|
|
3464
|
-
// topic to deliver into
|
|
3465
|
+
// START, so the tail entry may still be RUNNING) AND within the
|
|
3466
|
+
// turn-completion window (#4173) — otherwise a late reply belonging to an
|
|
3467
|
+
// OLDER turn could resolve its owner to a NEWER turn at the registry tail and
|
|
3468
|
+
// delete that turn's legit answer. The unbounded ROUTING use
|
|
3469
|
+
// (`endedOnly: false`) is unaffected: it only picks a topic to deliver into
|
|
3470
|
+
// and deletes nothing.
|
|
3465
3471
|
endedAt: number | null
|
|
3472
|
+
// #4173 — the turn-completion signal: ms the SESSION's real stop was
|
|
3473
|
+
// observed, or null while the window is OPEN (a flush-ended turn still
|
|
3474
|
+
// composing). Stamped in turn-end.ts / closed by stream-render's hooks;
|
|
3475
|
+
// full three-phase model in flushed-turn-supersede.ts.
|
|
3476
|
+
realEndObservedAt: number | null
|
|
3466
3477
|
// #1675 (over-ping safety net): wall-clock ms of the first reply
|
|
3467
3478
|
// this turn that landed with `disable_notification: false` (a real
|
|
3468
3479
|
// device ping). The conversational-pacing contract
|
|
@@ -3985,60 +3996,21 @@ function findLatestTurnForChat(chatId: string, opts: { endedOnly: boolean }): Cu
|
|
|
3985
3996
|
return latestTurnForChat(recentTurnsById.values(), chatId, opts)
|
|
3986
3997
|
}
|
|
3987
3998
|
|
|
3988
|
-
/**
|
|
3989
|
-
*
|
|
3990
|
-
*
|
|
3991
|
-
*
|
|
3992
|
-
* omitted the quoted-message and latest-ended recoveries, so a DM late reply —
|
|
3993
|
-
* no live turn, no `origin_turn_id` — resolved to a null owner and its flush
|
|
3994
|
-
* message was never superseded → duplicate).
|
|
3995
|
-
*
|
|
3996
|
-
* Precedence (first non-null wins), delegated to the pure
|
|
3997
|
-
* `resolveReplyOwnerTurnId` so the exact precedence is unit-tested:
|
|
3998
|
-
* 1. the live `currentTurn` passed in (null once the flush nulled the atom);
|
|
3999
|
-
* 2. `findTurnByOriginId(origin_turn_id)` — the model echo;
|
|
4000
|
-
* 3. `findTurnByQuotedMessageId(chat_id, reply_to)` — framework-owned quote;
|
|
4001
|
-
* 4. `findLatestTurnForChat(chat_id, {endedOnly:true})` — last ENDED turn.
|
|
4002
|
-
* Returns the CurrentTurn for the winning id (so callers can read its
|
|
4003
|
-
* `answerDelivered` latch), or null when every lookup missed.
|
|
4004
|
-
*/
|
|
3999
|
+
/** 2026-07 double-reply-on-DM fix (Part 1) — thin wrapper over the extracted
|
|
4000
|
+
* composition (`reply-owner-wiring.ts`, which also carries the #4173
|
|
4001
|
+
* turn-completion-window bounds). Kept here only to bind the gateway's
|
|
4002
|
+
* stateful lookups. */
|
|
4005
4003
|
function resolveReplyOwnerTurn(
|
|
4006
4004
|
liveTurn: CurrentTurn | null,
|
|
4007
4005
|
chatId: string,
|
|
4008
4006
|
args: Record<string, unknown>,
|
|
4009
4007
|
): { turn: CurrentTurn | null; tier: ReplyOwnerTier; candidates: ReplyOwnerCandidates } {
|
|
4010
|
-
|
|
4011
|
-
|
|
4012
|
-
|
|
4013
|
-
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
for (const t of [latestEnded, quoted, origin, liveTurn]) {
|
|
4017
|
-
if (t != null) byId.set(t.turnId, t)
|
|
4018
|
-
}
|
|
4019
|
-
// F2 — bound the DESTRUCTIVE latest-ended tier to the supersede TTL so a stale
|
|
4020
|
-
// latest-ended turn can't inherit deletion authority over a newer turn's flush
|
|
4021
|
-
// record. #3725: the lookup above is `endedOnly`, so `endedAt` is non-null here
|
|
4022
|
-
// and the age is ALWAYS a real number — a not-yet-ended turn is no longer a
|
|
4023
|
-
// candidate at all, and an explicit null age now fails CLOSED downstream.
|
|
4024
|
-
const latestEndedAgeMs =
|
|
4025
|
-
latestEnded?.endedAt != null ? Date.now() - latestEnded.endedAt : null
|
|
4026
|
-
const candidates: ReplyOwnerCandidates = {
|
|
4027
|
-
liveTurnId: liveTurn?.turnId ?? null,
|
|
4028
|
-
originTurnId: origin?.turnId ?? null,
|
|
4029
|
-
quotedTurnId: quoted?.turnId ?? null,
|
|
4030
|
-
latestEndedTurnId: latestEnded?.turnId ?? null,
|
|
4031
|
-
latestEndedAgeMs,
|
|
4032
|
-
latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
|
|
4033
|
-
}
|
|
4034
|
-
// #3429 — the winning tier AND the candidate set it came from travel with the
|
|
4035
|
-
// turn. Tier alone no longer decides the content-gate bypass: the
|
|
4036
|
-
// model-steerable `origin`/`quoted` tiers must be CORROBORATED against the
|
|
4037
|
-
// framework-derived `latestEndedTurnId` (`decideContentGateBypass`). All three
|
|
4038
|
-
// derive from these SAME candidates, so they can never disagree.
|
|
4039
|
-
const tier = resolveReplyOwnerTier(candidates)
|
|
4040
|
-
const winnerId = resolveReplyOwnerTurnId(candidates)
|
|
4041
|
-
return { turn: winnerId != null ? (byId.get(winnerId) ?? null) : null, tier, candidates }
|
|
4008
|
+
return resolveReplyOwnerTurnWith(
|
|
4009
|
+
{ findTurnByOriginId, findTurnByQuotedMessageId, findLatestTurnForChat, now: Date.now },
|
|
4010
|
+
liveTurn,
|
|
4011
|
+
chatId,
|
|
4012
|
+
args,
|
|
4013
|
+
)
|
|
4042
4014
|
}
|
|
4043
4015
|
|
|
4044
4016
|
/**
|
|
@@ -10759,12 +10731,27 @@ if (isGatewayMain) ipcServer = createIpcServer({
|
|
|
10759
10731
|
},
|
|
10760
10732
|
log: (msg) => process.stderr.write(`${msg}\n`),
|
|
10761
10733
|
})
|
|
10734
|
+
// #4173 — a MAIN-bridge death is a turn-completion proxy: the dead session
|
|
10735
|
+
// can never deliver a flush-ended turn's real turn_end, so close any open
|
|
10736
|
+
// flush windows now (the replay grace still covers a reconnect replay).
|
|
10737
|
+
// Scoped to a registered non-cron agent; rationale in stream-render.ts.
|
|
10738
|
+
if (client.agentName != null && !isCronIdentity(client.agentName)) {
|
|
10739
|
+
subagentReplyAuthority.reset() // #4176 — the sub-agents died with the session
|
|
10740
|
+
const closedWindows = closeFlushCompletionWindows(flushedTurnSupersede, Date.now())
|
|
10741
|
+
if (closedWindows > 0) {
|
|
10742
|
+
process.stderr.write(
|
|
10743
|
+
`telegram gateway: bridge disconnect closed ${closedWindows} open flush-completion window(s) (#4173)\n`,
|
|
10744
|
+
)
|
|
10745
|
+
}
|
|
10746
|
+
}
|
|
10762
10747
|
},
|
|
10763
10748
|
|
|
10764
10749
|
async onToolCall(client: IpcClient, msg: ToolCallMessage): Promise<ToolCallResult> {
|
|
10765
10750
|
process.stderr.write(`telegram gateway: ipc: tool_call tool=${msg.tool} agent=${client.agentName ?? '-'} clientId=${client.id ?? '-'} callId=${msg.id}\n`)
|
|
10766
10751
|
try {
|
|
10767
|
-
|
|
10752
|
+
// #4172 — the calling client's identity gates reply supersede authority
|
|
10753
|
+
// (see replyCallerIsForeignSession, cron-session.ts).
|
|
10754
|
+
const result = await executeToolCall(msg.tool, msg.args, client.agentName)
|
|
10768
10755
|
return { type: 'tool_call_result', id: msg.id, success: true, result }
|
|
10769
10756
|
} catch (err) {
|
|
10770
10757
|
return {
|
|
@@ -11972,13 +11959,18 @@ const ALLOWED_TOOLS = new Set([
|
|
|
11972
11959
|
'linear_agent_setup',
|
|
11973
11960
|
])
|
|
11974
11961
|
|
|
11975
|
-
async function executeToolCall(
|
|
11962
|
+
async function executeToolCall(
|
|
11963
|
+
tool: string,
|
|
11964
|
+
args: Record<string, unknown>,
|
|
11965
|
+
// #4172 — calling client identity; only the reply path consumes it today.
|
|
11966
|
+
callerAgentName: string | null = null,
|
|
11967
|
+
): Promise<unknown> {
|
|
11976
11968
|
if (!ALLOWED_TOOLS.has(tool)) {
|
|
11977
11969
|
throw new Error(`tool not allowed: ${tool}`)
|
|
11978
11970
|
}
|
|
11979
11971
|
switch (tool) {
|
|
11980
11972
|
case 'reply':
|
|
11981
|
-
return executeReply(args)
|
|
11973
|
+
return executeReply(args, callerAgentName)
|
|
11982
11974
|
case 'progress_update':
|
|
11983
11975
|
return executeProgressUpdate(args)
|
|
11984
11976
|
case 'react':
|
|
@@ -12490,14 +12482,22 @@ async function synthesizeVoiceOut(plan: {
|
|
|
12490
12482
|
}
|
|
12491
12483
|
}
|
|
12492
12484
|
|
|
12493
|
-
async function executeReply(
|
|
12485
|
+
async function executeReply(
|
|
12486
|
+
args: Record<string, unknown>,
|
|
12487
|
+
callerAgentName: string | null = null,
|
|
12488
|
+
): Promise<{ content: Array<{ type: string; text: string }> }> {
|
|
12494
12489
|
// #2996 P2: the ~1,400-line orchestration body moved VERBATIM to
|
|
12495
12490
|
// outbound-send-path.ts `sendReply()` — the single tested send primitive.
|
|
12496
12491
|
// #1664 — the turn is pinned HERE at entry (same read, same position as the
|
|
12497
12492
|
// old inline `const turn = currentTurn`) and passed in `req`; the façade
|
|
12498
12493
|
// also gets a live `getCurrentTurn()` accessor for the deliberate live
|
|
12499
12494
|
// re-reads (Amendment 9: each call site keeps its pin-vs-live choice).
|
|
12500
|
-
|
|
12495
|
+
// #4172 — a foreign-session caller gets no supersede/bypass/latch authority.
|
|
12496
|
+
return sendReply(gatewaySendReplyDeps(), {
|
|
12497
|
+
args,
|
|
12498
|
+
turn: currentTurn,
|
|
12499
|
+
callerIsForeignSession: replyCallerIsForeignSession(callerAgentName),
|
|
12500
|
+
})
|
|
12501
12501
|
}
|
|
12502
12502
|
|
|
12503
12503
|
/** Live gateway deps for the extracted send façade (#2996 P2). Built fresh
|
|
@@ -12540,6 +12540,7 @@ function gatewaySendReplyDeps(): SendReplyGatewayDeps {
|
|
|
12540
12540
|
resolveThreadId,
|
|
12541
12541
|
getLatestInboundMessageId,
|
|
12542
12542
|
getLastSubagentHandbackAt,
|
|
12543
|
+
subagentReplyAuthority,
|
|
12543
12544
|
recordOutbound,
|
|
12544
12545
|
emissionAuthorityFor,
|
|
12545
12546
|
clearActivitySummary,
|
|
@@ -20737,7 +20738,7 @@ bot.command("auth", async ctx => {
|
|
|
20737
20738
|
const { consentUrl, state, port, child } = await startLoopbackFlow(
|
|
20738
20739
|
parsed.provider,
|
|
20739
20740
|
parsed.email,
|
|
20740
|
-
{ replace: parsed.replace, write: parsed.write, orgMode: parsed.orgMode },
|
|
20741
|
+
{ replace: parsed.replace, write: parsed.write, calendar: parsed.calendar, orgMode: parsed.orgMode },
|
|
20741
20742
|
)
|
|
20742
20743
|
const newFlow = {
|
|
20743
20744
|
provider: parsed.provider,
|
|
@@ -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
|
}
|