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.
Files changed (106) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +979 -28
  3. package/dist/commands/server.js.map +1 -1
  4. package/dist/config/ConfigDefaults.d.ts.map +1 -1
  5. package/dist/config/ConfigDefaults.js +23 -0
  6. package/dist/config/ConfigDefaults.js.map +1 -1
  7. package/dist/core/CodexResumeMap.d.ts +95 -0
  8. package/dist/core/CodexResumeMap.d.ts.map +1 -0
  9. package/dist/core/CodexResumeMap.js +283 -0
  10. package/dist/core/CodexResumeMap.js.map +1 -0
  11. package/dist/core/MeshRpc.d.ts +3 -0
  12. package/dist/core/MeshRpc.d.ts.map +1 -1
  13. package/dist/core/MeshRpc.js +5 -0
  14. package/dist/core/MeshRpc.js.map +1 -1
  15. package/dist/core/ModelSwapService.d.ts +26 -11
  16. package/dist/core/ModelSwapService.d.ts.map +1 -1
  17. package/dist/core/ModelSwapService.js +59 -21
  18. package/dist/core/ModelSwapService.js.map +1 -1
  19. package/dist/core/PostUpdateMigrator.d.ts +16 -0
  20. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  21. package/dist/core/PostUpdateMigrator.js +62 -2
  22. package/dist/core/PostUpdateMigrator.js.map +1 -1
  23. package/dist/core/SessionManager.d.ts +4 -0
  24. package/dist/core/SessionManager.d.ts.map +1 -1
  25. package/dist/core/SessionManager.js +3 -0
  26. package/dist/core/SessionManager.js.map +1 -1
  27. package/dist/core/SessionRefresh.d.ts +36 -7
  28. package/dist/core/SessionRefresh.d.ts.map +1 -1
  29. package/dist/core/SessionRefresh.js +90 -29
  30. package/dist/core/SessionRefresh.js.map +1 -1
  31. package/dist/core/TopicProfileOrchestrator.d.ts +480 -0
  32. package/dist/core/TopicProfileOrchestrator.d.ts.map +1 -0
  33. package/dist/core/TopicProfileOrchestrator.js +1404 -0
  34. package/dist/core/TopicProfileOrchestrator.js.map +1 -0
  35. package/dist/core/TopicProfileResolver.d.ts +104 -0
  36. package/dist/core/TopicProfileResolver.d.ts.map +1 -0
  37. package/dist/core/TopicProfileResolver.js +231 -0
  38. package/dist/core/TopicProfileResolver.js.map +1 -0
  39. package/dist/core/TopicProfileStore.d.ts +308 -0
  40. package/dist/core/TopicProfileStore.d.ts.map +1 -0
  41. package/dist/core/TopicProfileStore.js +781 -0
  42. package/dist/core/TopicProfileStore.js.map +1 -0
  43. package/dist/core/TopicProfileTransferCarrier.d.ts +227 -0
  44. package/dist/core/TopicProfileTransferCarrier.d.ts.map +1 -0
  45. package/dist/core/TopicProfileTransferCarrier.js +533 -0
  46. package/dist/core/TopicProfileTransferCarrier.js.map +1 -0
  47. package/dist/core/TopicResumeMap.d.ts +67 -1
  48. package/dist/core/TopicResumeMap.d.ts.map +1 -1
  49. package/dist/core/TopicResumeMap.js +114 -1
  50. package/dist/core/TopicResumeMap.js.map +1 -1
  51. package/dist/core/classifyProfileChange.d.ts +83 -0
  52. package/dist/core/classifyProfileChange.d.ts.map +1 -0
  53. package/dist/core/classifyProfileChange.js +274 -0
  54. package/dist/core/classifyProfileChange.js.map +1 -0
  55. package/dist/core/devGatedFeatures.d.ts.map +1 -1
  56. package/dist/core/devGatedFeatures.js +6 -0
  57. package/dist/core/devGatedFeatures.js.map +1 -1
  58. package/dist/core/frameworkSessionLaunch.d.ts +26 -0
  59. package/dist/core/frameworkSessionLaunch.d.ts.map +1 -1
  60. package/dist/core/frameworkSessionLaunch.js +58 -0
  61. package/dist/core/frameworkSessionLaunch.js.map +1 -1
  62. package/dist/core/slackRefreshBinding.d.ts +83 -0
  63. package/dist/core/slackRefreshBinding.d.ts.map +1 -0
  64. package/dist/core/slackRefreshBinding.js +54 -0
  65. package/dist/core/slackRefreshBinding.js.map +1 -0
  66. package/dist/core/topicProfileIngress.d.ts +128 -0
  67. package/dist/core/topicProfileIngress.d.ts.map +1 -0
  68. package/dist/core/topicProfileIngress.js +249 -0
  69. package/dist/core/topicProfileIngress.js.map +1 -0
  70. package/dist/core/topicProfileValidation.d.ts +109 -0
  71. package/dist/core/topicProfileValidation.d.ts.map +1 -0
  72. package/dist/core/topicProfileValidation.js +247 -0
  73. package/dist/core/topicProfileValidation.js.map +1 -0
  74. package/dist/core/topicProfileWriteSurface.d.ts +222 -0
  75. package/dist/core/topicProfileWriteSurface.d.ts.map +1 -0
  76. package/dist/core/topicProfileWriteSurface.js +631 -0
  77. package/dist/core/topicProfileWriteSurface.js.map +1 -0
  78. package/dist/core/types.d.ts +37 -0
  79. package/dist/core/types.d.ts.map +1 -1
  80. package/dist/core/types.js.map +1 -1
  81. package/dist/lifeline/TelegramLifeline.d.ts.map +1 -1
  82. package/dist/lifeline/TelegramLifeline.js +6 -0
  83. package/dist/lifeline/TelegramLifeline.js.map +1 -1
  84. package/dist/messaging/TelegramAdapter.d.ts +10 -1
  85. package/dist/messaging/TelegramAdapter.d.ts.map +1 -1
  86. package/dist/messaging/TelegramAdapter.js +41 -1
  87. package/dist/messaging/TelegramAdapter.js.map +1 -1
  88. package/dist/scaffold/templates.d.ts.map +1 -1
  89. package/dist/scaffold/templates.js +9 -0
  90. package/dist/scaffold/templates.js.map +1 -1
  91. package/dist/server/AgentServer.d.ts +32 -0
  92. package/dist/server/AgentServer.d.ts.map +1 -1
  93. package/dist/server/AgentServer.js +52 -3
  94. package/dist/server/AgentServer.js.map +1 -1
  95. package/dist/server/CapabilityIndex.d.ts.map +1 -1
  96. package/dist/server/CapabilityIndex.js +1 -0
  97. package/dist/server/CapabilityIndex.js.map +1 -1
  98. package/dist/server/routes.d.ts +17 -0
  99. package/dist/server/routes.d.ts.map +1 -1
  100. package/dist/server/routes.js +271 -1
  101. package/dist/server/routes.js.map +1 -1
  102. package/package.json +1 -1
  103. package/src/data/builtin-manifest.json +66 -66
  104. package/src/scaffold/templates.ts +9 -0
  105. package/upgrades/1.3.490.md +34 -0
  106. 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