switchroom 0.15.45 → 0.16.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +122 -88
- package/dist/auth-broker/index.js +463 -177
- package/dist/cli/autoaccept-poll.js +4842 -35
- package/dist/cli/drive-write-pretool.mjs +17 -14
- package/dist/cli/notion-write-pretool.mjs +117 -86
- package/dist/cli/self-improve-apply-guard-pretool.mjs +626 -0
- package/dist/cli/self-improve-stop.mjs +428 -0
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +3158 -1178
- package/dist/host-control/main.js +2833 -355
- package/dist/vault/approvals/kernel-server.js +7479 -7439
- package/dist/vault/broker/server.js +11312 -11272
- package/examples/minimal.yaml +1 -0
- package/examples/switchroom.yaml +1 -0
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +88 -1
- package/profiles/_shared/execution-discipline.md.hbs +18 -0
- package/profiles/default/CLAUDE.md.hbs +0 -19
- package/telegram-plugin/.claude-plugin/plugin.json +2 -2
- package/telegram-plugin/answer-stream-flag.ts +12 -49
- package/telegram-plugin/answer-stream.ts +5 -150
- package/telegram-plugin/auth-snapshot-format.ts +280 -48
- package/telegram-plugin/auto-fallback-fleet.ts +44 -1
- package/telegram-plugin/context-exhaustion.ts +12 -0
- package/telegram-plugin/demo-mask.ts +154 -0
- package/telegram-plugin/dist/bridge/bridge.js +167 -124
- package/telegram-plugin/dist/gateway/gateway.js +3039 -1159
- package/telegram-plugin/dist/server.js +215 -172
- package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
- package/telegram-plugin/draft-stream.ts +47 -410
- package/telegram-plugin/final-answer-detect.ts +17 -12
- package/telegram-plugin/fleet-fallback-resume.ts +131 -0
- package/telegram-plugin/format.ts +56 -19
- package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
- package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
- package/telegram-plugin/gateway/auth-command.ts +70 -14
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
- package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
- package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
- package/telegram-plugin/gateway/current-turn-map.ts +188 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
- package/telegram-plugin/gateway/effort-command.ts +8 -3
- package/telegram-plugin/gateway/emission-authority.ts +369 -0
- package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
- package/telegram-plugin/gateway/gateway.ts +1837 -291
- package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
- package/telegram-plugin/gateway/ms365-write-approval.test.ts +4 -4
- package/telegram-plugin/gateway/represent-guard.ts +72 -0
- package/telegram-plugin/gateway/status-surface-log.test.ts +5 -4
- package/telegram-plugin/gateway/status-surface-log.ts +14 -3
- package/telegram-plugin/history.ts +33 -11
- package/telegram-plugin/hooks/repo-context-pretool.mjs +26 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +5 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +39 -15
- package/telegram-plugin/issues-card.ts +4 -0
- package/telegram-plugin/model-unavailable.ts +124 -0
- package/telegram-plugin/narrative-dedup.ts +69 -0
- package/telegram-plugin/over-ping-safety-net.ts +70 -4
- package/telegram-plugin/package.json +3 -3
- package/telegram-plugin/pending-work-progress.ts +12 -0
- package/telegram-plugin/permission-rule.ts +32 -5
- package/telegram-plugin/permission-title.ts +152 -9
- package/telegram-plugin/quota-check.ts +13 -0
- package/telegram-plugin/quota-watch.ts +135 -7
- package/telegram-plugin/registry/turns-schema.test.ts +24 -0
- package/telegram-plugin/registry/turns-schema.ts +9 -0
- package/telegram-plugin/runtime-metrics.ts +13 -0
- package/telegram-plugin/session-tail.ts +96 -11
- package/telegram-plugin/silence-poke.ts +170 -24
- package/telegram-plugin/slot-banner-driver.ts +3 -0
- package/telegram-plugin/status-no-truncate.ts +44 -0
- package/telegram-plugin/status-reactions.ts +20 -3
- package/telegram-plugin/stream-controller.ts +4 -23
- package/telegram-plugin/stream-reply-handler.ts +6 -24
- package/telegram-plugin/streaming-metrics.ts +91 -0
- package/telegram-plugin/subagent-watcher.ts +212 -66
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +47 -0
- package/telegram-plugin/tests/answer-stream-dedup.test.ts +9 -26
- package/telegram-plugin/tests/answer-stream-flag.test.ts +25 -58
- package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +41 -51
- package/telegram-plugin/tests/answer-stream.test.ts +2 -411
- package/telegram-plugin/tests/auth-add-flow.test.ts +488 -253
- package/telegram-plugin/tests/auth-command-format2.test.ts +71 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +376 -6
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +120 -0
- package/telegram-plugin/tests/cross-turn-card-gate.test.ts +424 -0
- package/telegram-plugin/tests/demo-mask.test.ts +127 -0
- package/telegram-plugin/tests/draft-stream.test.ts +0 -827
- package/telegram-plugin/tests/emission-authority-card-drain-gate.test.ts +236 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +488 -0
- package/telegram-plugin/tests/emission-authority-open-gate.test.ts +179 -0
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +395 -0
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +177 -0
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +146 -0
- package/telegram-plugin/tests/feed-open-gate.test.ts +259 -0
- package/telegram-plugin/tests/feed-survival.test.ts +526 -0
- package/telegram-plugin/tests/fleet-fallback-resume.test.ts +197 -0
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +117 -0
- package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +4 -11
- package/telegram-plugin/tests/history.test.ts +60 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +118 -0
- package/telegram-plugin/tests/narrative-dedup.test.ts +118 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +285 -0
- package/telegram-plugin/tests/over-ping-final-answer-decoupling.test.ts +194 -0
- package/telegram-plugin/tests/over-ping-safety-net.test.ts +2 -2
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +373 -0
- package/telegram-plugin/tests/permission-card-origin-kill-switch.test.ts +42 -0
- package/telegram-plugin/tests/permission-rule.test.ts +17 -0
- package/telegram-plugin/tests/permission-title.test.ts +206 -17
- package/telegram-plugin/tests/quota-watch.test.ts +252 -9
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -1
- package/telegram-plugin/tests/repo-context-pretool.test.ts +62 -0
- package/telegram-plugin/tests/represent-guard.test.ts +162 -0
- package/telegram-plugin/tests/session-tail.test.ts +147 -3
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +18 -0
- package/telegram-plugin/tests/status-card-budget-parity.test.ts +72 -0
- package/telegram-plugin/tests/status-surface-log.test.ts +146 -0
- package/telegram-plugin/tests/subagent-watcher-clip-narrative.test.ts +58 -0
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +102 -0
- package/telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts +225 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +147 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +597 -0
- package/telegram-plugin/tests/telegram-format.test.ts +101 -6
- package/telegram-plugin/tests/tool-activity-summary.test.ts +550 -15
- package/telegram-plugin/tests/tool-label-pretool.test.ts +73 -0
- package/telegram-plugin/tests/tool-label-sidecar.test.ts +44 -0
- package/telegram-plugin/tests/tool-labels.test.ts +67 -0
- package/telegram-plugin/tests/turn-liveness-floor.test.ts +196 -0
- package/telegram-plugin/tests/turn-liveness-invariant.test.ts +340 -0
- package/telegram-plugin/tests/welcome-text.test.ts +32 -3
- package/telegram-plugin/tests/worker-activity-feed.test.ts +470 -22
- package/telegram-plugin/tool-activity-summary.ts +375 -58
- package/telegram-plugin/turn-liveness-floor.ts +240 -0
- package/telegram-plugin/uat/assertions.ts +115 -0
- package/telegram-plugin/uat/driver.ts +68 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +119 -133
- package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +94 -0
- package/telegram-plugin/uat/scenarios/jtbd-cross-turn-card-dm.test.ts +109 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-thinkgap-dm.test.ts +478 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +396 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-feed-open-dm.test.ts +202 -0
- package/telegram-plugin/uat/scenarios/jtbd-reply-is-last-dm.test.ts +202 -0
- package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +93 -87
- package/telegram-plugin/welcome-text.ts +13 -1
- package/telegram-plugin/worker-activity-feed.ts +157 -82
- package/telegram-plugin/draft-transport.ts +0 -122
- package/telegram-plugin/tests/draft-retirement-wiring.test.ts +0 -82
- package/telegram-plugin/tests/draft-transport.test.ts +0 -211
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* current-turn-map.ts — per-topic `currentTurn` store behind the
|
|
3
|
+
* emission-authority kill-switch (PR-4e).
|
|
4
|
+
*
|
|
5
|
+
* ## What 4e changes (and, crucially, what it does NOT)
|
|
6
|
+
*
|
|
7
|
+
* The `claude` CLI is genuinely SEQUENTIAL: never two foreground turns
|
|
8
|
+
* executing CPU-simultaneously. So 4e is NOT about concurrent execution.
|
|
9
|
+
* It is about the ISOLATION of per-topic emission STATE.
|
|
10
|
+
*
|
|
11
|
+
* Before 4e the gateway tracked the live foreground turn in ONE ambient
|
|
12
|
+
* module-scope singleton (`let currentTurn`). That is correct for the
|
|
13
|
+
* synchronous-to-the-live-turn reads — but it is WRONG for the LATE async
|
|
14
|
+
* events that fire AFTER the live turn has flipped to a different topic:
|
|
15
|
+
*
|
|
16
|
+
* - a deferred card drain captured for topic A,
|
|
17
|
+
* - the orphaned-reply backstop for A,
|
|
18
|
+
* - an over-ping decision for A,
|
|
19
|
+
* - the answer-stream suppressor for A,
|
|
20
|
+
*
|
|
21
|
+
* any of which can fire AFTER `currentTurn` flipped from A to B. A bare
|
|
22
|
+
* `currentTurn === turnA` liveness check then reads B and FALSIFIES A's own
|
|
23
|
+
* liveness (or, worse, a teardown keyed on the ambient singleton clobbers B's
|
|
24
|
+
* card / ping / single-flight state). 4e keys the store by chat+thread so a
|
|
25
|
+
* late A-captured callback resolves A's authority, never contaminating B.
|
|
26
|
+
*
|
|
27
|
+
* ## Defining property: flag-OFF ≡ flag-ON ≡ base
|
|
28
|
+
*
|
|
29
|
+
* The kill-switch (`SWITCHROOM_EMISSION_AUTHORITY`, default OFF) is read ONCE
|
|
30
|
+
* here, mirroring `emission-authority.ts`'s `EMISSION_AUTHORITY_ENABLED`:
|
|
31
|
+
*
|
|
32
|
+
* - **Flag OFF (default):** the accessors operate on the singleton ONLY.
|
|
33
|
+
* `byKey` is never written (stays empty, zero alloc). Every read returns
|
|
34
|
+
* the singleton. Byte-equivalent to base — there is no map.
|
|
35
|
+
* - **Flag ON:** the per-topic `byKey` map is the source of truth, AND the
|
|
36
|
+
* singleton is retained as a "most-recent-set" MIRROR so every GLOBAL
|
|
37
|
+
* liveness read (`isBusy`, the `if (currentTurn != null) return` poke
|
|
38
|
+
* guards, the orphaned-reply guard) stays byte-identical: under the
|
|
39
|
+
* sequential-CLI invariant the most-recently-set turn IS the live turn,
|
|
40
|
+
* so the mirror answers "is anything live" exactly as the old singleton.
|
|
41
|
+
*
|
|
42
|
+
* No lock is added here. Map ops are synchronous `Map.get/set/delete` — no
|
|
43
|
+
* await, no lock — so the PR-4d no-deadlock invariant is untouched.
|
|
44
|
+
*
|
|
45
|
+
* ## Why a standalone module (not inline in gateway.ts)
|
|
46
|
+
*
|
|
47
|
+
* The gateway IIFE cannot be instantiated in-process, so the multi-topic
|
|
48
|
+
* non-contamination behaviour would be untestable if this lived inline. As a
|
|
49
|
+
* standalone module with a flag-read-once seam (mirroring
|
|
50
|
+
* emission-authority-card-drain-gate.test.ts's `loadFacade` re-import idiom),
|
|
51
|
+
* the per-topic isolation is driven directly in tests/per-topic-current-turn.test.ts.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
import { chatIdOfChatKey, type ChatKey } from './chat-key.js'
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Kill-switch — read ONCE at module top, `=== '1'` (default OFF), mirroring
|
|
58
|
+
* `emission-authority.ts`'s `EMISSION_AUTHORITY_ENABLED`. The same flag governs
|
|
59
|
+
* both modules: under it OFF the singleton is the only store; under it ON the
|
|
60
|
+
* per-topic map is authoritative with the singleton kept as a most-recent mirror.
|
|
61
|
+
*/
|
|
62
|
+
export const EMISSION_AUTHORITY_ENABLED =
|
|
63
|
+
process.env.SWITCHROOM_EMISSION_AUTHORITY === '1'
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Per-topic `currentTurn` store. Generic over the gateway's turn type (which is
|
|
67
|
+
* module-local to gateway.ts and not exported) so this module stays decoupled.
|
|
68
|
+
*
|
|
69
|
+
* - `byKey` is the FLAG-ON store, keyed by `statusKey(chatId, threadId)`.
|
|
70
|
+
* - `singleton` is the FLAG-OFF store AND, under flag-ON, the most-recent-set
|
|
71
|
+
* MIRROR that serves global "is anything live" reads byte-identically.
|
|
72
|
+
*
|
|
73
|
+
* The two thin accessors flag-branch in EXACTLY one place each.
|
|
74
|
+
*/
|
|
75
|
+
export class CurrentTurnMap<T> {
|
|
76
|
+
/** Flag-ON store. Never written under flag OFF (stays empty, zero alloc). */
|
|
77
|
+
readonly byKey = new Map<string, T>()
|
|
78
|
+
|
|
79
|
+
/** Flag-OFF store AND flag-ON most-recent-set mirror. */
|
|
80
|
+
private singleton: T | null = null
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Read the live turn.
|
|
84
|
+
*
|
|
85
|
+
* - Flag OFF: the singleton.
|
|
86
|
+
* - Flag ON: `byKey.get(key)` when a key is given (per-topic liveness);
|
|
87
|
+
* otherwise the singleton mirror (global "is anything live" read).
|
|
88
|
+
*/
|
|
89
|
+
get(key?: string): T | null {
|
|
90
|
+
if (EMISSION_AUTHORITY_ENABLED) {
|
|
91
|
+
if (key != null) return this.byKey.get(key) ?? null
|
|
92
|
+
return this.singleton
|
|
93
|
+
}
|
|
94
|
+
return this.singleton
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Set the live turn for `key`.
|
|
99
|
+
*
|
|
100
|
+
* - Flag OFF: assign the singleton (key ignored).
|
|
101
|
+
* - Flag ON: set `byKey[key]` AND update the singleton mirror to
|
|
102
|
+
* most-recent-set, so global liveness reads stay byte-identical.
|
|
103
|
+
*/
|
|
104
|
+
set(turn: T, key: string): void {
|
|
105
|
+
if (EMISSION_AUTHORITY_ENABLED) {
|
|
106
|
+
this.byKey.set(key, turn)
|
|
107
|
+
this.singleton = turn
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
this.singleton = turn
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Is `turn` still the live turn FOR ITS OWN topic `key`?
|
|
115
|
+
*
|
|
116
|
+
* - Flag OFF: `singleton === turn` (the ambient liveness check, verbatim).
|
|
117
|
+
* - Flag ON: `byKey.get(key) === turn` — so a B-flip never falsifies A's
|
|
118
|
+
* own liveness. This is the load-bearing cross-topic isolation read.
|
|
119
|
+
*/
|
|
120
|
+
isLiveForKey(turn: T, key: string): boolean {
|
|
121
|
+
if (EMISSION_AUTHORITY_ENABLED) {
|
|
122
|
+
return this.byKey.get(key) === turn
|
|
123
|
+
}
|
|
124
|
+
return this.singleton === turn
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* End (delete) `turn` for `key`, iff `key` still maps to `turn` — the
|
|
129
|
+
* leak-close-at-origin used by `endCurrentTurnAtomic` and the silence-poke
|
|
130
|
+
* fallback.
|
|
131
|
+
*
|
|
132
|
+
* - Flag OFF: clear the singleton iff it still points at `turn`.
|
|
133
|
+
* - Flag ON: `byKey.delete(key)` iff `byKey.get(key) === turn`, AND clear
|
|
134
|
+
* the singleton mirror iff it STILL points at `turn` (i.e. no later turn
|
|
135
|
+
* has flipped the mirror). Returns true iff a delete happened.
|
|
136
|
+
*/
|
|
137
|
+
endTurnForKey(turn: T, key: string): boolean {
|
|
138
|
+
if (EMISSION_AUTHORITY_ENABLED) {
|
|
139
|
+
if (this.byKey.get(key) !== turn) return false
|
|
140
|
+
this.byKey.delete(key)
|
|
141
|
+
if (this.singleton === turn) this.singleton = null
|
|
142
|
+
return true
|
|
143
|
+
}
|
|
144
|
+
if (this.singleton !== turn) return false
|
|
145
|
+
this.singleton = null
|
|
146
|
+
return true
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Clear EVERYTHING — every entry is a ghost (the disconnect-flush /
|
|
151
|
+
* bridge-died sweep). Drops the whole `byKey` map and the singleton mirror.
|
|
152
|
+
*/
|
|
153
|
+
clearAll(): void {
|
|
154
|
+
this.byKey.clear()
|
|
155
|
+
this.singleton = null
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Self-heal backstop: drop any `byKey` entries whose key belongs to `chatId`
|
|
160
|
+
* AND pass the per-sibling `isStale` gate — mirroring `purgeStaleTurnsForChat`
|
|
161
|
+
* (turn-state-purge.ts) exactly, including its `:`-prefix chat match and the
|
|
162
|
+
* one-agent-owns-supergroup safety (a LIVE sibling topic of the same chatId is
|
|
163
|
+
* spared unless it is itself stale). Also clears the singleton mirror iff it
|
|
164
|
+
* pointed at one of the swept entries. No-op under flag OFF (the map is empty).
|
|
165
|
+
*
|
|
166
|
+
* @param isStale per-sibling staleness gate; defaults to always-stale for the
|
|
167
|
+
* DM / single-topic case (every sibling is genuinely dangling), matching the
|
|
168
|
+
* `purgeStaleTurnsForChat` default.
|
|
169
|
+
* @returns the keys swept.
|
|
170
|
+
*/
|
|
171
|
+
purgeChatStale(chatId: string, isStale: (key: string) => boolean = () => true): string[] {
|
|
172
|
+
if (!chatId) return []
|
|
173
|
+
const swept: string[] = []
|
|
174
|
+
for (const key of [...this.byKey.keys()]) {
|
|
175
|
+
// A bare chatId (no `:`) cannot belong to a chat+thread keyspace —
|
|
176
|
+
// chatIdOfChatKey returns the whole string in that case, which can never
|
|
177
|
+
// equal `${chatId}:...`-derived keys, so it is correctly skipped.
|
|
178
|
+
if (!key.includes(':')) continue
|
|
179
|
+
if (chatIdOfChatKey(key as ChatKey) !== chatId) continue
|
|
180
|
+
if (!isStale(key)) continue // live sibling topic — leave its turn intact
|
|
181
|
+
const victim = this.byKey.get(key)
|
|
182
|
+
this.byKey.delete(key)
|
|
183
|
+
swept.push(key)
|
|
184
|
+
if (this.singleton === victim) this.singleton = null
|
|
185
|
+
}
|
|
186
|
+
return swept
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -157,10 +157,12 @@ export function flushOnAgentDisconnect<
|
|
|
157
157
|
// dangling-sweep above for activeTurnStartedAt.
|
|
158
158
|
if (claudeBusyKeys.size > 0) {
|
|
159
159
|
const orphanCount = claudeBusyKeys.size
|
|
160
|
+
const orphanKeys = [...claudeBusyKeys]
|
|
160
161
|
claudeBusyKeys.clear()
|
|
161
162
|
log(
|
|
162
163
|
`telegram gateway: disconnect-flush cleared ${orphanCount} orphan claudeBusyKeys ` +
|
|
163
|
-
`entr${orphanCount === 1 ? 'y' : 'ies'} (synthetic-inbound deliveries that never turn_ended)
|
|
164
|
+
`entr${orphanCount === 1 ? 'y' : 'ies'} (synthetic-inbound deliveries that never turn_ended)` +
|
|
165
|
+
` keys=${orphanKeys.join(',')}`,
|
|
164
166
|
)
|
|
165
167
|
}
|
|
166
168
|
|
|
@@ -6,9 +6,14 @@
|
|
|
6
6
|
* inline keyboard of the five levels the CLI offers
|
|
7
7
|
* (`low · medium · high · xhigh · max`, faster→smarter), the live level
|
|
8
8
|
* marked ✅. A tap types claude's own `/effort <level>` into the agent's
|
|
9
|
-
* tmux pane via the
|
|
10
|
-
*
|
|
11
|
-
* REPL command, no API, no SDK, no config mutation.
|
|
9
|
+
* tmux pane via the dedicated `applyEffort` driver
|
|
10
|
+
* (src/agents/effort-picker.ts) — the Claude-native mechanism: the
|
|
11
|
+
* unmodified CLI's REPL command, no API, no SDK, no config mutation.
|
|
12
|
+
* `applyEffort` (NOT the bare inject primitive) is used deliberately: it
|
|
13
|
+
* answers the "Change effort level?" confirmation modal so the pane never
|
|
14
|
+
* wedges. `/effort` is therefore on the inject BLOCKLIST (#2471) — raw
|
|
15
|
+
* `/inject /effort` would leave that modal open — and this command is the
|
|
16
|
+
* only sanctioned path.
|
|
12
17
|
*
|
|
13
18
|
* `/effort <level>` does the same non-interactively.
|
|
14
19
|
*
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-foreground-turn EMISSION-AUTHORITY façade (kill-switched no-op seam).
|
|
3
|
+
*
|
|
4
|
+
* This is the SEAM the message-emission-determinism refactor (PR-4 series,
|
|
5
|
+
* `docs/message-emission-determinism.md` §9 "deeper architectural move") routes
|
|
6
|
+
* the foreground-lane card/ping emission call sites through. It mirrors the
|
|
7
|
+
* one-concern-per-file precedent of `feed-open-gate.ts`: a single place that
|
|
8
|
+
* will eventually OWN the WHEN of foreground emission. The gateway constructs
|
|
9
|
+
* exactly ONE instance per foreground turn, alongside the `CurrentTurn` object,
|
|
10
|
+
* and passes the chat/thread key in EXPLICITLY (even though today it is sourced
|
|
11
|
+
* from the `currentTurn` singleton) — that explicit key is the seam PR-4e uses
|
|
12
|
+
* to swap the singleton for a per-topic map. The façade must NOT persist across
|
|
13
|
+
* turns: it is per-turn only, because cross-turn persistence would pre-empt the
|
|
14
|
+
* per-chat scoping decision PR-4e owns.
|
|
15
|
+
*
|
|
16
|
+
* ## PR-4a is a behaviourally-IDENTICAL no-op
|
|
17
|
+
*
|
|
18
|
+
* In PR-4a NO decision logic moves in. Every method is a THIN DELEGATE: it
|
|
19
|
+
* invokes the same statements the call site ran before, via an `apply` thunk
|
|
20
|
+
* the caller hands in (so the load-bearing literals — `drainActivitySummary`,
|
|
21
|
+
* `clearActivitySummary`, `decideOverPing`, the `finalAnswerEverDelivered`
|
|
22
|
+
* latch-set — stay AT THE CALL SITE and remain visible to the source-read
|
|
23
|
+
* wiring oracles). Framing (A) "construct-but-bypass": each method body branches
|
|
24
|
+
* on the kill-switch, and in PR-4a BOTH branches execute the SAME delegate, so
|
|
25
|
+
* the seam is a provable no-op even when the flag is ON. The flag's real teeth
|
|
26
|
+
* (moving `mayOpenActivityCard` / `decideOverPing` decisions INTO the façade)
|
|
27
|
+
* start in PR-4b — NOT here.
|
|
28
|
+
*
|
|
29
|
+
* ## PR-4d deadlock invariant (ship as a standing doc-comment)
|
|
30
|
+
*
|
|
31
|
+
* `mayDrain` is a PURE READ; it must NOT acquire `chatLock`. PR-4d acquires
|
|
32
|
+
* `chatLock` strictly INSIDE the deliver-before-drain gate decision (gateway.ts
|
|
33
|
+
* component-1, ref ~`:2730`), never the reverse, because a synthetic represent
|
|
34
|
+
* turn starts `finalAnswerDelivered=false` and would wedge the gate (ref
|
|
35
|
+
* ~`:1646`) if a card OPEN held the lock across the gate's release. Keep
|
|
36
|
+
* `mayDrain` lock-free so PR-4d has a clean read to build on.
|
|
37
|
+
*
|
|
38
|
+
* ## PR-4d ships: `mayDrainCardNow` unifies the card-drain path with the #2137
|
|
39
|
+
* deliver-before-drain gate
|
|
40
|
+
*
|
|
41
|
+
* PR-4d adds `mayDrainCardNow` — the card-path counterpart of the deliver-before-
|
|
42
|
+
* drain decision in `serialize-drain-gate.ts`. It is ALSO a pure, lock-free read:
|
|
43
|
+
*
|
|
44
|
+
* - Flag OFF (default): returns exactly the `mayDrain` single-flight read —
|
|
45
|
+
* byte-equivalent to base. The card single-flight gate is unchanged.
|
|
46
|
+
* - Flag ON: returns the `mayDrain` read AND-ed with `mayDrainBufferedInbound`,
|
|
47
|
+
* importing the PURE `mayDrainBufferedInbound` from `./serialize-drain-gate.js`
|
|
48
|
+
* (the façade imports the pure helper, NEVER the gateway). The card path
|
|
49
|
+
* threads `endingTurnFinalAnswerDelivered: null` so the deliver-before-drain
|
|
50
|
+
* predicate degenerates to `!turnInFlight`: the card single-flight is governed
|
|
51
|
+
* by `activityInFlight` (via `mayDrain`), NOT by an ending turn's delivery
|
|
52
|
+
* state. A synthetic represent turn (finalAnswerDelivered=false) therefore can
|
|
53
|
+
* never wedge the live foreground card path.
|
|
54
|
+
*
|
|
55
|
+
* The LOCK is acquired by the GATEWAY around the gate decision (a centralized
|
|
56
|
+
* `chatLock.run(statusKey(...), …)` helper that consults this method then kicks
|
|
57
|
+
* off `openOrEditCard`). `mayDrainCardNow` itself, like `mayDrain`, acquires NO
|
|
58
|
+
* lock — it is a pure read the gateway calls from inside the lock. The lock spans
|
|
59
|
+
* only the gate decision + the synchronous `openOrEditCard` kick-off (which only
|
|
60
|
+
* ASSIGNS `turn.activityInFlight = drainActivitySummary(...)`; the async send is
|
|
61
|
+
* NOT awaited inside the lock), so a card OPEN never holds `chatLock` across the
|
|
62
|
+
* gate's release.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
import {
|
|
66
|
+
computeFeedOpenVerdict,
|
|
67
|
+
type FeedOpenGateView,
|
|
68
|
+
type FeedOpenGateDeps,
|
|
69
|
+
} from './feed-open-gate.js'
|
|
70
|
+
import {
|
|
71
|
+
decideOverPing,
|
|
72
|
+
type OverPingDecision,
|
|
73
|
+
} from '../over-ping-safety-net.js'
|
|
74
|
+
import { mayDrainBufferedInbound } from './serialize-drain-gate.js'
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Kill-switch. Read ONCE at module top, `=== '1'` (default OFF), following the
|
|
78
|
+
* existing flag convention (`SWITCHROOM_FEED_HEARTBEAT` etc. in gateway.ts's
|
|
79
|
+
* flag region). In PR-4a this gates nothing behaviourally — both branches of
|
|
80
|
+
* every façade method run the same delegate — so unset and set are identical.
|
|
81
|
+
* It exists now so the seam is in place; PR-4b is where ON starts to differ.
|
|
82
|
+
*/
|
|
83
|
+
export const EMISSION_AUTHORITY_ENABLED =
|
|
84
|
+
process.env.SWITCHROOM_EMISSION_AUTHORITY === '1'
|
|
85
|
+
|
|
86
|
+
/** Which producer triggered a card OPEN/EDIT — carried for the PR-4b seam. */
|
|
87
|
+
export type EmissionProducer = 'narrative' | 'tool' | 'liveness'
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The minimal per-turn surface the façade needs. A structural subset of
|
|
91
|
+
* `CurrentTurn` — keeps this module decoupled from gateway.ts's giant turn type
|
|
92
|
+
* (and out of an import cycle) while still typing the reads it performs.
|
|
93
|
+
*/
|
|
94
|
+
export interface EmissionTurnView {
|
|
95
|
+
/** Single-flight slot: a drain Promise is in flight when non-null. */
|
|
96
|
+
activityInFlight: Promise<void> | null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Inputs to the over-ping claim/downgrade decision (carried for PR-4b). */
|
|
100
|
+
export interface PingClaimInput {
|
|
101
|
+
/** The model asked for a device ping (`disable_notification: false`). */
|
|
102
|
+
modelRequestedPing: boolean
|
|
103
|
+
/** This reply is a substantive final answer (`isSubstantiveFinalReply`). */
|
|
104
|
+
substantive: boolean
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The live per-turn ping-slot state the over-ping decision reads (PR-4c seam).
|
|
109
|
+
*
|
|
110
|
+
* A structural subset of `CurrentTurn` threaded in EXPLICITLY so the façade can
|
|
111
|
+
* call `decideOverPing` in its enabled branch without importing gateway.ts's
|
|
112
|
+
* turn type. These are READS only — the façade decides; the call-site
|
|
113
|
+
* `applyDecision` thunk performs the atomic `firstPingAt`/`firstPingWasSubstantive`
|
|
114
|
+
* pair-set (the #2562 atomicity invariant: two adjacent assignments, no await
|
|
115
|
+
* between, so a racing second reply reads a consistent pair).
|
|
116
|
+
*/
|
|
117
|
+
export interface PingClaimCtx {
|
|
118
|
+
/** Wall-clock ms of the FIRST ping this turn, or null if none has landed. */
|
|
119
|
+
firstPingAt: number | null
|
|
120
|
+
/** True iff the send that CLAIMED the slot was itself a substantive answer. */
|
|
121
|
+
firstPingWasSubstantive: boolean
|
|
122
|
+
/** Deterministic clock for the decision (the call site's `Date.now()`). */
|
|
123
|
+
nowMs: number
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The deliver-before-drain inputs the PR-4d card-drain gate threads into the
|
|
128
|
+
* pure `mayDrainBufferedInbound` predicate. A small structural subset (mirrors
|
|
129
|
+
* the `PingClaimCtx` / `FeedOpenGateDeps` shape) so the façade never imports the
|
|
130
|
+
* gateway's turn type — the gateway sources these and passes them in.
|
|
131
|
+
*
|
|
132
|
+
* `endingTurnFinalAnswerDelivered` is fixed to `null` on the card path (§5
|
|
133
|
+
* modeling decision): the predicate degenerates to `!turnInFlight`, so the card
|
|
134
|
+
* single-flight is governed by `activityInFlight` (via `mayDrain`), not by an
|
|
135
|
+
* ending turn's delivery state — a synthetic represent turn cannot wedge it.
|
|
136
|
+
*/
|
|
137
|
+
export interface CardDrainGateCtx {
|
|
138
|
+
/** A turn is in flight RIGHT NOW (`turnInFlightForGate()`), evaluated at the
|
|
139
|
+
* gate site. When true, never drain the card — claude is busy. */
|
|
140
|
+
turnInFlight: boolean
|
|
141
|
+
/** The ending turn's `finalAnswerDelivered`. Threaded `null` for the CARD
|
|
142
|
+
* path so the deliver-before-drain predicate degenerates to `!turnInFlight`
|
|
143
|
+
* (the card single-flight is governed by `activityInFlight`, not delivery). */
|
|
144
|
+
endingTurnFinalAnswerDelivered: boolean | null
|
|
145
|
+
/** Serialize-until-replied kill-switch state (`SERIALIZE_UNTIL_REPLIED_ENABLED`). */
|
|
146
|
+
enabled: boolean
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Per-foreground-turn emission-authority façade.
|
|
151
|
+
*
|
|
152
|
+
* Constructed once per turn with the chat/thread key passed in explicitly (the
|
|
153
|
+
* PR-4e seam). In PR-4a every method just runs the caller's delegate — no
|
|
154
|
+
* decision logic lives here yet.
|
|
155
|
+
*/
|
|
156
|
+
export class EmissionAuthority {
|
|
157
|
+
/**
|
|
158
|
+
* @param chatKey The chat/thread key this façade governs, passed in
|
|
159
|
+
* EXPLICITLY by the gateway (today sourced from the `currentTurn`
|
|
160
|
+
* singleton). PR-4e swaps the singleton for a per-topic map keyed on this.
|
|
161
|
+
* Unused in PR-4a beyond being held — it is the seam, not yet a decision
|
|
162
|
+
* input.
|
|
163
|
+
*/
|
|
164
|
+
constructor(private readonly chatKey: string) {}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* PR-4b OPEN-gate wiring. The gateway centralizes it in `emissionAuthorityFor`
|
|
168
|
+
* (one place) so the 6 `openOrEditCard` call sites stay byte-identical
|
|
169
|
+
* `(producer, apply)` — the façade reads the live turn view + injected history
|
|
170
|
+
* deps from HERE, not per-call. `viewProvider` is a thunk so the verdict reads
|
|
171
|
+
* the CURRENT card/latch/tool-count at gate time (they mutate during the turn).
|
|
172
|
+
* Lazily set on every `emissionAuthorityFor`; the disabled branch never reads
|
|
173
|
+
* it, so a turn that never wires it is harmless (only `openOrEditCard`'s
|
|
174
|
+
* EMISSION_AUTHORITY_ENABLED branch consults it).
|
|
175
|
+
*/
|
|
176
|
+
private feedOpenView?: () => FeedOpenGateView
|
|
177
|
+
private feedOpenDeps?: FeedOpenGateDeps
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Wire the PR-4b OPEN-gate inputs (idempotent). Called by the gateway's
|
|
181
|
+
* `emissionAuthorityFor` accessor so every routed turn's façade can compute
|
|
182
|
+
* the open verdict in its enabled branch. No-op-equivalent under the flag OFF.
|
|
183
|
+
*/
|
|
184
|
+
wireFeedOpenGate(
|
|
185
|
+
viewProvider: () => FeedOpenGateView,
|
|
186
|
+
deps: FeedOpenGateDeps,
|
|
187
|
+
): void {
|
|
188
|
+
this.feedOpenView = viewProvider
|
|
189
|
+
this.feedOpenDeps = deps
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The chat/thread key this façade was constructed for (PR-4e seam read). */
|
|
193
|
+
get key(): string {
|
|
194
|
+
return this.chatKey
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Card OPEN-or-EDIT for the foreground turn (delegates to
|
|
199
|
+
* `drainActivitySummary` at the call site via `apply`).
|
|
200
|
+
*
|
|
201
|
+
* PR-4b — the OPEN-gate decision RELOCATES here, behind the kill-switch:
|
|
202
|
+
*
|
|
203
|
+
* - **Disabled branch (default):** unchanged from PR-4a — `apply()` runs
|
|
204
|
+
* unconditionally. The drain's own inline OPEN gate is what refuses (and
|
|
205
|
+
* `break`s) a disallowed OPEN, exactly as on main. Byte-identical to main.
|
|
206
|
+
* - **Enabled branch:** compute the OPEN verdict from the live turn view +
|
|
207
|
+
* injected history deps (`computeFeedOpenVerdict`) and run `apply()` IFF
|
|
208
|
+
* `!(isOpen && !mayOpen)` is FALSE-y in the refuse sense — concretely
|
|
209
|
+
* `apply()` runs iff `isOpen || mayOpen`. That is EXACTLY the set of cases
|
|
210
|
+
* main did NOT `break` (main refuses iff `activityMessageId == null &&
|
|
211
|
+
* !mayOpenActivityCard(...)`, i.e. `!isOpen && !mayOpen`). Same pure inputs
|
|
212
|
+
* ⇒ same verdict, so flag-ON ≡ flag-OFF ≡ main: no emitted message differs.
|
|
213
|
+
*
|
|
214
|
+
* On flag-ON the drain still re-evaluates the SAME gate (now redundant) — an
|
|
215
|
+
* intentional, safe double-check: both are pure over identical inputs, and it
|
|
216
|
+
* also covers the documented in-flight-send race (a send already PAST the
|
|
217
|
+
* drain's check). A refused OPEN never calls `apply()`, so `activityInFlight`
|
|
218
|
+
* stays null and the pending render stays unadvanced — matching main's `break`
|
|
219
|
+
* + `finally` end-state. The delegate keeps the `turn.activityInFlight =
|
|
220
|
+
* drainActivitySummary(turn, <producer>)` assignment AT THE CALL SITE so the
|
|
221
|
+
* source-read wiring oracles still see it.
|
|
222
|
+
*/
|
|
223
|
+
openOrEditCard(
|
|
224
|
+
producer: EmissionProducer,
|
|
225
|
+
apply: () => void,
|
|
226
|
+
): void {
|
|
227
|
+
if (EMISSION_AUTHORITY_ENABLED) {
|
|
228
|
+
// The OPEN-gate inputs are wired by `emissionAuthorityFor` (centralized).
|
|
229
|
+
// If a turn somehow reaches here unwired, fall back to delegating (no
|
|
230
|
+
// behaviour change — the drain's own gate still governs the OPEN).
|
|
231
|
+
if (this.feedOpenView != null && this.feedOpenDeps != null) {
|
|
232
|
+
const { isOpen, mayOpen } = computeFeedOpenVerdict(
|
|
233
|
+
this.feedOpenView(),
|
|
234
|
+
producer,
|
|
235
|
+
this.feedOpenDeps,
|
|
236
|
+
)
|
|
237
|
+
// Refuse (skip apply) iff main would have `break`-refused the OPEN:
|
|
238
|
+
// `!isOpen && !mayOpen`. Apply otherwise (an EDIT — isOpen — is never
|
|
239
|
+
// gated; an OPEN-eligible producer — mayOpen — opens).
|
|
240
|
+
if (!isOpen && !mayOpen) return
|
|
241
|
+
}
|
|
242
|
+
apply()
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
apply()
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Finalize the card before a substantive reply send (delegates to
|
|
250
|
+
* `clearActivitySummary` at the call site via `apply`).
|
|
251
|
+
*
|
|
252
|
+
* PR-4a: a pure pass-through. Both branches run `apply`.
|
|
253
|
+
*/
|
|
254
|
+
finalizeCard(apply: () => void): void {
|
|
255
|
+
if (EMISSION_AUTHORITY_ENABLED) {
|
|
256
|
+
apply()
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
apply()
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Mark that a substantive final answer was delivered this turn — the sticky
|
|
264
|
+
* lever-1 latch (delegates to the `finalAnswerEverDelivered = true` set at the
|
|
265
|
+
* call site via `apply`).
|
|
266
|
+
*
|
|
267
|
+
* PR-4a: a pure pass-through. Both branches run `apply`.
|
|
268
|
+
*/
|
|
269
|
+
markSubstantiveFinalDelivered(apply: () => void): void {
|
|
270
|
+
if (EMISSION_AUTHORITY_ENABLED) {
|
|
271
|
+
apply()
|
|
272
|
+
return
|
|
273
|
+
}
|
|
274
|
+
apply()
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Claim-or-downgrade the turn's one notification ping.
|
|
279
|
+
*
|
|
280
|
+
* PR-4c — the over-ping DECISION relocates here, behind the kill-switch, the
|
|
281
|
+
* same structural way PR-4b moved the OPEN gate. Nothing new is extracted:
|
|
282
|
+
* `decideOverPing` is already a pure predicate in `over-ping-safety-net.ts`;
|
|
283
|
+
* PR-4c only relocates the *call* into the enabled branch and keeps the
|
|
284
|
+
* *effects* (stderr, metric, the atomic `firstPingAt`/`firstPingWasSubstantive`
|
|
285
|
+
* pair-set, the `disableNotification`/`wasOverPingSuppressed` closure writes)
|
|
286
|
+
* at the call site, parameterized by the decision the façade hands back.
|
|
287
|
+
*
|
|
288
|
+
* - **Disabled branch (default):** unchanged from PR-4a — `disabled()` runs,
|
|
289
|
+
* and that thunk holds its OWN literal `decideOverPing(...)` call + the full
|
|
290
|
+
* effects block, VERBATIM from PR-4b-base. The disabled path is therefore
|
|
291
|
+
* provably byte-identical to base: nothing about the decision moves out of
|
|
292
|
+
* the call site when the flag is OFF.
|
|
293
|
+
* - **Enabled branch:** compute the decision HERE via `decideOverPing` (the
|
|
294
|
+
* SAME pure predicate, the SAME inputs threaded through `ctx` + `input`) and
|
|
295
|
+
* hand it to `applyDecision(decision)`, which performs the identical effects
|
|
296
|
+
* at the call site. Same pure inputs ⇒ same decision ⇒ flag-ON ≡ flag-OFF ≡
|
|
297
|
+
* base: not one device ping differs.
|
|
298
|
+
*
|
|
299
|
+
* SYNCHRONOUS by contract — no `async`, no `await`. The
|
|
300
|
+
* `decideOverPing → applyDecision → pair-set` chain runs in one synchronous
|
|
301
|
+
* block so the #2562 atomicity invariant holds across the façade boundary: the
|
|
302
|
+
* façade decides whether to claim/upgrade; the call-site `applyDecision` thunk
|
|
303
|
+
* performs the two-adjacent-line `firstPingAt`/`firstPingWasSubstantive`
|
|
304
|
+
* pair-set with no await between.
|
|
305
|
+
*/
|
|
306
|
+
claimOrDowngradePing(
|
|
307
|
+
input: PingClaimInput,
|
|
308
|
+
ctx: PingClaimCtx,
|
|
309
|
+
applyDecision: (decision: OverPingDecision) => void,
|
|
310
|
+
disabled: () => void,
|
|
311
|
+
): void {
|
|
312
|
+
if (EMISSION_AUTHORITY_ENABLED) {
|
|
313
|
+
const decision = decideOverPing({
|
|
314
|
+
modelRequestedPing: input.modelRequestedPing,
|
|
315
|
+
firstPingAt: ctx.firstPingAt,
|
|
316
|
+
substantive: input.substantive,
|
|
317
|
+
firstPingWasSubstantive: ctx.firstPingWasSubstantive,
|
|
318
|
+
nowMs: ctx.nowMs,
|
|
319
|
+
})
|
|
320
|
+
applyDecision(decision)
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
disabled()
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Single-flight read: may a drain start? Returns `turn.activityInFlight ==
|
|
328
|
+
* null` — the EXACT guard every drain call site already performs.
|
|
329
|
+
*
|
|
330
|
+
* PURE READ, NO lock acquisition (see the PR-4d deadlock invariant in this
|
|
331
|
+
* module's header). PR-4d needs this read clean so it can acquire `chatLock`
|
|
332
|
+
* elsewhere without the lattice inverting.
|
|
333
|
+
*/
|
|
334
|
+
mayDrain(turn: EmissionTurnView): boolean {
|
|
335
|
+
return turn.activityInFlight == null
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* PR-4d card-drain gate: may the card drain start RIGHT NOW, unifying the
|
|
340
|
+
* single-flight read with the #2137 deliver-before-drain serialization gate.
|
|
341
|
+
*
|
|
342
|
+
* - **Flag OFF (default):** returns exactly `this.mayDrain(turn)` — the card
|
|
343
|
+
* single-flight read, byte-equivalent to base. Nothing else is consulted.
|
|
344
|
+
* - **Flag ON:** returns `this.mayDrain(turn) && mayDrainBufferedInbound(ctx)`
|
|
345
|
+
* — the single-flight read AND the pure deliver-before-drain predicate
|
|
346
|
+
* (imported from `./serialize-drain-gate.js`). The gateway threads
|
|
347
|
+
* `endingTurnFinalAnswerDelivered: null` for the card path (§5 modeling
|
|
348
|
+
* decision), so `mayDrainBufferedInbound` degenerates to `!turnInFlight`:
|
|
349
|
+
* the card single-flight stays governed by `activityInFlight` (via
|
|
350
|
+
* `mayDrain`), NOT by an ending turn's delivery state.
|
|
351
|
+
*
|
|
352
|
+
* PURE READ — see the PR-4d deadlock invariant in this module's header. The
|
|
353
|
+
* GATEWAY serializes this decision via the chat mutex AROUND the call; this
|
|
354
|
+
* method only reads. Routes the single-flight via the pure `this.mayDrain`.
|
|
355
|
+
*/
|
|
356
|
+
mayDrainCardNow(turn: EmissionTurnView, ctx: CardDrainGateCtx): boolean {
|
|
357
|
+
if (EMISSION_AUTHORITY_ENABLED) {
|
|
358
|
+
return (
|
|
359
|
+
this.mayDrain(turn) &&
|
|
360
|
+
mayDrainBufferedInbound({
|
|
361
|
+
turnInFlight: ctx.turnInFlight,
|
|
362
|
+
endingTurnFinalAnswerDelivered: ctx.endingTurnFinalAnswerDelivered,
|
|
363
|
+
enabled: ctx.enabled,
|
|
364
|
+
})
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
return this.mayDrain(turn)
|
|
368
|
+
}
|
|
369
|
+
}
|