switchroom 0.18.32 → 0.19.0
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/auth-broker/index.js +17 -1
- package/dist/cli/switchroom.js +847 -729
- package/dist/host-control/main.js +18 -2
- package/dist/vault/approvals/kernel-server.js +17 -1
- package/dist/vault/broker/server.js +44 -2
- package/package.json +2 -2
- package/profiles/_base/start.sh.hbs +105 -18
- package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
- package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
- package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
- package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
- package/telegram-plugin/gateway/boot-card.ts +169 -1
- package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
- package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
- package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
- package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
- package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
- package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
- package/telegram-plugin/gateway/gateway.ts +1347 -6758
- package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
- package/telegram-plugin/gateway/inbound-router.ts +400 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
- package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
- package/telegram-plugin/gateway/model-command.ts +23 -0
- package/telegram-plugin/gateway/narrative-lane.ts +865 -0
- package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
- package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
- package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
- package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
- package/telegram-plugin/gateway/secret-request-card.ts +45 -0
- package/telegram-plugin/gateway/stream-render.ts +2166 -0
- package/telegram-plugin/gateway/turn-end.ts +606 -0
- package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
- package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
- package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
- package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
- package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
- package/telegram-plugin/narrative-dedup.ts +24 -1
- package/telegram-plugin/narrative-flush.ts +2 -2
- package/telegram-plugin/render/render.ts +25 -1
- package/telegram-plugin/status-no-truncate.ts +13 -0
- package/telegram-plugin/subagent-watcher.ts +186 -3
- package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
- package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
- package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
- package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
- package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
- package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
- package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +15 -6
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
- package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
- package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
- package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +47 -10
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
- package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
- package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +14 -3
- package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
- package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
- package/telegram-plugin/tests/model-command.test.ts +30 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +27 -9
- package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
- package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
- package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +16 -7
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
- package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
- package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
- package/telegram-plugin/tests/render/render.test.ts +42 -0
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +28 -18
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
- package/telegram-plugin/tests/stop-command.test.ts +22 -12
- package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
- package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
- package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +14 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
- package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
- package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
- package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
- package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
- package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +110 -20
- package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
- package/telegram-plugin/tool-activity-summary.ts +83 -35
- package/telegram-plugin/turn-flush-safety.ts +80 -14
- package/telegram-plugin/uat/restart-capability.ts +76 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
- package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
- package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
- package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
- package/telegram-plugin/worker-activity-feed.ts +10 -4
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* turn-end.ts — the turn-END funnel + drain chokepoint (#2996 P8 PR-B).
|
|
3
|
+
*
|
|
4
|
+
* Extracted VERBATIM from gateway.ts (the 4757-5271 family): the canonical
|
|
5
|
+
* `endCurrentTurnAtomic` funnel, the fallback `purgeReactionTracking` purge
|
|
6
|
+
* (incl. the M2 pending-restart drain in its idle tail), the narrow
|
|
7
|
+
* `releaseTurnBufferGate` first-reply release, and the buffer-drain chokepoint
|
|
8
|
+
* (`performBufferDrain` / `drainBufferedIfAllowed` / `armNoReplyDrainTimer`).
|
|
9
|
+
*
|
|
10
|
+
* DI contract (the stream-render precedent): STATE stays in gateway.ts; this
|
|
11
|
+
* module owns only the logic. All mutable stores, live singletons and
|
|
12
|
+
* gateway-bound helpers arrive through `TurnEndDeps`
|
|
13
|
+
* (`gatewayTurnEndDeps()` in gateway.ts — exact-by-construction via
|
|
14
|
+
* `ReturnType`). Volatile reads (`currentTurn`, `inboundSpool`, `ipcServer`)
|
|
15
|
+
* cross as GETTERS so every call sees the live value; `endingTurn` stays a
|
|
16
|
+
* passed value (design §3 PR-B risk row). Pure decision helpers are imported
|
|
17
|
+
* directly — they are already extracted modules with their own suites.
|
|
18
|
+
*
|
|
19
|
+
* P9 note (clusters B/D): the module-scope `currentTurn` reads that lived in
|
|
20
|
+
* these bodies are funnelled through `deps.getCurrentTurn()` here, so they
|
|
21
|
+
* leave gateway.ts with this move (P9 §5 recommendation).
|
|
22
|
+
*
|
|
23
|
+
* gateway.ts keeps thin function-declaration wrappers with the original
|
|
24
|
+
* names, so every callsite (stream-render deps, halt path, disconnect-flush,
|
|
25
|
+
* silence-poke fallback, executeReply) is byte-identical.
|
|
26
|
+
*/
|
|
27
|
+
import type { CurrentTurn, TurnEndDeps } from './gateway.js'
|
|
28
|
+
import type { ChatKey as _ChatKey } from './inbound-delivery-machine.js'
|
|
29
|
+
import { chatIdOfChatKey } from './chat-key.js'
|
|
30
|
+
import { mayDrainBufferedInbound, shouldArmNoReplyDrain } from './serialize-drain-gate.js'
|
|
31
|
+
import { redeliverBufferedInbound } from './pending-inbound-buffer.js'
|
|
32
|
+
import { decideObligationTurnEnd } from './obligation-turn-end.js'
|
|
33
|
+
import { formatTurnLifecycle, detectStatusSurfaceDegraded } from './status-surface-log.js'
|
|
34
|
+
|
|
35
|
+
export function createTurnEndFunnel(deps: TurnEndDeps) {
|
|
36
|
+
const {
|
|
37
|
+
// Flags (module-load consts in gateway; captured once — same semantics).
|
|
38
|
+
SERIALIZE_UNTIL_REPLIED_ENABLED,
|
|
39
|
+
SERIALIZE_NOREPLY_DRAIN_MS,
|
|
40
|
+
OBLIGATION_LEDGER_ENABLED,
|
|
41
|
+
// Volatile getters (live reads — P9 getter funnel).
|
|
42
|
+
getCurrentTurn,
|
|
43
|
+
getInboundSpool,
|
|
44
|
+
sendToAgent,
|
|
45
|
+
// Gateway-bound helpers (by name).
|
|
46
|
+
turnInFlightForGate,
|
|
47
|
+
turnLiveForItsTopic,
|
|
48
|
+
endCurrentTurnForKey,
|
|
49
|
+
clearAllCurrentTurns,
|
|
50
|
+
statusKey,
|
|
51
|
+
reapQueuedStatus,
|
|
52
|
+
stopTurnTypingLoop,
|
|
53
|
+
stopEarlyLivenessOpen,
|
|
54
|
+
reconcileStatusPin,
|
|
55
|
+
trackRedeliveredInbound,
|
|
56
|
+
drainPendingSessionCommand,
|
|
57
|
+
triggerSelfRestart,
|
|
58
|
+
maybeProactiveCompact,
|
|
59
|
+
snapshotContextOccupancy,
|
|
60
|
+
emitTurnRecord,
|
|
61
|
+
flushPendingUserFailureNotices,
|
|
62
|
+
clearAnswerReadyFlushTimeout,
|
|
63
|
+
shadowEmit,
|
|
64
|
+
removeActiveReaction,
|
|
65
|
+
resolveAgentDirFromEnv,
|
|
66
|
+
// Stores (stable references owned by gateway).
|
|
67
|
+
activeStatusReactions,
|
|
68
|
+
activeReactionMsgIds,
|
|
69
|
+
activeTurnStartedAt,
|
|
70
|
+
busyAckPostedKeys,
|
|
71
|
+
busyAckRecheckTimers,
|
|
72
|
+
reactionTransitionCounts,
|
|
73
|
+
firstTextReplyLogged,
|
|
74
|
+
pendingRestarts,
|
|
75
|
+
pendingInboundBuffer,
|
|
76
|
+
obligationLedger,
|
|
77
|
+
} = deps
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Component 1 — deliver-before-drain. The single chokepoint that both
|
|
81
|
+
* turn-end drain sites (`purgeReactionTracking`, `releaseTurnBufferGate`)
|
|
82
|
+
* route through. Drains the pending-inbound buffer ONLY when
|
|
83
|
+
* `mayDrainBufferedInbound` says so: claude is idle AND (the
|
|
84
|
+
* serialize-until-replied kill switch is off, OR there is no ending-turn
|
|
85
|
+
* handle, OR the ending turn delivered its final answer). A no-reply turn
|
|
86
|
+
* (finalAnswerDelivered=false) deliberately does NOT drain here — the
|
|
87
|
+
* bounded escape-hatch timer in `endCurrentTurnAtomic` covers that
|
|
88
|
+
* liveness case. The 300s silence-poke fallback (`redeliverBufferedInbound`
|
|
89
|
+
* called directly, bypassing this gate) remains the long-stop.
|
|
90
|
+
*/
|
|
91
|
+
function performBufferDrain(reason: string): void {
|
|
92
|
+
const selfAgentForFlush = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
93
|
+
if (pendingInboundBuffer.depth(selfAgentForFlush) <= 0) return
|
|
94
|
+
const fr = redeliverBufferedInbound(
|
|
95
|
+
pendingInboundBuffer,
|
|
96
|
+
selfAgentForFlush,
|
|
97
|
+
(m) => sendToAgent(selfAgentForFlush, m),
|
|
98
|
+
getInboundSpool(),
|
|
99
|
+
trackRedeliveredInbound,
|
|
100
|
+
)
|
|
101
|
+
if (fr.redelivered > 0) {
|
|
102
|
+
process.stderr.write(
|
|
103
|
+
`telegram gateway: ${reason} flushed ${fr.redelivered}/${fr.drained} ` +
|
|
104
|
+
`held inbound for ${selfAgentForFlush}` +
|
|
105
|
+
`${fr.rebuffered > 0 ? ` (${fr.rebuffered} re-buffered)` : ''}\n`,
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function drainBufferedIfAllowed(endingTurn: CurrentTurn | undefined, reason: string): void {
|
|
111
|
+
if (
|
|
112
|
+
!mayDrainBufferedInbound({
|
|
113
|
+
turnInFlight: turnInFlightForGate(),
|
|
114
|
+
endingTurnFinalAnswerDelivered: endingTurn?.finalAnswerDelivered ?? null,
|
|
115
|
+
enabled: SERIALIZE_UNTIL_REPLIED_ENABLED,
|
|
116
|
+
})
|
|
117
|
+
) {
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
performBufferDrain(reason)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Component 2 — bounded no-reply escape hatch (THE liveness guarantee).
|
|
125
|
+
*
|
|
126
|
+
* A turn that legitimately ends with NO reply (handback ack, NO_REPLY /
|
|
127
|
+
* HEARTBEAT_OK marker, silent-end, greeting already handled) sets
|
|
128
|
+
* `finalAnswerDelivered=false`. Under component 1's serialize gate that
|
|
129
|
+
* turn would block `drainBufferedIfAllowed` FOREVER — and the 300s
|
|
130
|
+
* silence-poke is disarmed for these silent-end turns, so without this
|
|
131
|
+
* timer a queued cross-topic message would never be released (a permanent
|
|
132
|
+
* wedge). This timer is the bounded force-drain: SERIALIZE_NOREPLY_DRAIN_MS
|
|
133
|
+
* (default 2500ms) after such a turn ends with a buffered inbound waiting,
|
|
134
|
+
* drain unconditionally — the serialize gate's delivered-check is bypassed
|
|
135
|
+
* because the turn ended for real with no reply coming. The drain still
|
|
136
|
+
* respects `turnInFlightForGate()` indirectly: if a new turn started in the
|
|
137
|
+
* window (e.g. the 300s fallback or another path drained first), the buffer
|
|
138
|
+
* is already empty so `performBufferDrain` is a depth-checked no-op.
|
|
139
|
+
*
|
|
140
|
+
* Liveness proof: a no-reply turn followed by a queued cross-topic message
|
|
141
|
+
* releases within SERIALIZE_NOREPLY_DRAIN_MS. The 300s silence-poke
|
|
142
|
+
* unwedge fallback (`redeliverBufferedInbound` at the silence-poke
|
|
143
|
+
* framework-fallback, called directly) remains the independent long-stop.
|
|
144
|
+
*/
|
|
145
|
+
function armNoReplyDrainTimer(turn: CurrentTurn): void {
|
|
146
|
+
const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
147
|
+
// Pure guard (shared with the test): arm only for a no-reply turn that
|
|
148
|
+
// has a buffered inbound waiting, and only when the feature is enabled.
|
|
149
|
+
if (
|
|
150
|
+
!shouldArmNoReplyDrain({
|
|
151
|
+
enabled: SERIALIZE_UNTIL_REPLIED_ENABLED,
|
|
152
|
+
finalAnswerDelivered: turn.finalAnswerDelivered,
|
|
153
|
+
bufferedDepth: pendingInboundBuffer.depth(selfAgent),
|
|
154
|
+
})
|
|
155
|
+
) {
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
// Idempotent: clear any prior timer for this turn before re-arming.
|
|
159
|
+
if (turn.noReplyDrainTimer != null) {
|
|
160
|
+
clearTimeout(turn.noReplyDrainTimer)
|
|
161
|
+
turn.noReplyDrainTimer = null
|
|
162
|
+
}
|
|
163
|
+
turn.noReplyDrainTimer = setTimeout(() => {
|
|
164
|
+
turn.noReplyDrainTimer = null
|
|
165
|
+
process.stderr.write(
|
|
166
|
+
`telegram gateway: no-reply bounded drain (${SERIALIZE_NOREPLY_DRAIN_MS}ms) — ` +
|
|
167
|
+
`turn ${turn.turnId} ended without a reply; force-draining buffered inbound\n`,
|
|
168
|
+
)
|
|
169
|
+
performBufferDrain('no-reply-bounded-drain')
|
|
170
|
+
}, SERIALIZE_NOREPLY_DRAIN_MS)
|
|
171
|
+
turn.noReplyDrainTimer.unref?.()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function purgeReactionTracking(key: string, endingTurn?: CurrentTurn): void {
|
|
175
|
+
// Phase 2b: turn end. The key was registered via setTurnStarted when
|
|
176
|
+
// the inbound arrived; purge is the canonical turn-end signal.
|
|
177
|
+
//
|
|
178
|
+
// outboundEmitted: read from the explicit `endingTurn` parameter when
|
|
179
|
+
// provided (canonical path via endCurrentTurnAtomic — module-scope
|
|
180
|
+
// currentTurn is already null by the time we get here), falling back
|
|
181
|
+
// to `currentTurn?.replyCalled` for the legacy callsites that haven't
|
|
182
|
+
// been threaded yet (sibling-key purges, restart-init cleanup).
|
|
183
|
+
// Without this explicit-turn handoff the shadow trace would report
|
|
184
|
+
// outboundEmitted=false on every replied turn (the dominant happy
|
|
185
|
+
// path), producing strictly worse data than the blind `true` it
|
|
186
|
+
// replaced. Invariant #5's `lastOutboundAt` correctness depends on
|
|
187
|
+
// this signal being accurate.
|
|
188
|
+
const outboundEmitted = endingTurn != null
|
|
189
|
+
? endingTurn.replyCalled === true
|
|
190
|
+
: getCurrentTurn()?.replyCalled === true
|
|
191
|
+
shadowEmit({ kind: 'turnEnd', key: key as _ChatKey, at: Date.now(), outboundEmitted })
|
|
192
|
+
const msgInfo = activeReactionMsgIds.get(key)
|
|
193
|
+
activeStatusReactions.delete(key)
|
|
194
|
+
activeReactionMsgIds.delete(key)
|
|
195
|
+
activeTurnStartedAt.delete(key)
|
|
196
|
+
// Component 5 (reap) — defense-in-depth. The happy path deletes the
|
|
197
|
+
// queued-status placeholder on the answer (executeReply / stream /
|
|
198
|
+
// turn-flush). This catches the abnormal turn-end (silent-marker, wedge,
|
|
199
|
+
// sibling purge) so a stale "Queued"/"On it" line can never dangle in
|
|
200
|
+
// the topic. Idempotent: a no-op when already reaped. Prefer the ending
|
|
201
|
+
// turn's session ids (canonical ownership); else parse the chatKey.
|
|
202
|
+
if (endingTurn != null) {
|
|
203
|
+
reapQueuedStatus(endingTurn.sessionChatId, endingTurn.sessionThreadId)
|
|
204
|
+
} else {
|
|
205
|
+
const pqChatId = chatIdOfChatKey(key as _ChatKey)
|
|
206
|
+
const pqThreadPart = (key as string).slice(pqChatId.length + 1)
|
|
207
|
+
const pqThread = pqThreadPart === '_' || pqThreadPart === '' ? null : Number(pqThreadPart)
|
|
208
|
+
reapQueuedStatus(pqChatId, Number.isFinite(pqThread) ? (pqThread as number) : undefined)
|
|
209
|
+
}
|
|
210
|
+
// #2995 — reset the ending turn's OWN busy-ack dedupe + cancel its pending
|
|
211
|
+
// deferred re-check. Per-key, not a global clear: a purge for topic A must
|
|
212
|
+
// not reset topic B's dedupe or kill B's armed re-check. A buffered topic's
|
|
213
|
+
// entry clears when ITS turn eventually runs and ends (this same path), and
|
|
214
|
+
// the re-check timer self-guards on turnId anyway (defense-in-depth).
|
|
215
|
+
busyAckPostedKeys.delete(key)
|
|
216
|
+
const busyAckRecheck = busyAckRecheckTimers.get(key)
|
|
217
|
+
if (busyAckRecheck != null) {
|
|
218
|
+
clearTimeout(busyAckRecheck)
|
|
219
|
+
busyAckRecheckTimers.delete(key)
|
|
220
|
+
}
|
|
221
|
+
// #2527: clear the per-key reaction-transition counter and first-reply
|
|
222
|
+
// sentinel alongside the controller so we don't leak state across turns.
|
|
223
|
+
reactionTransitionCounts.delete(key)
|
|
224
|
+
firstTextReplyLogged.delete(key)
|
|
225
|
+
// Human-feel UX: stop the turn-long `typing…` indicator started in
|
|
226
|
+
// the turn-start block. `purgeReactionTracking` is the canonical
|
|
227
|
+
// turn-end, so this is the single owner of the stop. (If an abnormal
|
|
228
|
+
// abort skips purge, the stray loop self-heals: the next turn on this
|
|
229
|
+
// chat calls `startTurnTypingLoop`, which stops the old interval
|
|
230
|
+
// first.)
|
|
231
|
+
// PR3 supergroup-mode: stop the per-(chat,thread) typing loop, not
|
|
232
|
+
// the whole chat's. Prefer the ending-turn's session ids (the
|
|
233
|
+
// canonical turn ownership); fall back to parsing the chatKey
|
|
234
|
+
// for sibling-purge / restart-cleanup callers that don't have a
|
|
235
|
+
// Turn handle.
|
|
236
|
+
if (endingTurn != null) {
|
|
237
|
+
stopTurnTypingLoop(endingTurn.sessionChatId, endingTurn.sessionThreadId ?? null)
|
|
238
|
+
} else {
|
|
239
|
+
const chatId = chatIdOfChatKey(key as _ChatKey)
|
|
240
|
+
const threadPart = (key as string).slice(chatId.length + 1)
|
|
241
|
+
const threadId = threadPart === '_' || threadPart === '' ? null : Number(threadPart)
|
|
242
|
+
stopTurnTypingLoop(chatId, Number.isFinite(threadId) ? threadId : null)
|
|
243
|
+
}
|
|
244
|
+
// Cancel the enqueue-time early-open timer (paired with
|
|
245
|
+
// `scheduleEarlyLivenessOpen` at turn start). `key` is the same status-key the
|
|
246
|
+
// timer was registered under, so this is the single owner of the cancel. A
|
|
247
|
+
// leaked timer would otherwise fire its `openLivenessFeedIfDue` against a
|
|
248
|
+
// successor turn; the timer's own turnId match is the second guard, this is
|
|
249
|
+
// the first. Idempotent — a no-op when no timer is registered.
|
|
250
|
+
stopEarlyLivenessOpen(key as string)
|
|
251
|
+
// Status-pin: turn end is the canonical unpin point for the foreground
|
|
252
|
+
// pin. `purgeReactionTracking` is the single turn-end owner (all normal /
|
|
253
|
+
// abnormal exit branches funnel here), so this is the one place the
|
|
254
|
+
// foreground status pin is dropped. Idempotent: a no-op when nothing was
|
|
255
|
+
// pinned (trivial turn that never opened a status message). The
|
|
256
|
+
// drop-on-unpin contract in reconcilePin guarantees state clears even if
|
|
257
|
+
// the unpin API throws — a stuck pin can never outlive its turn.
|
|
258
|
+
{
|
|
259
|
+
const pinChatId = endingTurn != null
|
|
260
|
+
? endingTurn.sessionChatId
|
|
261
|
+
: chatIdOfChatKey(key as _ChatKey)
|
|
262
|
+
void reconcileStatusPin(`fg:${key}`, pinChatId, { pinned: false })
|
|
263
|
+
}
|
|
264
|
+
if (msgInfo) {
|
|
265
|
+
const agentDir = resolveAgentDirFromEnv()
|
|
266
|
+
if (agentDir != null) removeActiveReaction(agentDir, msgInfo.chatId, msgInfo.messageId)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// If no more active turns and a restart is pending, perform it now.
|
|
270
|
+
//
|
|
271
|
+
// Cycle BOTH the agent unit and the gateway unit (us). Rationale: users
|
|
272
|
+
// who ran `switchroom agent restart <name> --graceful-restart` after a
|
|
273
|
+
// code change expect their telegram-plugin edits to land, and that code
|
|
274
|
+
// only reloads when this gateway process restarts. Restarting only the
|
|
275
|
+
// agent unit leaves us running the stale code until something else kicks
|
|
276
|
+
// us over, which is a foot-gun (as observed on 2026-04-21 when a
|
|
277
|
+
// klanker gateway ran pre-reorder progress-card code for half a day).
|
|
278
|
+
//
|
|
279
|
+
// Use detached spawn for the combined restart so the systemctl job
|
|
280
|
+
// survives us getting killed by our own restart. Fire-and-forget;
|
|
281
|
+
// response to the client was already sent when the restart was
|
|
282
|
+
// scheduled, so nobody is waiting on this.
|
|
283
|
+
//
|
|
284
|
+
// Gated on the delivery machine (turns claude has actually been handed),
|
|
285
|
+
// not `activeTurnStartedAt.size` (receipt-eager), so a buffered topic-B
|
|
286
|
+
// inbound doesn't pin this gate forever while claude is genuinely idle
|
|
287
|
+
// (the supergroup deadlock). The turnEnd event was emitted just above
|
|
288
|
+
// (purgeReactionTracking head), so the machine is already idle here.
|
|
289
|
+
// #1556: the deterministic delivery point. claude has just gone idle —
|
|
290
|
+
// flush any inbound held mid-turn so the channel notification lands at
|
|
291
|
+
// the idle prompt and submits as a fresh turn (instead of stranding in
|
|
292
|
+
// the composer, the lawgpt wedge). Component 1 (deliver-before-drain):
|
|
293
|
+
// routed through `drainBufferedIfAllowed`, which additionally gates on
|
|
294
|
+
// the ending turn having delivered its reply so a buffered cross-topic
|
|
295
|
+
// message can't drain ahead of the just-ended turn's late reply (the
|
|
296
|
+
// Brevo→Meta wrong-topic bug). Zero-churn: the helper depth-checks
|
|
297
|
+
// first. Lossless: redeliver re-buffers any per-message miss.
|
|
298
|
+
drainBufferedIfAllowed(endingTurn, 'turn-complete')
|
|
299
|
+
|
|
300
|
+
// Restart / compaction stay on the bare turn-end signal (NOT the
|
|
301
|
+
// serialize gate): a pending self-restart or proactive compaction must
|
|
302
|
+
// fire when claude is idle regardless of whether the last turn replied.
|
|
303
|
+
if (!turnInFlightForGate()) {
|
|
304
|
+
// Apply any /model|/effort command queued mid-turn (#3017) BEFORE the
|
|
305
|
+
// restart drain reads the pending-restart map — drainPendingSessionCommand
|
|
306
|
+
// itself checks pendingRestarts and, when a restart is also pending,
|
|
307
|
+
// reports "restarting" on the ack card instead of a false confirmation.
|
|
308
|
+
// The sr-*→Claude menu apply may ITSELF enqueue a restart, which the
|
|
309
|
+
// restart drain below then picks up. Async + best-effort (own try/catch).
|
|
310
|
+
void drainPendingSessionCommand()
|
|
311
|
+
if (pendingRestarts.size > 0) {
|
|
312
|
+
for (const [agentName, _timestamp] of pendingRestarts.entries()) {
|
|
313
|
+
triggerSelfRestart(agentName, 'turn-complete-pending-restart');
|
|
314
|
+
pendingRestarts.delete(agentName);
|
|
315
|
+
}
|
|
316
|
+
} else {
|
|
317
|
+
// Strictly lower priority than a pending restart: if we just
|
|
318
|
+
// kicked a restart the process is going away and compacting is
|
|
319
|
+
// moot, so only evaluate when no restart drained this pass.
|
|
320
|
+
maybeProactiveCompact();
|
|
321
|
+
}
|
|
322
|
+
// Context-headroom snapshot (RFC context-headroom-surface) — write the
|
|
323
|
+
// current occupancy + cap so `switchroom status`/`doctor`/web can show
|
|
324
|
+
// headroom. Independent of proactive-compaction (writes even when no cap
|
|
325
|
+
// is configured) and best-effort (never throws). Runs on the same idle
|
|
326
|
+
// signal — never mid-turn.
|
|
327
|
+
snapshotContextOccupancy();
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Narrow buffer-gate release. Clears the per-key
|
|
333
|
+
* `activeTurnStartedAt` entry and triggers the held-inbound flush
|
|
334
|
+
* if the fleet went idle, WITHOUT touching the reaction
|
|
335
|
+
* controller, the active-reaction message-id, or the typing loop.
|
|
336
|
+
*
|
|
337
|
+
* Why split from `purgeReactionTracking`. #1718's contract keeps
|
|
338
|
+
* `activeStatusReactions[key]` alive across the turn so the
|
|
339
|
+
* working-state ladder can re-paint on every tool/thinking event
|
|
340
|
+
* (and the steer-vs-queue logic at the inbound handler reads the
|
|
341
|
+
* controller — gateway.ts:8322-8323 — to classify mid-turn
|
|
342
|
+
* messages). Wiping the controller mid-turn would either collapse
|
|
343
|
+
* the ladder to 👍 prematurely (#1713 regression) or break the
|
|
344
|
+
* steer detection.
|
|
345
|
+
*
|
|
346
|
+
* The BUFFER gate (`activeTurnStartedAt`) is a separate concern:
|
|
347
|
+
* it gates `shouldBufferInbound` (gateway.ts:8603) and the
|
|
348
|
+
* "claude is idle" flush at `purgeReactionTracking`'s tail. The
|
|
349
|
+
* #1728/#1729 fix released both halves together by gating on
|
|
350
|
+
* `isFinalAnswerReply`, but a trivial-prompt reply that sets
|
|
351
|
+
* `disable_notification: true` and is < 200 chars (e.g. the model
|
|
352
|
+
* mis-classifies "4" as an interim ack) returns false from
|
|
353
|
+
* `isFinalAnswerReply`, so neither half releases and the gate
|
|
354
|
+
* wedges (v0.13.30 UAT regression — every subsequent inbound logs
|
|
355
|
+
* `held mid-turn ... will flush on turn-complete` forever).
|
|
356
|
+
*
|
|
357
|
+
* `releaseTurnBufferGate` is called from `executeReply` on EVERY
|
|
358
|
+
* successful reply finalize — regardless of `isFinalAnswerReply` —
|
|
359
|
+
* so the buffer gate releases independently of the reaction
|
|
360
|
+
* state. The reaction controller stays for #1713's bidirectional
|
|
361
|
+
* ladder + steer detection; only the gate flips.
|
|
362
|
+
*
|
|
363
|
+
* Idempotent: a second release is a no-op `.delete()` on an
|
|
364
|
+
* already-empty key.
|
|
365
|
+
*
|
|
366
|
+
* @internal exported only via the `gateway.ts` module — used by
|
|
367
|
+
* `executeReply`'s post-send block and by tests via source-level
|
|
368
|
+
* pinning in `vault-approval-posture.test.ts` / wedge-guard suites.
|
|
369
|
+
*/
|
|
370
|
+
function releaseTurnBufferGate(key: string, endingTurn?: CurrentTurn): void {
|
|
371
|
+
if (!activeTurnStartedAt.has(key)) return
|
|
372
|
+
activeTurnStartedAt.delete(key)
|
|
373
|
+
// Shadow trace so the structural turn-end metric still records.
|
|
374
|
+
// outboundEmitted=true is correct here — we only reach this from
|
|
375
|
+
// executeReply AFTER an outbound landed.
|
|
376
|
+
shadowEmit({ kind: 'turnEnd', key: key as _ChatKey, at: Date.now(), outboundEmitted: true })
|
|
377
|
+
|
|
378
|
+
// Mirror the deterministic-delivery flush from `purgeReactionTracking`.
|
|
379
|
+
// When the fleet hits zero-active-turns, drain any held inbound. This is
|
|
380
|
+
// the load-bearing wedge fix: the gate that pinned msg 1874+ in
|
|
381
|
+
// test-harness's 13:02 UAT now opens after the reply.
|
|
382
|
+
//
|
|
383
|
+
// Component 1 (deliver-before-drain): routed through the shared
|
|
384
|
+
// `drainBufferedIfAllowed`. `releaseTurnBufferGate` is called on EVERY
|
|
385
|
+
// reply finalize — interim ack AND final answer. The serialize gate
|
|
386
|
+
// checks `endingTurn.finalAnswerDelivered`, so an INTERIM ack ("On it")
|
|
387
|
+
// does NOT drain the cross-topic buffer (its turn hasn't delivered its
|
|
388
|
+
// real answer yet); only the final-answer reply releases it. That is
|
|
389
|
+
// exactly the serialize-until-replied contract. When the kill switch is
|
|
390
|
+
// off, or no turn handle is threaded, the helper falls back to the
|
|
391
|
+
// legacy drain-on-idle behaviour.
|
|
392
|
+
drainBufferedIfAllowed(endingTurn, 'reply-released-gate')
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Atomic null-and-purge for a wedged turn. Every site that ends a
|
|
397
|
+
* turn by nulling `currentTurn` MUST also clear the turn's statusKey
|
|
398
|
+
* from `activeTurnStartedAt` — else a dangling entry survives and
|
|
399
|
+
* `#1556`'s turn-gate holds every new inbound mid-turn forever
|
|
400
|
+
* (gymbro / klanker held-mid-turn symptom, 2026-05-20).
|
|
401
|
+
*
|
|
402
|
+
* Pre-this, three turn-end paths (silent-marker / turn-flush /
|
|
403
|
+
* `turn_end`) nulled `currentTurn` on code-paths whose
|
|
404
|
+
* `purgeReactionTracking` calls weren't reached on every branch,
|
|
405
|
+
* leaving sibling entries under the turn's statusKey that the
|
|
406
|
+
* silence-poke framework-fallback's `purgeReactionTracking(fbKey)`
|
|
407
|
+
* couldn't catch (different key shape). The fallback now also sweeps
|
|
408
|
+
* siblings for `fbChatId` (`turn-state-purge.ts`) as defense-in-depth,
|
|
409
|
+
* but THIS helper closes the leak at origin: null and purge are
|
|
410
|
+
* inseparable at every call site.
|
|
411
|
+
*
|
|
412
|
+
* Idempotent: a second purge is a no-op `.delete()` on a key already
|
|
413
|
+
* gone — handlers that already purge elsewhere are unharmed.
|
|
414
|
+
*/
|
|
415
|
+
function endCurrentTurnAtomic(
|
|
416
|
+
turn: CurrentTurn,
|
|
417
|
+
opts?: { deferRecord?: boolean; deferObligationClose?: boolean },
|
|
418
|
+
): number | null {
|
|
419
|
+
// PR-4e — keyed liveness + keyed clear (leak-close-at-origin). Flag-OFF: the
|
|
420
|
+
// guard is `currentTurn === turn` and the clear nulls the singleton, verbatim.
|
|
421
|
+
// Flag-ON: the guard becomes `byKey.get(turn'sKey) === turn` (so a flip to
|
|
422
|
+
// another topic doesn't spuriously short-circuit THIS topic's teardown) and
|
|
423
|
+
// the clear does `byKey.delete(key)` + nulls the mirror iff it still points at
|
|
424
|
+
// `turn`. `endCurrentTurnForKey` returns false (no delete) when the entry no
|
|
425
|
+
// longer matches — the same early-return semantics as the old `!== turn` guard.
|
|
426
|
+
const key = statusKey(turn.sessionChatId, turn.sessionThreadId)
|
|
427
|
+
if (!turnLiveForItsTopic(turn)) return null
|
|
428
|
+
// PR A — the turn is ending (this is the ONE place every turn-end path funnels
|
|
429
|
+
// through, incl. the answer-ready flush's own synthetic turn_end). Clear the
|
|
430
|
+
// quiescence timer so a real turn_end that lands first cancels a pending flush,
|
|
431
|
+
// guaranteeing exactly-once delivery.
|
|
432
|
+
clearAnswerReadyFlushTimeout(turn)
|
|
433
|
+
endCurrentTurnForKey(turn, key) // currentTurnByKey.delete(key) + mirror clear
|
|
434
|
+
// Status-surface observability: one line at every turn CLEAR (with how far
|
|
435
|
+
// the turn got), plus a DEGRADED warning when the turn did tool work but the
|
|
436
|
+
// live feed never opened because its sends failed (the resume-400 signature).
|
|
437
|
+
const turnEndedAt = Date.now()
|
|
438
|
+
// 2026-07 double-reply-on-DM fix (F2) — stamp the turn's end time so the
|
|
439
|
+
// `findLatestEndedTurnForChat` supersede tier can be recency-bounded to the
|
|
440
|
+
// supersede TTL (a stale latest-ended turn must not inherit deletion
|
|
441
|
+
// authority over a newer turn's flush record). Set once; idempotent on the
|
|
442
|
+
// deferRecord flush path (which calls this synchronously before its send).
|
|
443
|
+
turn.endedAt = turnEndedAt
|
|
444
|
+
process.stderr.write(
|
|
445
|
+
`telegram gateway: ${formatTurnLifecycle('clear', 'turn_end', turn, turnEndedAt)}\n`,
|
|
446
|
+
)
|
|
447
|
+
// PR B — the turn-flush backstop defers the record write to its async send
|
|
448
|
+
// IIFE (passing `{ deferRecord: true }`) so the recorded `status` reflects the
|
|
449
|
+
// REAL send outcome (`turn.deliveryOutcome`) rather than the speculative
|
|
450
|
+
// `finalAnswerDelivered` flag set before the send ran. All synchronous
|
|
451
|
+
// turn-end paths still emit here, unchanged. `turnEndedAt` is returned so the
|
|
452
|
+
// deferred caller stamps the same ended-at (stable `duration_ms`).
|
|
453
|
+
if (opts?.deferRecord !== true) {
|
|
454
|
+
emitTurnRecord(turn, turnEndedAt)
|
|
455
|
+
}
|
|
456
|
+
const degraded = detectStatusSurfaceDegraded(turn)
|
|
457
|
+
if (degraded != null) {
|
|
458
|
+
process.stderr.write(
|
|
459
|
+
`telegram gateway: status-surface DEGRADED reason=${degraded.reason} ` +
|
|
460
|
+
`turnId=${turn.turnId} chat=${turn.sessionChatId} ` +
|
|
461
|
+
`thread=${turn.sessionThreadId ?? '-'} ${degraded.detail}\n`,
|
|
462
|
+
)
|
|
463
|
+
}
|
|
464
|
+
// PR2 obligation-ledger CLOSE-at-turn-end. Close the ended turn's obligation
|
|
465
|
+
// when it delivered a final answer. finalAnswerDelivered is the right signal
|
|
466
|
+
// HERE (not isSubstantiveFinalReply at reply-time): a SHORT genuine answer
|
|
467
|
+
// ("4") is final-but-not-substantive, so the reply-time substantive-close
|
|
468
|
+
// missed it → it looked unanswered → the idle sweep double-asked every short
|
|
469
|
+
// turn (canary, v0.14.59). At turn_end the #2141 logic has already demoted a
|
|
470
|
+
// bare interim ack to non-final, so finalAnswerDelivered===true means GENUINELY
|
|
471
|
+
// answered. This runs before the next idle sweep, so a short answer closes
|
|
472
|
+
// cleanly (no double-ask); an ack-then-ghost / no-reply turn ends with
|
|
473
|
+
// finalAnswerDelivered===false → stays open → re-presented (the intended
|
|
474
|
+
// catch). close() is a no-op for synthetic turns (turnId not in the ledger).
|
|
475
|
+
// No-op when the flag is off.
|
|
476
|
+
//
|
|
477
|
+
// #2624 — sr-* model short-reply cascade fix. When the model routes through
|
|
478
|
+
// LiteLLM (sr-* path), the claude CLI calls reply("OK", {disable_notification:
|
|
479
|
+
// true}) for short answers — below the 200-char backstop and notification-
|
|
480
|
+
// suppressed, so isFinalAnswerReply returns false and finalAnswerDelivered stays
|
|
481
|
+
// false. This triggers a cascade: obligation re-presents with growing 25k-token
|
|
482
|
+
// context, blocking the agent for minutes. The ack-then-ghost case that
|
|
483
|
+
// obligations are designed to catch ends via silence_fallback, NOT turn_end.
|
|
484
|
+
// At turn_end with replyCalled=true the model explicitly signalled completion
|
|
485
|
+
// AND replied, so the obligation is satisfied regardless of finalAnswerDelivered.
|
|
486
|
+
// #3276 finding 1 — the turn-flush backstop passes `deferObligationClose` so
|
|
487
|
+
// the obligation disposition reflects the REAL send outcome (resolved in its
|
|
488
|
+
// async finally after the bounded retry), NOT the speculative fire-time
|
|
489
|
+
// `finalAnswerDelivered=true`. Closing here would satisfy the obligation
|
|
490
|
+
// before the send is known to have landed, re-introducing the silent-drop on
|
|
491
|
+
// terminal failure. Every synchronous turn-end path is unchanged.
|
|
492
|
+
if (OBLIGATION_LEDGER_ENABLED && opts?.deferObligationClose !== true) {
|
|
493
|
+
if (decideObligationTurnEnd(turn.finalAnswerDelivered, turn.replyCalled) === 'close') {
|
|
494
|
+
obligationLedger.close(turn.turnId)
|
|
495
|
+
} else {
|
|
496
|
+
// Turn ended WITHOUT any reply (no ack, no answer). If this turn was
|
|
497
|
+
// handling an open obligation, stamp its grace clock so the idle sweep
|
|
498
|
+
// waits before re-presenting/escalating. No-op when turn.turnId isn't
|
|
499
|
+
// in the ledger (synthetic / already-closed turn).
|
|
500
|
+
obligationLedger.noteTurnEnded(turn.turnId, Date.now())
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
// Component 2 — clear any prior no-reply drain timer for this turn; a
|
|
504
|
+
// fresh end re-evaluates below. (Idempotent — null when never armed.)
|
|
505
|
+
if (turn.noReplyDrainTimer != null) {
|
|
506
|
+
clearTimeout(turn.noReplyDrainTimer)
|
|
507
|
+
turn.noReplyDrainTimer = null
|
|
508
|
+
}
|
|
509
|
+
// Teardown the narrative gate's early-paint timer so it can neither leak past
|
|
510
|
+
// the turn nor fire against a torn-down turn. (Idempotent — no-op when never
|
|
511
|
+
// armed / already fired. `flushPendingNarrativeAtTurnEnd` on the turn_end event
|
|
512
|
+
// normally disarms it first; this is the belt-and-braces teardown net.)
|
|
513
|
+
turn.narrativeGate?.teardown()
|
|
514
|
+
// Pass `turn` so purgeReactionTracking sees the authoritative
|
|
515
|
+
// replyCalled flag even though we just nulled module-scope
|
|
516
|
+
// currentTurn. Without this, the shadow trace's outboundEmitted
|
|
517
|
+
// would be false on every replied turn (the dominant happy path),
|
|
518
|
+
// producing strictly worse data than the blind `true` it replaced.
|
|
519
|
+
// Component 1: purgeReactionTracking runs the serialize-gated drain —
|
|
520
|
+
// it drains only if this turn delivered its final answer.
|
|
521
|
+
purgeReactionTracking(statusKey(turn.sessionChatId, turn.sessionThreadId), turn)
|
|
522
|
+
// Component 2 — bounded no-reply escape hatch. If this turn ended
|
|
523
|
+
// WITHOUT delivering (finalAnswerDelivered=false) the serialize gate
|
|
524
|
+
// above did NOT drain. Arm the bounded timer so a queued cross-topic
|
|
525
|
+
// message still releases within SERIALIZE_NOREPLY_DRAIN_MS instead of
|
|
526
|
+
// wedging forever. No-op when this turn delivered, when nothing is
|
|
527
|
+
// buffered, or when the serialize feature is off.
|
|
528
|
+
armNoReplyDrainTimer(turn)
|
|
529
|
+
// #3293 finding 1 — resolve any deferred non-operator failure notice against
|
|
530
|
+
// this turn's outcome: replied → the turn recovered from the error line, the
|
|
531
|
+
// gate drops the notice; reply-less → the turn genuinely died, the notice is
|
|
532
|
+
// sent now. replyCalled covers the short-answer/#2624 shape where
|
|
533
|
+
// finalAnswerDelivered stays false despite an explicit reply. No-op when
|
|
534
|
+
// nothing is pending (the overwhelmingly common path). #3294 — `key` scopes
|
|
535
|
+
// resolution to THIS turn's topic under keyed liveness.
|
|
536
|
+
flushPendingUserFailureNotices(turn.finalAnswerDelivered || turn.replyCalled, key)
|
|
537
|
+
return turnEndedAt
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* PR-E (#2996 P8, amendment M1) — the single auditable turn-end entry point,
|
|
542
|
+
* reached by production callsites ONLY when SWITCHROOM_TURN_END_FUNNEL_V2=1
|
|
543
|
+
* (the gateway wrappers gate on the flag; default OFF runs the PR-B paths
|
|
544
|
+
* verbatim). Dispatches to the EXACT legacy shape per reason — goal:
|
|
545
|
+
* byte-identical effects, verified by the PR-A shadowEmit sequence oracle
|
|
546
|
+
* run under both flag settings.
|
|
547
|
+
*
|
|
548
|
+
* FIVE end shapes (M1), not three:
|
|
549
|
+
* 1. 'turn-end' — the canonical funnel (endCurrentTurnAtomic).
|
|
550
|
+
* 2. 'fallback-purge' — purge without/with a turn handle (disconnect
|
|
551
|
+
* flush, silence-poke framework fallback).
|
|
552
|
+
* 3. 'reply-gate-release' — first-reply buffer-gate release (executeReply).
|
|
553
|
+
* 4. 'phantom-ttl-clear' — the /model|/effort busy-check hard-TTL clear.
|
|
554
|
+
* 5. 'bridge-died-clear' — the onDanglingTurnsSwept disconnect clear.
|
|
555
|
+
* Shapes 4-5 are GHOST-clears: the turns are dead (a stale atom / a dead
|
|
556
|
+
* bridge), so their effect set is EXACTLY the atom clear and nothing else —
|
|
557
|
+
* no purge, no obligation close, no drain, no shadow turnEnd. Routing them
|
|
558
|
+
* through the full funnel would close obligations and drain buffers against
|
|
559
|
+
* a dead bridge.
|
|
560
|
+
*/
|
|
561
|
+
function endTurn(
|
|
562
|
+
reason: TurnEndReason,
|
|
563
|
+
args: {
|
|
564
|
+
turn?: CurrentTurn
|
|
565
|
+
opts?: { deferRecord?: boolean; deferObligationClose?: boolean }
|
|
566
|
+
key?: string
|
|
567
|
+
endingTurn?: CurrentTurn
|
|
568
|
+
} = {},
|
|
569
|
+
): number | null | undefined {
|
|
570
|
+
switch (reason) {
|
|
571
|
+
case 'turn-end':
|
|
572
|
+
return endCurrentTurnAtomic(args.turn as CurrentTurn, args.opts)
|
|
573
|
+
case 'fallback-purge':
|
|
574
|
+
purgeReactionTracking(args.key as string, args.endingTurn)
|
|
575
|
+
return undefined
|
|
576
|
+
case 'reply-gate-release':
|
|
577
|
+
releaseTurnBufferGate(args.key as string, args.endingTurn)
|
|
578
|
+
return undefined
|
|
579
|
+
case 'phantom-ttl-clear':
|
|
580
|
+
case 'bridge-died-clear':
|
|
581
|
+
// M1 — atom-clear ONLY. See the docblock above; harness case 9 pins
|
|
582
|
+
// that a ghost-clear emits NO shadow turnEnd, closes NO obligation,
|
|
583
|
+
// drains NO buffer.
|
|
584
|
+
clearAllCurrentTurns()
|
|
585
|
+
return undefined
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return {
|
|
590
|
+
performBufferDrain,
|
|
591
|
+
drainBufferedIfAllowed,
|
|
592
|
+
armNoReplyDrainTimer,
|
|
593
|
+
purgeReactionTracking,
|
|
594
|
+
releaseTurnBufferGate,
|
|
595
|
+
endCurrentTurnAtomic,
|
|
596
|
+
endTurn,
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** PR-E — the five named end shapes (amendment M1). */
|
|
601
|
+
export type TurnEndReason =
|
|
602
|
+
| 'turn-end'
|
|
603
|
+
| 'fallback-purge'
|
|
604
|
+
| 'reply-gate-release'
|
|
605
|
+
| 'phantom-ttl-clear'
|
|
606
|
+
| 'bridge-died-clear'
|