switchroom 0.12.22 → 0.12.23
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 +23 -2
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +5 -5
- package/telegram-plugin/gateway/inbound-delivery-machine.ts +435 -0
- package/telegram-plugin/tests/inbound-delivery-machine.test.ts +475 -0
- package/telegram-plugin/uat/scenarios/jtbd-fast-trivial-dm.test.ts +127 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-survives-restart-dm.test.ts +239 -0
- package/telegram-plugin/uat/scenarios/jtbd-wake-audit-content-dm.test.ts +145 -0
package/dist/cli/switchroom.js
CHANGED
|
@@ -47247,8 +47247,8 @@ var {
|
|
|
47247
47247
|
} = import__.default;
|
|
47248
47248
|
|
|
47249
47249
|
// src/build-info.ts
|
|
47250
|
-
var VERSION = "0.12.
|
|
47251
|
-
var COMMIT_SHA = "
|
|
47250
|
+
var VERSION = "0.12.23";
|
|
47251
|
+
var COMMIT_SHA = "6c99950";
|
|
47252
47252
|
|
|
47253
47253
|
// src/cli/agent.ts
|
|
47254
47254
|
init_source();
|
|
@@ -48583,11 +48583,32 @@ function installHindsightPlugin(agentName, agentDir, switchroomConfig) {
|
|
|
48583
48583
|
rmSync3(destPath, { recursive: true, force: true });
|
|
48584
48584
|
}
|
|
48585
48585
|
copyDirRecursive2(sourcePath, destPath);
|
|
48586
|
+
applyHindsightSettingsOverrides(destPath);
|
|
48586
48587
|
const bankId = agentMemory?.collection ?? agentName;
|
|
48587
48588
|
const mcpUrl = memory.config?.url ?? "http://127.0.0.1:8888/mcp/";
|
|
48588
48589
|
const apiBaseUrl = mcpUrl.replace(/\/mcp\/?$/, "").replace(/\/$/, "");
|
|
48589
48590
|
return { pluginDir: destPath, apiBaseUrl, bankId };
|
|
48590
48591
|
}
|
|
48592
|
+
function applyHindsightSettingsOverrides(pluginDestPath) {
|
|
48593
|
+
const settingsPath = join8(pluginDestPath, "settings.json");
|
|
48594
|
+
if (!existsSync11(settingsPath))
|
|
48595
|
+
return;
|
|
48596
|
+
let raw;
|
|
48597
|
+
try {
|
|
48598
|
+
raw = readFileSync11(settingsPath, "utf-8");
|
|
48599
|
+
} catch {
|
|
48600
|
+
return;
|
|
48601
|
+
}
|
|
48602
|
+
let settings;
|
|
48603
|
+
try {
|
|
48604
|
+
settings = JSON.parse(raw);
|
|
48605
|
+
} catch {
|
|
48606
|
+
return;
|
|
48607
|
+
}
|
|
48608
|
+
settings.retainEveryNTurns = 1;
|
|
48609
|
+
writeFileSync5(settingsPath, JSON.stringify(settings, null, 2) + `
|
|
48610
|
+
`, "utf-8");
|
|
48611
|
+
}
|
|
48591
48612
|
function buildWorkspaceContext(args) {
|
|
48592
48613
|
const {
|
|
48593
48614
|
name,
|
package/package.json
CHANGED
|
@@ -47126,11 +47126,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
47126
47126
|
}
|
|
47127
47127
|
|
|
47128
47128
|
// ../src/build-info.ts
|
|
47129
|
-
var VERSION = "0.12.
|
|
47130
|
-
var COMMIT_SHA = "
|
|
47131
|
-
var COMMIT_DATE = "2026-05-
|
|
47132
|
-
var LATEST_PR =
|
|
47133
|
-
var COMMITS_AHEAD_OF_TAG =
|
|
47129
|
+
var VERSION = "0.12.23";
|
|
47130
|
+
var COMMIT_SHA = "6c99950";
|
|
47131
|
+
var COMMIT_DATE = "2026-05-20T04:16:33Z";
|
|
47132
|
+
var LATEST_PR = 1580;
|
|
47133
|
+
var COMMITS_AHEAD_OF_TAG = 1;
|
|
47134
47134
|
|
|
47135
47135
|
// gateway/boot-version.ts
|
|
47136
47136
|
function formatRelativeAgo(iso) {
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InboundDeliveryStateMachine — pure transition function for the
|
|
3
|
+
* gateway's inbound→bridge→outbound pipeline.
|
|
4
|
+
*
|
|
5
|
+
* Per `docs/rfcs/inbound-delivery-state-machine.md` (RFC merged in
|
|
6
|
+
* PR #1576): the gateway's delivery state was implicit and scattered
|
|
7
|
+
* across 8+ pieces of mutable state. The wedge cluster of 2026-05-19
|
|
8
|
+
* (9 PRs in 36h all patching variants of "inbound stranded → 5-min
|
|
9
|
+
* silence-poke fallback") and the v0.12.22 self-blocking gate bug
|
|
10
|
+
* (#1573, symptom-level) shared one root cause: no model anywhere in
|
|
11
|
+
* the codebase said "given these inputs, what should the gateway do."
|
|
12
|
+
*
|
|
13
|
+
* This module IS that model.
|
|
14
|
+
*
|
|
15
|
+
* ## Contract
|
|
16
|
+
*
|
|
17
|
+
* transition(state, event) → { state', effects[] }
|
|
18
|
+
*
|
|
19
|
+
* Pure. No I/O. No timers. No mutation of inputs. The gateway
|
|
20
|
+
* dispatcher receives `{ state', effects[] }` and EXECUTES the
|
|
21
|
+
* effects against the real bridge/buffer/spool/Telegram. The
|
|
22
|
+
* machine never touches those directly.
|
|
23
|
+
*
|
|
24
|
+
* Property-tested by 5 invariants (see
|
|
25
|
+
* `tests/inbound-delivery-machine.test.ts`):
|
|
26
|
+
*
|
|
27
|
+
* #1 — Every `inbound` event is delivered XOR persisted
|
|
28
|
+
* #2 — Every `setTurnStarted(key)` paired with `clearTurnStarted(key)`
|
|
29
|
+
* before the next end-of-life event for that key
|
|
30
|
+
* #3 — Per-chat sibling-key cleanup on `turnEnd`
|
|
31
|
+
* #4 — `permVerdict` delivered iff bridge alive; else persisted +
|
|
32
|
+
* re-delivered on next `bridgeUp`
|
|
33
|
+
* #5 — Spurious-fallback suppression (no `firePoke('fallback')` if
|
|
34
|
+
* the model produced an outbound for this key in the last 60s)
|
|
35
|
+
*
|
|
36
|
+
* ## Scope of this PR
|
|
37
|
+
*
|
|
38
|
+
* This is PR 1 of the 3-PR cutover (per RFC). The module is exported
|
|
39
|
+
* but NOT WIRED into `gateway.ts`. PR 2 will swap the gateway's
|
|
40
|
+
* imperative paths to dispatch through this machine. PR 3 will
|
|
41
|
+
* delete the now-redundant primitives.
|
|
42
|
+
*
|
|
43
|
+
* Zero production behavior change in this PR. The property test is
|
|
44
|
+
* the only gate.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
48
|
+
// Branded types — chat-key namespace
|
|
49
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Canonical chat-thread key. Use the existing `chatKey()` helper from
|
|
53
|
+
* `./chat-key.ts` to construct one — that helper collapses
|
|
54
|
+
* 0/null/undefined thread IDs to the same token (#1564 sibling-key
|
|
55
|
+
* canonicalization). The state machine treats `ChatKey` as opaque.
|
|
56
|
+
*/
|
|
57
|
+
export type ChatKey = string & { readonly __brand: 'ChatKey' }
|
|
58
|
+
|
|
59
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
60
|
+
// State
|
|
61
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Global delivery state. Mirrors the existing `currentTurn` singleton
|
|
65
|
+
* but explicit. The gateway has ONE bridge connection (single claude
|
|
66
|
+
* process per agent container), so global state is the right model.
|
|
67
|
+
*/
|
|
68
|
+
export type GlobalState =
|
|
69
|
+
| { kind: 'bridge_dead' }
|
|
70
|
+
| { kind: 'bridge_alive_idle' }
|
|
71
|
+
| { kind: 'bridge_alive_in_turn'; activeTurn: ChatKey }
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Per-key state. Lifts the scattered `activeTurnStartedAt` Map and
|
|
75
|
+
* silence-poke's per-key `lastOutboundAt` tracking into ONE place.
|
|
76
|
+
*
|
|
77
|
+
* `turnStartedAt`: when this chat's current turn began (null = no
|
|
78
|
+
* turn active for this key). Mirrors the existing
|
|
79
|
+
* `activeTurnStartedAt[key]` value.
|
|
80
|
+
*
|
|
81
|
+
* `lastOutboundAt`: when the model last produced an outbound for
|
|
82
|
+
* this key. CARRIES ACROSS TURNS — this is invariant #5's data: even
|
|
83
|
+
* if a new turn starts (overlapping turns case from the
|
|
84
|
+
* 2026-05-20 mid-turn silence wedge), the model's recent outbound is
|
|
85
|
+
* preserved so a spurious fallback fire is suppressed.
|
|
86
|
+
*/
|
|
87
|
+
export interface PerKeyState {
|
|
88
|
+
readonly turnStartedAt: number | null
|
|
89
|
+
readonly lastOutboundAt: number | null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface State {
|
|
93
|
+
readonly global: GlobalState
|
|
94
|
+
readonly perKey: ReadonlyMap<ChatKey, PerKeyState>
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function initialState(): State {
|
|
98
|
+
return {
|
|
99
|
+
global: { kind: 'bridge_dead' },
|
|
100
|
+
perKey: new Map(),
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
105
|
+
// Events
|
|
106
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
export interface InboundMessage {
|
|
109
|
+
readonly msgId: number
|
|
110
|
+
readonly isSteering: boolean
|
|
111
|
+
readonly payload: unknown
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface PermissionVerdict {
|
|
115
|
+
readonly requestId: string
|
|
116
|
+
readonly behavior: 'allow' | 'deny' | 'allow_once' | 'allow_always'
|
|
117
|
+
readonly payload: unknown
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface SpooledInbound {
|
|
121
|
+
readonly key: ChatKey
|
|
122
|
+
readonly msg: InboundMessage
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export type Event =
|
|
126
|
+
| { kind: 'bridgeUp'; at: number }
|
|
127
|
+
| { kind: 'bridgeDown'; at: number }
|
|
128
|
+
| { kind: 'turnStart'; key: ChatKey; at: number }
|
|
129
|
+
| { kind: 'turnEnd'; key: ChatKey; at: number; outboundEmitted: boolean }
|
|
130
|
+
| { kind: 'inbound'; key: ChatKey; msg: InboundMessage; at: number }
|
|
131
|
+
| { kind: 'permVerdict'; verdict: PermissionVerdict; at: number }
|
|
132
|
+
| { kind: 'modelOutbound'; key: ChatKey; at: number }
|
|
133
|
+
| { kind: 'tick'; now: number }
|
|
134
|
+
|
|
135
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
136
|
+
// Effects (returned, not performed)
|
|
137
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
export type Effect =
|
|
140
|
+
| { kind: 'deliverToBridge'; key: ChatKey; msg: InboundMessage }
|
|
141
|
+
| { kind: 'bufferInbound'; key: ChatKey; msg: InboundMessage }
|
|
142
|
+
| { kind: 'persistInbound'; key: ChatKey; msg: InboundMessage }
|
|
143
|
+
| { kind: 'drainBuffer' }
|
|
144
|
+
| { kind: 'setTurnStarted'; key: ChatKey; at: number }
|
|
145
|
+
| { kind: 'clearTurnStarted'; key: ChatKey }
|
|
146
|
+
| { kind: 'noteOutbound'; key: ChatKey; at: number }
|
|
147
|
+
| { kind: 'firePoke'; key: ChatKey; level: 'soft' | 'firm' | 'fallback' }
|
|
148
|
+
| { kind: 'deliverPermVerdict'; verdict: PermissionVerdict }
|
|
149
|
+
| { kind: 'persistPermVerdict'; verdict: PermissionVerdict }
|
|
150
|
+
| { kind: 'redeliverPersistedPermVerdicts' }
|
|
151
|
+
| { kind: 'logTrace'; stage: string; key?: ChatKey; metadata?: Readonly<Record<string, unknown>> }
|
|
152
|
+
|
|
153
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
154
|
+
// Tunable timings (match the production silence-poke ladder for now;
|
|
155
|
+
// the RFC includes a recommendation to tighten these in a follow-up,
|
|
156
|
+
// but parity-first for the PR-2 cutover).
|
|
157
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
export const TURN_TTL_MS = 300_000 // 5 min — silence-poke fallback threshold
|
|
160
|
+
export const SOFT_POKE_MS = 75_000
|
|
161
|
+
export const FIRM_POKE_MS = 180_000
|
|
162
|
+
export const OUTBOUND_RECENT_MS = 60_000 // invariant #5 suppression window
|
|
163
|
+
|
|
164
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
165
|
+
// Transition function
|
|
166
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
export interface Transition {
|
|
169
|
+
readonly state: State
|
|
170
|
+
readonly effects: readonly Effect[]
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function emptyPerKey(): PerKeyState {
|
|
174
|
+
return { turnStartedAt: null, lastOutboundAt: null }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function updatePerKey(
|
|
178
|
+
state: State,
|
|
179
|
+
key: ChatKey,
|
|
180
|
+
update: (prior: PerKeyState) => PerKeyState,
|
|
181
|
+
): State {
|
|
182
|
+
const prior = state.perKey.get(key) ?? emptyPerKey()
|
|
183
|
+
const next = update(prior)
|
|
184
|
+
const m = new Map(state.perKey)
|
|
185
|
+
// Empty entries (both fields null) are pruned to keep the map tight
|
|
186
|
+
// — invariant #2's test reads the map size and we don't want stale
|
|
187
|
+
// empty entries inflating it.
|
|
188
|
+
if (next.turnStartedAt == null && next.lastOutboundAt == null) {
|
|
189
|
+
m.delete(key)
|
|
190
|
+
} else {
|
|
191
|
+
m.set(key, next)
|
|
192
|
+
}
|
|
193
|
+
return { ...state, perKey: m }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function chatIdOfKey(key: ChatKey): string {
|
|
197
|
+
// ChatKey shape is `${chatId}:${threadOrUnderscore}`. Splitting on
|
|
198
|
+
// the FIRST colon gives the chatId — robust to threads/suffixes.
|
|
199
|
+
const idx = key.indexOf(':')
|
|
200
|
+
return idx === -1 ? key : key.slice(0, idx)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Sweep all sibling keys for a chatId — Invariant #3. After the last
|
|
205
|
+
* turnEnd for a chatId, no sibling thread keys should remain.
|
|
206
|
+
*
|
|
207
|
+
* Effect: emits `clearTurnStarted` for every sibling key still
|
|
208
|
+
* holding `turnStartedAt != null`. Returns updated state.
|
|
209
|
+
*
|
|
210
|
+
* The state machine's invariant is enforced because we PROACTIVELY
|
|
211
|
+
* purge siblings on turnEnd. The production sibling-key sweep
|
|
212
|
+
* (#1564's `purgeStaleTurnsForChat`) becomes redundant.
|
|
213
|
+
*/
|
|
214
|
+
function sweepSiblings(
|
|
215
|
+
state: State,
|
|
216
|
+
chatId: string,
|
|
217
|
+
exceptKey: ChatKey,
|
|
218
|
+
): { state: State; effects: Effect[] } {
|
|
219
|
+
const effects: Effect[] = []
|
|
220
|
+
let next = state
|
|
221
|
+
for (const [k, v] of state.perKey) {
|
|
222
|
+
if (k === exceptKey) continue
|
|
223
|
+
if (chatIdOfKey(k) !== chatId) continue
|
|
224
|
+
if (v.turnStartedAt == null) continue
|
|
225
|
+
effects.push({ kind: 'clearTurnStarted', key: k })
|
|
226
|
+
next = updatePerKey(next, k, (p) => ({ ...p, turnStartedAt: null }))
|
|
227
|
+
}
|
|
228
|
+
return { state: next, effects }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function transition(state: State, event: Event): Transition {
|
|
232
|
+
switch (event.kind) {
|
|
233
|
+
case 'bridgeUp': {
|
|
234
|
+
if (state.global.kind !== 'bridge_dead') {
|
|
235
|
+
// Idempotent: a second bridgeUp is a no-op.
|
|
236
|
+
return { state, effects: [{ kind: 'logTrace', stage: 'bridgeUp_redundant' }] }
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
state: { ...state, global: { kind: 'bridge_alive_idle' } },
|
|
240
|
+
effects: [
|
|
241
|
+
{ kind: 'redeliverPersistedPermVerdicts' },
|
|
242
|
+
{ kind: 'drainBuffer' },
|
|
243
|
+
{ kind: 'logTrace', stage: 'bridge_recover' },
|
|
244
|
+
],
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
case 'bridgeDown': {
|
|
249
|
+
// Keep perKey state intact — the next bridgeUp + turnEnd will
|
|
250
|
+
// resolve naturally. Clearing turn state on bridge flap was the
|
|
251
|
+
// wedge-cluster's "drain on disconnect" footgun.
|
|
252
|
+
return {
|
|
253
|
+
state: { ...state, global: { kind: 'bridge_dead' } },
|
|
254
|
+
effects: [{ kind: 'logTrace', stage: 'bridge_flap' }],
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
case 'inbound': {
|
|
259
|
+
const isSteering = event.msg.isSteering
|
|
260
|
+
const inTurn = state.global.kind === 'bridge_alive_in_turn'
|
|
261
|
+
const alive = state.global.kind !== 'bridge_dead'
|
|
262
|
+
|
|
263
|
+
if (!alive) {
|
|
264
|
+
return {
|
|
265
|
+
state,
|
|
266
|
+
effects: [
|
|
267
|
+
{ kind: 'bufferInbound', key: event.key, msg: event.msg },
|
|
268
|
+
{ kind: 'persistInbound', key: event.key, msg: event.msg },
|
|
269
|
+
{ kind: 'logTrace', stage: 'inbound_bridge_dead_buffer', key: event.key },
|
|
270
|
+
],
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (inTurn && !isSteering) {
|
|
275
|
+
// Mid-turn non-steering inbound: buffer (the #1556 contract).
|
|
276
|
+
return {
|
|
277
|
+
state,
|
|
278
|
+
effects: [
|
|
279
|
+
{ kind: 'bufferInbound', key: event.key, msg: event.msg },
|
|
280
|
+
{ kind: 'persistInbound', key: event.key, msg: event.msg },
|
|
281
|
+
{ kind: 'logTrace', stage: 'inbound_held_mid_turn', key: event.key, metadata: { msgId: event.msg.msgId } },
|
|
282
|
+
],
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Alive + (idle OR steering): deliver immediately.
|
|
287
|
+
// Steering messages reach claude mid-turn intentionally; they
|
|
288
|
+
// do NOT start a new turn (existing turn continues).
|
|
289
|
+
if (isSteering) {
|
|
290
|
+
return {
|
|
291
|
+
state,
|
|
292
|
+
effects: [
|
|
293
|
+
{ kind: 'deliverToBridge', key: event.key, msg: event.msg },
|
|
294
|
+
{ kind: 'logTrace', stage: 'steer_delivered_mid_turn', key: event.key },
|
|
295
|
+
],
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Fresh turn: state transitions to in_turn(key), perKey
|
|
300
|
+
// turnStartedAt is set, message delivered.
|
|
301
|
+
const next: State = {
|
|
302
|
+
global: { kind: 'bridge_alive_in_turn', activeTurn: event.key },
|
|
303
|
+
perKey: state.perKey,
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
state: updatePerKey(next, event.key, (p) => ({ ...p, turnStartedAt: event.at })),
|
|
307
|
+
effects: [
|
|
308
|
+
{ kind: 'setTurnStarted', key: event.key, at: event.at },
|
|
309
|
+
{ kind: 'deliverToBridge', key: event.key, msg: event.msg },
|
|
310
|
+
{ kind: 'logTrace', stage: 'fresh_turn_deliver', key: event.key, metadata: { msgId: event.msg.msgId } },
|
|
311
|
+
],
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
case 'turnStart': {
|
|
316
|
+
// External signal that a turn has begun (e.g. session_event
|
|
317
|
+
// from bridge). Distinct from the implicit turn-start in
|
|
318
|
+
// `inbound`: a turn can begin without a fresh inbound (cron
|
|
319
|
+
// injection, scheduled fire).
|
|
320
|
+
const next: State = state.global.kind === 'bridge_alive_in_turn'
|
|
321
|
+
? state
|
|
322
|
+
: { ...state, global: { kind: 'bridge_alive_in_turn', activeTurn: event.key } }
|
|
323
|
+
return {
|
|
324
|
+
state: updatePerKey(next, event.key, (p) => ({ ...p, turnStartedAt: event.at })),
|
|
325
|
+
effects: [
|
|
326
|
+
{ kind: 'setTurnStarted', key: event.key, at: event.at },
|
|
327
|
+
{ kind: 'logTrace', stage: 'turn_start', key: event.key },
|
|
328
|
+
],
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
case 'turnEnd': {
|
|
333
|
+
// Clear turn state for the ending key AND sweep siblings
|
|
334
|
+
// (invariant #3). Transition global to idle if the ending turn
|
|
335
|
+
// was the active one.
|
|
336
|
+
const chatId = chatIdOfKey(event.key)
|
|
337
|
+
const stateAfterClear = updatePerKey(state, event.key, (p) => ({
|
|
338
|
+
turnStartedAt: null,
|
|
339
|
+
lastOutboundAt: event.outboundEmitted ? event.at : p.lastOutboundAt,
|
|
340
|
+
}))
|
|
341
|
+
const sweep = sweepSiblings(stateAfterClear, chatId, event.key)
|
|
342
|
+
const wasActive = state.global.kind === 'bridge_alive_in_turn' && state.global.activeTurn === event.key
|
|
343
|
+
const next: State = wasActive
|
|
344
|
+
? { ...sweep.state, global: { kind: 'bridge_alive_idle' } }
|
|
345
|
+
: sweep.state
|
|
346
|
+
const effects: Effect[] = [
|
|
347
|
+
{ kind: 'clearTurnStarted', key: event.key },
|
|
348
|
+
...sweep.effects,
|
|
349
|
+
]
|
|
350
|
+
if (event.outboundEmitted) {
|
|
351
|
+
effects.push({ kind: 'noteOutbound', key: event.key, at: event.at })
|
|
352
|
+
}
|
|
353
|
+
effects.push({ kind: 'drainBuffer' })
|
|
354
|
+
effects.push({ kind: 'logTrace', stage: 'turn_complete', key: event.key, metadata: { outboundEmitted: event.outboundEmitted } })
|
|
355
|
+
return { state: next, effects }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
case 'modelOutbound': {
|
|
359
|
+
// Updates lastOutboundAt for the key. Does NOT change global
|
|
360
|
+
// state. This is invariant #5's data: it carries across turn
|
|
361
|
+
// boundaries so a spurious fallback can be suppressed.
|
|
362
|
+
return {
|
|
363
|
+
state: updatePerKey(state, event.key, (p) => ({ ...p, lastOutboundAt: event.at })),
|
|
364
|
+
effects: [
|
|
365
|
+
{ kind: 'noteOutbound', key: event.key, at: event.at },
|
|
366
|
+
],
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
case 'permVerdict': {
|
|
371
|
+
const alive = state.global.kind !== 'bridge_dead'
|
|
372
|
+
if (alive) {
|
|
373
|
+
return {
|
|
374
|
+
state,
|
|
375
|
+
effects: [
|
|
376
|
+
{ kind: 'deliverPermVerdict', verdict: event.verdict },
|
|
377
|
+
{ kind: 'logTrace', stage: 'perm_verdict_delivered' },
|
|
378
|
+
],
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return {
|
|
382
|
+
state,
|
|
383
|
+
effects: [
|
|
384
|
+
{ kind: 'persistPermVerdict', verdict: event.verdict },
|
|
385
|
+
{ kind: 'logTrace', stage: 'perm_verdict_persisted' },
|
|
386
|
+
],
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
case 'tick': {
|
|
391
|
+
// Scan perKey for stale turns. For each entry with a non-null
|
|
392
|
+
// turnStartedAt where `now - turnStartedAt > TURN_TTL_MS`:
|
|
393
|
+
// - Check lastOutboundAt: if it's null OR more than
|
|
394
|
+
// OUTBOUND_RECENT_MS old, fire the fallback poke + clear.
|
|
395
|
+
// - Otherwise suppress (invariant #5).
|
|
396
|
+
const effects: Effect[] = []
|
|
397
|
+
let next = state
|
|
398
|
+
for (const [k, v] of state.perKey) {
|
|
399
|
+
if (v.turnStartedAt == null) continue
|
|
400
|
+
const age = event.now - v.turnStartedAt
|
|
401
|
+
if (age <= TURN_TTL_MS) {
|
|
402
|
+
// Not yet stale enough for fallback. Soft/firm pokes are
|
|
403
|
+
// not modeled here yet — they're advisory, the gateway
|
|
404
|
+
// emits them; the state machine governs the fallback gate.
|
|
405
|
+
continue
|
|
406
|
+
}
|
|
407
|
+
// Stale enough for fallback. Check the suppression window.
|
|
408
|
+
const recentOutbound =
|
|
409
|
+
v.lastOutboundAt != null && (event.now - v.lastOutboundAt) < OUTBOUND_RECENT_MS
|
|
410
|
+
if (recentOutbound) {
|
|
411
|
+
// Invariant #5: model recently broke silence; suppress fire.
|
|
412
|
+
effects.push({ kind: 'logTrace', stage: 'fallback_suppressed', key: k, metadata: { recentOutboundMs: event.now - (v.lastOutboundAt ?? 0) } })
|
|
413
|
+
continue
|
|
414
|
+
}
|
|
415
|
+
// Fire the fallback + clear the turn.
|
|
416
|
+
effects.push({ kind: 'firePoke', key: k, level: 'fallback' })
|
|
417
|
+
effects.push({ kind: 'clearTurnStarted', key: k })
|
|
418
|
+
next = updatePerKey(next, k, (p) => ({ ...p, turnStartedAt: null }))
|
|
419
|
+
// If this was the active turn globally, drop to idle.
|
|
420
|
+
if (next.global.kind === 'bridge_alive_in_turn' && next.global.activeTurn === k) {
|
|
421
|
+
next = { ...next, global: { kind: 'bridge_alive_idle' } }
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return { state: next, effects }
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
430
|
+
// Test-only helpers — mirror silence-poke.ts's __XForTests idiom
|
|
431
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
432
|
+
|
|
433
|
+
export function __chatIdOfKeyForTests(key: ChatKey): string {
|
|
434
|
+
return chatIdOfKey(key)
|
|
435
|
+
}
|