switchroom 0.16.46 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +83 -81
- package/dist/auth-broker/index.js +104 -88
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -15
- package/dist/cli/notion-write-pretool.mjs +85 -83
- package/dist/cli/skill-validate-pretool.mjs +91 -91
- package/dist/cli/switchroom.js +1720 -1392
- package/dist/cli/ui/index.html +84 -12
- package/dist/host-control/main.js +209 -173
- package/dist/vault/approvals/kernel-server.js +86 -83
- package/dist/vault/broker/server.js +284 -139
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +1 -1
- package/profiles/_base/start.sh.hbs +54 -3
- package/skills/switchroom-architecture/telegram.md +8 -15
- package/skills/switchroom-cli/SKILL.md +4 -5
- package/skills/telegram-test-harness/SKILL.md +1 -1
- package/telegram-plugin/README.md +18 -29
- package/telegram-plugin/bridge/bridge.ts +1 -41
- package/telegram-plugin/bridge/tool-filter.ts +3 -4
- package/telegram-plugin/dist/bridge/bridge.js +120 -155
- package/telegram-plugin/dist/gateway/gateway.js +1127 -1029
- package/telegram-plugin/dist/server.js +168 -203
- package/telegram-plugin/gateway/busy-key-reaper.ts +113 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +11 -0
- package/telegram-plugin/gateway/escalation-bridge-gate.ts +46 -0
- package/telegram-plugin/gateway/gate-parity-probe.ts +102 -0
- package/telegram-plugin/gateway/gateway.ts +566 -631
- package/telegram-plugin/gateway/inbound-delivery-confirm.ts +89 -7
- package/telegram-plugin/gateway/inbound-spool.ts +108 -10
- package/telegram-plugin/gateway/model-command.ts +51 -3
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +26 -0
- package/telegram-plugin/gateway/represent-guard.ts +28 -11
- package/telegram-plugin/gateway/status-pin-store.ts +124 -45
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +19 -0
- package/telegram-plugin/history.ts +5 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +1 -2
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +9 -1
- package/telegram-plugin/registry/subagents-schema.ts +126 -1
- package/telegram-plugin/registry/turns-schema.ts +65 -1
- package/telegram-plugin/session-tail.ts +26 -4
- package/telegram-plugin/slot-banner-driver.ts +42 -2
- package/telegram-plugin/status-query-telemetry.ts +100 -0
- package/telegram-plugin/stream-reply-handler.ts +15 -16
- package/telegram-plugin/subagent-watcher.ts +182 -30
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +4 -10
- package/telegram-plugin/tests/busy-key-reaper.test.ts +191 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +11 -17
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +5 -26
- package/telegram-plugin/tests/escalation-bridge-gate.test.ts +38 -0
- package/telegram-plugin/tests/gate-parity-probe.test.ts +171 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +13 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +14 -11
- package/telegram-plugin/tests/inbound-delivery-confirm.test.ts +146 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +143 -0
- package/telegram-plugin/tests/model-command.test.ts +54 -1
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +5 -11
- package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +329 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +53 -0
- package/telegram-plugin/tests/progress-update-redact.test.ts +99 -0
- package/telegram-plugin/tests/registry-turns.test.ts +67 -0
- package/telegram-plugin/tests/represent-guard.test.ts +42 -6
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +1 -0
- package/telegram-plugin/tests/session-tail.test.ts +10 -1
- package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +246 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +0 -14
- package/telegram-plugin/tests/status-pin-store.test.ts +220 -5
- package/telegram-plugin/tests/status-query-telemetry.test.ts +115 -0
- package/telegram-plugin/tests/subagent-nested-dispatch.test.ts +209 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +37 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +167 -0
- package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +46 -3
- package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +70 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +16 -0
- package/telegram-plugin/tests/tool-filter.test.ts +1 -3
- package/telegram-plugin/tests/tool-label-pretool.test.ts +1 -4
- package/telegram-plugin/tests/turn-flush-safety.test.ts +222 -1
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +46 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +202 -9
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +25 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +295 -0
- package/telegram-plugin/tool-activity-summary.ts +19 -0
- package/telegram-plugin/turn-flush-safety.ts +16 -1
- package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +8 -9
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +1 -1
- package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +1 -1
- package/telegram-plugin/worker-activity-feed.ts +75 -15
- package/vendor/hindsight-memory/CHANGELOG.md +24 -0
- package/vendor/hindsight-memory/README.md +5 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +31 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +41 -2
- package/vendor/hindsight-memory/scripts/lib/content.py +4 -1
- package/vendor/hindsight-memory/scripts/lib/daemon.py +11 -2
- package/vendor/hindsight-memory/scripts/recall.py +74 -1
- package/vendor/hindsight-memory/scripts/retain.py +8 -1
- package/vendor/hindsight-memory/scripts/tests/test_config_client_casts.py +111 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +85 -1
- package/vendor/hindsight-memory/scripts/tests/test_recall_tag_filters.py +107 -0
- package/vendor/hindsight-memory/settings.json +4 -0
- package/vendor/hindsight-memory/tests/test_client.py +130 -0
- package/vendor/hindsight-memory/tests/test_config.py +47 -0
- package/vendor/hindsight-memory/tests/test_content.py +18 -0
- package/vendor/hindsight-memory/tests/test_hooks.py +62 -0
- package/telegram-plugin/gateway/error-envelope-card.ts +0 -64
- package/telegram-plugin/gateway/resolve-calling-subagent.ts +0 -78
- package/telegram-plugin/silent-reply.ts +0 -58
- package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +0 -79
- package/telegram-plugin/tests/resolve-calling-subagent.test.ts +0 -269
- package/telegram-plugin/tests/silent-reply-guard.test.ts +0 -122
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2787 Mechanism B — the `claudeBusyKeys` orphan-reaper lifecycle, extracted
|
|
3
|
+
* so the gateway glue that mutates the busy-key set stays in lockstep with its
|
|
4
|
+
* shadow insertion-timestamp map and is unit-testable WITHOUT importing
|
|
5
|
+
* `gateway.ts` (which boots the bot + IPC listener on module load and so cannot
|
|
6
|
+
* run inside a unit test). The gateway keeps ownership of the two containers
|
|
7
|
+
* (`claudeBusyKeys: Set`, `claudeBusyKeySince: Map`) and calls these helpers at
|
|
8
|
+
* every mark / reap site; `disconnect-flush.ts` clears them at bridge death.
|
|
9
|
+
* The test harness (`tests/busy-key-reaper.test.ts`) wires the SAME containers,
|
|
10
|
+
* the SAME delivery queue, and the real `flushOnAgentDisconnect` to reproduce
|
|
11
|
+
* the disconnect → reconnect → re-mark and slow-vs-orphan delivery sequences.
|
|
12
|
+
*
|
|
13
|
+
* Two invariants this module enforces:
|
|
14
|
+
*
|
|
15
|
+
* 1. LOCKSTEP. The timestamp map shadows actual SET MEMBERSHIP, not its own
|
|
16
|
+
* prior presence. `markBusyKeyLockstep` stamps a fresh timestamp whenever a
|
|
17
|
+
* key transitions idle→busy (`claudeBusyKeys.has(key) === false`). Gating on
|
|
18
|
+
* the set — not on `since.has` — is load-bearing: a disconnect flush clears
|
|
19
|
+
* `claudeBusyKeys` directly, so if the shadow map ever lagged, a `!since.has`
|
|
20
|
+
* guard would decline to re-stamp on the reconnect→re-mark and leave a
|
|
21
|
+
* stale, >TTL-old timestamp on a freshly-marked key — which the reaper would
|
|
22
|
+
* then reap out from under a live delivery.
|
|
23
|
+
*
|
|
24
|
+
* 2. SLOW-DELIVERY SAFETY (the #1922 hazard). `busy` is marked EAGERLY at
|
|
25
|
+
* delivery, and the gateway→claude enqueue-ack lag can be up to ~5 MINUTES
|
|
26
|
+
* under load (#1922). During that eager-mark→enqueue window the gateway's
|
|
27
|
+
* `currentTurn` is null yet the turn is real-and-merely-slow. A bare
|
|
28
|
+
* time-grace reaper cannot tell "slow" from "orphaned" and would reap a
|
|
29
|
+
* genuine delivery, re-opening the idle-drain gate while claude is about to
|
|
30
|
+
* process the very inbound whose key it just reaped (duplicate / concurrent
|
|
31
|
+
* delivery on the CORE inbound path). So the reap is PROOF-GATED: a key is
|
|
32
|
+
* reaped ONLY when it has NO entry in the delivery-confirm queue. A
|
|
33
|
+
* slow-but-real inbound is tracked there from delivery until claude's
|
|
34
|
+
* `enqueue` ack (via the never-drop re-deliver loop), so any key still
|
|
35
|
+
* awaiting its turn is present and skipped — no matter how slow. The time
|
|
36
|
+
* grace is retained as defense-in-depth, defaulting well above the observed
|
|
37
|
+
* ack-lag tail. Only a busy-marked key with NO pending delivery AND age past
|
|
38
|
+
* the grace is a true orphan (its turn acked and should have cleared busy at
|
|
39
|
+
* turn_end, or it was a steer/interrupt inbound excluded from tracking that
|
|
40
|
+
* amends a now-absent turn) — reaping those can never clobber a slow one.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/** Lockstep mark: add to the busy set and, on the idle→busy transition only,
|
|
44
|
+
* stamp the insertion time. Keyed on set membership so a re-mark after a
|
|
45
|
+
* disconnect flush (which cleared the set directly) always re-stamps fresh. */
|
|
46
|
+
export function markBusyKeyLockstep(
|
|
47
|
+
keys: Set<string>,
|
|
48
|
+
since: Map<string, number>,
|
|
49
|
+
key: string,
|
|
50
|
+
now: number,
|
|
51
|
+
): void {
|
|
52
|
+
const wasBusy = keys.has(key)
|
|
53
|
+
keys.add(key)
|
|
54
|
+
if (!wasBusy) since.set(key, now)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Lockstep clear: delete from both containers. */
|
|
58
|
+
export function clearBusyKeyLockstep(
|
|
59
|
+
keys: Set<string>,
|
|
60
|
+
since: Map<string, number>,
|
|
61
|
+
key: string,
|
|
62
|
+
): void {
|
|
63
|
+
keys.delete(key)
|
|
64
|
+
since.delete(key)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ReapOptions {
|
|
68
|
+
/** Grace before a busy-marked key with no pending delivery is a true orphan. */
|
|
69
|
+
ttlMs: number
|
|
70
|
+
/** True iff a delivery-confirm entry is still tracked for this key — i.e. a
|
|
71
|
+
* slow-but-real inbound awaiting its enqueue ack. Such keys are NEVER reaped,
|
|
72
|
+
* regardless of age (the #1922 slow-delivery guarantee). */
|
|
73
|
+
hasPendingDelivery: (key: string) => boolean
|
|
74
|
+
/** Optional line logger for each reaped key (observability). */
|
|
75
|
+
log?: (msg: string) => void
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Reap orphaned busy markers. Call ONLY once the gateway has asserted no turn is
|
|
80
|
+
* in flight (`currentTurn == null`). Returns the keys reaped (for the caller's
|
|
81
|
+
* metrics / assertions). Also prunes shadow-map entries whose key already left
|
|
82
|
+
* the set, so the map can't grow unbounded.
|
|
83
|
+
*/
|
|
84
|
+
export function reapOrphanBusyKeys(
|
|
85
|
+
keys: Set<string>,
|
|
86
|
+
since: Map<string, number>,
|
|
87
|
+
now: number,
|
|
88
|
+
opts: ReapOptions,
|
|
89
|
+
): string[] {
|
|
90
|
+
const reaped: string[] = []
|
|
91
|
+
for (const key of [...since.keys()]) {
|
|
92
|
+
if (!keys.has(key)) {
|
|
93
|
+
since.delete(key)
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
// Proof gate: a key with a pending delivery is a slow-but-real inbound still
|
|
97
|
+
// awaiting its enqueue ack (the never-drop re-deliver loop owns it) — NEVER
|
|
98
|
+
// an orphan, regardless of age. Skip so a multi-minute (#1922) delivery
|
|
99
|
+
// can't be reaped out from under claude.
|
|
100
|
+
if (opts.hasPendingDelivery(key)) continue
|
|
101
|
+
const stampedAt = since.get(key) ?? now
|
|
102
|
+
if (now - stampedAt >= opts.ttlMs) {
|
|
103
|
+
keys.delete(key)
|
|
104
|
+
since.delete(key)
|
|
105
|
+
reaped.push(key)
|
|
106
|
+
opts.log?.(
|
|
107
|
+
`telegram gateway: reaped orphan busy key=${key} (no turn in flight, ` +
|
|
108
|
+
`no pending delivery, >${opts.ttlMs}ms) — unwedging idle-drain`,
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return reaped
|
|
113
|
+
}
|
|
@@ -42,6 +42,12 @@ export interface DisconnectFlushDeps<Ctrl extends { finalize: (reason?: 'done' |
|
|
|
42
42
|
* activeTurnStartedAt — the bridge just died, every turn it
|
|
43
43
|
* was handed is dead by definition. */
|
|
44
44
|
claudeBusyKeys: Set<string>
|
|
45
|
+
/** #2787: insertion-timestamp map for `claudeBusyKeys`, backing the orphan
|
|
46
|
+
* reaper. MUST stay in lockstep with `claudeBusyKeys` at every clear site —
|
|
47
|
+
* a stale timestamp surviving a clear makes a later re-marked key look
|
|
48
|
+
* >TTL-old and get reaped against a live delivery. Deleted alongside every
|
|
49
|
+
* `claudeBusyKeys.delete`/`.clear` below. */
|
|
50
|
+
claudeBusyKeySince: Map<string, number>
|
|
45
51
|
|
|
46
52
|
/** Open draft-stream handles keyed by chat:thread:replyId. */
|
|
47
53
|
activeDraftStreams: Map<string, Stream>
|
|
@@ -82,6 +88,7 @@ export function flushOnAgentDisconnect<
|
|
|
82
88
|
activeReactionMsgIds,
|
|
83
89
|
activeTurnStartedAt,
|
|
84
90
|
claudeBusyKeys,
|
|
91
|
+
claudeBusyKeySince,
|
|
85
92
|
activeDraftStreams,
|
|
86
93
|
clearActiveReactions,
|
|
87
94
|
disposeProgressDriver,
|
|
@@ -109,6 +116,7 @@ export function flushOnAgentDisconnect<
|
|
|
109
116
|
activeReactionMsgIds.delete(key)
|
|
110
117
|
activeTurnStartedAt.delete(key)
|
|
111
118
|
claudeBusyKeys.delete(key)
|
|
119
|
+
claudeBusyKeySince.delete(key) // #2787: keep orphan-TTL map in lockstep
|
|
112
120
|
}
|
|
113
121
|
clearActiveReactions()
|
|
114
122
|
|
|
@@ -129,6 +137,7 @@ export function flushOnAgentDisconnect<
|
|
|
129
137
|
activeTurnStartedAt.delete(k)
|
|
130
138
|
activeReactionMsgIds.delete(k)
|
|
131
139
|
claudeBusyKeys.delete(k)
|
|
140
|
+
claudeBusyKeySince.delete(k) // #2787: keep orphan-TTL map in lockstep
|
|
132
141
|
}
|
|
133
142
|
log(
|
|
134
143
|
`telegram gateway: disconnect-flush swept ${danglingKeys.length} dangling turn key(s) ` +
|
|
@@ -156,6 +165,8 @@ export function flushOnAgentDisconnect<
|
|
|
156
165
|
const orphanCount = claudeBusyKeys.size
|
|
157
166
|
const orphanKeys = [...claudeBusyKeys]
|
|
158
167
|
claudeBusyKeys.clear()
|
|
168
|
+
// #2787: keep the orphan-TTL map in lockstep with the set it shadows.
|
|
169
|
+
for (const k of orphanKeys) claudeBusyKeySince.delete(k)
|
|
159
170
|
log(
|
|
160
171
|
`telegram gateway: disconnect-flush cleared ${orphanCount} orphan claudeBusyKeys ` +
|
|
161
172
|
`entr${orphanCount === 1 ? 'y' : 'ies'} (synthetic-inbound deliveries that never turn_ended)` +
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* escalation-bridge-gate.ts — the bridge-alive gate for the obligation sweep's
|
|
3
|
+
* ESCALATE branch, extracted from obligationSweep so the "do NOT direct-send the
|
|
4
|
+
* 'I may have missed this' nudge while the Telegram bridge is down" decision
|
|
5
|
+
* (#2788 Gap A) is EXECUTABLE in a pure unit test.
|
|
6
|
+
*
|
|
7
|
+
* The bug (#2788 Gap A): the obligation sweep keeps running while the Telegram
|
|
8
|
+
* bridge is disconnected. Unlike the represent branch (which goes through the
|
|
9
|
+
* bridge → buffer and is naturally stranded behind a dead bridge), the escalate
|
|
10
|
+
* branch DIRECT-SENDS via `bot.api.sendMessage`, bypassing the bridge. So during
|
|
11
|
+
* a transient bridge flap the user could receive a false "I may have missed this"
|
|
12
|
+
* even though the real reply is simply queued behind the outage and will land the
|
|
13
|
+
* moment the bridge recovers.
|
|
14
|
+
*
|
|
15
|
+
* The fix is a deferral, not a drop: obligations already survive bridge death
|
|
16
|
+
* (they live in the durable ledger and are re-evaluated every sweep), so gating
|
|
17
|
+
* the escalate SEND on bridge-alive and returning without closing leaves the
|
|
18
|
+
* obligation OPEN. The next sweep after the bridge reconnects re-drives it — the
|
|
19
|
+
* nudge is deferred until it can reflect a real, post-recovery miss. This adds NO
|
|
20
|
+
* unbounded liveness dependency: the entire gateway's inbound delivery already
|
|
21
|
+
* rests on the bridge eventually reconnecting / the process restarting.
|
|
22
|
+
*
|
|
23
|
+
* PURE — no Telegram, no IPC; the gateway injects `bridgeAlive` (read from
|
|
24
|
+
* `ipcServer.getClient(agent)?.isAlive()`).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export interface EscalationBridgeGateDeps {
|
|
28
|
+
/**
|
|
29
|
+
* True when this agent's bridge sidecar is currently registered and alive on
|
|
30
|
+
* the IPC server — i.e. a direct escalate send can actually reflect delivery
|
|
31
|
+
* reality rather than racing a queued-behind-a-dead-bridge reply.
|
|
32
|
+
*/
|
|
33
|
+
bridgeAlive: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Decide whether the escalate branch should DEFER (skip this sweep, leave the
|
|
38
|
+
* obligation OPEN) because the bridge is down.
|
|
39
|
+
*
|
|
40
|
+
* Returns true ⇒ defer: do not send the nudge, do not close — the durable
|
|
41
|
+
* obligation is re-driven on a later sweep once the bridge recovers.
|
|
42
|
+
* Returns false ⇒ the bridge is alive; proceed with escalation.
|
|
43
|
+
*/
|
|
44
|
+
export function shouldDeferEscalationForBridge(deps: EscalationBridgeGateDeps): boolean {
|
|
45
|
+
return !deps.bridgeAlive
|
|
46
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gate-parity drift probe — scaffolding for the inbound-delivery
|
|
3
|
+
* state-machine cutover (#2794, RFC PR3b→PR4).
|
|
4
|
+
*
|
|
5
|
+
* ## Why this exists
|
|
6
|
+
*
|
|
7
|
+
* The inbound-delivery pipeline is mid-migration and TRIPLE-maintained:
|
|
8
|
+
*
|
|
9
|
+
* 1. the pure state machine (`inbound-delivery-machine.ts`) — the model,
|
|
10
|
+
* 2. the shadow (`inbound-delivery-machine-shadow.ts`) — module-scope
|
|
11
|
+
* machine state advanced by `shadowEmit`, now AUTHORITATIVE for the
|
|
12
|
+
* turn-in-flight gate via `isMachineInTurn()`,
|
|
13
|
+
* 3. the legacy imperative `claudeBusyKeys` set — still fully maintained
|
|
14
|
+
* in parallel and read as the kill-switch fallback.
|
|
15
|
+
*
|
|
16
|
+
* Per #2794 the standing risk is DRIFT: because the same turn-lifecycle
|
|
17
|
+
* fact ("is a turn in flight?") is tracked in two live places, a fix in
|
|
18
|
+
* one can silently diverge from the other. The machine is authoritative
|
|
19
|
+
* today, but `claudeBusyKeys` is still wired and still read on the
|
|
20
|
+
* kill-switch path — so the two MUST NOT drift in the dangerous direction.
|
|
21
|
+
*
|
|
22
|
+
* ## What "agree" means (and the ONE intended divergence)
|
|
23
|
+
*
|
|
24
|
+
* The machine and `claudeBusyKeys` are designed to agree on every
|
|
25
|
+
* WELL-FORMED schedule (every turnStart has a matching turnEnd). They are
|
|
26
|
+
* deliberately allowed to diverge in exactly ONE direction on a MALFORMED
|
|
27
|
+
* schedule — an orphaned turnStart (turn B opens before turn A's turnEnd
|
|
28
|
+
* ever lands, the gymbro/clerk 5-min dangle of 2026-05-28):
|
|
29
|
+
*
|
|
30
|
+
* - `busykeys_dangle` — machine reads idle (self-healed via TTL tick or
|
|
31
|
+
* single-activeTurn reopen) while `claudeBusyKeys`
|
|
32
|
+
* still holds an orphan key. This is EXPECTED and
|
|
33
|
+
* GOOD: it is precisely the wedge the machine was
|
|
34
|
+
* made authoritative to kill. Not a drift alarm.
|
|
35
|
+
*
|
|
36
|
+
* - `machine_over_holds` — machine reads in-flight while `claudeBusyKeys`
|
|
37
|
+
* is empty. This is the DANGEROUS direction: the
|
|
38
|
+
* now-authoritative gate would hold closed while
|
|
39
|
+
* the imperative view says idle — a NEW wedge
|
|
40
|
+
* class the cutover must never introduce. This
|
|
41
|
+
* is the drift #2794 warns about; surface it.
|
|
42
|
+
*
|
|
43
|
+
* This module is a pure classifier plus a log-only runtime probe. It
|
|
44
|
+
* performs NO I/O of its own beyond an optional injected log sink and
|
|
45
|
+
* changes NO delivery behaviour — it only observes. Deleting it once the
|
|
46
|
+
* `claudeBusyKeys` shadow is removed in PR4 is a no-op for behaviour.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
export type GateParityDivergence = 'none' | 'busykeys_dangle' | 'machine_over_holds'
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Classify the relationship between the machine's authoritative
|
|
53
|
+
* turn-in-flight read and the legacy imperative `claudeBusyKeys` size.
|
|
54
|
+
* Pure — no side effects.
|
|
55
|
+
*/
|
|
56
|
+
export function gateParityDivergence(
|
|
57
|
+
machineInTurn: boolean,
|
|
58
|
+
busyKeysSize: number,
|
|
59
|
+
): GateParityDivergence {
|
|
60
|
+
const busy = busyKeysSize > 0
|
|
61
|
+
if (machineInTurn === busy) return 'none'
|
|
62
|
+
// machine idle, busyKeys non-empty → the orphan dangle the machine heals.
|
|
63
|
+
if (!machineInTurn && busy) return 'busykeys_dangle'
|
|
64
|
+
// machine in-flight, busyKeys empty → the dangerous over-hold.
|
|
65
|
+
return 'machine_over_holds'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* True only for divergences that indicate a real cutover regression
|
|
70
|
+
* (the machine holding a gate the imperative shadow believes is open).
|
|
71
|
+
* The benign `busykeys_dangle` — the very wedge the machine fixes — is
|
|
72
|
+
* NOT flagged, so this probe has zero false positives on the known-good
|
|
73
|
+
* self-heal path.
|
|
74
|
+
*/
|
|
75
|
+
export function isDangerousGateDivergence(d: GateParityDivergence): boolean {
|
|
76
|
+
return d === 'machine_over_holds'
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Log-only runtime drift canary. Call at the authoritative gate read.
|
|
81
|
+
* Emits a single grep-friendly `gw-trace gate-drift` line ONLY on the
|
|
82
|
+
* dangerous over-hold direction. Returns the machine value UNCHANGED so
|
|
83
|
+
* it can wrap the gate read without altering behaviour:
|
|
84
|
+
*
|
|
85
|
+
* return probeGateParity(isMachineInTurn(), claudeBusyKeys.size)
|
|
86
|
+
*
|
|
87
|
+
* @param log optional sink (default stderr) — test hook.
|
|
88
|
+
*/
|
|
89
|
+
export function probeGateParity(
|
|
90
|
+
machineInTurn: boolean,
|
|
91
|
+
busyKeysSize: number,
|
|
92
|
+
log: (line: string) => void = (line) => process.stderr.write(line),
|
|
93
|
+
): boolean {
|
|
94
|
+
const d = gateParityDivergence(machineInTurn, busyKeysSize)
|
|
95
|
+
if (isDangerousGateDivergence(d)) {
|
|
96
|
+
log(
|
|
97
|
+
`gw-trace gate-drift kind=${d} machineInTurn=${machineInTurn} ` +
|
|
98
|
+
`busyKeys=${busyKeysSize} note=machine-authoritative-gate-holds-while-imperative-idle\n`,
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
return machineInTurn
|
|
102
|
+
}
|