switchroom 0.12.21 → 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 +7 -6
- package/telegram-plugin/gateway/gateway.ts +40 -1
- 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-always-on-after-restart-dm.test.ts +157 -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) {
|
|
@@ -51275,6 +51275,7 @@ async function handleInbound(ctx, text, downloadImage, attachment) {
|
|
|
51275
51275
|
return;
|
|
51276
51276
|
}
|
|
51277
51277
|
const inboundReceivedAt = Date.now();
|
|
51278
|
+
const turnInFlightAtReceipt = activeTurnStartedAt.size > 0;
|
|
51278
51279
|
const access = result.access;
|
|
51279
51280
|
const from = ctx.from;
|
|
51280
51281
|
const chat_id = String(ctx.chat.id);
|
|
@@ -51834,7 +51835,7 @@ ${preBlock(write.output)}`;
|
|
|
51834
51835
|
};
|
|
51835
51836
|
const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
|
|
51836
51837
|
if (decideInboundDelivery({
|
|
51837
|
-
turnInFlight:
|
|
51838
|
+
turnInFlight: turnInFlightAtReceipt,
|
|
51838
51839
|
isSteering
|
|
51839
51840
|
}) === "buffer-until-idle") {
|
|
51840
51841
|
pendingInboundBuffer.push(selfAgent, inboundMsg);
|
|
@@ -6637,6 +6637,39 @@ async function handleInbound(
|
|
|
6637
6637
|
// network RTT) but not a user-perceived end-to-end measurement.
|
|
6638
6638
|
const inboundReceivedAt = Date.now()
|
|
6639
6639
|
|
|
6640
|
+
// #1556 self-blocking fix (v0.12.22): snapshot the live turn-state
|
|
6641
|
+
// BEFORE the fresh-turn branch (line ~7357) sets activeTurnStartedAt
|
|
6642
|
+
// for THIS inbound. The #1556 delivery gate further down asks "is a
|
|
6643
|
+
// turn ALREADY in flight" — but if we read activeTurnStartedAt.size
|
|
6644
|
+
// at the gate, we see the entry this handler just wrote, and buffer
|
|
6645
|
+
// the very message that just started the turn. Symptom: every first
|
|
6646
|
+
// post-restart message in each thread was held 5 minutes until the
|
|
6647
|
+
// silence-poke fallback drained the buffer; the "5-min blank after
|
|
6648
|
+
// restart" wedge documented in
|
|
6649
|
+
// feedback_5min_restart_wedge_violates_vision.md.
|
|
6650
|
+
//
|
|
6651
|
+
// Why ONLY first-after-restart, not every steady-state message: the
|
|
6652
|
+
// fresh-turn branch fires only when `priorActive = activeStatusReactions.get(key)`
|
|
6653
|
+
// returns null (no controller currently running for this chat+thread).
|
|
6654
|
+
// In a live conversation, the controller from the previous turn
|
|
6655
|
+
// typically hasn't been cleared yet when the user's follow-up
|
|
6656
|
+
// arrives (or follow-ups arrive mid-turn, taking the queued path) —
|
|
6657
|
+
// so the else branch at ~7313 is skipped and the .set never fires.
|
|
6658
|
+
// A fresh container after restart has EMPTY `activeStatusReactions`,
|
|
6659
|
+
// so the very first message in each thread is guaranteed to enter
|
|
6660
|
+
// the fresh-turn branch and trigger the self-block.
|
|
6661
|
+
//
|
|
6662
|
+
// Why snapshot, not move-the-set(): the .set() at ~7357 is embedded
|
|
6663
|
+
// in a coupled init bundle (controller, msgIds, signalTracker.reset,
|
|
6664
|
+
// silencePoke.startTurn, the 👀 reaction emit). Moving only the .set
|
|
6665
|
+
// splits the bundle in ways future maintainers will drift; moving
|
|
6666
|
+
// the WHOLE bundle past the gate changes user-visible ack timing
|
|
6667
|
+
// (👀 wouldn't land until after the gate decides to deliver, hiding
|
|
6668
|
+
// an ack on the buffered path). The snapshot is the minimal precise
|
|
6669
|
+
// fix. Phase 2b's state-machine extraction will revisit this
|
|
6670
|
+
// structurally.
|
|
6671
|
+
const turnInFlightAtReceipt = activeTurnStartedAt.size > 0
|
|
6672
|
+
|
|
6640
6673
|
const access = result.access
|
|
6641
6674
|
const from = ctx.from!
|
|
6642
6675
|
const chat_id = String(ctx.chat!.id)
|
|
@@ -7546,9 +7579,15 @@ async function handleInbound(
|
|
|
7546
7579
|
// idle-drain flush it the instant claude goes idle, where the channel
|
|
7547
7580
|
// notification submits cleanly as a fresh turn. Steering messages are
|
|
7548
7581
|
// exempt — reaching claude mid-turn is the whole point of /steer.
|
|
7582
|
+
//
|
|
7583
|
+
// CRITICAL: turnInFlight reads the snapshot taken at receipt above,
|
|
7584
|
+
// not `activeTurnStartedAt.size > 0` live. The fresh-turn branch at
|
|
7585
|
+
// line ~7357 already populated the Map for THIS inbound's turn;
|
|
7586
|
+
// reading the live size here would self-block (see the comment on
|
|
7587
|
+
// turnInFlightAtReceipt for the wedge symptom this fixes).
|
|
7549
7588
|
if (
|
|
7550
7589
|
decideInboundDelivery({
|
|
7551
|
-
turnInFlight:
|
|
7590
|
+
turnInFlight: turnInFlightAtReceipt,
|
|
7552
7591
|
isSteering,
|
|
7553
7592
|
}) === 'buffer-until-idle'
|
|
7554
7593
|
) {
|
|
@@ -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
|
+
}
|