instar 1.3.489 → 1.3.490
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/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +979 -28
- package/dist/commands/server.js.map +1 -1
- package/dist/config/ConfigDefaults.d.ts.map +1 -1
- package/dist/config/ConfigDefaults.js +23 -0
- package/dist/config/ConfigDefaults.js.map +1 -1
- package/dist/core/CodexResumeMap.d.ts +95 -0
- package/dist/core/CodexResumeMap.d.ts.map +1 -0
- package/dist/core/CodexResumeMap.js +283 -0
- package/dist/core/CodexResumeMap.js.map +1 -0
- package/dist/core/MeshRpc.d.ts +3 -0
- package/dist/core/MeshRpc.d.ts.map +1 -1
- package/dist/core/MeshRpc.js +5 -0
- package/dist/core/MeshRpc.js.map +1 -1
- package/dist/core/ModelSwapService.d.ts +26 -11
- package/dist/core/ModelSwapService.d.ts.map +1 -1
- package/dist/core/ModelSwapService.js +59 -21
- package/dist/core/ModelSwapService.js.map +1 -1
- package/dist/core/PostUpdateMigrator.d.ts +16 -0
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +62 -2
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/SessionManager.d.ts +4 -0
- package/dist/core/SessionManager.d.ts.map +1 -1
- package/dist/core/SessionManager.js +3 -0
- package/dist/core/SessionManager.js.map +1 -1
- package/dist/core/SessionRefresh.d.ts +36 -7
- package/dist/core/SessionRefresh.d.ts.map +1 -1
- package/dist/core/SessionRefresh.js +90 -29
- package/dist/core/SessionRefresh.js.map +1 -1
- package/dist/core/TopicProfileOrchestrator.d.ts +480 -0
- package/dist/core/TopicProfileOrchestrator.d.ts.map +1 -0
- package/dist/core/TopicProfileOrchestrator.js +1404 -0
- package/dist/core/TopicProfileOrchestrator.js.map +1 -0
- package/dist/core/TopicProfileResolver.d.ts +104 -0
- package/dist/core/TopicProfileResolver.d.ts.map +1 -0
- package/dist/core/TopicProfileResolver.js +231 -0
- package/dist/core/TopicProfileResolver.js.map +1 -0
- package/dist/core/TopicProfileStore.d.ts +308 -0
- package/dist/core/TopicProfileStore.d.ts.map +1 -0
- package/dist/core/TopicProfileStore.js +781 -0
- package/dist/core/TopicProfileStore.js.map +1 -0
- package/dist/core/TopicProfileTransferCarrier.d.ts +227 -0
- package/dist/core/TopicProfileTransferCarrier.d.ts.map +1 -0
- package/dist/core/TopicProfileTransferCarrier.js +533 -0
- package/dist/core/TopicProfileTransferCarrier.js.map +1 -0
- package/dist/core/TopicResumeMap.d.ts +67 -1
- package/dist/core/TopicResumeMap.d.ts.map +1 -1
- package/dist/core/TopicResumeMap.js +114 -1
- package/dist/core/TopicResumeMap.js.map +1 -1
- package/dist/core/classifyProfileChange.d.ts +83 -0
- package/dist/core/classifyProfileChange.d.ts.map +1 -0
- package/dist/core/classifyProfileChange.js +274 -0
- package/dist/core/classifyProfileChange.js.map +1 -0
- package/dist/core/devGatedFeatures.d.ts.map +1 -1
- package/dist/core/devGatedFeatures.js +6 -0
- package/dist/core/devGatedFeatures.js.map +1 -1
- package/dist/core/frameworkSessionLaunch.d.ts +26 -0
- package/dist/core/frameworkSessionLaunch.d.ts.map +1 -1
- package/dist/core/frameworkSessionLaunch.js +58 -0
- package/dist/core/frameworkSessionLaunch.js.map +1 -1
- package/dist/core/slackRefreshBinding.d.ts +83 -0
- package/dist/core/slackRefreshBinding.d.ts.map +1 -0
- package/dist/core/slackRefreshBinding.js +54 -0
- package/dist/core/slackRefreshBinding.js.map +1 -0
- package/dist/core/topicProfileIngress.d.ts +128 -0
- package/dist/core/topicProfileIngress.d.ts.map +1 -0
- package/dist/core/topicProfileIngress.js +249 -0
- package/dist/core/topicProfileIngress.js.map +1 -0
- package/dist/core/topicProfileValidation.d.ts +109 -0
- package/dist/core/topicProfileValidation.d.ts.map +1 -0
- package/dist/core/topicProfileValidation.js +247 -0
- package/dist/core/topicProfileValidation.js.map +1 -0
- package/dist/core/topicProfileWriteSurface.d.ts +222 -0
- package/dist/core/topicProfileWriteSurface.d.ts.map +1 -0
- package/dist/core/topicProfileWriteSurface.js +631 -0
- package/dist/core/topicProfileWriteSurface.js.map +1 -0
- package/dist/core/types.d.ts +37 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/lifeline/TelegramLifeline.d.ts.map +1 -1
- package/dist/lifeline/TelegramLifeline.js +6 -0
- package/dist/lifeline/TelegramLifeline.js.map +1 -1
- package/dist/messaging/TelegramAdapter.d.ts +10 -1
- package/dist/messaging/TelegramAdapter.d.ts.map +1 -1
- package/dist/messaging/TelegramAdapter.js +41 -1
- package/dist/messaging/TelegramAdapter.js.map +1 -1
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +9 -0
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/server/AgentServer.d.ts +32 -0
- package/dist/server/AgentServer.d.ts.map +1 -1
- package/dist/server/AgentServer.js +52 -3
- package/dist/server/AgentServer.js.map +1 -1
- package/dist/server/CapabilityIndex.d.ts.map +1 -1
- package/dist/server/CapabilityIndex.js +1 -0
- package/dist/server/CapabilityIndex.js.map +1 -1
- package/dist/server/routes.d.ts +17 -0
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +271 -1
- package/dist/server/routes.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +66 -66
- package/src/scaffold/templates.ts +9 -0
- package/upgrades/1.3.490.md +34 -0
- package/upgrades/side-effects/topic-profile.md +142 -0
|
@@ -0,0 +1,1404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TopicProfileOrchestrator — the §8 orchestration core of TOPIC-PROFILE-SPEC:
|
|
3
|
+
* the debounce / idle-confirmation / kill-respawn machinery that applies a
|
|
4
|
+
* profile change SAFELY, plus the §10.4 spawn-failure circuit breaker and the
|
|
5
|
+
* §14 dry-run shadow regime.
|
|
6
|
+
*
|
|
7
|
+
* Spec clauses owned here (each enforced in code, not prose):
|
|
8
|
+
*
|
|
9
|
+
* §8 two-phase lock — the per-topic store lock is held in TWO SHORT PHASES,
|
|
10
|
+
* never across the debounce window: WRITE (mutate → arm/extend debounce →
|
|
11
|
+
* release) and RESPAWN (re-acquire → re-resolve AT THIS MOMENT → re-confirm
|
|
12
|
+
* idle → [kill → respawn] → release). Re-resolution + idle re-confirm +
|
|
13
|
+
* net-unchanged skip run at DEQUEUE time inside the lock (round-4).
|
|
14
|
+
*
|
|
15
|
+
* Debounce slots — per-topic pending slot + trailing-edge timer; the store
|
|
16
|
+
* write is immediate, only the RESPAWN is debounced. N changes in the
|
|
17
|
+
* window collapse to ONE respawn against the final resolved profile; a
|
|
18
|
+
* net-unchanged sequence fires ZERO respawns and closes its loop out loud.
|
|
19
|
+
* The framework-switch arm carries the heavier window.
|
|
20
|
+
*
|
|
21
|
+
* Idle is a precondition re-checked at kill time, never a value carried from
|
|
22
|
+
* classification (round-2 TOCTOU). The read is three-valued; at kill time
|
|
23
|
+
* UNCONFIRMED IS BUSY (defer) — never permission to kill. Pane-idle is not
|
|
24
|
+
* task-done: the autonomous-session registry is consulted, and an active
|
|
25
|
+
* autonomous/time-boxed run is busy until it completes (only the explicit
|
|
26
|
+
* "switch now" confirm overrides). PROTECTED SESSIONS ARE NEVER
|
|
27
|
+
* PROFILE-KILLED — and "switch now" never overrides protection (round-5).
|
|
28
|
+
*
|
|
29
|
+
* Kill-path precision — a same-framework resume respawn kills via the
|
|
30
|
+
* resume-saving path (the re-save is wanted); a FRESH respawn PARKS (never
|
|
31
|
+
* deletes) BOTH resume stores' entries before the kill and sets a
|
|
32
|
+
* topic-scoped, time-bounded durable suppression marker so no writer
|
|
33
|
+
* (heartbeat, post-spawn save, kill listener, shutdown save) can re-persist
|
|
34
|
+
* a stale id during the window — the gates are installed at the resume-map
|
|
35
|
+
* chokepoints via claudeResumeWriteGate()/codexResumeWriteGate().
|
|
36
|
+
*
|
|
37
|
+
* Disclosure-of-record — EVERY accepted write discloses; writes inside the
|
|
38
|
+
* active debounce window coalesce into ONE delta-carrying disclosure
|
|
39
|
+
* ("was: <pre-burst> → now: <final> (N changes)"); a per-topic rate cap
|
|
40
|
+
* backstops outside the window with a delta-carrying overflow summary; the
|
|
41
|
+
* undo snapshot (store.mutate shiftPrevious) moves once per disclosed
|
|
42
|
+
* burst — undo always restores the profile the operator last saw disclosed
|
|
43
|
+
* (R7-4). Disclosures carry the audit seq and set the relay dedup bypass.
|
|
44
|
+
*
|
|
45
|
+
* Global stagger — profile-triggered respawns share a global concurrency cap
|
|
46
|
+
* (max K in flight, FIFO queue); same-cwd codex spawns are serialized so
|
|
47
|
+
* two codex sessions never spawn inside the same capture fence window
|
|
48
|
+
* (bounded wait: prior fence resolves or the RESPAWN-phase TTL, round-7).
|
|
49
|
+
*
|
|
50
|
+
* §9 interplay — only operator pin writes arm the respawn debounce;
|
|
51
|
+
* escalation consults read-only and serializes through runExclusive().
|
|
52
|
+
* Any profile-triggered kill clears the topic's escalation marker and
|
|
53
|
+
* releases its lease BEFORE computing expected-live; expected-live =
|
|
54
|
+
* resolved baseline ⊕ any active escalation marker (no controller
|
|
55
|
+
* ping-pong).
|
|
56
|
+
*
|
|
57
|
+
* §10.4 breaker — LIVE in every regime (exempt from BOTH `enabled` and
|
|
58
|
+
* `dryRun`; legacy-path failures count when attributable). Attribution is
|
|
59
|
+
* an ALLOWLIST (cli-not-found / launch-arg-rejected /
|
|
60
|
+
* model-rejected-by-account); ambient classes never increment; the counter
|
|
61
|
+
* resets on any successful spawn. A trip parks the profile
|
|
62
|
+
* intended-but-unhealthy, reverts to last-known-good (or default),
|
|
63
|
+
* un-parks the matching-framework resume entry (none-loss when the
|
|
64
|
+
* transcript survives), notifies the operator, audits
|
|
65
|
+
* system:circuit-breaker, and respawns IMMEDIATELY (the one keep-working
|
|
66
|
+
* exception to regime gating). Re-apply of the same recently-tripped
|
|
67
|
+
* profile requires an explicit cooldown confirm; switch-now,
|
|
68
|
+
* propose-confirm and the cooldown confirm share ONE armed slot per topic.
|
|
69
|
+
*
|
|
70
|
+
* §14 dry-run shadow — under `enabled && dryRun`, NEW-axis writes persist to
|
|
71
|
+
* the shadow intendedProfile field that resolution ignores ([dry-run]
|
|
72
|
+
* notice); the true→false flip CLEARS every shadow (never promotes) with
|
|
73
|
+
* one coalesced expired-intents notice; exempted framework writes and the
|
|
74
|
+
* recovery writes (re-apply / clear) are always LIVE store writes in every
|
|
75
|
+
* regime, with the recovery writes' APPLICATION arm regime-governed (no
|
|
76
|
+
* profile-triggered kill outside fully-live; told out loud).
|
|
77
|
+
*
|
|
78
|
+
* Dependency-injected throughout (ModelSwapService house style): every
|
|
79
|
+
* side-effecting surface is a constructor dep; pure logic
|
|
80
|
+
* (classifyProfileChange, profilesEqual) is imported directly.
|
|
81
|
+
*/
|
|
82
|
+
import fs from 'node:fs';
|
|
83
|
+
import path from 'node:path';
|
|
84
|
+
import { classifyProfileChange, } from './classifyProfileChange.js';
|
|
85
|
+
import { FlushRefusedError, ProfileLockTimeoutError, ProfileValidationRefusal, } from './TopicProfileStore.js';
|
|
86
|
+
import { DegradationReporter } from '../monitoring/DegradationReporter.js';
|
|
87
|
+
const BREAKER_ATTRIBUTABLE = new Set([
|
|
88
|
+
'cli-not-found',
|
|
89
|
+
'launch-arg-rejected',
|
|
90
|
+
'model-rejected-by-account',
|
|
91
|
+
]);
|
|
92
|
+
// ── hardcoded v1 constants (§12.5 — no invented knobs) ──────────────────────
|
|
93
|
+
const RESPAWN_PHASE_TTL_MS = 90_000;
|
|
94
|
+
const LOCK_ACQUIRE_TIMEOUT_MS = 10_000;
|
|
95
|
+
const SUPPRESSION_TTL_MS = 10 * 60_000;
|
|
96
|
+
const DISCLOSURE_RATE_CAP = 4;
|
|
97
|
+
const DISCLOSURE_RATE_WINDOW_MS = 60_000;
|
|
98
|
+
const REAPPLY_COOLDOWN_MS = 10 * 60_000;
|
|
99
|
+
const CONFIRM_ARM_RATE_CAP = 5;
|
|
100
|
+
const CONFIRM_ARM_RATE_WINDOW_MS = 60_000;
|
|
101
|
+
const PROFILE_AXES = ['framework', 'model', 'modelTier', 'escalationOverride', 'thinkingMode'];
|
|
102
|
+
export class TopicProfileOrchestrator {
|
|
103
|
+
deps;
|
|
104
|
+
now;
|
|
105
|
+
pendingSlots = new Map();
|
|
106
|
+
confirmSlots = new Map();
|
|
107
|
+
confirmArmTimes = new Map();
|
|
108
|
+
confirmArmCooldownUntil = new Map();
|
|
109
|
+
/** FIFO respawn queue + global stagger cap (§8). */
|
|
110
|
+
respawnQueue = [];
|
|
111
|
+
inFlightRespawns = 0;
|
|
112
|
+
activeRespawnTopics = new Set();
|
|
113
|
+
drainWakeTimer = null;
|
|
114
|
+
/** Same-cwd codex fence windows (cwd → until epoch ms). */
|
|
115
|
+
codexFenceWindows = new Map();
|
|
116
|
+
/** Per-topic immediate-disclosure timestamps + overflow accumulation. */
|
|
117
|
+
disclosureTimes = new Map();
|
|
118
|
+
overflow = new Map();
|
|
119
|
+
durable = { lastApplied: {}, suppressions: {}, breakerTrips: {} };
|
|
120
|
+
auditSeq = 0;
|
|
121
|
+
lastSeenDryRun = null;
|
|
122
|
+
constructor(deps) {
|
|
123
|
+
this.deps = deps;
|
|
124
|
+
this.now = deps.now ?? (() => Date.now());
|
|
125
|
+
this.loadDurable();
|
|
126
|
+
this.lastSeenDryRun = this.cfg().dryRun;
|
|
127
|
+
}
|
|
128
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
129
|
+
// Write surface entry — regime-gated (§5.2 / §14)
|
|
130
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
131
|
+
/**
|
|
132
|
+
* Apply a validated NEW-axis profile patch under the current regime.
|
|
133
|
+
* - fully-live (`enabled && !dryRun`): live mutate + debounce-armed respawn.
|
|
134
|
+
* - `enabled && dryRun`: shadow intendedProfile write, `[dry-run]` notice —
|
|
135
|
+
* resolution never reads it, the flip never promotes it (§14).
|
|
136
|
+
* - `!enabled`: REFUSED (existing pins stay honored on read) — EXCEPT a
|
|
137
|
+
* clear-only patch, which is a §5.2(b) recovery write, live everywhere.
|
|
138
|
+
* The caller has already run §10.1 identity + §10.2 validation; this method
|
|
139
|
+
* re-enforces the store-side invariants regardless (defense in depth).
|
|
140
|
+
*/
|
|
141
|
+
async requestProfileChange(topicKey, patch, attribution) {
|
|
142
|
+
const key = String(topicKey);
|
|
143
|
+
const cfg = this.cfg();
|
|
144
|
+
this.checkDryRunFlip();
|
|
145
|
+
const clearOnly = isClearOnlyPatch(patch);
|
|
146
|
+
const isSystemWrite = attribution.updatedBy.startsWith('system:');
|
|
147
|
+
if (!cfg.enabled && !clearOnly && !isSystemWrite) {
|
|
148
|
+
const reply = 'the topic-profile feature is not enabled on this agent — existing pins stay honored, but new pins are refused';
|
|
149
|
+
this.audit_({ type: 'write-refused', topic: key, reason: 'disabled', origin: attribution.origin });
|
|
150
|
+
return { outcome: 'refused', reply, reason: 'disabled' };
|
|
151
|
+
}
|
|
152
|
+
if (cfg.enabled && cfg.dryRun && !clearOnly && !isSystemWrite) {
|
|
153
|
+
// §14 shadow write — never silently live.
|
|
154
|
+
await this.deps.store.setShadow(key, patch, attribution.updatedBy);
|
|
155
|
+
const seq = ++this.auditSeq;
|
|
156
|
+
const reply = `[dry-run] would set ${renderPatch(patch)} on this topic — recorded as a dry-run intent (not applied)`;
|
|
157
|
+
this.deps.disclose(key, this.stamp(reply, seq), { allowDuplicate: true, auditSeq: seq });
|
|
158
|
+
this.audit_({ type: 'shadow-recorded', topic: key, seq, patch: renderPatch(patch), origin: attribution.origin });
|
|
159
|
+
return { outcome: 'shadow-recorded', reply, auditSeq: seq };
|
|
160
|
+
}
|
|
161
|
+
return this.applyLiveWrite(key, patch, attribution, { recovery: clearOnly });
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* §5.2(d) — an exempted framework-arm write (the legacy `/route` lane):
|
|
165
|
+
* ALWAYS a live store write regardless of `enabled`/`dryRun`. The legacy
|
|
166
|
+
* path performs its own immediate respawn; NO §8 machinery is engaged here
|
|
167
|
+
* — but under `enabled && dryRun` the §8 orchestration runs in SHADOW,
|
|
168
|
+
* logging the `[dry-run]` decisions it WOULD have made (audit-only, never
|
|
169
|
+
* operator-facing). Returns the disclosure-of-record metadata the legacy
|
|
170
|
+
* reply must carry (audit stamp + dedup bypass + parked-supersession name).
|
|
171
|
+
*/
|
|
172
|
+
async applyExemptFrameworkWrite(topicKey, framework, attribution) {
|
|
173
|
+
const key = String(topicKey);
|
|
174
|
+
const cfg = this.cfg();
|
|
175
|
+
const pre = this.deps.store.resolve(key);
|
|
176
|
+
// The legacy reply is the delta-carrying disclosure-of-record — it
|
|
177
|
+
// anchors the §5.1 undo-snapshot cadence (shift once per disclosure).
|
|
178
|
+
const result = await this.deps.store.mutate(key, { framework, updatedBy: attribution.updatedBy }, { shiftPrevious: true });
|
|
179
|
+
const seq = ++this.auditSeq;
|
|
180
|
+
this.audit_({
|
|
181
|
+
type: 'write-accepted',
|
|
182
|
+
lane: 'legacy-exempt-framework',
|
|
183
|
+
topic: key,
|
|
184
|
+
seq,
|
|
185
|
+
framework,
|
|
186
|
+
origin: attribution.origin,
|
|
187
|
+
supersededParked: result.supersededParked,
|
|
188
|
+
});
|
|
189
|
+
// §14 canary: shadow-observe the would-be §8 decisions against this real
|
|
190
|
+
// traffic — audit/maturation-log only, never an operator message.
|
|
191
|
+
if (cfg.enabled && cfg.dryRun && result.changed) {
|
|
192
|
+
this.shadowObserveSwitch(key, pre, this.deps.store.resolve(key));
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
changed: result.changed,
|
|
196
|
+
auditSeq: seq,
|
|
197
|
+
supersededParked: result.supersededParked,
|
|
198
|
+
supersessionNote: result.supersededParked
|
|
199
|
+
? 'your previously-parked profile pin was superseded by this change'
|
|
200
|
+
: null,
|
|
201
|
+
meta: { allowDuplicate: true, auditSeq: seq },
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* §5.2(b) recovery writes — re-apply a §10.4 parked pin, or clear. LIVE in
|
|
206
|
+
* every regime (never refused as a new pin, never shadowed). The
|
|
207
|
+
* APPLICATION arm is regime-governed: outside fully-live there is NO
|
|
208
|
+
* profile-triggered kill — the write applies at the next natural spawn /
|
|
209
|
+
* boot sweep, and the confirmation says so out loud.
|
|
210
|
+
*/
|
|
211
|
+
async requestRecoveryWrite(topicKey, action, attribution, opts = {}) {
|
|
212
|
+
const key = String(topicKey);
|
|
213
|
+
if (action === 'clear') {
|
|
214
|
+
const live = await this.applyLiveWrite(key, { framework: null, model: null, modelTier: null, thinkingMode: null, escalationOverride: null }, attribution, { recovery: true });
|
|
215
|
+
if (live.outcome === 'refused')
|
|
216
|
+
return { outcome: 'refused', reply: live.reply, reason: live.reason };
|
|
217
|
+
return { outcome: 'applied', reply: live.reply };
|
|
218
|
+
}
|
|
219
|
+
// re-apply
|
|
220
|
+
const parked = this.deps.store.parkedFor(key);
|
|
221
|
+
if (!parked) {
|
|
222
|
+
const reply = "nothing parked — you've since set a new profile (or none ever parked here)";
|
|
223
|
+
this.audit_({ type: 'reapply-refused', topic: key, reason: 'nothing-parked' });
|
|
224
|
+
return { outcome: 'refused', reply, reason: 'nothing-parked' };
|
|
225
|
+
}
|
|
226
|
+
// §10.4 cooldown guard: re-applying the SAME profile that just tripped
|
|
227
|
+
// needs the consequence stated + an explicit confirm.
|
|
228
|
+
const trip = this.durable.breakerTrips[key];
|
|
229
|
+
const parkedKey = profileKeyOf(parked.profile);
|
|
230
|
+
const withinCooldown = trip && trip.profileKey === parkedKey && this.now() - Date.parse(trip.at) < REAPPLY_COOLDOWN_MS;
|
|
231
|
+
if (withinCooldown && !opts.confirmed) {
|
|
232
|
+
const n = this.cfg().spawnFailureBreakerThreshold;
|
|
233
|
+
const echo = `this exact profile failed ${n} times a few minutes ago — apply it anyway?`;
|
|
234
|
+
this.armConfirm(key, 'cooldown-reapply', echo, async () => {
|
|
235
|
+
const r = await this.requestRecoveryWrite(key, 'reapply', attribution, { confirmed: true });
|
|
236
|
+
return r.reply;
|
|
237
|
+
});
|
|
238
|
+
this.audit_({ type: 'reapply-cooldown-confirm-armed', topic: key });
|
|
239
|
+
return { outcome: 'confirm-required', reply: echo };
|
|
240
|
+
}
|
|
241
|
+
const patch = {
|
|
242
|
+
framework: parked.profile.framework ?? null,
|
|
243
|
+
model: parked.profile.model ?? null,
|
|
244
|
+
modelTier: parked.profile.modelTier ?? null,
|
|
245
|
+
thinkingMode: parked.profile.thinkingMode ?? null,
|
|
246
|
+
escalationOverride: parked.profile.escalationOverride ?? null,
|
|
247
|
+
};
|
|
248
|
+
const live = await this.applyLiveWrite(key, patch, attribution, {
|
|
249
|
+
recovery: true,
|
|
250
|
+
reapplyOfParked: true,
|
|
251
|
+
confirmedOverCooldown: Boolean(withinCooldown && opts.confirmed),
|
|
252
|
+
});
|
|
253
|
+
if (live.outcome === 'refused')
|
|
254
|
+
return { outcome: 'refused', reply: live.reply, reason: live.reason };
|
|
255
|
+
await this.deps.store.clearParked(key);
|
|
256
|
+
return { outcome: 'applied', reply: live.reply };
|
|
257
|
+
}
|
|
258
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
259
|
+
// WRITE phase (§8 phase 1)
|
|
260
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
261
|
+
async applyLiveWrite(key, patch, attribution, flags = {}) {
|
|
262
|
+
const cfg = this.cfg();
|
|
263
|
+
const fullyLive = cfg.enabled && !cfg.dryRun;
|
|
264
|
+
const slot = this.pendingSlots.get(key);
|
|
265
|
+
const overflowActive = this.overflow.has(key);
|
|
266
|
+
// §5.1 undo cadence: `previous` shifts ONCE per delta-carrying disclosure
|
|
267
|
+
// — at the FIRST write of an active coalescing window or rate-cap
|
|
268
|
+
// overflow period (no slot + no overflow ⇒ this write OPENS a disclosed
|
|
269
|
+
// burst → shift; either active ⇒ this write coalesces into the
|
|
270
|
+
// already-shifted burst → no shift). Undo always restores the profile
|
|
271
|
+
// the operator last saw disclosed (R7-4).
|
|
272
|
+
const shiftPrevious = !slot && !overflowActive;
|
|
273
|
+
const preBurst = shiftPrevious ? this.deps.store.resolve(key) : null;
|
|
274
|
+
let result;
|
|
275
|
+
try {
|
|
276
|
+
result = await this.deps.store.mutate(key, { ...patch, updatedBy: attribution.updatedBy }, {
|
|
277
|
+
shiftPrevious: Boolean(shiftPrevious),
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
if (err instanceof ProfileValidationRefusal) {
|
|
282
|
+
this.audit_({
|
|
283
|
+
type: 'write-refused',
|
|
284
|
+
topic: key,
|
|
285
|
+
reason: err.validation.failure,
|
|
286
|
+
field: err.validation.field,
|
|
287
|
+
origin: attribution.origin,
|
|
288
|
+
});
|
|
289
|
+
return { outcome: 'refused', reply: err.validation.reason, reason: err.validation.failure };
|
|
290
|
+
}
|
|
291
|
+
if (err instanceof ProfileLockTimeoutError) {
|
|
292
|
+
// §8 — a spoken refusal, never a silent drop.
|
|
293
|
+
const reply = "couldn't apply — this topic's session is mid-restart; say it again in a minute";
|
|
294
|
+
this.audit_({ type: 'write-refused', topic: key, reason: 'lock-timeout', origin: attribution.origin });
|
|
295
|
+
return { outcome: 'refused', reply, reason: 'lock-timeout' };
|
|
296
|
+
}
|
|
297
|
+
if (err instanceof FlushRefusedError) {
|
|
298
|
+
// §5.1 — durability precedes acknowledgment; the store rolled back.
|
|
299
|
+
const reply = "couldn't save that change durably — nothing was applied; please try again";
|
|
300
|
+
this.audit_({ type: 'write-refused', topic: key, reason: 'flush-failed', origin: attribution.origin });
|
|
301
|
+
return { outcome: 'refused', reply, reason: 'flush-failed' };
|
|
302
|
+
}
|
|
303
|
+
throw err;
|
|
304
|
+
}
|
|
305
|
+
if (!result.changed) {
|
|
306
|
+
return { outcome: 'no-change', reply: 'already set — no change needed' };
|
|
307
|
+
}
|
|
308
|
+
const seq = ++this.auditSeq;
|
|
309
|
+
this.audit_({
|
|
310
|
+
type: flags.reapplyOfParked ? 'reapply-accepted' : 'write-accepted',
|
|
311
|
+
topic: key,
|
|
312
|
+
seq,
|
|
313
|
+
patch: renderPatch(patch),
|
|
314
|
+
origin: attribution.origin,
|
|
315
|
+
recovery: Boolean(flags.recovery),
|
|
316
|
+
confirmedOverCooldown: Boolean(flags.confirmedOverCooldown),
|
|
317
|
+
supersededParked: result.supersededParked,
|
|
318
|
+
});
|
|
319
|
+
// ── application arm ──────────────────────────────────────────────────
|
|
320
|
+
const session = this.deps.sessions.getSessionForTopic(key);
|
|
321
|
+
const resolvedNow = this.deps.resolveProfile(key);
|
|
322
|
+
let reply;
|
|
323
|
+
if (!fullyLive) {
|
|
324
|
+
// §5.2(b): recovery (and system) writes apply with NO profile-triggered
|
|
325
|
+
// kill outside the fully-live regime — told out loud.
|
|
326
|
+
reply = flags.reapplyOfParked
|
|
327
|
+
? "re-applied — takes effect at this topic's next session restart"
|
|
328
|
+
: `${renderPatch(patch)} saved — takes effect at this topic's next session restart`;
|
|
329
|
+
this.discloseWrite(key, reply, seq, attribution, { slotActive: false });
|
|
330
|
+
return { outcome: 'applied', reply, auditSeq: seq, supersededParked: result.supersededParked };
|
|
331
|
+
}
|
|
332
|
+
if (!session) {
|
|
333
|
+
reply = `${renderPatch(patch)} pinned — applies at this topic's next session start`;
|
|
334
|
+
this.discloseWrite(key, reply, seq, attribution, { slotActive: Boolean(slot) });
|
|
335
|
+
return { outcome: 'applied', reply, auditSeq: seq, supersededParked: result.supersededParked };
|
|
336
|
+
}
|
|
337
|
+
// Preview classification (window choice + busy wording ONLY — idle and
|
|
338
|
+
// everything else re-confirms at dequeue inside the lock).
|
|
339
|
+
const lastApplied = this.durable.lastApplied[key]?.profile ?? null;
|
|
340
|
+
const preview = classifyProfileChange(appliedToPseudo(lastApplied) ?? this.deps.store.previousFor(key), resolvedToPseudo(resolvedNow), this.sessionState(key, session));
|
|
341
|
+
if (preview.swapMethod === 'none') {
|
|
342
|
+
// A write landing during an ACTIVE window still coalesces into the
|
|
343
|
+
// pending respawn (§8) — e.g. the toggle-back leg of a net-unchanged
|
|
344
|
+
// sequence classifies 'none' against the live characteristics, but the
|
|
345
|
+
// dequeue-time re-resolution (and the net-unchanged loop-closing
|
|
346
|
+
// disclosure) must count it.
|
|
347
|
+
if (this.pendingSlots.has(key)) {
|
|
348
|
+
this.armRespawn(key, cfg.respawnDebounceMs, {
|
|
349
|
+
frameworkArm: false,
|
|
350
|
+
preBurst,
|
|
351
|
+
origin: attribution.origin,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
reply = `${renderPatch(patch)} set${result.supersededParked ? ' (your parked pin was superseded)' : ''} — ${preview.reason}`;
|
|
355
|
+
this.discloseWrite(key, reply, seq, attribution, { slotActive: Boolean(slot) });
|
|
356
|
+
return { outcome: 'applied', reply, auditSeq: seq, supersededParked: result.supersededParked };
|
|
357
|
+
}
|
|
358
|
+
const frameworkArm = preview.changedFields.includes('framework');
|
|
359
|
+
const windowMs = frameworkArm ? cfg.frameworkSwitchDebounceMs : cfg.respawnDebounceMs;
|
|
360
|
+
if (preview.refuseOrConfirm && preview.protectedDeferral) {
|
|
361
|
+
reply =
|
|
362
|
+
'this session is protected — unprotect it first, or the switch applies at the next natural restart';
|
|
363
|
+
}
|
|
364
|
+
else if (preview.refuseOrConfirm) {
|
|
365
|
+
// §8 busy framework switch — refuse-or-confirm + arm "switch now".
|
|
366
|
+
reply =
|
|
367
|
+
"This topic is mid-task right now — switching frameworks would interrupt the running build and lose its in-flight work. I'll apply the switch the moment it goes idle, or say 'switch now' to interrupt.";
|
|
368
|
+
this.armConfirm(key, 'switch-now', reply, async () => this.executeSwitchNow(key));
|
|
369
|
+
}
|
|
370
|
+
else if (preview.protectedDeferral) {
|
|
371
|
+
reply =
|
|
372
|
+
'this session is protected — unprotect it first, or the change applies at the next natural restart';
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
reply = `${renderPatch(patch)} pinned — applying in ~${Math.round(windowMs / 1000)}s`;
|
|
376
|
+
}
|
|
377
|
+
if (result.supersededParked)
|
|
378
|
+
reply += ' (your parked pin was superseded by this change)';
|
|
379
|
+
this.armRespawn(key, windowMs, { frameworkArm, preBurst, origin: attribution.origin });
|
|
380
|
+
this.discloseWrite(key, reply, seq, attribution, { slotActive: Boolean(slot) });
|
|
381
|
+
return { outcome: 'applied', reply, auditSeq: seq, supersededParked: result.supersededParked };
|
|
382
|
+
}
|
|
383
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
384
|
+
// Debounce slot + RESPAWN phase (§8 phase 2)
|
|
385
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
386
|
+
/**
|
|
387
|
+
* §8 write-surface seam (ProfileOrchestratorLike). Called by the write
|
|
388
|
+
* surface AFTER it has performed the live store write in the fully-live
|
|
389
|
+
* regime — the surface owns the mutate, the orchestrator owns the debounced,
|
|
390
|
+
* idle-gated respawn. Re-resolves nothing here: it arms the per-topic
|
|
391
|
+
* debounce window (heavier window when the framework axis changed) and the
|
|
392
|
+
* trailing-edge fire enqueues the §8 respawn. A no-op when the orchestrator
|
|
393
|
+
* is not in the fully-live regime (the surface only calls this when fully-live,
|
|
394
|
+
* but the guard keeps the seam safe under a regime flip mid-burst).
|
|
395
|
+
*/
|
|
396
|
+
onProfileWrite(topicKey, info) {
|
|
397
|
+
const key = String(topicKey);
|
|
398
|
+
const cfg = this.cfg();
|
|
399
|
+
if (!cfg.enabled || cfg.dryRun)
|
|
400
|
+
return;
|
|
401
|
+
const slot = this.pendingSlots.get(key);
|
|
402
|
+
// Capture the live characteristics at burst start so a net-unchanged burst
|
|
403
|
+
// closes the loop with the "was:" rendering (§8 debounce contract).
|
|
404
|
+
const preBurst = slot ? slot.preBurst : this.deps.store.resolve(key);
|
|
405
|
+
this.armRespawn(key, cfg.respawnDebounceMs, {
|
|
406
|
+
frameworkArm: info.frameworkChanged,
|
|
407
|
+
preBurst,
|
|
408
|
+
origin: info.origin,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
armRespawn(key, windowMs, info) {
|
|
412
|
+
let slot = this.pendingSlots.get(key);
|
|
413
|
+
if (!slot) {
|
|
414
|
+
slot = {
|
|
415
|
+
timer: null,
|
|
416
|
+
preBurst: info.preBurst,
|
|
417
|
+
changeCount: 0,
|
|
418
|
+
origins: new Set(),
|
|
419
|
+
frameworkArm: false,
|
|
420
|
+
deferred: false,
|
|
421
|
+
forceNoInFlight: false,
|
|
422
|
+
disclosedDefer: new Set(),
|
|
423
|
+
switchNowOverride: false,
|
|
424
|
+
};
|
|
425
|
+
this.pendingSlots.set(key, slot);
|
|
426
|
+
}
|
|
427
|
+
slot.changeCount += 1;
|
|
428
|
+
slot.origins.add(info.origin);
|
|
429
|
+
slot.frameworkArm = slot.frameworkArm || info.frameworkArm;
|
|
430
|
+
slot.deferred = false;
|
|
431
|
+
// Trailing edge: each write extends the window; a burst containing a
|
|
432
|
+
// framework switch uses the heavier window.
|
|
433
|
+
const effectiveWindow = slot.frameworkArm
|
|
434
|
+
? Math.max(windowMs, this.cfg().frameworkSwitchDebounceMs)
|
|
435
|
+
: windowMs;
|
|
436
|
+
if (slot.timer)
|
|
437
|
+
clearTimeout(slot.timer);
|
|
438
|
+
slot.timer = setTimeout(() => this.onDebounceFire(key), effectiveWindow);
|
|
439
|
+
slot.timer.unref?.();
|
|
440
|
+
}
|
|
441
|
+
onDebounceFire(key) {
|
|
442
|
+
const slot = this.pendingSlots.get(key);
|
|
443
|
+
if (slot)
|
|
444
|
+
slot.timer = null;
|
|
445
|
+
this.enqueueRespawn({
|
|
446
|
+
topicKey: key,
|
|
447
|
+
breakerRevert: false,
|
|
448
|
+
switchNowOverride: slot?.switchNowOverride ?? false,
|
|
449
|
+
enqueuedAt: this.now(),
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
enqueueRespawn(task) {
|
|
453
|
+
this.respawnQueue.push(task);
|
|
454
|
+
void this.drainQueue();
|
|
455
|
+
}
|
|
456
|
+
/** Global stagger: max K respawns in flight; FIFO; codex same-cwd serialized. */
|
|
457
|
+
async drainQueue() {
|
|
458
|
+
const cfg = this.cfg();
|
|
459
|
+
while (this.inFlightRespawns < cfg.maxConcurrentProfileRespawns && this.respawnQueue.length > 0) {
|
|
460
|
+
const task = this.respawnQueue.shift();
|
|
461
|
+
if (this.activeRespawnTopics.has(task.topicKey)) {
|
|
462
|
+
// Same topic already executing — its terminal re-resolves; drop.
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
// Same-cwd codex fence serialization (§7/§8): never spawn two codex
|
|
466
|
+
// sessions inside one fence window — bounded by the RESPAWN-phase TTL.
|
|
467
|
+
const blockedUntil = this.codexBlockedUntil(task.topicKey);
|
|
468
|
+
if (blockedUntil !== null) {
|
|
469
|
+
task.firstBlockedAt = task.firstBlockedAt ?? this.now();
|
|
470
|
+
if (this.now() - task.firstBlockedAt < RESPAWN_PHASE_TTL_MS) {
|
|
471
|
+
this.respawnQueue.push(task);
|
|
472
|
+
this.scheduleDrainWake(blockedUntil);
|
|
473
|
+
// Nothing else can run this pass if the only tasks are blocked —
|
|
474
|
+
// avoid a hot spin by breaking when the queue is all-blocked.
|
|
475
|
+
if (this.respawnQueue.every((t) => this.codexBlockedUntil(t.topicKey) !== null))
|
|
476
|
+
break;
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
// Timed out waiting — proceed; a multi-candidate fence degrades
|
|
480
|
+
// honestly per the zero-or-one rule (never a blind capture).
|
|
481
|
+
this.audit_({ type: 'codex-fence-wait-timeout', topic: task.topicKey });
|
|
482
|
+
}
|
|
483
|
+
this.inFlightRespawns += 1;
|
|
484
|
+
this.activeRespawnTopics.add(task.topicKey);
|
|
485
|
+
void this.executeRespawn(task)
|
|
486
|
+
.catch((err) => {
|
|
487
|
+
// @silent-fallback-ok: not silent — the failure is recorded to the
|
|
488
|
+
// durable orchestrator audit trail as type:'respawn-error', and the
|
|
489
|
+
// finally below releases the in-flight slot so the queue drains.
|
|
490
|
+
this.audit_({ type: 'respawn-error', topic: task.topicKey, error: String(err) });
|
|
491
|
+
})
|
|
492
|
+
.finally(() => {
|
|
493
|
+
this.inFlightRespawns -= 1;
|
|
494
|
+
this.activeRespawnTopics.delete(task.topicKey);
|
|
495
|
+
void this.drainQueue();
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
codexBlockedUntil(topicKey) {
|
|
500
|
+
const resolved = this.deps.resolveProfile(topicKey);
|
|
501
|
+
if (resolved.framework !== 'codex-cli')
|
|
502
|
+
return null;
|
|
503
|
+
const session = this.deps.sessions.getSessionForTopic(topicKey);
|
|
504
|
+
const cwd = session?.cwd;
|
|
505
|
+
if (!cwd)
|
|
506
|
+
return null;
|
|
507
|
+
const until = this.codexFenceWindows.get(cwd);
|
|
508
|
+
if (until == null || until <= this.now()) {
|
|
509
|
+
if (until != null)
|
|
510
|
+
this.codexFenceWindows.delete(cwd);
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
return until;
|
|
514
|
+
}
|
|
515
|
+
scheduleDrainWake(at) {
|
|
516
|
+
if (this.drainWakeTimer)
|
|
517
|
+
return;
|
|
518
|
+
const delay = Math.max(50, at - this.now());
|
|
519
|
+
this.drainWakeTimer = setTimeout(() => {
|
|
520
|
+
this.drainWakeTimer = null;
|
|
521
|
+
void this.drainQueue();
|
|
522
|
+
}, delay);
|
|
523
|
+
this.drainWakeTimer.unref?.();
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* RESPAWN phase — re-acquire the per-topic lock, re-resolve AT THIS MOMENT,
|
|
527
|
+
* re-confirm idle, then [kill → respawn]. TTL-bounded: a wedged kill/spawn
|
|
528
|
+
* aborts the phase and leaves the divergence to the reconcile sweep.
|
|
529
|
+
*/
|
|
530
|
+
async executeRespawn(task) {
|
|
531
|
+
const key = task.topicKey;
|
|
532
|
+
try {
|
|
533
|
+
await this.deps.store.withTopicLock(key, async () => {
|
|
534
|
+
await withTimeout(this.respawnPhase(task), RESPAWN_PHASE_TTL_MS, () => this.audit_({ type: 'respawn-ttl-abort', topic: key }));
|
|
535
|
+
}, LOCK_ACQUIRE_TIMEOUT_MS);
|
|
536
|
+
}
|
|
537
|
+
catch (err) {
|
|
538
|
+
if (err instanceof ProfileLockTimeoutError) {
|
|
539
|
+
this.audit_({ type: 'respawn-lock-timeout', topic: key });
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
throw err;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
async respawnPhase(task) {
|
|
546
|
+
const key = task.topicKey;
|
|
547
|
+
const slot = this.pendingSlots.get(key);
|
|
548
|
+
// Re-resolve from the cache AT THIS MOMENT (§8 dequeue-time resolution).
|
|
549
|
+
const resolved = this.deps.resolveProfile(key);
|
|
550
|
+
const lastApplied = this.durable.lastApplied[key]?.profile ?? null;
|
|
551
|
+
// Expected-live = resolved baseline ⊕ any active escalation marker (§9 —
|
|
552
|
+
// a session legitimately on the escalated model under `inherit` is never
|
|
553
|
+
// read as divergence).
|
|
554
|
+
if (this.matchesExpectedLive(key, lastApplied, resolved)) {
|
|
555
|
+
if (slot && slot.changeCount > 1 && this.pseudoEquals(slot.preBurst, resolvedToPseudo(resolved))) {
|
|
556
|
+
// Net-unchanged teardown closes its loop out loud (§8).
|
|
557
|
+
const seq = ++this.auditSeq;
|
|
558
|
+
this.deps.disclose(key, this.stamp("you're back where you started — no restart needed", seq), {
|
|
559
|
+
allowDuplicate: true,
|
|
560
|
+
auditSeq: seq,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
this.audit_({ type: 'respawn-skipped', topic: key, reason: 'already-applied' });
|
|
564
|
+
this.teardownSlot(key);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const session = this.deps.sessions.getSessionForTopic(key);
|
|
568
|
+
if (!session) {
|
|
569
|
+
// Session gone/replaced — abort spawn-only; the next natural spawn
|
|
570
|
+
// reconciles (the boot sweep is the backstop). §8 round-3 (c).
|
|
571
|
+
this.audit_({ type: 'respawn-skipped', topic: key, reason: 'session-gone' });
|
|
572
|
+
this.teardownSlot(key);
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
const state = this.sessionState(key, session);
|
|
576
|
+
// §14 unconfirmed-attempt choreography: after an in-flight swap returned
|
|
577
|
+
// 'unconfirmed' we never guess again — reclassify with the in-flight row
|
|
578
|
+
// unavailable so the classifier yields the kill+--resume fallback.
|
|
579
|
+
const effectiveState = slot?.forceNoInFlight
|
|
580
|
+
? { ...state, inFlightSwapConfirmedRecently: false }
|
|
581
|
+
: state;
|
|
582
|
+
const classification = classifyProfileChange(appliedToPseudo(lastApplied), resolvedToPseudo(resolved), effectiveState);
|
|
583
|
+
if (classification.swapMethod === 'none') {
|
|
584
|
+
this.audit_({ type: 'respawn-skipped', topic: key, reason: classification.reason });
|
|
585
|
+
this.teardownSlot(key);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
// Protected sessions are never profile-killed; "switch now" NEVER
|
|
589
|
+
// overrides protection (§8 round-5) — the in-flight (no-kill) row also
|
|
590
|
+
// defers on a protected session, mirroring FABLE's refusal.
|
|
591
|
+
if (classification.protectedDeferral) {
|
|
592
|
+
this.deferSlot(key, 'protected', () => 'this session is protected — unprotect it first, or the switch applies at the next natural restart');
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
const switchNow = task.switchNowOverride || slot?.switchNowOverride === true;
|
|
596
|
+
// §7 in-flight row (Claude modelTier, confirmed-idle, canary passed).
|
|
597
|
+
if (classification.swapMethod === 'in-flight') {
|
|
598
|
+
const tier = resolved.modelTier ?? 'default';
|
|
599
|
+
const swap = await this.deps.inFlightSwap.swap(session.sessionName, tier);
|
|
600
|
+
if (swap.status === 'swapped' || swap.status === 'noop') {
|
|
601
|
+
this.recordApplied(key, resolvedToApplied(resolved));
|
|
602
|
+
this.audit_({ type: 'in-flight-applied', topic: key, tier, status: swap.status });
|
|
603
|
+
this.discloseTerminal(key, slot, resolved, classification.swapMethod, null);
|
|
604
|
+
this.teardownSlot(key);
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
// Unconfirmed/refused: do not guess — the next confirmed-idle window
|
|
608
|
+
// applies the kill+--resume fallback, disclosed (§14 choreography).
|
|
609
|
+
if (slot)
|
|
610
|
+
slot.forceNoInFlight = true;
|
|
611
|
+
this.deferSlot(key, `in-flight-${swap.status}`, () => null);
|
|
612
|
+
this.audit_({ type: 'in-flight-deferred', topic: key, status: swap.status, reason: swap.reason });
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
// Idle is a precondition re-checked AT KILL TIME inside the lock —
|
|
616
|
+
// unconfirmed is treated as busy (defer), never permission to kill. An
|
|
617
|
+
// active autonomous run is busy regardless of pane state. Only the
|
|
618
|
+
// explicit "switch now" confirm overrides busy/autonomous — never
|
|
619
|
+
// protection (checked above).
|
|
620
|
+
if (classification.deferUntilIdle && !switchNow) {
|
|
621
|
+
this.deferSlot(key, 'busy', () => classification.refuseOrConfirm
|
|
622
|
+
? "This topic is mid-task right now — switching frameworks would interrupt the running build and lose its in-flight work. I'll apply the switch the moment it goes idle, or say 'switch now' to interrupt."
|
|
623
|
+
: "this topic is mid-task — I'll apply it when this task finishes (checked periodically) or at the next session restart, whichever comes first");
|
|
624
|
+
if (classification.refuseOrConfirm && !this.confirmSlots.has(key)) {
|
|
625
|
+
this.armConfirm(key, 'switch-now', 'switch now to interrupt the running task', async () => this.executeSwitchNow(key));
|
|
626
|
+
}
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
// ── kill → respawn ────────────────────────────────────────────────────
|
|
630
|
+
// Any profile-triggered kill clears the topic's escalation marker and
|
|
631
|
+
// releases its lease BEFORE expected-live is next computed (§8 round-5).
|
|
632
|
+
this.deps.escalation.clearMarkerAndReleaseLease(key);
|
|
633
|
+
let method = classification.swapMethod;
|
|
634
|
+
let resumeId;
|
|
635
|
+
let lossNote = null;
|
|
636
|
+
let fresh = classification.freshRespawn;
|
|
637
|
+
const oldFramework = (lastApplied?.framework ?? 'claude-code');
|
|
638
|
+
if (method === 'resume' && oldFramework === 'codex-cli') {
|
|
639
|
+
// §7 codex capture-at-kill against the spawn fence — zero-or-one.
|
|
640
|
+
const fence = this.deps.codexFence(key);
|
|
641
|
+
let captured = { outcome: 'none', candidateCount: 0 };
|
|
642
|
+
if (fence) {
|
|
643
|
+
captured = await this.deps.codexResume.captureAtKill(key, session.sessionName, fence);
|
|
644
|
+
}
|
|
645
|
+
if (captured.outcome === 'captured' && captured.rolloutId) {
|
|
646
|
+
resumeId = captured.rolloutId;
|
|
647
|
+
}
|
|
648
|
+
else {
|
|
649
|
+
const existing = this.deps.codexResume.get(key);
|
|
650
|
+
if (existing) {
|
|
651
|
+
resumeId = existing;
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
method = 'continuation';
|
|
655
|
+
fresh = true;
|
|
656
|
+
lossNote =
|
|
657
|
+
"couldn't pin this codex conversation's resume id — continuing with recent history + memory";
|
|
658
|
+
this.audit_({ type: 'codex-capture-degraded', topic: key, outcome: captured.outcome });
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
else if (method === 'resume') {
|
|
663
|
+
// §8 pre-kill verification: the resume entry must exist (hook
|
|
664
|
+
// provenance) BEFORE the kill — absent ⇒ disclose the real loss class
|
|
665
|
+
// up front instead of promising none-loss we cannot deliver.
|
|
666
|
+
resumeId = this.deps.claudeResume.resumeId(key) ?? undefined;
|
|
667
|
+
if (!resumeId && !this.deps.claudeResume.ready(key)) {
|
|
668
|
+
method = 'continuation';
|
|
669
|
+
fresh = true;
|
|
670
|
+
lossNote =
|
|
671
|
+
'no resumable transcript id was captured for this session — continuing with recent history + memory';
|
|
672
|
+
this.audit_({ type: 'claude-resume-degraded', topic: key });
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
if (fresh) {
|
|
676
|
+
// Park BOTH resume stores' entries BEFORE the kill (park, not delete —
|
|
677
|
+
// §8 round-5) and set the topic-scoped, time-bounded durable
|
|
678
|
+
// suppression marker so no save-on-kill writer re-persists a stale id.
|
|
679
|
+
this.deps.claudeResume.park(key, 'mid-framework-switch');
|
|
680
|
+
this.deps.codexResume.park(key, 'mid-framework-switch');
|
|
681
|
+
this.setSuppression(key);
|
|
682
|
+
await this.deps.sessions.killFresh(session.sessionName);
|
|
683
|
+
}
|
|
684
|
+
else {
|
|
685
|
+
await this.deps.sessions.killForResume(session.sessionName);
|
|
686
|
+
}
|
|
687
|
+
const outcome = await this.deps.sessions.spawn(key, resolved, { method, resumeId });
|
|
688
|
+
if (outcome.ok) {
|
|
689
|
+
this.recordApplied(key, resolvedToApplied(resolved));
|
|
690
|
+
this.clearSuppression(key);
|
|
691
|
+
if (resolved.framework === 'codex-cli') {
|
|
692
|
+
const cwd = this.deps.sessions.getSessionForTopic(key)?.cwd ?? session.cwd;
|
|
693
|
+
this.codexFenceWindows.set(cwd, this.now() + RESPAWN_PHASE_TTL_MS);
|
|
694
|
+
}
|
|
695
|
+
this.discloseTerminal(key, slot, resolved, method, lossNote);
|
|
696
|
+
this.audit_({ type: 'respawn-applied', topic: key, method, fresh, breakerRevert: task.breakerRevert });
|
|
697
|
+
}
|
|
698
|
+
else {
|
|
699
|
+
this.recordSpawnFailureInternal(key, outcome.failureClass ?? 'unknown');
|
|
700
|
+
this.audit_({ type: 'respawn-spawn-failed', topic: key, failureClass: outcome.failureClass ?? 'unknown' });
|
|
701
|
+
}
|
|
702
|
+
this.teardownSlot(key);
|
|
703
|
+
}
|
|
704
|
+
deferSlot(key, reason, text) {
|
|
705
|
+
let slot = this.pendingSlots.get(key);
|
|
706
|
+
if (!slot) {
|
|
707
|
+
// A boot-sweep/breaker task can defer with no prior write slot.
|
|
708
|
+
slot = {
|
|
709
|
+
timer: null,
|
|
710
|
+
preBurst: null,
|
|
711
|
+
changeCount: 1,
|
|
712
|
+
origins: new Set(['system']),
|
|
713
|
+
frameworkArm: false,
|
|
714
|
+
deferred: false,
|
|
715
|
+
forceNoInFlight: false,
|
|
716
|
+
disclosedDefer: new Set(),
|
|
717
|
+
switchNowOverride: false,
|
|
718
|
+
};
|
|
719
|
+
this.pendingSlots.set(key, slot);
|
|
720
|
+
}
|
|
721
|
+
slot.deferred = true;
|
|
722
|
+
slot.deferredReason = reason;
|
|
723
|
+
const t = text();
|
|
724
|
+
if (t && !slot.disclosedDefer.has(reason)) {
|
|
725
|
+
slot.disclosedDefer.add(reason);
|
|
726
|
+
const seq = ++this.auditSeq;
|
|
727
|
+
this.deps.disclose(key, this.stamp(t, seq), { allowDuplicate: true, auditSeq: seq });
|
|
728
|
+
}
|
|
729
|
+
this.audit_({ type: 'respawn-deferred', topic: key, reason });
|
|
730
|
+
}
|
|
731
|
+
teardownSlot(key) {
|
|
732
|
+
const slot = this.pendingSlots.get(key);
|
|
733
|
+
if (slot?.timer)
|
|
734
|
+
clearTimeout(slot.timer);
|
|
735
|
+
this.pendingSlots.delete(key);
|
|
736
|
+
// A confirm armed for this pending change expires with the slot.
|
|
737
|
+
const confirm = this.confirmSlots.get(key);
|
|
738
|
+
if (confirm?.kind === 'switch-now')
|
|
739
|
+
this.confirmSlots.delete(key);
|
|
740
|
+
}
|
|
741
|
+
/** Terminal disclosure: coalesced burst delta + the honest swap line. */
|
|
742
|
+
discloseTerminal(key, slot, resolved, method, lossNote) {
|
|
743
|
+
const parts = [];
|
|
744
|
+
if (slot && slot.changeCount > 1) {
|
|
745
|
+
const was = renderProfileShort(slot.preBurst);
|
|
746
|
+
const now = renderProfileShort(resolvedToPseudo(resolved));
|
|
747
|
+
parts.push(`was: ${was} → now: ${now} (${slot.changeCount} changes, origins: ${[...slot.origins].join(', ')})`);
|
|
748
|
+
}
|
|
749
|
+
if (method === 'continuation') {
|
|
750
|
+
parts.push(`Switching this topic to ${resolved.framework}. The full transcript can't follow across that boundary, so I'm carrying recent history + memory — continuing from there.`);
|
|
751
|
+
}
|
|
752
|
+
if (lossNote)
|
|
753
|
+
parts.push(lossNote);
|
|
754
|
+
if (parts.length === 0)
|
|
755
|
+
return;
|
|
756
|
+
const seq = ++this.auditSeq;
|
|
757
|
+
this.deps.disclose(key, this.stamp(parts.join(' '), seq), { allowDuplicate: true, auditSeq: seq });
|
|
758
|
+
}
|
|
759
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
760
|
+
// "switch now" / confirm slot (§8 / §10.1(c) / §10.4 — ONE armed slot)
|
|
761
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
762
|
+
/**
|
|
763
|
+
* Arm a confirm. All three confirm surfaces (switch-now, propose-confirm,
|
|
764
|
+
* cooldown-reapply) share THIS one slot per topic: arming supersedes the
|
|
765
|
+
* prior armed confirm (the §10.1(c) re-echo discipline — a bare "yes" can
|
|
766
|
+
* only ever fire the most-recently-echoed confirm). Re-proposals are
|
|
767
|
+
* rate-bounded; churn past the bound tears the slot down for a cooldown
|
|
768
|
+
* and is audited as a suspicion signal.
|
|
769
|
+
*/
|
|
770
|
+
armConfirm(topicKey, kind, echo, run) {
|
|
771
|
+
const key = String(topicKey);
|
|
772
|
+
const now = this.now();
|
|
773
|
+
const cooldownUntil = this.confirmArmCooldownUntil.get(key) ?? 0;
|
|
774
|
+
if (now < cooldownUntil) {
|
|
775
|
+
this.audit_({ type: 'confirm-arm-refused', topic: key, reason: 'arm-rate-cooldown' });
|
|
776
|
+
return false;
|
|
777
|
+
}
|
|
778
|
+
const times = (this.confirmArmTimes.get(key) ?? []).filter((t) => now - t < CONFIRM_ARM_RATE_WINDOW_MS);
|
|
779
|
+
times.push(now);
|
|
780
|
+
this.confirmArmTimes.set(key, times);
|
|
781
|
+
if (times.length > CONFIRM_ARM_RATE_CAP) {
|
|
782
|
+
this.confirmSlots.delete(key);
|
|
783
|
+
this.confirmArmCooldownUntil.set(key, now + CONFIRM_ARM_RATE_WINDOW_MS);
|
|
784
|
+
this.audit_({ type: 'confirm-arm-churn', topic: key, suspicion: true });
|
|
785
|
+
return false;
|
|
786
|
+
}
|
|
787
|
+
const prior = this.confirmSlots.get(key);
|
|
788
|
+
if (prior)
|
|
789
|
+
this.audit_({ type: 'confirm-superseded', topic: key, prior: prior.kind, next: kind });
|
|
790
|
+
this.confirmSlots.set(key, {
|
|
791
|
+
kind,
|
|
792
|
+
echo,
|
|
793
|
+
armedAt: now,
|
|
794
|
+
expiresAt: now + this.cfg().switchNowConfirmTtlMs,
|
|
795
|
+
run,
|
|
796
|
+
});
|
|
797
|
+
this.audit_({ type: 'confirm-armed', topic: key, kind });
|
|
798
|
+
return true;
|
|
799
|
+
}
|
|
800
|
+
/** Record the platform message id of the rendered echo (§10.1 ordering). */
|
|
801
|
+
attachConfirmEchoMessageId(topicKey, messageId) {
|
|
802
|
+
const slot = this.confirmSlots.get(String(topicKey));
|
|
803
|
+
if (slot)
|
|
804
|
+
slot.echoMessageId = messageId;
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Fire the armed confirm. The caller (server-side ingress parse) has
|
|
808
|
+
* already established first-party origin + forward-metadata exclusion;
|
|
809
|
+
* this enforces TTL, supersession, and the platform-message-id ordering
|
|
810
|
+
* when ids are available (a confirm answering a superseded/older echo is
|
|
811
|
+
* refused toward re-echo).
|
|
812
|
+
*/
|
|
813
|
+
async fireConfirm(topicKey, opts = {}) {
|
|
814
|
+
const key = String(topicKey);
|
|
815
|
+
const slot = this.confirmSlots.get(key);
|
|
816
|
+
if (!slot) {
|
|
817
|
+
return { fired: false, reply: 'nothing is pending confirmation right now' };
|
|
818
|
+
}
|
|
819
|
+
if (this.now() > slot.expiresAt) {
|
|
820
|
+
this.confirmSlots.delete(key);
|
|
821
|
+
return { fired: false, reply: 'that proposal has expired — say what you want again' };
|
|
822
|
+
}
|
|
823
|
+
if (slot.echoMessageId != null && opts.messageId != null && opts.messageId <= slot.echoMessageId) {
|
|
824
|
+
return {
|
|
825
|
+
fired: false,
|
|
826
|
+
reply: `I re-proposed — please confirm the new version: ${slot.echo}`,
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
this.confirmSlots.delete(key);
|
|
830
|
+
this.audit_({ type: 'confirm-fired', topic: key, kind: slot.kind });
|
|
831
|
+
const reply = await slot.run();
|
|
832
|
+
return { fired: true, reply };
|
|
833
|
+
}
|
|
834
|
+
/** The currently-armed confirm echo (readout / re-echo surfaces). */
|
|
835
|
+
armedConfirm(topicKey) {
|
|
836
|
+
const slot = this.confirmSlots.get(String(topicKey));
|
|
837
|
+
if (!slot || this.now() > slot.expiresAt)
|
|
838
|
+
return null;
|
|
839
|
+
return { kind: slot.kind, echo: slot.echo };
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* Operator's "switch now" — overrides busy/autonomous deferral for the
|
|
843
|
+
* SPECIFIC pending change that armed it. NEVER overrides protection.
|
|
844
|
+
* With no armed pending switch this is a no-op with a plain reply.
|
|
845
|
+
*/
|
|
846
|
+
async handleSwitchNow(topicKey) {
|
|
847
|
+
const key = String(topicKey);
|
|
848
|
+
const slot = this.confirmSlots.get(key);
|
|
849
|
+
if (!slot || slot.kind !== 'switch-now') {
|
|
850
|
+
return { fired: false, reply: 'there is no pending switch to apply right now' };
|
|
851
|
+
}
|
|
852
|
+
return this.fireConfirm(key);
|
|
853
|
+
}
|
|
854
|
+
async executeSwitchNow(key) {
|
|
855
|
+
const slot = this.pendingSlots.get(key);
|
|
856
|
+
if (!slot)
|
|
857
|
+
return 'that switch already applied (or was withdrawn) — nothing to do';
|
|
858
|
+
// Protection re-check happens inside the respawn phase — switch-now
|
|
859
|
+
// never overrides it (§8 round-5).
|
|
860
|
+
slot.switchNowOverride = true;
|
|
861
|
+
if (slot.timer) {
|
|
862
|
+
clearTimeout(slot.timer);
|
|
863
|
+
slot.timer = null;
|
|
864
|
+
}
|
|
865
|
+
this.enqueueRespawn({
|
|
866
|
+
topicKey: key,
|
|
867
|
+
breakerRevert: false,
|
|
868
|
+
switchNowOverride: true,
|
|
869
|
+
enqueuedAt: this.now(),
|
|
870
|
+
});
|
|
871
|
+
return 'switching now — interrupting the running task as you asked';
|
|
872
|
+
}
|
|
873
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
874
|
+
// Spawn-outcome intake — §10.4 breaker (LIVE in every regime)
|
|
875
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
876
|
+
/**
|
|
877
|
+
* Called at EVERY successful spawn (natural, sentinel, or profile-triggered;
|
|
878
|
+
* wiring obligation on the spawn path): records the last-applied-profile
|
|
879
|
+
* marker (durable — the boot sweep compares against it across restarts),
|
|
880
|
+
* resets the breaker counter, clears any suppression, and registers the
|
|
881
|
+
* codex fence window for same-cwd serialization.
|
|
882
|
+
*/
|
|
883
|
+
recordSpawnSuccess(topicKey, applied, opts = {}) {
|
|
884
|
+
const key = String(topicKey);
|
|
885
|
+
this.recordApplied(key, applied);
|
|
886
|
+
void this.deps.store.resetBreaker(key).catch(() => { });
|
|
887
|
+
this.clearSuppression(key);
|
|
888
|
+
if (applied.framework === 'codex-cli' && opts.cwd) {
|
|
889
|
+
this.codexFenceWindows.set(opts.cwd, this.now() + RESPAWN_PHASE_TTL_MS);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* Called on a failed spawn attempt for a topic, from ANY path — including
|
|
894
|
+
* the legacy `/route` respawn (§10.4: legacy-path failures count when
|
|
895
|
+
* attributable; the breaker is never dormant).
|
|
896
|
+
*/
|
|
897
|
+
recordSpawnFailure(topicKey, failureClass) {
|
|
898
|
+
this.recordSpawnFailureInternal(String(topicKey), failureClass);
|
|
899
|
+
}
|
|
900
|
+
recordSpawnFailureInternal(key, failureClass) {
|
|
901
|
+
if (!BREAKER_ATTRIBUTABLE.has(failureClass)) {
|
|
902
|
+
// Ambient classes never increment — an unattributed counter would turn
|
|
903
|
+
// any outage into a silent override of operator authority.
|
|
904
|
+
this.audit_({ type: 'spawn-failure-ambient', topic: key, failureClass });
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
const current = this.deps.store.resolve(key);
|
|
908
|
+
if (!current) {
|
|
909
|
+
// No pin — nothing to attribute the failure TO (default profile
|
|
910
|
+
// failures are not the breaker's business).
|
|
911
|
+
this.audit_({ type: 'spawn-failure-no-pin', topic: key, failureClass });
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
void this.deps.store
|
|
915
|
+
.incrementBreaker(key)
|
|
916
|
+
.then((count) => {
|
|
917
|
+
this.audit_({ type: 'breaker-increment', topic: key, failureClass, count });
|
|
918
|
+
if (count >= this.cfg().spawnFailureBreakerThreshold) {
|
|
919
|
+
return this.tripBreaker(key, failureClass);
|
|
920
|
+
}
|
|
921
|
+
return undefined;
|
|
922
|
+
})
|
|
923
|
+
.catch((err) => this.audit_({ type: 'breaker-error', topic: key, error: String(err) }));
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* §10.4 trip: park the failing profile intended-but-unhealthy, revert to
|
|
927
|
+
* last-known-good (or the global default), un-park the matching-framework
|
|
928
|
+
* resume entry (the revert is none-loss when the transcript survives),
|
|
929
|
+
* notify, audit system:circuit-breaker, and respawn IMMEDIATELY — the one
|
|
930
|
+
* keep-working exception, live in EVERY regime.
|
|
931
|
+
*/
|
|
932
|
+
async tripBreaker(key, failureClass) {
|
|
933
|
+
const failing = this.deps.store.resolve(key);
|
|
934
|
+
const lastKnownGood = this.deps.store.previousFor(key);
|
|
935
|
+
await this.deps.store.parkAndRevert(key, `spawn-failure-breaker:${failureClass}`, lastKnownGood);
|
|
936
|
+
// Remember the tripped profile for the re-apply cooldown confirm.
|
|
937
|
+
if (failing) {
|
|
938
|
+
this.durable.breakerTrips[key] = {
|
|
939
|
+
profileKey: profileKeyOf(failing),
|
|
940
|
+
at: new Date(this.now()).toISOString(),
|
|
941
|
+
};
|
|
942
|
+
this.saveDurable();
|
|
943
|
+
}
|
|
944
|
+
// Un-park the matching-framework resume entry so the revert resumes the
|
|
945
|
+
// surviving transcript instead of CONTINUATION'ing.
|
|
946
|
+
const revertFramework = (lastKnownGood?.framework ?? 'claude-code');
|
|
947
|
+
if (revertFramework === 'codex-cli')
|
|
948
|
+
this.deps.codexResume.unpark(key);
|
|
949
|
+
else
|
|
950
|
+
this.deps.claudeResume.unpark(key);
|
|
951
|
+
const seq = ++this.auditSeq;
|
|
952
|
+
this.audit_({
|
|
953
|
+
type: 'breaker-revert',
|
|
954
|
+
topic: key,
|
|
955
|
+
principal: 'system:circuit-breaker',
|
|
956
|
+
failureClass,
|
|
957
|
+
seq,
|
|
958
|
+
revertedTo: renderProfileShort(lastKnownGood),
|
|
959
|
+
});
|
|
960
|
+
this.deps.disclose(key, this.stamp("Couldn't launch with the requested profile — reverting this topic to its last working settings to keep it usable. Your pin is parked; say re-apply when it's fixed.", seq), { allowDuplicate: true, auditSeq: seq });
|
|
961
|
+
// Immediate keep-working respawn — exempt from regime gating AND from
|
|
962
|
+
// the debounce (a failing profile means the session is already down).
|
|
963
|
+
this.enqueueRespawn({
|
|
964
|
+
topicKey: key,
|
|
965
|
+
breakerRevert: true,
|
|
966
|
+
switchNowOverride: false,
|
|
967
|
+
enqueuedAt: this.now(),
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
/** Marks a topic's codex capture fence resolved (rollout observed). */
|
|
971
|
+
markCodexFenceResolved(cwd) {
|
|
972
|
+
this.codexFenceWindows.delete(cwd);
|
|
973
|
+
void this.drainQueue();
|
|
974
|
+
}
|
|
975
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
976
|
+
// Periodic tick + boot reconcile sweep (§8)
|
|
977
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
978
|
+
/**
|
|
979
|
+
* Piggybacks an existing periodic cadence (reaper/watchdog — no per-topic
|
|
980
|
+
* pollers): retries deferred (busy-abort) swaps and re-checks the dry-run
|
|
981
|
+
* flip lever.
|
|
982
|
+
*/
|
|
983
|
+
tick() {
|
|
984
|
+
this.checkDryRunFlip();
|
|
985
|
+
for (const [key, slot] of this.pendingSlots) {
|
|
986
|
+
if (slot.deferred && slot.timer === null && !this.activeRespawnTopics.has(key)) {
|
|
987
|
+
slot.deferred = false;
|
|
988
|
+
this.enqueueRespawn({
|
|
989
|
+
topicKey: key,
|
|
990
|
+
breakerRevert: false,
|
|
991
|
+
switchNowOverride: slot.switchNowOverride,
|
|
992
|
+
enqueuedAt: this.now(),
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
// Expire stale suppressions (time-bounded by construction).
|
|
997
|
+
let changed = false;
|
|
998
|
+
for (const [key, until] of Object.entries(this.durable.suppressions)) {
|
|
999
|
+
if (Date.parse(until) <= this.now()) {
|
|
1000
|
+
delete this.durable.suppressions[key];
|
|
1001
|
+
changed = true;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
if (changed)
|
|
1005
|
+
this.saveDurable();
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Boot-time reconcile sweep (§8 round-3): the pending slot is
|
|
1009
|
+
* process-local, but tmux sessions and the store survive a restart
|
|
1010
|
+
* mid-debounce. Compares each live topic-bound session's last-applied
|
|
1011
|
+
* profile against the store (⊕ escalation marker) and arms the normal
|
|
1012
|
+
* debounced, idle-gated respawn on divergence — in the fully-live regime.
|
|
1013
|
+
* In gated regimes divergence is audited and left to the next natural
|
|
1014
|
+
* spawn (no profile-triggered kill outside fully-live, §5.2(b)).
|
|
1015
|
+
* Stale escalation markers (session gone) are cleared FIRST.
|
|
1016
|
+
*/
|
|
1017
|
+
bootReconcileSweep() {
|
|
1018
|
+
const cfg = this.cfg();
|
|
1019
|
+
const live = this.deps.sessions.listTopicSessions();
|
|
1020
|
+
const liveKeys = new Set(live.map((s) => s.topicKey));
|
|
1021
|
+
// Stale-marker clear BEFORE computing expected-live (§8 round-5).
|
|
1022
|
+
for (const markerTopic of this.deps.escalation.listMarkerTopics()) {
|
|
1023
|
+
if (!liveKeys.has(markerTopic)) {
|
|
1024
|
+
this.deps.escalation.clearMarkerAndReleaseLease(markerTopic);
|
|
1025
|
+
this.audit_({ type: 'stale-escalation-marker-cleared', topic: markerTopic });
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
const divergent = [];
|
|
1029
|
+
for (const { topicKey } of live) {
|
|
1030
|
+
const resolved = this.deps.resolveProfile(topicKey);
|
|
1031
|
+
const lastApplied = this.durable.lastApplied[topicKey]?.profile ?? null;
|
|
1032
|
+
if (!this.matchesExpectedLive(topicKey, lastApplied, resolved)) {
|
|
1033
|
+
divergent.push(topicKey);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
for (const topicKey of divergent) {
|
|
1037
|
+
if (cfg.enabled && !cfg.dryRun) {
|
|
1038
|
+
this.armRespawn(topicKey, cfg.respawnDebounceMs, {
|
|
1039
|
+
frameworkArm: false,
|
|
1040
|
+
preBurst: null,
|
|
1041
|
+
origin: 'system',
|
|
1042
|
+
});
|
|
1043
|
+
const seq = ++this.auditSeq;
|
|
1044
|
+
this.deps.disclose(topicKey, this.stamp("this topic's pinned profile wasn't applied before my last restart — applying it now (the session will briefly restart when idle)", seq), { allowDuplicate: true, auditSeq: seq });
|
|
1045
|
+
this.audit_({ type: 'boot-sweep-armed', topic: topicKey, seq });
|
|
1046
|
+
}
|
|
1047
|
+
else {
|
|
1048
|
+
this.audit_({ type: 'boot-sweep-divergence-observed', topic: topicKey, regime: 'gated' });
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
/**
|
|
1053
|
+
* §14 — the dryRun true→false flip clears EVERY topic's shadow (intents
|
|
1054
|
+
* are NEVER promoted, at the flip or ever) and surfaces the expired
|
|
1055
|
+
* would-be intents ONCE as a single coalesced notice.
|
|
1056
|
+
*/
|
|
1057
|
+
checkDryRunFlip() {
|
|
1058
|
+
const dryRun = this.cfg().dryRun;
|
|
1059
|
+
const prior = this.lastSeenDryRun;
|
|
1060
|
+
this.lastSeenDryRun = dryRun;
|
|
1061
|
+
if (prior === true && dryRun === false) {
|
|
1062
|
+
void this.deps.store.clearAllShadows().then((cleared) => {
|
|
1063
|
+
if (cleared.length === 0)
|
|
1064
|
+
return;
|
|
1065
|
+
const seq = ++this.auditSeq;
|
|
1066
|
+
const list = cleared
|
|
1067
|
+
.map((c) => `topic ${c.topicKey}: ${renderPatch(c.shadow.fields)}`)
|
|
1068
|
+
.join('; ');
|
|
1069
|
+
// One coalesced notice — delivered to each affected topic's
|
|
1070
|
+
// conversation so every recorded intent's owner sees it once.
|
|
1071
|
+
for (const c of cleared) {
|
|
1072
|
+
this.deps.disclose(c.topicKey, this.stamp(`dry-run ended — these recorded intents were never applied; re-issue any you still want: ${list}`, seq), { allowDuplicate: true, auditSeq: seq });
|
|
1073
|
+
}
|
|
1074
|
+
this.audit_({ type: 'dry-run-flip-cleared-shadows', count: cleared.length, seq });
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1079
|
+
// §9 escalation interplay + resume-writer gates
|
|
1080
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1081
|
+
/**
|
|
1082
|
+
* Lock pass-through for the escalation authority (§5.1/§9): an escalation
|
|
1083
|
+
* swap serializes its live-session mutation through the SAME per-topic
|
|
1084
|
+
* lock — ordering, not shared fields (it writes only its own marker and
|
|
1085
|
+
* NEVER arms the respawn debounce).
|
|
1086
|
+
*/
|
|
1087
|
+
runExclusive(topicKey, fn) {
|
|
1088
|
+
return this.deps.store.withTopicLock(topicKey, fn);
|
|
1089
|
+
}
|
|
1090
|
+
/**
|
|
1091
|
+
* §8 resume-writer gates — install via TopicResumeMap.setWriteGate /
|
|
1092
|
+
* the codex map's wiring. A writer may persist a Claude UUID only for a
|
|
1093
|
+
* topic whose resolved framework IS claude-code and that is not
|
|
1094
|
+
* mid-framework-switch (the durable suppression marker).
|
|
1095
|
+
*/
|
|
1096
|
+
claudeResumeWriteGate(topicId) {
|
|
1097
|
+
const key = String(topicId);
|
|
1098
|
+
if (this.suppressionActive(key)) {
|
|
1099
|
+
return { allowed: false, reason: 'mid-framework-switch' };
|
|
1100
|
+
}
|
|
1101
|
+
const resolved = this.deps.resolveProfile(key);
|
|
1102
|
+
if (resolved.framework !== 'claude-code') {
|
|
1103
|
+
return { allowed: false, reason: `resolved framework is ${resolved.framework}` };
|
|
1104
|
+
}
|
|
1105
|
+
return { allowed: true };
|
|
1106
|
+
}
|
|
1107
|
+
codexResumeWriteGate(topicId) {
|
|
1108
|
+
const key = String(topicId);
|
|
1109
|
+
if (this.suppressionActive(key)) {
|
|
1110
|
+
return { allowed: false, reason: 'mid-framework-switch' };
|
|
1111
|
+
}
|
|
1112
|
+
const resolved = this.deps.resolveProfile(key);
|
|
1113
|
+
if (resolved.framework !== 'codex-cli') {
|
|
1114
|
+
return { allowed: false, reason: `resolved framework is ${resolved.framework}` };
|
|
1115
|
+
}
|
|
1116
|
+
return { allowed: true };
|
|
1117
|
+
}
|
|
1118
|
+
/** Introspection (readout / tests). */
|
|
1119
|
+
pendingFor(topicKey) {
|
|
1120
|
+
const slot = this.pendingSlots.get(String(topicKey));
|
|
1121
|
+
if (!slot)
|
|
1122
|
+
return null;
|
|
1123
|
+
return { deferred: slot.deferred, changeCount: slot.changeCount };
|
|
1124
|
+
}
|
|
1125
|
+
suppressionActive(topicKey) {
|
|
1126
|
+
const until = this.durable.suppressions[String(topicKey)];
|
|
1127
|
+
return until != null && Date.parse(until) > this.now();
|
|
1128
|
+
}
|
|
1129
|
+
/** Clear timers (tests / shutdown). */
|
|
1130
|
+
dispose() {
|
|
1131
|
+
for (const slot of this.pendingSlots.values()) {
|
|
1132
|
+
if (slot.timer)
|
|
1133
|
+
clearTimeout(slot.timer);
|
|
1134
|
+
}
|
|
1135
|
+
this.pendingSlots.clear();
|
|
1136
|
+
for (const o of this.overflow.values())
|
|
1137
|
+
clearTimeout(o.timer);
|
|
1138
|
+
this.overflow.clear();
|
|
1139
|
+
if (this.drainWakeTimer)
|
|
1140
|
+
clearTimeout(this.drainWakeTimer);
|
|
1141
|
+
this.drainWakeTimer = null;
|
|
1142
|
+
}
|
|
1143
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1144
|
+
// §14 shadow observation (legacy-served switches under enabled && dryRun)
|
|
1145
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1146
|
+
shadowObserveSwitch(key, pre, post) {
|
|
1147
|
+
const session = this.deps.sessions.getSessionForTopic(key);
|
|
1148
|
+
if (!session) {
|
|
1149
|
+
this.audit_({ type: 'dry-run-shadow-decision', topic: key, decision: 'no-live-session — would apply at next spawn' });
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
const state = this.sessionState(key, session);
|
|
1153
|
+
const classification = classifyProfileChange(pre, post, state);
|
|
1154
|
+
this.audit_({
|
|
1155
|
+
type: 'dry-run-shadow-decision',
|
|
1156
|
+
topic: key,
|
|
1157
|
+
decision: classification.refuseOrConfirm
|
|
1158
|
+
? 'would refuse-until-idle (busy framework switch)'
|
|
1159
|
+
: classification.deferUntilIdle
|
|
1160
|
+
? 'would defer until idle'
|
|
1161
|
+
: `would ${classification.swapMethod} after ${this.cfg().frameworkSwitchDebounceMs}ms debounce`,
|
|
1162
|
+
swapMethod: classification.swapMethod,
|
|
1163
|
+
expectedLoss: classification.expectedLoss,
|
|
1164
|
+
wouldPark: classification.freshRespawn,
|
|
1165
|
+
maturationSignal: true,
|
|
1166
|
+
feature: 'topic-profile',
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1170
|
+
// Disclosure accounting (§8 — coalescing + rate cap + undo cadence)
|
|
1171
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1172
|
+
discloseWrite(key, text, seq, attribution, info) {
|
|
1173
|
+
if (info.slotActive) {
|
|
1174
|
+
// Coalesces into the pending slot's terminal disclosure — the burst's
|
|
1175
|
+
// single delta-carrying notice covers this write.
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
const now = this.now();
|
|
1179
|
+
const times = (this.disclosureTimes.get(key) ?? []).filter((t) => now - t < DISCLOSURE_RATE_WINDOW_MS);
|
|
1180
|
+
const existingOverflow = this.overflow.get(key);
|
|
1181
|
+
if (existingOverflow) {
|
|
1182
|
+
existingOverflow.count += 1;
|
|
1183
|
+
existingOverflow.origins.add(attribution.origin);
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
if (times.length >= DISCLOSURE_RATE_CAP) {
|
|
1187
|
+
// Enter the overflow period — itself treated as a disclosed burst for
|
|
1188
|
+
// the undo shift (the FIRST overflow write captured `previous`).
|
|
1189
|
+
const timer = setTimeout(() => this.flushOverflow(key), DISCLOSURE_RATE_WINDOW_MS);
|
|
1190
|
+
timer.unref?.();
|
|
1191
|
+
this.overflow.set(key, {
|
|
1192
|
+
count: 1,
|
|
1193
|
+
origins: new Set([attribution.origin]),
|
|
1194
|
+
preOverflow: this.deps.store.previousFor(key),
|
|
1195
|
+
timer,
|
|
1196
|
+
});
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
times.push(now);
|
|
1200
|
+
this.disclosureTimes.set(key, times);
|
|
1201
|
+
const originNote = attribution.origin === 'http' ? ' (profile changed via API)' : '';
|
|
1202
|
+
this.deps.disclose(key, this.stamp(text + originNote, seq), { allowDuplicate: true, auditSeq: seq });
|
|
1203
|
+
}
|
|
1204
|
+
flushOverflow(key) {
|
|
1205
|
+
const o = this.overflow.get(key);
|
|
1206
|
+
if (!o)
|
|
1207
|
+
return;
|
|
1208
|
+
this.overflow.delete(key);
|
|
1209
|
+
const seq = ++this.auditSeq;
|
|
1210
|
+
const final = this.deps.store.resolve(key);
|
|
1211
|
+
// Delta-carrying summary (§8 round-9): was → now, count, origins.
|
|
1212
|
+
this.deps.disclose(key, this.stamp(`was: ${renderProfileShort(o.preOverflow)} → now: ${renderProfileShort(final)} (${o.count} changes, origins: ${[...o.origins].join(', ')})`, seq), { allowDuplicate: true, auditSeq: seq });
|
|
1213
|
+
this.audit_({ type: 'disclosure-overflow-summary', topic: key, count: o.count, seq });
|
|
1214
|
+
}
|
|
1215
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1216
|
+
// internals
|
|
1217
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1218
|
+
cfg() {
|
|
1219
|
+
return this.deps.getConfig();
|
|
1220
|
+
}
|
|
1221
|
+
stamp(text, seq) {
|
|
1222
|
+
// §8 — disclosures carry the audit sequence/timestamp in the rendered
|
|
1223
|
+
// text so the relay's exact-duplicate window can never silently swallow
|
|
1224
|
+
// a repeat notice.
|
|
1225
|
+
return `${text} [#${seq}]`;
|
|
1226
|
+
}
|
|
1227
|
+
audit_(event) {
|
|
1228
|
+
try {
|
|
1229
|
+
this.deps.audit({ ts: new Date(this.now()).toISOString(), source: 'topic-profile-orchestrator', ...event });
|
|
1230
|
+
}
|
|
1231
|
+
catch {
|
|
1232
|
+
// best-effort — never throws into the orchestration path
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
sessionState(key, session) {
|
|
1236
|
+
const v = this.deps.verification();
|
|
1237
|
+
return {
|
|
1238
|
+
exists: true,
|
|
1239
|
+
idle: this.deps.sessions.readIdle(session.sessionName),
|
|
1240
|
+
autonomousActive: this.deps.autonomousActive(key),
|
|
1241
|
+
isProtected: this.deps.isProtectedSession(session.sessionName),
|
|
1242
|
+
claudeResumeReady: this.deps.claudeResume.ready(key),
|
|
1243
|
+
codexRolloutCaptured: this.deps.codexResume.get(key) !== null || this.deps.codexFence(key) !== null,
|
|
1244
|
+
inFlightSwapConfirmedRecently: v.inFlightSwapConfirmedRecently,
|
|
1245
|
+
thinkingOffOnResumeVerified: v.thinkingOffOnResumeVerified,
|
|
1246
|
+
thinkingLevelResumeVerified: v.thinkingLevelResumeVerified,
|
|
1247
|
+
crossModelResumeVerified: v.crossModelResumeVerified,
|
|
1248
|
+
claudeThinkingControlAvailable: v.claudeThinkingControlAvailable,
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
matchesExpectedLive(key, lastApplied, resolved) {
|
|
1252
|
+
if (!lastApplied)
|
|
1253
|
+
return false;
|
|
1254
|
+
const base = resolvedToApplied(resolved);
|
|
1255
|
+
if (appliedEquals(lastApplied, base))
|
|
1256
|
+
return true;
|
|
1257
|
+
const marker = this.deps.escalation.activeMarker(key);
|
|
1258
|
+
if (marker) {
|
|
1259
|
+
const overlay = { ...base, model: marker.model, modelTier: null };
|
|
1260
|
+
if (appliedEquals(lastApplied, overlay))
|
|
1261
|
+
return true;
|
|
1262
|
+
}
|
|
1263
|
+
return false;
|
|
1264
|
+
}
|
|
1265
|
+
pseudoEquals(a, b) {
|
|
1266
|
+
if (a === null && b === null)
|
|
1267
|
+
return true;
|
|
1268
|
+
if (a === null || b === null)
|
|
1269
|
+
return false;
|
|
1270
|
+
return PROFILE_AXES.every((f) => (a[f] ?? null) === (b[f] ?? null));
|
|
1271
|
+
}
|
|
1272
|
+
recordApplied(key, applied) {
|
|
1273
|
+
this.durable.lastApplied[key] = { profile: applied, at: new Date(this.now()).toISOString() };
|
|
1274
|
+
this.saveDurable();
|
|
1275
|
+
}
|
|
1276
|
+
setSuppression(key) {
|
|
1277
|
+
this.durable.suppressions[key] = new Date(this.now() + SUPPRESSION_TTL_MS).toISOString();
|
|
1278
|
+
this.saveDurable();
|
|
1279
|
+
}
|
|
1280
|
+
clearSuppression(key) {
|
|
1281
|
+
if (this.durable.suppressions[key]) {
|
|
1282
|
+
delete this.durable.suppressions[key];
|
|
1283
|
+
this.saveDurable();
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
loadDurable() {
|
|
1287
|
+
try {
|
|
1288
|
+
if (fs.existsSync(this.deps.stateFilePath)) {
|
|
1289
|
+
const parsed = JSON.parse(fs.readFileSync(this.deps.stateFilePath, 'utf-8'));
|
|
1290
|
+
this.durable = {
|
|
1291
|
+
lastApplied: parsed.lastApplied && typeof parsed.lastApplied === 'object' ? parsed.lastApplied : {},
|
|
1292
|
+
suppressions: parsed.suppressions && typeof parsed.suppressions === 'object' ? parsed.suppressions : {},
|
|
1293
|
+
breakerTrips: parsed.breakerTrips && typeof parsed.breakerTrips === 'object' ? parsed.breakerTrips : {},
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
catch (err) {
|
|
1298
|
+
console.warn(`[TopicProfileOrchestrator] Failed to load ${this.deps.stateFilePath}: ${err}`);
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
saveDurable() {
|
|
1302
|
+
try {
|
|
1303
|
+
const dir = path.dirname(this.deps.stateFilePath);
|
|
1304
|
+
if (!fs.existsSync(dir))
|
|
1305
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1306
|
+
const tmp = `${this.deps.stateFilePath}.${process.pid}.tmp`;
|
|
1307
|
+
fs.writeFileSync(tmp, JSON.stringify(this.durable, null, 2), 'utf-8');
|
|
1308
|
+
fs.renameSync(tmp, this.deps.stateFilePath);
|
|
1309
|
+
}
|
|
1310
|
+
catch (err) {
|
|
1311
|
+
console.warn(`[TopicProfileOrchestrator] Failed to persist side-state: ${err}`);
|
|
1312
|
+
DegradationReporter.getInstance().report({
|
|
1313
|
+
feature: 'TopicProfileOrchestrator.saveDurable',
|
|
1314
|
+
primary: 'Persist orchestrator side-state (lastApplied, disclosure suppressions, breaker trips)',
|
|
1315
|
+
fallback: 'Continue with in-memory state only; the file stays stale until the next successful save',
|
|
1316
|
+
reason: `Side-state write failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1317
|
+
impact: 'A restart before the next successful save loses breaker-trip history and disclosure dedupe (duplicate notices, reset breakers)',
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1323
|
+
// helpers (pure)
|
|
1324
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1325
|
+
function isClearOnlyPatch(patch) {
|
|
1326
|
+
const supplied = PROFILE_AXES.filter((f) => patch[f] !== undefined);
|
|
1327
|
+
return supplied.length > 0 && supplied.every((f) => patch[f] === null);
|
|
1328
|
+
}
|
|
1329
|
+
/** Convert the launch-applied characteristics into a classifier pseudo-profile. */
|
|
1330
|
+
function appliedToPseudo(applied) {
|
|
1331
|
+
if (!applied)
|
|
1332
|
+
return null;
|
|
1333
|
+
return {
|
|
1334
|
+
framework: applied.framework,
|
|
1335
|
+
model: applied.modelTier ? null : applied.model,
|
|
1336
|
+
modelTier: applied.modelTier,
|
|
1337
|
+
thinkingMode: applied.thinkingMode,
|
|
1338
|
+
updatedAt: '',
|
|
1339
|
+
updatedBy: '',
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
function resolvedToPseudo(resolved) {
|
|
1343
|
+
return {
|
|
1344
|
+
framework: resolved.framework,
|
|
1345
|
+
model: resolved.modelTier ? null : (resolved.model ?? null),
|
|
1346
|
+
modelTier: resolved.modelTier ?? null,
|
|
1347
|
+
thinkingMode: resolved.thinkingMode ?? null,
|
|
1348
|
+
updatedAt: '',
|
|
1349
|
+
updatedBy: '',
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
/** The characteristics a spawn against this resolution applies. */
|
|
1353
|
+
export function resolvedToApplied(resolved) {
|
|
1354
|
+
return {
|
|
1355
|
+
framework: resolved.framework,
|
|
1356
|
+
model: resolved.modelTier ? null : (resolved.model ?? null),
|
|
1357
|
+
modelTier: resolved.modelTier ?? null,
|
|
1358
|
+
thinkingMode: resolved.thinkingMode ?? null,
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
function appliedEquals(a, b) {
|
|
1362
|
+
return (a.framework === b.framework &&
|
|
1363
|
+
(a.model ?? null) === (b.model ?? null) &&
|
|
1364
|
+
(a.modelTier ?? null) === (b.modelTier ?? null) &&
|
|
1365
|
+
(a.thinkingMode ?? null) === (b.thinkingMode ?? null));
|
|
1366
|
+
}
|
|
1367
|
+
/** Identity key for the §10.4 cooldown-confirm "same profile" check. */
|
|
1368
|
+
function profileKeyOf(p) {
|
|
1369
|
+
return PROFILE_AXES.map((f) => `${f}=${p[f] ?? ''}`).join('|');
|
|
1370
|
+
}
|
|
1371
|
+
function renderPatch(patch) {
|
|
1372
|
+
const parts = [];
|
|
1373
|
+
for (const f of PROFILE_AXES) {
|
|
1374
|
+
if (patch[f] === undefined)
|
|
1375
|
+
continue;
|
|
1376
|
+
parts.push(patch[f] === null ? `${f} cleared` : `${f}: ${String(patch[f])}`);
|
|
1377
|
+
}
|
|
1378
|
+
return parts.length > 0 ? parts.join(', ') : 'profile';
|
|
1379
|
+
}
|
|
1380
|
+
function renderProfileShort(p) {
|
|
1381
|
+
if (!p)
|
|
1382
|
+
return 'defaults';
|
|
1383
|
+
const parts = [];
|
|
1384
|
+
for (const f of PROFILE_AXES) {
|
|
1385
|
+
const v = p[f];
|
|
1386
|
+
if (v != null)
|
|
1387
|
+
parts.push(`${f}=${String(v)}`);
|
|
1388
|
+
}
|
|
1389
|
+
return parts.length > 0 ? parts.join(' ') : 'defaults';
|
|
1390
|
+
}
|
|
1391
|
+
/** TTL race — on timeout the phase logically aborts (lock released by return). */
|
|
1392
|
+
async function withTimeout(p, ms, onTimeout) {
|
|
1393
|
+
let timer = null;
|
|
1394
|
+
const timeout = new Promise((res) => {
|
|
1395
|
+
timer = setTimeout(() => res('timeout'), ms);
|
|
1396
|
+
timer.unref?.();
|
|
1397
|
+
});
|
|
1398
|
+
const result = await Promise.race([p.then(() => 'done'), timeout]);
|
|
1399
|
+
if (timer)
|
|
1400
|
+
clearTimeout(timer);
|
|
1401
|
+
if (result === 'timeout')
|
|
1402
|
+
onTimeout();
|
|
1403
|
+
}
|
|
1404
|
+
//# sourceMappingURL=TopicProfileOrchestrator.js.map
|