switchroom 0.18.11 → 0.18.13
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 +37 -5
- package/dist/auth-broker/index.js +116 -78
- package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +38 -6
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +3529 -4543
- package/dist/cli/ui/index.html +163 -17
- package/dist/host-control/main.js +605 -2847
- package/dist/vault/approvals/kernel-server.js +120 -13
- package/dist/vault/broker/server.js +259 -157
- package/package.json +3 -4
- package/profiles/_base/start.sh.hbs +65 -0
- package/profiles/_shared/vault-protocol.md.hbs +3 -1
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/bridge/bridge.ts +37 -0
- package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
- package/telegram-plugin/dist/bridge/bridge.js +122 -4
- package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
- package/telegram-plugin/dist/server.js +139 -5
- package/telegram-plugin/flood-circuit-breaker.ts +493 -21
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +602 -0
- package/telegram-plugin/gateway/auth-command.ts +92 -2
- package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
- package/telegram-plugin/gateway/boot-card.ts +12 -5
- package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
- package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
- package/telegram-plugin/gateway/gateway.ts +1695 -230
- package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
- package/telegram-plugin/gateway/idle-clear.ts +90 -6
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
- package/telegram-plugin/gateway/inject-handler.ts +8 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
- package/telegram-plugin/gateway/ipc-server.ts +43 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
- package/telegram-plugin/gateway/model-command.ts +9 -3
- package/telegram-plugin/gateway/pending-session-command.ts +13 -1
- package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
- package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
- package/telegram-plugin/gateway/queued-card-store.ts +217 -0
- package/telegram-plugin/gateway/session-model-file.ts +26 -1
- package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
- package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
- package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
- package/telegram-plugin/history.ts +51 -0
- package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
- package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
- package/telegram-plugin/hooks/hooks.json +9 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
- package/telegram-plugin/model-unavailable.ts +41 -11
- package/telegram-plugin/operator-events.ts +23 -0
- package/telegram-plugin/outbound-field-redact.ts +69 -0
- package/telegram-plugin/package.json +0 -1
- package/telegram-plugin/permission-rule.ts +1 -0
- package/telegram-plugin/permission-title.ts +1 -0
- package/telegram-plugin/render/render.ts +32 -14
- package/telegram-plugin/retry-api-call.ts +212 -2
- package/telegram-plugin/scoped-approval.ts +11 -2
- package/telegram-plugin/secret-detect/chunker.ts +18 -4
- package/telegram-plugin/secret-detect/index.ts +12 -56
- package/telegram-plugin/send-gate-degraded.test.ts +574 -0
- package/telegram-plugin/send-gate-observability.test.ts +470 -0
- package/telegram-plugin/send-gate-observability.ts +355 -0
- package/telegram-plugin/send-gate.test.ts +717 -0
- package/telegram-plugin/send-gate.ts +1056 -0
- package/telegram-plugin/session-tail.ts +82 -7
- package/telegram-plugin/shared/bot-runtime.ts +17 -5
- package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
- package/telegram-plugin/status-pin-driver.ts +52 -7
- package/telegram-plugin/status-pin.ts +81 -0
- package/telegram-plugin/subagent-watcher.ts +173 -18
- package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
- package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
- package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
- package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
- package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
- package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
- package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
- package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
- package/telegram-plugin/tests/history.test.ts +115 -0
- package/telegram-plugin/tests/idle-clear.test.ts +233 -3
- package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
- package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
- package/telegram-plugin/tests/model-command.test.ts +14 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
- package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
- package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
- package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
- package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
- package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
- package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
- package/telegram-plugin/tests/render/render.test.ts +88 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
- package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
- package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
- package/telegram-plugin/tests/session-model-file.test.ts +50 -0
- package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
- package/telegram-plugin/tests/status-pin.test.ts +275 -1
- package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
- package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
- package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
- package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
- package/telegram-plugin/typing-emitter.ts +224 -0
- package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
- package/telegram-plugin/welcome-text.ts +42 -0
- package/telegram-plugin/worktree-watch-cwds.ts +194 -5
- package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
- package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
- package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
- package/vendor/hindsight-memory/scripts/session_end.py +14 -3
- package/vendor/hindsight-memory/scripts/session_start.py +21 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
- package/vendor/hindsight-memory/tests/test_pending.py +44 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
- package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
- package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
- package/telegram-plugin/channel-envelope-safety.ts +0 -56
- package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
- package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
|
@@ -125,6 +125,29 @@ export function hostdRequestId(prefix: string): string {
|
|
|
125
125
|
return `${prefix}-${Date.now()}-${randomBytes(4).toString("hex")}`;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* #1841 — attach an operator-passphrase attestation to a hostd request.
|
|
130
|
+
*
|
|
131
|
+
* Mirrors the existing gateway→broker plaintext-forward used for
|
|
132
|
+
* `vault_request_save`: after `/vault unlock` the gateway caches the
|
|
133
|
+
* operator passphrase per-chat, and forwards it on the wire when an admin
|
|
134
|
+
* agent invokes a privileged hostd verb. hostd never holds the passphrase —
|
|
135
|
+
* it re-forwards this value to the vault broker and treats a broker DENIED
|
|
136
|
+
* as a gate failure (see `AttestVerifier` in `src/host-control/server.ts`).
|
|
137
|
+
*
|
|
138
|
+
* Returns the request unchanged when no passphrase is cached (the
|
|
139
|
+
* feature-off / not-yet-unlocked posture stays byte-identical to today).
|
|
140
|
+
* Pure — returns a new object rather than mutating in place, so a caller
|
|
141
|
+
* can pass a request literal.
|
|
142
|
+
*/
|
|
143
|
+
export function withOperatorAttestation<T extends HostdRequest>(
|
|
144
|
+
req: T,
|
|
145
|
+
passphrase: string | undefined,
|
|
146
|
+
): T {
|
|
147
|
+
if (!passphrase) return req;
|
|
148
|
+
return { ...req, operator_passphrase: passphrase };
|
|
149
|
+
}
|
|
150
|
+
|
|
128
151
|
/**
|
|
129
152
|
* Single-shot `get_status` snapshot for `targetRequestId` (NOT a
|
|
130
153
|
* poll-until-terminal — {@link pollHostdStatus} does that). Used by
|
|
@@ -4,22 +4,63 @@
|
|
|
4
4
|
* instead of resuming a stale, context-heavy thread. Long-term memory lives in
|
|
5
5
|
* Hindsight, so a clear loses only the in-session scratch.
|
|
6
6
|
*
|
|
7
|
+
* WHAT "IDLE" MEANS HERE (the #3084/#3107 semantics, fixed 2026-07-11):
|
|
8
|
+
*
|
|
9
|
+
* Idle = nothing has happened since the last thing happened.
|
|
10
|
+
*
|
|
11
|
+
* "Something happened" is ANY observable event on this agent:
|
|
12
|
+
* - an inbound Telegram message,
|
|
13
|
+
* - a cron / inject_inbound fire,
|
|
14
|
+
* - ANY event off the claude session stream — turn start, thinking, a tool
|
|
15
|
+
* call, a tool result, streamed text, a sub-agent event, turn end.
|
|
16
|
+
*
|
|
17
|
+
* It emphatically does NOT mean "no turn has *started* recently". That was the
|
|
18
|
+
* original reading and it inverted the invariant: the idle window was measured
|
|
19
|
+
* from a turn's START, so the longer and harder an agent worked, the more
|
|
20
|
+
* certain the wipe. On 2026-07-11 `overlord` worked for three hours straight
|
|
21
|
+
* (subagents, ~25 PRs) after its last recorded turn boundary and was `/clear`ed
|
|
22
|
+
* the moment the window elapsed — its whole working context destroyed for being
|
|
23
|
+
* productive. See `reference/jobs/always-available.md`.
|
|
24
|
+
*
|
|
25
|
+
* Two independent clocks feed the decision, and the window is measured from
|
|
26
|
+
* whichever is later:
|
|
27
|
+
* - `lastActivityAt` — stamped on every event above (the load-bearing fix:
|
|
28
|
+
* an agent that is DOING something is not idle, turn boundaries or not).
|
|
29
|
+
* - `lastTurnEndedAt` — stamped when a turn ends (defence in depth: the
|
|
30
|
+
* instant a turn ends, `turnInFlight` stops suppressing the clear, so a
|
|
31
|
+
* turn that ran longer than the window would otherwise be wiped on the very
|
|
32
|
+
* next tick — the timer must never be "already expired" at turn end).
|
|
33
|
+
*
|
|
34
|
+
* A genuinely idle session — no inbound, no cron, no model activity for the
|
|
35
|
+
* whole window — is still cleared. That is the feature and it stays.
|
|
36
|
+
*
|
|
7
37
|
* Sibling of proactive-compact.ts (occupancy-driven /compact at the turn-end
|
|
8
38
|
* idle gate). This one is wall-clock-driven: it fires `/clear` from a periodic
|
|
9
39
|
* interval because a fully-idle agent never ends a turn, so the turn-end gate
|
|
10
40
|
* alone would never see it. Both inject via the same primitive and both refuse
|
|
11
41
|
* to fire mid-turn (turnInFlight guard).
|
|
12
42
|
*
|
|
13
|
-
* The decider
|
|
14
|
-
*
|
|
43
|
+
* The decider and the event classifier are pure so the whole
|
|
44
|
+
* fire-once / re-arm / not-mid-turn / not-just-worked / disabled logic is
|
|
45
|
+
* unit-tested without the gateway.
|
|
15
46
|
*/
|
|
16
47
|
|
|
17
48
|
/** Default idle window when `session.idle_clear_after` is unset (3h). ON by default. */
|
|
18
49
|
export const DEFAULT_IDLE_CLEAR_MS = 3 * 60 * 60 * 1000;
|
|
19
50
|
|
|
20
51
|
export interface IdleClearState {
|
|
21
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* Epoch ms of the last activity: inbound, cron fire, or ANY claude session
|
|
54
|
+
* stream event (turn start, tool call, tool result, text, sub-agent event,
|
|
55
|
+
* turn end). Not just turn starts — see the header.
|
|
56
|
+
*/
|
|
22
57
|
lastActivityAt: number;
|
|
58
|
+
/**
|
|
59
|
+
* Epoch ms when a turn last ended (null = no turn has ended this process).
|
|
60
|
+
* The window is measured from `max(lastActivityAt, lastTurnEndedAt)` so a turn
|
|
61
|
+
* that outran the idle window is not cleared the instant it finishes.
|
|
62
|
+
*/
|
|
63
|
+
lastTurnEndedAt: number | null;
|
|
23
64
|
/** Idle window in ms. <= 0 disables auto-clear. */
|
|
24
65
|
idleClearMs: number;
|
|
25
66
|
/** Already auto-cleared since the last activity? Prevents re-clearing every tick. */
|
|
@@ -32,11 +73,17 @@ export interface IdleClearDecision {
|
|
|
32
73
|
clear: boolean;
|
|
33
74
|
}
|
|
34
75
|
|
|
76
|
+
/** Epoch ms of the last thing that happened — activity or a turn ending. */
|
|
77
|
+
export function lastEventAt(state: IdleClearState): number {
|
|
78
|
+
return Math.max(state.lastActivityAt, state.lastTurnEndedAt ?? 0);
|
|
79
|
+
}
|
|
80
|
+
|
|
35
81
|
/**
|
|
36
82
|
* Decide whether to auto-clear this evaluation. Fires exactly once per idle
|
|
37
83
|
* period: only when enabled, not mid-turn, not already cleared, and the idle
|
|
38
|
-
* window has elapsed
|
|
39
|
-
* (with `lastActivityAt`) on the next
|
|
84
|
+
* window has elapsed since the last thing that happened. The caller sets
|
|
85
|
+
* `alreadyCleared` on fire and resets it (with `lastActivityAt`) on the next
|
|
86
|
+
* activity to re-arm.
|
|
40
87
|
*/
|
|
41
88
|
export function decideIdleClear(
|
|
42
89
|
state: IdleClearState,
|
|
@@ -45,10 +92,47 @@ export function decideIdleClear(
|
|
|
45
92
|
if (state.idleClearMs <= 0) return { clear: false }; // disabled
|
|
46
93
|
if (state.turnInFlight) return { clear: false }; // never mid-turn
|
|
47
94
|
if (state.alreadyCleared) return { clear: false }; // once per idle period
|
|
48
|
-
|
|
95
|
+
// Measured from the LAST thing that happened — activity or a turn ending —
|
|
96
|
+
// never from a turn's start. A turn that ran longer than the window ends with
|
|
97
|
+
// a fresh turn-end stamp, so it is not idle the moment it finishes.
|
|
98
|
+
if (now - lastEventAt(state) < state.idleClearMs) return { clear: false };
|
|
49
99
|
return { clear: true };
|
|
50
100
|
}
|
|
51
101
|
|
|
102
|
+
/** What a claude session-stream event means for the idle clocks. */
|
|
103
|
+
export interface IdleEventSignal {
|
|
104
|
+
/** Genuine agent activity → stamp `lastActivityAt` (and re-arm). */
|
|
105
|
+
activity: boolean;
|
|
106
|
+
/** A turn ended → stamp `lastTurnEndedAt`. */
|
|
107
|
+
turnEnded: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Classify a session-stream event for the idle clocks. Pure, so the gateway's
|
|
112
|
+
* dispatcher stays a one-liner and the semantics are testable.
|
|
113
|
+
*
|
|
114
|
+
* EVERY genuine stream event is activity — thinking, tool_use, tool_result,
|
|
115
|
+
* text, model, enqueue (turn start), turn_end, and all `sub_agent_*` kinds.
|
|
116
|
+
* The claude session emits nothing at all while the agent is truly idle, so
|
|
117
|
+
* "any event = activity" is exactly the intended reading and cannot keep a
|
|
118
|
+
* dormant session alive forever.
|
|
119
|
+
*
|
|
120
|
+
* The one exception is the gateway's OWN synthetic `turn_end` (durationMs ===
|
|
121
|
+
* -1), re-dispatched by the orphaned-reply backstop: it is the gateway talking
|
|
122
|
+
* to itself, not the model doing something, so it does not stamp activity — but
|
|
123
|
+
* it DOES end the turn (the in-flight gate opens), so it stamps the turn-end
|
|
124
|
+
* clock. Same `!(turn_end && durationMs === -1)` predicate the per-turn liveness
|
|
125
|
+
* stamp already uses for "genuine stream event".
|
|
126
|
+
*/
|
|
127
|
+
export function classifyIdleEvent(
|
|
128
|
+
kind: string,
|
|
129
|
+
durationMs?: number,
|
|
130
|
+
): IdleEventSignal {
|
|
131
|
+
const turnEnded = kind === "turn_end";
|
|
132
|
+
const synthetic = turnEnded && durationMs === -1;
|
|
133
|
+
return { activity: !synthetic, turnEnded };
|
|
134
|
+
}
|
|
135
|
+
|
|
52
136
|
/**
|
|
53
137
|
* Parse a `^\d+[smh]$` duration (the SessionSchema format, e.g. "3h", "30m",
|
|
54
138
|
* "7200s") to ms. Returns null on a malformed string so the caller can fall
|
|
@@ -27,6 +27,16 @@
|
|
|
27
27
|
* buffer: agent=X buffered ...` etc.) is the validation that the
|
|
28
28
|
* machine is bit-identical with reality.
|
|
29
29
|
*
|
|
30
|
+
* Trace verbosity (#3025): the TTL `tick` event fires every ~30s and on a
|
|
31
|
+
* healthy idle agent emits a zero-signal `event=tick effects=[]
|
|
32
|
+
* global=bridge_alive_idle` line. Those no-op ticks are SUPPRESSED by
|
|
33
|
+
* default (they filled gateway-supervisor.log to 555MB on clerk). Set
|
|
34
|
+
* `SWITCHROOM_GW_TRACE=1` to restore the full firehose — every tick, poll,
|
|
35
|
+
* and event line — when debugging the delivery/poll path. The gate lives
|
|
36
|
+
* in `../shared/gw-trace-gate.ts` and also governs the `tg-post` poll
|
|
37
|
+
* heartbeats. Real events, turn-expiring ticks, and in-turn ticks always
|
|
38
|
+
* log regardless of the flag.
|
|
39
|
+
*
|
|
30
40
|
* Toggle off via `SWITCHROOM_DELIVERY_MACHINE_SHADOW=0` — a kill
|
|
31
41
|
* switch for the case where the shadow emits prove problematic
|
|
32
42
|
* (e.g., trace volume too high). The default is ON.
|
|
@@ -39,6 +49,7 @@ import {
|
|
|
39
49
|
initialState,
|
|
40
50
|
transition,
|
|
41
51
|
} from './inbound-delivery-machine.js'
|
|
52
|
+
import { shouldEmitShadowTrace } from '../shared/gw-trace-gate.js'
|
|
42
53
|
|
|
43
54
|
let state: State = initialState()
|
|
44
55
|
const enabled = process.env.SWITCHROOM_DELIVERY_MACHINE_SHADOW !== '0'
|
|
@@ -101,11 +112,21 @@ export function shadowEmit(event: Event): readonly Effect[] {
|
|
|
101
112
|
// low volume (one line per gateway event, not per effect). The
|
|
102
113
|
// format matches gateway.ts's existing `tg-post method=...` shape
|
|
103
114
|
// so log aggregation can pick it up without a new parser.
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
)
|
|
115
|
+
// #3025: the TTL `tick` event fires every ~30s. On a healthy idle
|
|
116
|
+
// agent it produces no effects and leaves the machine idle — a
|
|
117
|
+
// zero-signal heartbeat that (unrotated) grew this log to 555MB on
|
|
118
|
+
// clerk. Suppress those no-op ticks unless the operator opted into
|
|
119
|
+
// the full firehose (SWITCHROOM_GW_TRACE=1). Real events, ticks that
|
|
120
|
+
// expire a turn (non-empty effects), and in-turn ticks still log.
|
|
121
|
+
if (
|
|
122
|
+
shouldEmitShadowTrace(event.kind, result.effects.length, state.global.kind)
|
|
123
|
+
) {
|
|
124
|
+
const effectKinds = result.effects.map((e) => e.kind).join(',')
|
|
125
|
+
const eventDetail = formatEventDetail(event)
|
|
126
|
+
process.stderr.write(
|
|
127
|
+
`gw-trace shadow event=${event.kind}${eventDetail} effects=[${effectKinds}] global=${state.global.kind} perKeySize=${state.perKey.size}\n`,
|
|
128
|
+
)
|
|
129
|
+
}
|
|
109
130
|
return result.effects
|
|
110
131
|
} catch (err) {
|
|
111
132
|
process.stderr.write(
|
|
@@ -103,6 +103,14 @@ function shapeReply(
|
|
|
103
103
|
accent: 'issue',
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
|
+
if (code === 'args_not_allowed') {
|
|
107
|
+
// #730 — a bare-verb-only command was injected with trailing args.
|
|
108
|
+
// errorMessage is already actionable ("args not permitted for /memory …").
|
|
109
|
+
return {
|
|
110
|
+
body: `${verbHtml} — ${deps.escapeHtml(msg)}`,
|
|
111
|
+
accent: 'issue',
|
|
112
|
+
}
|
|
113
|
+
}
|
|
106
114
|
if (code === 'session_missing') {
|
|
107
115
|
return {
|
|
108
116
|
body: 'tmux session not found — agent must be running under the tmux supervisor (the default). Remove \`experimental.legacy_pty: true\` if set.',
|
|
@@ -153,6 +153,22 @@ export interface PendingPermissionStatusEvent {
|
|
|
153
153
|
requestId?: string;
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
/**
|
|
157
|
+
* #2975 Stage 2 — the gateway's answer to a hostd `check_pre_approved` query.
|
|
158
|
+
* Read-only: `preApproved: true` means the queried `(agentName, unifiedDiff)`
|
|
159
|
+
* pair byte-matches a correlation the gateway pre-registered when the operator
|
|
160
|
+
* approved a mental-model proposal (or "🔁 Always allow"), so hostd may skip
|
|
161
|
+
* the `config_propose_edit` rate limit for that persist. Sourced from the
|
|
162
|
+
* gateway's in-memory correlation maps WITHOUT mutating them (the single-use
|
|
163
|
+
* auto-resolve delete happens later on the real `request_config_approval`).
|
|
164
|
+
*/
|
|
165
|
+
export interface PreApprovedResultEvent {
|
|
166
|
+
type: "pre_approved_result";
|
|
167
|
+
/** Echoes the correlationId from the originating check_pre_approved query. */
|
|
168
|
+
correlationId: string;
|
|
169
|
+
preApproved: boolean;
|
|
170
|
+
}
|
|
171
|
+
|
|
156
172
|
export type GatewayToClient =
|
|
157
173
|
| InboundMessage
|
|
158
174
|
| PermissionEvent
|
|
@@ -163,7 +179,8 @@ export type GatewayToClient =
|
|
|
163
179
|
| Ms365ApprovalPostedEvent
|
|
164
180
|
| ConfigApprovalResolvedEvent
|
|
165
181
|
| RolloutStatusPostedEvent
|
|
166
|
-
| PendingPermissionStatusEvent
|
|
182
|
+
| PendingPermissionStatusEvent
|
|
183
|
+
| PreApprovedResultEvent;
|
|
167
184
|
|
|
168
185
|
// === Bridge (Client) -> Gateway messages ===
|
|
169
186
|
|
|
@@ -421,7 +438,7 @@ export interface RequestConfigApprovalMessage {
|
|
|
421
438
|
export interface RequestConfigFinalizeMessage {
|
|
422
439
|
type: "request_config_finalize";
|
|
423
440
|
requestId: string;
|
|
424
|
-
outcome: "applied" | "reconcile_failed_rolled_back";
|
|
441
|
+
outcome: "applied" | "aborted_config_changed" | "reconcile_failed_rolled_back";
|
|
425
442
|
/** Optional short diagnostic appended to the card body. */
|
|
426
443
|
detail?: string;
|
|
427
444
|
/**
|
|
@@ -596,6 +613,31 @@ export interface QueryPendingPermissionMessage {
|
|
|
596
613
|
correlationId: string;
|
|
597
614
|
}
|
|
598
615
|
|
|
616
|
+
/**
|
|
617
|
+
* #2975 Stage 2 — hostd → gateway read-only pre-approval query. hostd asks
|
|
618
|
+
* whether this EXACT `(agentName, unifiedDiff)` pair is already operator-
|
|
619
|
+
* consented (pre-registered on the gateway when the operator approved a
|
|
620
|
+
* mental-model proposal / tapped "🔁 Always allow"), so it can skip the
|
|
621
|
+
* `config_propose_edit` per-hour rate limit for that persist.
|
|
622
|
+
*
|
|
623
|
+
* Trust model: identical to `query_pending_permission` — the socket is
|
|
624
|
+
* per-agent inside the container; `agentName` is validated server-side AND
|
|
625
|
+
* checked against the gateway's own SWITCHROOM_AGENT_NAME in the handler. This
|
|
626
|
+
* message can NEVER mutate gateway state: the handler only *reads* the
|
|
627
|
+
* correlation maps (byte-exact match), the same forge-resistance gate the
|
|
628
|
+
* auto-resolve path enforces. hostd answers `pre_approved_result`; the query
|
|
629
|
+
* is not reachable via any agent-facing hostd verb (it only ever travels
|
|
630
|
+
* hostd → gateway over the approval-gateway socket).
|
|
631
|
+
*/
|
|
632
|
+
export interface CheckPreApprovedMessage {
|
|
633
|
+
type: "check_pre_approved";
|
|
634
|
+
agentName: string;
|
|
635
|
+
/** Caller-generated correlation id, echoed in the reply. */
|
|
636
|
+
correlationId: string;
|
|
637
|
+
/** Full unified diff to byte-match against the pre-registered correlations. */
|
|
638
|
+
unifiedDiff: string;
|
|
639
|
+
}
|
|
640
|
+
|
|
599
641
|
export type ClientToGateway =
|
|
600
642
|
| RegisterMessage
|
|
601
643
|
| ToolCallMessage
|
|
@@ -616,4 +658,5 @@ export type ClientToGateway =
|
|
|
616
658
|
| PostSkillProposalMessage
|
|
617
659
|
| RolloutStatusPostMessage
|
|
618
660
|
| RolloutStatusEditMessage
|
|
619
|
-
| QueryPendingPermissionMessage
|
|
661
|
+
| QueryPendingPermissionMessage
|
|
662
|
+
| CheckPreApprovedMessage;
|
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
SendOutboundMessage,
|
|
8
8
|
QuotaWallDetectedMessage,
|
|
9
9
|
QueryPendingPermissionMessage,
|
|
10
|
+
CheckPreApprovedMessage,
|
|
10
11
|
PostSkillProposalMessage,
|
|
11
12
|
OperatorEventForward,
|
|
12
13
|
PermissionRequestForward,
|
|
@@ -80,6 +81,15 @@ export interface IpcServerOptions {
|
|
|
80
81
|
* feature (mixed-version safety).
|
|
81
82
|
*/
|
|
82
83
|
onQueryPendingPermission?: (client: IpcClient, msg: QueryPendingPermissionMessage) => void;
|
|
84
|
+
/**
|
|
85
|
+
* #2975 Stage 2 — hostd's read-only pre-approval query. Handler answers from
|
|
86
|
+
* the gateway's correlation maps by EXACT diff byte-match and replies with a
|
|
87
|
+
* `pre_approved_result` event. MUST NOT mutate gateway state. Optional: when
|
|
88
|
+
* no handler is wired (older gateway build, or a fixture that doesn't need
|
|
89
|
+
* it), the server replies `preApproved: false` immediately so hostd fails
|
|
90
|
+
* closed to today's rate-limited behaviour (mixed-version safety).
|
|
91
|
+
*/
|
|
92
|
+
onCheckPreApproved?: (client: IpcClient, msg: CheckPreApprovedMessage) => void;
|
|
83
93
|
/**
|
|
84
94
|
* #2670 one-tap self-improvement — persist a skill-improvement proposal
|
|
85
95
|
* and post its Approve/Dismiss card. Handler persists the draft bundle to
|
|
@@ -352,6 +362,19 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
|
|
|
352
362
|
|| (m.correlationId as string).length > 64) return false;
|
|
353
363
|
return true;
|
|
354
364
|
}
|
|
365
|
+
case "check_pre_approved": {
|
|
366
|
+
// #2975 Stage 2 — hostd read-only pre-approval query. Wire shape only;
|
|
367
|
+
// the handler byte-matches the diff against the correlation maps and
|
|
368
|
+
// never mutates state.
|
|
369
|
+
if (typeof m.agentName !== "string"
|
|
370
|
+
|| !AGENT_NAME_RE.test(m.agentName as string)) return false;
|
|
371
|
+
if (typeof m.correlationId !== "string"
|
|
372
|
+
|| (m.correlationId as string).length === 0
|
|
373
|
+
|| (m.correlationId as string).length > 64) return false;
|
|
374
|
+
if (typeof m.unifiedDiff !== "string"
|
|
375
|
+
|| (m.unifiedDiff as string).length === 0) return false;
|
|
376
|
+
return true;
|
|
377
|
+
}
|
|
355
378
|
case "request_config_approval": {
|
|
356
379
|
// #1623 — hostd-initiated config-edit approval card. Wire shape
|
|
357
380
|
// only; the handler module validates the diff content.
|
|
@@ -375,6 +398,7 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
|
|
|
375
398
|
|| (m.requestId as string).length === 0
|
|
376
399
|
|| (m.requestId as string).length > 64) return false;
|
|
377
400
|
if (m.outcome !== "applied"
|
|
401
|
+
&& m.outcome !== "aborted_config_changed"
|
|
378
402
|
&& m.outcome !== "reconcile_failed_rolled_back") return false;
|
|
379
403
|
if (m.detail !== undefined
|
|
380
404
|
&& (typeof m.detail !== "string"
|
|
@@ -472,6 +496,7 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
|
|
|
472
496
|
onSendOutbound,
|
|
473
497
|
onQuotaWallDetected,
|
|
474
498
|
onQueryPendingPermission,
|
|
499
|
+
onCheckPreApproved,
|
|
475
500
|
onPostSkillProposal,
|
|
476
501
|
onRequestDriveApproval,
|
|
477
502
|
onRequestMs365Approval,
|
|
@@ -609,6 +634,24 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
|
|
|
609
634
|
}
|
|
610
635
|
}
|
|
611
636
|
break;
|
|
637
|
+
case "check_pre_approved":
|
|
638
|
+
if (onCheckPreApproved) {
|
|
639
|
+
onCheckPreApproved(client, msg as CheckPreApprovedMessage);
|
|
640
|
+
} else {
|
|
641
|
+
// No handler wired — fail closed to "not pre-approved" so hostd
|
|
642
|
+
// applies the ordinary rate limit (byte-identical to pre-#2975-S2
|
|
643
|
+
// behaviour; the Stage 1 retry then covers an approved persist).
|
|
644
|
+
try {
|
|
645
|
+
client.send({
|
|
646
|
+
type: "pre_approved_result",
|
|
647
|
+
correlationId: (msg as CheckPreApprovedMessage).correlationId,
|
|
648
|
+
preApproved: false,
|
|
649
|
+
});
|
|
650
|
+
} catch {
|
|
651
|
+
/* best effort */
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
break;
|
|
612
655
|
case "request_drive_approval":
|
|
613
656
|
if (onRequestDriveApproval) {
|
|
614
657
|
// Handler is async — fire-and-forget here; the handler
|
|
@@ -46,6 +46,14 @@ export interface MentalModelPendingProposal {
|
|
|
46
46
|
export type ConfigEditDispatchResult =
|
|
47
47
|
| { state: "applied" }
|
|
48
48
|
| { state: "denied"; reason: string }
|
|
49
|
+
/**
|
|
50
|
+
* hostd threw `E_RATE_LIMITED` (the agent's config_propose_edit bucket is
|
|
51
|
+
* exhausted). `retryAtMs` is the epoch-ms parsed from the structured
|
|
52
|
+
* `fix.retry_after` hostd emits — the moment the sliding window frees a
|
|
53
|
+
* slot. #2975 Stage 1 schedules exactly ONE re-dispatch at that time so an
|
|
54
|
+
* operator-APPROVED persist is never silently lost to the throttle.
|
|
55
|
+
*/
|
|
56
|
+
| { state: "rate_limited"; reason: string; retryAtMs: number }
|
|
49
57
|
| { state: "error"; reason: string };
|
|
50
58
|
|
|
51
59
|
export interface ResolveDeps {
|
|
@@ -79,6 +87,33 @@ export interface ResolveDeps {
|
|
|
79
87
|
ensureModel?: (spec: MentalModelProposeSpec) => Promise<void>;
|
|
80
88
|
/** Deliver a synthetic inbound to wake the agent with the outcome. */
|
|
81
89
|
injectInbound: (inbound: InboundMessage) => void;
|
|
90
|
+
/**
|
|
91
|
+
* #2975 Stage 1 — schedule EXACTLY ONE bounded re-dispatch of an
|
|
92
|
+
* operator-approved persist that hostd rate-limited (`E_RATE_LIMITED`).
|
|
93
|
+
* `delayMs` is the wait until hostd's `fix.retry_after` window opens; `fn`
|
|
94
|
+
* re-runs the persist ONCE (it never re-schedules — bounded, no loop).
|
|
95
|
+
* Injectable so tests drive it with a mocked clock. When absent, a rate-
|
|
96
|
+
* limited persist can't be retried and falls straight to the loud-failure
|
|
97
|
+
* path (no silent loss).
|
|
98
|
+
*
|
|
99
|
+
* Stage-1 caveat (documented, accepted): the timer lives only in gateway
|
|
100
|
+
* memory. A gateway restart between schedule and fire LOSES the retry —
|
|
101
|
+
* acceptable for Stage 1; Stage 2 (hostd `checkPreApproved` bypass) removes
|
|
102
|
+
* the rate-limit collision entirely so no retry is needed.
|
|
103
|
+
*/
|
|
104
|
+
scheduleRetry?: (delayMs: number, fn: () => void | Promise<void>) => void;
|
|
105
|
+
/**
|
|
106
|
+
* #2975 Stage 1 — edit the operator's proposal card to the
|
|
107
|
+
* "approved — applying at HH:MM (rate window)" state so a rate-deferred
|
|
108
|
+
* (but approved) change is visibly not lost while the retry is pending.
|
|
109
|
+
*/
|
|
110
|
+
editProposalCardRateWindow?: (retryAtMs: number) => void;
|
|
111
|
+
/**
|
|
112
|
+
* #2975 Stage 1 — loud, operator-visible failure (operator-events path)
|
|
113
|
+
* when the single scheduled retry ALSO fails, so an approved change is
|
|
114
|
+
* never silently dropped. Must not throw.
|
|
115
|
+
*/
|
|
116
|
+
notifyPersistFailed?: (reason: string) => void;
|
|
82
117
|
/** Injectable clock for deterministic tests. */
|
|
83
118
|
now?: () => number;
|
|
84
119
|
/** Injectable git binary for the diff subprocess (tests). */
|
|
@@ -90,7 +125,14 @@ export type ResolveOutcome =
|
|
|
90
125
|
| { outcome: "applied" }
|
|
91
126
|
| { outcome: "denied" }
|
|
92
127
|
| { outcome: "rejected"; reason: string }
|
|
93
|
-
| { outcome: "failed"; reason: string }
|
|
128
|
+
| { outcome: "failed"; reason: string }
|
|
129
|
+
/**
|
|
130
|
+
* #2975 Stage 1 — the persist was rate-limited but the operator already
|
|
131
|
+
* approved, so exactly one re-dispatch is scheduled at `retryAtMs`. The
|
|
132
|
+
* proposal card was edited to the "applying at HH:MM" state; the agent's
|
|
133
|
+
* turn resolves later when the retry injects its own applied/failed inbound.
|
|
134
|
+
*/
|
|
135
|
+
| { outcome: "scheduled_retry"; retryAtMs: number };
|
|
94
136
|
|
|
95
137
|
/**
|
|
96
138
|
* Resolve an operator's Approve / Deny decision on a mental-model proposal.
|
|
@@ -145,57 +187,123 @@ export async function resolveMentalModelProposal(
|
|
|
145
187
|
return { outcome: "rejected", reason: built.detail };
|
|
146
188
|
}
|
|
147
189
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
try {
|
|
153
|
-
dispatch = await deps.dispatchConfigEdit({
|
|
154
|
-
agent: pending.agent,
|
|
155
|
-
diff: built.diff,
|
|
156
|
-
reason:
|
|
157
|
-
`Declare agent-proposed mental model "${pending.spec.name}"` +
|
|
158
|
-
(pending.reason ? ` — ${pending.reason}` : ""),
|
|
159
|
-
});
|
|
160
|
-
} catch (err) {
|
|
161
|
-
dispatch = { state: "error", reason: (err as Error).message };
|
|
162
|
-
} finally {
|
|
163
|
-
// Single-shot: drop the correlation whether or not hostd consumed it.
|
|
164
|
-
deps.clearPreApproval(pending.agent, built.diff);
|
|
165
|
-
}
|
|
190
|
+
const persistReason =
|
|
191
|
+
`Declare agent-proposed mental model "${pending.spec.name}"` +
|
|
192
|
+
(pending.reason ? ` — ${pending.reason}` : "");
|
|
193
|
+
const clock = deps.now ?? Date.now;
|
|
166
194
|
|
|
167
|
-
|
|
195
|
+
// ONE dispatch attempt. Atomically re-registers the single-tap correlation
|
|
196
|
+
// with the SAME diff bytes each call BEFORE dispatching — the register and
|
|
197
|
+
// dispatch MUST use identical bytes (forge-resistance contract, file header
|
|
198
|
+
// lines 54-63): hostd's config-approval callback auto-resolves only on an
|
|
199
|
+
// exact byte-match, so a rebuilt/whitespace-drifted diff would fail to
|
|
200
|
+
// auto-approve and post a SECOND operator card. Because the initial and the
|
|
201
|
+
// #2975 retry both re-run this closure over the same captured `built.diff`,
|
|
202
|
+
// they are byte-identical by construction.
|
|
203
|
+
const dispatchOnce = async (): Promise<ConfigEditDispatchResult> => {
|
|
204
|
+
deps.registerPreApproval(pending.agent, built.diff);
|
|
205
|
+
try {
|
|
206
|
+
return await deps.dispatchConfigEdit({
|
|
207
|
+
agent: pending.agent,
|
|
208
|
+
diff: built.diff,
|
|
209
|
+
reason: persistReason,
|
|
210
|
+
});
|
|
211
|
+
} catch (err) {
|
|
212
|
+
return { state: "error", reason: (err as Error).message };
|
|
213
|
+
} finally {
|
|
214
|
+
// Single-shot: drop the correlation whether or not hostd consumed it.
|
|
215
|
+
deps.clearPreApproval(pending.agent, built.diff);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
// Applied — belt-and-suspenders ensure (reconcile already ensures via #2874;
|
|
220
|
+
// this makes the model available without waiting for the restart). Never let
|
|
221
|
+
// an ensure failure flip a successful declaration into a "failed" inbound —
|
|
222
|
+
// the model IS declared and will be ensured at reconcile regardless.
|
|
223
|
+
const finishApplied = async (): Promise<void> => {
|
|
224
|
+
if (deps.ensureModel) {
|
|
225
|
+
try {
|
|
226
|
+
await deps.ensureModel(pending.spec);
|
|
227
|
+
} catch (err) {
|
|
228
|
+
deps.log?.(
|
|
229
|
+
`mental_model_propose: best-effort ensure threw (declaration still applied) for ${pending.agent} "${pending.spec.name}": ${(err as Error).message}`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
deps.injectInbound(
|
|
234
|
+
buildMentalModelProposeAppliedInbound({
|
|
235
|
+
ctx,
|
|
236
|
+
stageId,
|
|
237
|
+
operatorId,
|
|
238
|
+
nowMs: clock(),
|
|
239
|
+
}),
|
|
240
|
+
);
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const finishFailed = (reason: string, loud: boolean): void => {
|
|
168
244
|
deps.log?.(
|
|
169
|
-
`mental_model_propose: config edit
|
|
245
|
+
`mental_model_propose: config edit failed for ${pending.agent} "${pending.spec.name}": ${reason}`,
|
|
170
246
|
);
|
|
247
|
+
// Loud, operator-visible failure only on the RETRY exhaustion path — the
|
|
248
|
+
// initial-failure path already surfaces via the card edit + agent inbound.
|
|
249
|
+
if (loud) {
|
|
250
|
+
try {
|
|
251
|
+
deps.notifyPersistFailed?.(reason);
|
|
252
|
+
} catch {
|
|
253
|
+
// notify must never break the failed-inbound delivery below.
|
|
254
|
+
}
|
|
255
|
+
}
|
|
171
256
|
deps.injectInbound(
|
|
172
257
|
buildMentalModelProposeFailedInbound({
|
|
173
258
|
ctx,
|
|
174
259
|
stageId,
|
|
175
260
|
operatorId,
|
|
176
|
-
reason
|
|
177
|
-
nowMs,
|
|
261
|
+
reason,
|
|
262
|
+
nowMs: clock(),
|
|
178
263
|
}),
|
|
179
264
|
);
|
|
180
|
-
|
|
181
|
-
}
|
|
265
|
+
};
|
|
182
266
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
267
|
+
const dispatch = await dispatchOnce();
|
|
268
|
+
|
|
269
|
+
// #2975 Stage 1 backstop — an operator-APPROVED persist that hostd rate-
|
|
270
|
+
// limited must NOT be silently lost. Schedule EXACTLY ONE re-dispatch at the
|
|
271
|
+
// window-open time hostd handed back in `fix.retry_after`, and flip the card
|
|
272
|
+
// to the "applying at HH:MM (rate window)" state so the operator sees it's
|
|
273
|
+
// deferred, not dropped.
|
|
274
|
+
if (dispatch.state === "rate_limited") {
|
|
275
|
+
if (deps.scheduleRetry) {
|
|
276
|
+
const { retryAtMs } = dispatch;
|
|
277
|
+
deps.editProposalCardRateWindow?.(retryAtMs);
|
|
278
|
+
const delayMs = Math.max(0, retryAtMs - clock());
|
|
191
279
|
deps.log?.(
|
|
192
|
-
`mental_model_propose:
|
|
280
|
+
`mental_model_propose: config edit rate-limited for ${pending.agent} "${pending.spec.name}"; scheduling one retry in ${delayMs}ms (window opens ${new Date(retryAtMs).toISOString()})`,
|
|
193
281
|
);
|
|
282
|
+
deps.scheduleRetry(delayMs, async () => {
|
|
283
|
+
// Bounded: this is the ONE and ONLY retry — it never re-schedules.
|
|
284
|
+
const retryResult = await dispatchOnce();
|
|
285
|
+
if (retryResult.state === "applied") {
|
|
286
|
+
await finishApplied();
|
|
287
|
+
} else {
|
|
288
|
+
// Every non-applied state (denied / rate_limited / error) carries a
|
|
289
|
+
// reason. A second rate_limit is treated as a terminal failure — the
|
|
290
|
+
// retry is bounded to one; we do NOT re-schedule.
|
|
291
|
+
finishFailed(retryResult.reason, true);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
return { outcome: "scheduled_retry", retryAtMs };
|
|
194
295
|
}
|
|
296
|
+
// No scheduler wired — can't defer the retry; fail loudly rather than
|
|
297
|
+
// silently drop the approved change.
|
|
298
|
+
finishFailed(dispatch.reason, true);
|
|
299
|
+
return { outcome: "failed", reason: dispatch.reason };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (dispatch.state !== "applied") {
|
|
303
|
+
finishFailed(dispatch.reason, false);
|
|
304
|
+
return { outcome: "failed", reason: dispatch.reason };
|
|
195
305
|
}
|
|
196
306
|
|
|
197
|
-
|
|
198
|
-
buildMentalModelProposeAppliedInbound({ ctx, stageId, operatorId, nowMs }),
|
|
199
|
-
);
|
|
307
|
+
await finishApplied();
|
|
200
308
|
return { outcome: "applied" };
|
|
201
309
|
}
|
|
@@ -521,9 +521,15 @@ export const SR_MODEL_ALIASES: Record<string, string> = {
|
|
|
521
521
|
* session to verify, so the operator is asked to re-issue after boot.
|
|
522
522
|
*/
|
|
523
523
|
export function isOfflineTrustedModelToken(token: string): boolean {
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
524
|
+
// #3043 item 1: the live accept path normalizes case (isClaudeModel /
|
|
525
|
+
// expandSrAlias lowercase before matching), so a queued `/model OPUS` is
|
|
526
|
+
// accepted live — but this persist-time gate compared case-sensitively and
|
|
527
|
+
// then refused it with the over-conservative "couldn't verify" card. Lowercase
|
|
528
|
+
// once so the persist decision matches what the live path already accepted.
|
|
529
|
+
const lower = token.toLowerCase()
|
|
530
|
+
if ((MODEL_ALIASES as readonly string[]).includes(lower)) return true
|
|
531
|
+
if (lower in SR_MODEL_ALIASES) return true
|
|
532
|
+
return Object.values(SR_MODEL_ALIASES).includes(lower)
|
|
527
533
|
}
|
|
528
534
|
|
|
529
535
|
export function expandSrAlias(arg: string): string {
|