instar 1.3.729 → 1.3.730

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 (65) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +174 -1
  3. package/dist/commands/server.js.map +1 -1
  4. package/dist/core/ModelSwapService.d.ts +13 -0
  5. package/dist/core/ModelSwapService.d.ts.map +1 -1
  6. package/dist/core/ModelSwapService.js +11 -0
  7. package/dist/core/ModelSwapService.js.map +1 -1
  8. package/dist/core/ModelTierEscalation.d.ts +10 -0
  9. package/dist/core/ModelTierEscalation.d.ts.map +1 -1
  10. package/dist/core/ModelTierEscalation.js +4 -0
  11. package/dist/core/ModelTierEscalation.js.map +1 -1
  12. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  13. package/dist/core/PostUpdateMigrator.js +18 -0
  14. package/dist/core/PostUpdateMigrator.js.map +1 -1
  15. package/dist/core/ProactiveSwapMonitor.d.ts +74 -20
  16. package/dist/core/ProactiveSwapMonitor.d.ts.map +1 -1
  17. package/dist/core/ProactiveSwapMonitor.js +349 -28
  18. package/dist/core/ProactiveSwapMonitor.js.map +1 -1
  19. package/dist/core/QuotaAwareScheduler.d.ts +70 -3
  20. package/dist/core/QuotaAwareScheduler.d.ts.map +1 -1
  21. package/dist/core/QuotaAwareScheduler.js +100 -16
  22. package/dist/core/QuotaAwareScheduler.js.map +1 -1
  23. package/dist/core/SessionManager.d.ts +28 -0
  24. package/dist/core/SessionManager.d.ts.map +1 -1
  25. package/dist/core/SessionManager.js +102 -0
  26. package/dist/core/SessionManager.js.map +1 -1
  27. package/dist/core/SessionRefresh.d.ts +116 -1
  28. package/dist/core/SessionRefresh.d.ts.map +1 -1
  29. package/dist/core/SessionRefresh.js +280 -17
  30. package/dist/core/SessionRefresh.js.map +1 -1
  31. package/dist/core/SwapAntiThrash.d.ts +329 -0
  32. package/dist/core/SwapAntiThrash.d.ts.map +1 -0
  33. package/dist/core/SwapAntiThrash.js +1025 -0
  34. package/dist/core/SwapAntiThrash.js.map +1 -0
  35. package/dist/core/SwapLedger.d.ts +168 -0
  36. package/dist/core/SwapLedger.d.ts.map +1 -0
  37. package/dist/core/SwapLedger.js +275 -0
  38. package/dist/core/SwapLedger.js.map +1 -0
  39. package/dist/core/SwapWorkGate.d.ts +100 -0
  40. package/dist/core/SwapWorkGate.d.ts.map +1 -0
  41. package/dist/core/SwapWorkGate.js +175 -0
  42. package/dist/core/SwapWorkGate.js.map +1 -0
  43. package/dist/core/devGatedFeatures.d.ts.map +1 -1
  44. package/dist/core/devGatedFeatures.js +6 -0
  45. package/dist/core/devGatedFeatures.js.map +1 -1
  46. package/dist/core/types.d.ts +63 -1
  47. package/dist/core/types.d.ts.map +1 -1
  48. package/dist/core/types.js.map +1 -1
  49. package/dist/monitoring/guardManifest.d.ts.map +1 -1
  50. package/dist/monitoring/guardManifest.js +40 -1
  51. package/dist/monitoring/guardManifest.js.map +1 -1
  52. package/dist/scaffold/templates.d.ts.map +1 -1
  53. package/dist/scaffold/templates.js +1 -0
  54. package/dist/scaffold/templates.js.map +1 -1
  55. package/dist/server/AgentServer.d.ts.map +1 -1
  56. package/dist/server/AgentServer.js +19 -0
  57. package/dist/server/AgentServer.js.map +1 -1
  58. package/dist/server/routes.d.ts.map +1 -1
  59. package/dist/server/routes.js +36 -3
  60. package/dist/server/routes.js.map +1 -1
  61. package/package.json +1 -1
  62. package/src/data/builtin-manifest.json +65 -65
  63. package/src/scaffold/templates.ts +1 -0
  64. package/upgrades/1.3.730.md +112 -0
  65. package/upgrades/side-effects/swap-continuity-antithrash.md +118 -0
@@ -0,0 +1,1025 @@
1
+ /**
2
+ * SwapAntiThrash — Piece 1 of swap-continuity-antithrash: the anti-thrash
3
+ * brakes on the proactive account swap (docs/specs/swap-continuity-antithrash.md §3).
4
+ *
5
+ * The engine sits at the proactive DECISION chokepoint (ProactiveSwapMonitor's
6
+ * evaluate pass) and owns:
7
+ * - brake (a): the all-hot brake — refuse when no eligible target measures
8
+ * under the target ceiling (§3.1); "measures" requires a PRESENT + FRESH
9
+ * quota reading (bound 0, R4-M1) — no reading is NEVER treated as 0%.
10
+ * - brake (b): per-session dwell (45 min default), restart-safe via the
11
+ * swap ledger (§3.2).
12
+ * - brake (c): target-materially-better — validity gate + absolute ceiling +
13
+ * relative improvement, in the normative filter→score→verify order (§3.3),
14
+ * plus the intra-tick per-target pile-on cap.
15
+ * - thrash detection + the two-tier breaker (T1 inversion / T2 frequency,
16
+ * §3.5) with episode continuation, restart-proof derivation from the
17
+ * ledger, and P17 one-deduped-attention-item episodes.
18
+ * - execution-failure accounting (§3.6): failed rows, per-session
19
+ * exponential backoff (proactive), streak escalation — kind-separated so a
20
+ * reactive streak never mixes with a proactive one.
21
+ * - the ledger-lost pause (I12): while the swap ledger is unwritable, every
22
+ * proactive intent refuses `ledger-lost` (counter-only) and resumes
23
+ * level-triggered on the first successful append.
24
+ * - measurement-blind surfacing (I13, R5-m1): evaluated per tick over the
25
+ * POOL ITSELF, candidacy-independent.
26
+ *
27
+ * The REACTIVE path is untouched by every brake (I6) — the engine only ever
28
+ * OBSERVES reactive swaps (dwell clock-start, hop alerts, failed-row streaks).
29
+ */
30
+ import { bindingUtilization, scoreAccount } from './QuotaAwareScheduler.js';
31
+ import { isLocallyExecutable } from './SubscriptionPool.js';
32
+ /**
33
+ * Resolve the antiThrash knobs with shipped defaults (§7). All numeric reads
34
+ * use nullish coalescing (zero is a legal disable for several knobs). An
35
+ * ABSENT block on a proactiveSwap-enabled install resolves
36
+ * `enabled:true, dryRun:true` (§12 migration default-direction assertion —
37
+ * the dry-run soak is the default, never a silent skip).
38
+ */
39
+ export function resolveAntiThrashKnobs(block, proactive) {
40
+ return {
41
+ enabled: block?.enabled ?? true,
42
+ dryRun: block?.dryRun ?? true,
43
+ thresholdPct: proactive?.thresholdPct ?? 80,
44
+ tickMs: proactive?.tickMs ?? 180_000,
45
+ targetHeadroomPct: block?.targetHeadroomPct ?? 15,
46
+ minImprovementPct: block?.minImprovementPct ?? 15,
47
+ dwellMs: block?.dwellMs ?? 2_700_000,
48
+ reversalWindowMs: block?.reversalWindowMs ?? 1_800_000,
49
+ thrashBreakerThreshold: block?.thrashBreakerThreshold ?? 2,
50
+ thrashBreakerBackoffMs: block?.thrashBreakerBackoffMs ?? 3_600_000,
51
+ swapFrequencyThreshold: block?.swapFrequencyThreshold ?? 3,
52
+ swapFrequencyWindowMs: block?.swapFrequencyWindowMs ?? 10_800_000,
53
+ allHotHeartbeatMs: block?.allHotHeartbeatMs ?? 1_800_000,
54
+ reactiveHopAlertThreshold: block?.reactiveHopAlertThreshold ?? 2,
55
+ quotaFreshnessMs: block?.quotaFreshnessMs ?? 1_800_000,
56
+ };
57
+ }
58
+ /**
59
+ * The §3.2 one-formula retention/hydration bound (R3-M1, continuation term
60
+ * R5-m2): retention ⊇ every detection window AND every continuation window,
61
+ * structurally. 4 h at shipped defaults (the last term dominates). NOT a knob.
62
+ */
63
+ export function retentionBoundMs(k) {
64
+ return Math.max(k.dwellMs, k.reversalWindowMs, k.thrashBreakerBackoffMs, k.swapFrequencyWindowMs, k.thrashBreakerBackoffMs + Math.max(k.reversalWindowMs, k.swapFrequencyWindowMs));
65
+ }
66
+ /**
67
+ * Cross-knob coherence warnings (§7, R4-L2 — warn-only, never a startup
68
+ * error). Returns the warning lines (the caller logs each exactly once).
69
+ */
70
+ export function crossKnobWarnings(k, pollIntervalMs) {
71
+ const out = [];
72
+ if (k.swapFrequencyThreshold > 1 && k.dwellMs > k.swapFrequencyWindowMs / (k.swapFrequencyThreshold - 1)) {
73
+ out.push(`[SwapAntiThrash] config warning: dwellMs (${k.dwellMs}) > swapFrequencyWindowMs/(swapFrequencyThreshold-1) ` +
74
+ `(${Math.round(k.swapFrequencyWindowMs / (k.swapFrequencyThreshold - 1))}) — the T2 rotation detector is disarmed ` +
75
+ `(dwell-paced hops can no longer fit the frequency window)`);
76
+ }
77
+ if (k.quotaFreshnessMs < pollIntervalMs) {
78
+ out.push(`[SwapAntiThrash] config warning: quotaFreshnessMs (${k.quotaFreshnessMs}) < quota poll interval (${pollIntervalMs}) — ` +
79
+ `every reading goes stale between polls; proactive optimization degrades toward permanent refusal (safe, but almost certainly a misconfiguration)`);
80
+ }
81
+ return out;
82
+ }
83
+ export function readingValidity(acct, nowMs, quotaFreshnessMs) {
84
+ const snap = acct.lastQuota;
85
+ const utilPct = bindingUtilization(snap);
86
+ if (!snap)
87
+ return { valid: false, utilPct, invalidReason: 'absent' };
88
+ const measured = snap.measuredAt ? Date.parse(snap.measuredAt) : NaN;
89
+ if (!Number.isFinite(measured))
90
+ return { valid: false, utilPct, invalidReason: 'absent' };
91
+ if (nowMs - measured > quotaFreshnessMs)
92
+ return { valid: false, utilPct, invalidReason: 'stale' };
93
+ return { valid: true, utilPct };
94
+ }
95
+ export class SwapAntiThrashEngine {
96
+ ledger;
97
+ getKnobs;
98
+ machineId;
99
+ raiseAttention;
100
+ logger;
101
+ now;
102
+ // ── in-memory write-through index (hydrated at boot, §3.5 read path) ──
103
+ executions = [];
104
+ lastSwapAtBySession = new Map();
105
+ inversionIncrementsMs = [];
106
+ breaker = null;
107
+ /** Signature-keyed continuation memory: most recent CLOSE per signature. */
108
+ continuationMemory = new Map();
109
+ alertedEpisodeIds = new Set();
110
+ failureStreaks = new Map(); // `${kind}:${session}`
111
+ failureBackoffUntil = new Map(); // proactive only
112
+ lastDroppedAt = new Map(); // re-intent backoff (§4.2)
113
+ stateRows = new Map(); // all-hot / target-unmeasured per session
114
+ breakerSuppressionRows = new Map(); // per session per episode
115
+ reactiveHops = new Map();
116
+ reactiveHopAlerted = new Set();
117
+ blindSinceMs = null;
118
+ blindEpisodeId = null;
119
+ tickSeq = 0;
120
+ // ── counters (§6.3) ──
121
+ refusalsByReason = new Map();
122
+ reversalsDetected = 0;
123
+ pairLevelDetections = 0;
124
+ frequencyCrossings = 0;
125
+ droppedCount = 0;
126
+ invalidatedCount = 0;
127
+ proceededWithMitigations = 0;
128
+ ledgerLostRefusals = 0;
129
+ constructor(opts) {
130
+ this.ledger = opts.ledger;
131
+ this.getKnobs = opts.getKnobs;
132
+ this.machineId = opts.machineId ?? 'local';
133
+ this.raiseAttention = opts.raiseAttention ?? (() => { });
134
+ this.logger = opts.logger ?? { log: () => { }, warn: () => { } };
135
+ this.now = opts.now ?? (() => Date.now());
136
+ }
137
+ // ─────────────────────────────────────────────────────────────────────────
138
+ // Hydration (boot, unconditional — R4-L3)
139
+ // ─────────────────────────────────────────────────────────────────────────
140
+ hydrate() {
141
+ const { rows } = this.ledger.hydrate();
142
+ const k = this.getKnobs();
143
+ const nowMs = this.now();
144
+ let openMarker = null;
145
+ const closesBySig = new Map();
146
+ const lastFailedRun = new Map();
147
+ for (const row of rows) {
148
+ const tsMs = Date.parse(row.ts);
149
+ if (!Number.isFinite(tsMs))
150
+ continue;
151
+ if (row.decision === 'swapped' || row.decision === 'proceeded') {
152
+ if (row.session && row.from && row.to && row.kind !== 'interactive') {
153
+ this.executions.push({ tsMs, session: row.session, from: row.from, to: row.to, kind: row.kind });
154
+ this.lastSwapAtBySession.set(row.session, Math.max(tsMs, this.lastSwapAtBySession.get(row.session) ?? 0));
155
+ // an executed swap resets the session's failure streak
156
+ lastFailedRun.delete(`proactive:${row.session}`);
157
+ lastFailedRun.delete(`reactive:${row.session}`);
158
+ }
159
+ }
160
+ if (row.decision === 'dropped' && row.session) {
161
+ this.lastDroppedAt.set(row.session, Math.max(tsMs, this.lastDroppedAt.get(row.session) ?? 0));
162
+ }
163
+ if (row.decision === 'failed' && row.session && (row.kind === 'proactive' || row.kind === 'reactive')) {
164
+ const key = `${row.kind}:${row.session}`;
165
+ const s = lastFailedRun.get(key);
166
+ if (s) {
167
+ s.count += 1;
168
+ s.lastFailMs = tsMs;
169
+ if (s.count >= 3)
170
+ s.alerted = true;
171
+ }
172
+ else {
173
+ lastFailedRun.set(key, { count: 1, firstFailMs: tsMs, lastFailMs: tsMs, alerted: false });
174
+ }
175
+ }
176
+ // Breaker derivation anchors ONLY on rows carrying breakerDeadline
177
+ // (equivalently episodeKind 'thrash-breaker') — R3-m1: an all-hot or
178
+ // failure-streak row can never anchor or close the breaker.
179
+ if (row.breakerDeadline && row.episodeKind === 'thrash-breaker') {
180
+ if (openMarker === null || tsMs >= openMarker.tsMs)
181
+ openMarker = { row, tsMs };
182
+ }
183
+ if (row.episodeKind === 'thrash-breaker' &&
184
+ row.transition === 'leave' &&
185
+ row.triggerSignature &&
186
+ row.episodeId) {
187
+ closesBySig.set(signatureKey(row.triggerSignature), { episodeId: row.episodeId, closedAtMs: tsMs });
188
+ }
189
+ if (row.episodeId)
190
+ this.alertedEpisodeIds.add(row.episodeId);
191
+ // all-hot / target-unmeasured state re-derivation (enter/heartbeat
192
+ // without a following leave ⇒ still in state).
193
+ if (row.session && (row.reason === 'all-hot' || row.reason === 'target-unmeasured') && row.transition) {
194
+ if (row.transition === 'leave') {
195
+ this.stateRows.delete(row.session);
196
+ }
197
+ else {
198
+ this.stateRows.set(row.session, {
199
+ reason: row.reason,
200
+ enteredAtMs: row.transition === 'enter' ? tsMs : (this.stateRows.get(row.session)?.enteredAtMs ?? tsMs),
201
+ lastRowMs: tsMs,
202
+ episodeId: row.episodeId ?? `${row.reason}-${row.session}-${tsMs}`,
203
+ touchedTick: 0,
204
+ });
205
+ }
206
+ }
207
+ }
208
+ this.failureStreaks = lastFailedRun;
209
+ for (const [key, s] of lastFailedRun) {
210
+ if (key.startsWith('proactive:')) {
211
+ const session = key.slice('proactive:'.length);
212
+ const backoff = Math.min(k.tickMs * Math.pow(2, s.count), k.dwellMs);
213
+ this.failureBackoffUntil.set(session, s.lastFailMs + backoff);
214
+ }
215
+ }
216
+ this.continuationMemory = closesBySig;
217
+ if (openMarker) {
218
+ const row = openMarker.row;
219
+ const deadlineMs = Date.parse(row.breakerDeadline);
220
+ const sig = row.triggerSignature ?? { tier: 'T1' };
221
+ const episodeId = row.episodeId ?? `thrash-${openMarker.tsMs}`;
222
+ const sigKey = signatureKey(sig);
223
+ if (Number.isFinite(deadlineMs) && deadlineMs > nowMs) {
224
+ // Restart in the middle of the backoff: boot OPEN with the ORIGINAL
225
+ // deadline; episodeId dedupe prevents a re-alert (I8, R2-M2).
226
+ this.breaker = {
227
+ episodeId,
228
+ openedAtMs: row.breakerOpenedAt ? Date.parse(row.breakerOpenedAt) : openMarker.tsMs,
229
+ deadlineMs,
230
+ signature: sig,
231
+ };
232
+ this.alertedEpisodeIds.add(episodeId);
233
+ }
234
+ else if (Number.isFinite(deadlineMs) && !closesBySig.has(sigKey)) {
235
+ // Down-across-the-deadline (R6-m3): the close row was never written
236
+ // because no live process existed at backoff-elapse. Synthesize the
237
+ // close IN MEMORY at the deadline — nothing is written at boot (the
238
+ // read path stays read-only); a same-signature re-cross inside the
239
+ // continuation window JOINS the episode instead of re-alerting.
240
+ this.continuationMemory.set(sigKey, { episodeId, closedAtMs: deadlineMs });
241
+ this.alertedEpisodeIds.add(episodeId);
242
+ }
243
+ }
244
+ // Prune anything the formula says is out of window.
245
+ this.pruneIndex(nowMs, k);
246
+ }
247
+ // ─────────────────────────────────────────────────────────────────────────
248
+ // Per-tick housekeeping (prune, breaker close, measurement-blind, leaves)
249
+ // ─────────────────────────────────────────────────────────────────────────
250
+ beginTick(accounts, nowMs, proactiveEnabled) {
251
+ this.tickSeq += 1;
252
+ const k = this.getKnobs();
253
+ this.pruneIndex(nowMs, k);
254
+ this.maybeCloseBreaker(nowMs, k);
255
+ this.evaluateMeasurementBlind(accounts, nowMs, k, proactiveEnabled);
256
+ }
257
+ /** End-of-tick: emit LEAVE rows for all-hot/target-unmeasured states no longer observed. */
258
+ endTick(nowMs) {
259
+ for (const [session, entry] of [...this.stateRows]) {
260
+ if (entry.touchedTick !== this.tickSeq) {
261
+ this.appendRow({
262
+ ts: new Date(nowMs).toISOString(),
263
+ kind: 'proactive',
264
+ decision: 'refused',
265
+ callerClass: 'proactive-swap',
266
+ session,
267
+ machineId: this.machineId,
268
+ from: '',
269
+ reason: entry.reason,
270
+ transition: 'leave',
271
+ episodeId: entry.episodeId,
272
+ episodeKind: entry.reason === 'all-hot' ? 'all-hot' : 'measurement-blind',
273
+ });
274
+ this.stateRows.delete(session);
275
+ }
276
+ }
277
+ }
278
+ pruneIndex(nowMs, k) {
279
+ const bound = retentionBoundMs(k);
280
+ const cutoff = nowMs - bound;
281
+ this.executions = this.executions.filter((e) => e.tsMs >= cutoff);
282
+ this.inversionIncrementsMs = this.inversionIncrementsMs.filter((t) => nowMs - t <= k.reversalWindowMs);
283
+ for (const [s, t] of [...this.lastSwapAtBySession])
284
+ if (t < cutoff)
285
+ this.lastSwapAtBySession.delete(s);
286
+ for (const [s, t] of [...this.lastDroppedAt])
287
+ if (t < cutoff)
288
+ this.lastDroppedAt.delete(s);
289
+ for (const [s, hops] of [...this.reactiveHops]) {
290
+ const fresh = hops.filter((t) => nowMs - t <= k.reversalWindowMs);
291
+ if (fresh.length === 0)
292
+ this.reactiveHops.delete(s);
293
+ else
294
+ this.reactiveHops.set(s, fresh);
295
+ }
296
+ for (const [key, cont] of [...this.continuationMemory]) {
297
+ if (cont.closedAtMs < cutoff)
298
+ this.continuationMemory.delete(key);
299
+ }
300
+ }
301
+ maybeCloseBreaker(nowMs, k) {
302
+ if (this.breaker && nowMs >= this.breaker.deadlineMs) {
303
+ const ep = this.breaker;
304
+ // The CLOSE row: the suppression `leave` row carrying the episode's
305
+ // triggerSignature (§3.5 R5-m2) so continuation memory re-derives
306
+ // across restarts.
307
+ this.appendRow({
308
+ ts: new Date(nowMs).toISOString(),
309
+ kind: 'proactive',
310
+ decision: 'refused',
311
+ callerClass: 'proactive-swap',
312
+ session: ep.signature.session ?? 'pool',
313
+ machineId: this.machineId,
314
+ from: '',
315
+ reason: 'thrash-breaker',
316
+ transition: 'leave',
317
+ episodeId: ep.episodeId,
318
+ episodeKind: 'thrash-breaker',
319
+ breakerOpenedAt: new Date(ep.openedAtMs).toISOString(),
320
+ breakerDeadline: new Date(ep.deadlineMs).toISOString(),
321
+ triggerSignature: ep.signature,
322
+ });
323
+ this.continuationMemory.set(signatureKey(ep.signature), {
324
+ episodeId: ep.episodeId,
325
+ closedAtMs: nowMs,
326
+ });
327
+ this.breaker = null;
328
+ this.breakerSuppressionRows.clear();
329
+ this.logger.log(`[SwapAntiThrash] thrash breaker half-open (episode ${ep.episodeId}) — proactive swapping may resume`);
330
+ }
331
+ }
332
+ evaluateMeasurementBlind(accounts, nowMs, k, proactiveEnabled) {
333
+ // POOL-LEVEL trigger (R5-m1): proactiveSwap enabled AND ≥2 non-disabled
334
+ // accounts (R6-L4) AND zero of them carry a present, fresh reading —
335
+ // evaluated over the pool ITSELF, never over a candidate evaluation's
336
+ // alternate set (whole-pool blindness must fire even when stale sources
337
+ // mean zero candidate evaluations run).
338
+ const nonDisabled = accounts.filter((a) => a.status !== 'disabled');
339
+ const blind = proactiveEnabled &&
340
+ nonDisabled.length >= 2 &&
341
+ nonDisabled.every((a) => !readingValidity(a, nowMs, k.quotaFreshnessMs).valid);
342
+ if (!blind) {
343
+ this.blindSinceMs = null;
344
+ this.blindEpisodeId = null;
345
+ return;
346
+ }
347
+ if (this.blindSinceMs === null) {
348
+ this.blindSinceMs = nowMs;
349
+ this.blindEpisodeId = `measurement-blind-${nowMs}`;
350
+ return;
351
+ }
352
+ if (nowMs - this.blindSinceMs > k.allHotHeartbeatMs && this.blindEpisodeId && !this.alertedEpisodeIds.has(this.blindEpisodeId)) {
353
+ this.alertedEpisodeIds.add(this.blindEpisodeId);
354
+ this.raiseAttention(`swap-antithrash-${this.blindEpisodeId}`, 'Proactive account optimization is measurement-blind', `No fresh quota readings on any pool account for ${Math.round((nowMs - this.blindSinceMs) / 60000)}+ min — proactive swapping is effectively paused until the quota poller recovers (reactive rescue is unaffected).`);
355
+ }
356
+ }
357
+ // ─────────────────────────────────────────────────────────────────────────
358
+ // The brake pipeline (§3 — evaluated per candidate per tick)
359
+ // ─────────────────────────────────────────────────────────────────────────
360
+ /** Is this account eligible as a proactive SOURCE (valid fresh reading ≥ threshold)? §3.3 source leg. */
361
+ sourceEligible(acct, nowMs) {
362
+ const k = this.getKnobs();
363
+ const v = readingValidity(acct, nowMs, k.quotaFreshnessMs);
364
+ return v.valid && v.utilPct >= k.thresholdPct;
365
+ }
366
+ /** Is the session inside re-intent backoff after a ceiling drop (§4.2)? */
367
+ inReIntentBackoff(session, nowMs) {
368
+ const k = this.getKnobs();
369
+ const dropped = this.lastDroppedAt.get(session);
370
+ return dropped !== undefined && nowMs - dropped < k.dwellMs;
371
+ }
372
+ /**
373
+ * Evaluate one proactive swap intent through the full brake pipeline.
374
+ * Order: ledger-lost → breaker → dwell → filter (bound 0 + ceiling) →
375
+ * score → verify (improvement) → reversal. Writes refusal rows (with the
376
+ * state-transition machinery where mandated) as a side effect.
377
+ */
378
+ evaluateIntent(input) {
379
+ const k = this.getKnobs();
380
+ const nowMs = input.nowMs;
381
+ // 0. failure backoff (§3.6) / re-intent backoff (§4.2) — no intent generated.
382
+ const backoff = this.failureBackoffUntil.get(input.session);
383
+ if (backoff !== undefined && nowMs < backoff)
384
+ return { action: 'skip', why: 'failure-backoff' };
385
+ if (this.inReIntentBackoff(input.session, nowMs))
386
+ return { action: 'skip', why: 'reintent-backoff' };
387
+ // 1. Ledger-lost pause (I12, R3-M3): level-triggered — try to resume first.
388
+ if (!this.ledger.tryResume()) {
389
+ this.ledgerLostRefusals += 1;
390
+ this.bumpRefusal('ledger-lost');
391
+ this.raiseLedgerLostAttention();
392
+ return { action: 'refuse', reason: 'ledger-lost' };
393
+ }
394
+ // 2. Thrash breaker (evaluated before all-hot — §3.1 pipeline order).
395
+ if (this.breaker) {
396
+ this.recordBreakerSuppression(input, nowMs, k);
397
+ return { action: 'refuse', reason: 'thrash-breaker' };
398
+ }
399
+ // 3. Dwell (brake b).
400
+ const lastSwap = this.lastSwapAtBySession.get(input.session);
401
+ if (lastSwap !== undefined && nowMs - lastSwap < k.dwellMs) {
402
+ this.recordSimpleRefusal(input, 'dwell', nowMs, {
403
+ dwellRemainingMs: k.dwellMs - (nowMs - lastSwap),
404
+ });
405
+ return { action: 'refuse', reason: 'dwell' };
406
+ }
407
+ // 4. FILTER (§3.3 normative order): validity gate (bound 0) + absolute
408
+ // ceiling (bound 1) over the alternate set, minus this tick's used targets.
409
+ const ceiling = k.thresholdPct - k.targetHeadroomPct;
410
+ const alternates = input.accounts.filter((a) => a.id !== input.fromAccountId && isLocallyExecutable(a));
411
+ let unmeasuredAlternates = 0;
412
+ const filtered = [];
413
+ let validHotCount = 0;
414
+ for (const a of alternates) {
415
+ const v = readingValidity(a, nowMs, k.quotaFreshnessMs);
416
+ if (!v.valid) {
417
+ unmeasuredAlternates += 1;
418
+ continue;
419
+ }
420
+ if (v.utilPct >= ceiling) {
421
+ validHotCount += 1;
422
+ continue;
423
+ }
424
+ if (input.targetsUsedThisTick.has(a.id))
425
+ continue; // intra-tick per-target cap
426
+ filtered.push({ acct: a, utilPct: v.utilPct });
427
+ }
428
+ const sourceValidity = readingValidity(input.accounts.find((a) => a.id === input.fromAccountId) ?? { lastQuota: null }, nowMs, k.quotaFreshnessMs);
429
+ if (filtered.length === 0) {
430
+ // Empty-filter classification, ONE rule (§3.1/§3.3 R5-L2): `all-hot`
431
+ // iff EVERY alternate carried a VALID reading at/above the ceiling;
432
+ // `target-unmeasured` the moment ANY alternate lacked a valid reading.
433
+ // (Targets removed only by the per-tick cap resolve as
434
+ // no-material-target — the pool is not hot, this tick is just full.)
435
+ let reason;
436
+ if (unmeasuredAlternates > 0)
437
+ reason = 'target-unmeasured';
438
+ else if (validHotCount > 0 && validHotCount === alternates.length)
439
+ reason = 'all-hot';
440
+ else if (alternates.length === 0)
441
+ reason = 'all-hot'; // no alternates at all — nothing to move to
442
+ else
443
+ reason = 'no-material-target';
444
+ if (reason === 'all-hot' || reason === 'target-unmeasured') {
445
+ this.recordStateRefusal(input, reason, nowMs, k, unmeasuredAlternates);
446
+ }
447
+ else {
448
+ this.recordSimpleRefusal(input, reason, nowMs, {});
449
+ }
450
+ return { action: 'refuse', reason };
451
+ }
452
+ // A refusal state this session was in has ended (a target survived) —
453
+ // endTick() will emit the leave row because we do not touch stateRows here.
454
+ // 5. SCORE with the existing use-before-reset scoring — over the FILTERED
455
+ // cool set ONLY (§3.3: the scoring was never the bug; applying it over
456
+ // the hot band was).
457
+ let best = null;
458
+ for (const f of filtered) {
459
+ const s = scoreAccount(f.acct, nowMs);
460
+ if (!best || s > best.score)
461
+ best = { acct: f.acct, utilPct: f.utilPct, score: s };
462
+ }
463
+ const target = best;
464
+ // 6. VERIFY the survivor against bound 2 (relative improvement).
465
+ const fromUtil = sourceValidity.utilPct;
466
+ if (fromUtil - target.utilPct < k.minImprovementPct) {
467
+ this.recordSimpleRefusal(input, 'no-material-target', nowMs, {
468
+ to: target.acct.id,
469
+ fromUtilPct: fromUtil,
470
+ toUtilPct: target.utilPct,
471
+ });
472
+ return { action: 'refuse', reason: 'no-material-target' };
473
+ }
474
+ // 7. Reversal refusal (same-session keyed, §3.5): the intent's (from,to)
475
+ // is the inverse of this session's most recent executed swap within the
476
+ // reversal window.
477
+ const recent = this.mostRecentExecution(input.session, nowMs, k.reversalWindowMs);
478
+ if (recent && recent.from === target.acct.id && recent.to === input.fromAccountId) {
479
+ this.reversalsDetected += 1;
480
+ const row = this.buildRefusalRow(input, 'reversal', nowMs, {
481
+ to: target.acct.id,
482
+ fromUtilPct: fromUtil,
483
+ toUtilPct: target.utilPct,
484
+ });
485
+ this.addInversionIncrement(nowMs, k, row, {
486
+ tier: 'T1',
487
+ pair: pairKey(input.fromAccountId, target.acct.id),
488
+ });
489
+ this.bumpRefusal('reversal');
490
+ this.appendRow(row);
491
+ return { action: 'refuse', reason: 'reversal' };
492
+ }
493
+ return {
494
+ action: 'execute',
495
+ targetAccountId: target.acct.id,
496
+ fromUtilPct: fromUtil,
497
+ toUtilPct: target.utilPct,
498
+ };
499
+ }
500
+ // ─────────────────────────────────────────────────────────────────────────
501
+ // Recording — executed / failed / deferred / dropped / invalidated / reactive
502
+ // ─────────────────────────────────────────────────────────────────────────
503
+ /** Record an EXECUTED proactive swap: index write-through, pair-level +
504
+ * frequency detection (which may open the breaker and stamp this row). */
505
+ recordProactiveExecuted(args) {
506
+ const k = this.getKnobs();
507
+ const row = {
508
+ ts: new Date(args.nowMs).toISOString(),
509
+ kind: 'proactive',
510
+ decision: 'swapped',
511
+ callerClass: 'proactive-swap',
512
+ session: args.session,
513
+ machineId: this.machineId,
514
+ from: args.from,
515
+ to: args.to,
516
+ ...(args.fromUtilPct !== undefined ? { fromUtilPct: args.fromUtilPct } : {}),
517
+ ...(args.toUtilPct !== undefined ? { toUtilPct: args.toUtilPct } : {}),
518
+ ...(args.deferralAgeMs !== undefined ? { deferralAgeMs: args.deferralAgeMs } : {}),
519
+ ...(args.deferCount !== undefined ? { deferCount: args.deferCount } : {}),
520
+ ...(args.defaultAccountChanged ? { defaultAccountChanged: true } : {}),
521
+ ...(args.dryRun ? { dryRun: true } : {}),
522
+ };
523
+ // Pair-level inversion detection (any session, §3.5 — detection-only).
524
+ const inverted = this.executions.some((e) => args.nowMs - e.tsMs <= k.reversalWindowMs &&
525
+ e.from === args.to &&
526
+ e.to === args.from);
527
+ // Write-through index update — ALWAYS, regardless of append outcome (R4-m1).
528
+ this.executions.push({ tsMs: args.nowMs, session: args.session, from: args.from, to: args.to, kind: 'proactive' });
529
+ this.lastSwapAtBySession.set(args.session, args.nowMs);
530
+ this.failureStreaks.delete(`proactive:${args.session}`);
531
+ this.failureBackoffUntil.delete(args.session);
532
+ if (inverted) {
533
+ this.pairLevelDetections += 1;
534
+ this.addInversionIncrement(args.nowMs, k, row, {
535
+ tier: 'T1',
536
+ pair: pairKey(args.from, args.to),
537
+ });
538
+ }
539
+ // Frequency detector (T2, §3.5): this session's proactive executions in
540
+ // the frequency window (including this one) reaching the threshold OPENS
541
+ // the breaker directly — the crossing IS the episode trigger.
542
+ const freq = this.executions.filter((e) => e.session === args.session && e.kind === 'proactive' && args.nowMs - e.tsMs <= k.swapFrequencyWindowMs).length;
543
+ if (freq >= k.swapFrequencyThreshold && !this.breaker) {
544
+ this.frequencyCrossings += 1;
545
+ this.openBreaker(args.nowMs, k, row, { tier: 'T2', session: args.session });
546
+ }
547
+ this.appendRow(row);
548
+ }
549
+ /** Observe a REACTIVE swap (I6 — never gates; dwell clock-start + hop alerts). */
550
+ recordReactiveExecuted(args) {
551
+ const k = this.getKnobs();
552
+ // Index priming happens REGARDLESS of append outcome (R4-m1) — a
553
+ // just-rescued session must be dwell-covered even through a ledger outage.
554
+ this.executions.push({ tsMs: args.nowMs, session: args.session, from: args.from, to: args.to, kind: 'reactive' });
555
+ this.lastSwapAtBySession.set(args.session, args.nowMs);
556
+ this.failureStreaks.delete(`reactive:${args.session}`);
557
+ const hops = this.reactiveHops.get(args.session) ?? [];
558
+ hops.push(args.nowMs);
559
+ const fresh = hops.filter((t) => args.nowMs - t <= k.reversalWindowMs);
560
+ this.reactiveHops.set(args.session, fresh);
561
+ if (fresh.length >= k.reactiveHopAlertThreshold) {
562
+ const epId = `reactive-hops-${args.session}-${fresh[0]}`;
563
+ if (!this.reactiveHopAlerted.has(epId)) {
564
+ this.reactiveHopAlerted.add(epId);
565
+ this.raiseAttention(`swap-antithrash-${epId}`, 'Reactive account swaps are cascading', `Session "${args.session}" has emergency-hopped accounts ${fresh.length} times in the last ${Math.round(k.reversalWindowMs / 60000)} min — the pool is genuinely saturated (every hop was a forced rescue; the reactive guarantee is working, but there is no cool account to land on).`);
566
+ }
567
+ }
568
+ this.appendRow({
569
+ ts: new Date(args.nowMs).toISOString(),
570
+ kind: 'reactive',
571
+ decision: 'swapped',
572
+ callerClass: 'reactive-swap',
573
+ session: args.session,
574
+ machineId: this.machineId,
575
+ from: args.from,
576
+ to: args.to,
577
+ ...(args.defaultAccountChanged ? { defaultAccountChanged: true } : {}),
578
+ });
579
+ }
580
+ /** A REACTIVE swap was refused by the pre-existing refresh rate cap — the
581
+ * one state where a session is stranded on a walled account (§3.1 trigger 2). */
582
+ noteReactiveRateCapRefusal(session, nowMs) {
583
+ const epId = `reactive-ratecap-${session}`;
584
+ if (!this.reactiveHopAlerted.has(epId)) {
585
+ this.reactiveHopAlerted.add(epId);
586
+ this.raiseAttention(`swap-antithrash-${epId}`, 'Reactive rescue rate-capped — session stranded on a walled account', `Session "${session}" hit its account's rate limit but the refresh rate cap refused another swap this window (${new Date(nowMs).toISOString()}) — no further mechanical rescue exists until the window clears.`);
587
+ }
588
+ }
589
+ /** Record a swap-execution failure (§3.6). errorClass is a CONSTRUCTOR NAME
590
+ * or fixed enum member ONLY — never .message/.stack. */
591
+ recordExecFailure(args) {
592
+ const k = this.getKnobs();
593
+ const key = `${args.kind}:${args.session}`;
594
+ const s = this.failureStreaks.get(key);
595
+ const streak = s
596
+ ? { ...s, count: s.count + 1, lastFailMs: args.nowMs }
597
+ : { count: 1, firstFailMs: args.nowMs, lastFailMs: args.nowMs, alerted: false };
598
+ this.failureStreaks.set(key, streak);
599
+ if (args.kind === 'proactive') {
600
+ // Exponential backoff, capped at dwellMs. Reactive NEVER backs off (I6).
601
+ const backoff = Math.min(k.tickMs * Math.pow(2, streak.count), k.dwellMs);
602
+ this.failureBackoffUntil.set(args.session, args.nowMs + backoff);
603
+ }
604
+ const episodeId = `failure-${args.kind}-${args.session}-${streak.firstFailMs}`;
605
+ if (streak.count >= 3 && !streak.alerted) {
606
+ streak.alerted = true;
607
+ this.failureStreaks.set(key, streak);
608
+ this.raiseAttention(`swap-antithrash-${episodeId}`, `${args.kind === 'reactive' ? 'Reactive' : 'Proactive'} account swap is failing repeatedly`, `Swap execution for session "${args.session}" has failed ${streak.count} consecutive times (${args.errorClass}).` +
609
+ (args.kind === 'proactive' ? ' The session is in execution-failure backoff.' : ' Reactive rescue keeps retrying (never skipped).'));
610
+ }
611
+ this.appendRow({
612
+ ts: new Date(args.nowMs).toISOString(),
613
+ kind: args.kind,
614
+ decision: 'failed',
615
+ callerClass: args.kind === 'proactive' ? 'proactive-swap' : 'reactive-swap',
616
+ session: args.session,
617
+ machineId: this.machineId,
618
+ from: args.from,
619
+ ...(args.to ? { to: args.to } : {}),
620
+ reason: 'swap-exec-failed',
621
+ errorClass: args.errorClass,
622
+ episodeId,
623
+ episodeKind: 'failure-streak',
624
+ });
625
+ }
626
+ recordDeferred(args) {
627
+ this.bumpRefusal(args.reason);
628
+ this.appendRow({
629
+ ts: new Date(args.nowMs).toISOString(),
630
+ kind: 'proactive',
631
+ decision: 'deferred',
632
+ callerClass: 'proactive-swap',
633
+ session: args.session,
634
+ machineId: this.machineId,
635
+ from: args.from,
636
+ to: args.to,
637
+ reason: args.reason,
638
+ deferralAgeMs: args.deferralAgeMs,
639
+ deferCount: args.deferCount,
640
+ inFlight: args.inFlight,
641
+ subagentLeg: args.subagentLeg,
642
+ ...(args.dryRun ? { dryRun: true } : {}),
643
+ });
644
+ }
645
+ recordDropped(args) {
646
+ this.droppedCount += 1;
647
+ // Re-intent backoff clock-start (write-through regardless of append).
648
+ this.lastDroppedAt.set(args.session, args.nowMs);
649
+ this.appendRow({
650
+ ts: new Date(args.nowMs).toISOString(),
651
+ kind: 'proactive',
652
+ decision: 'dropped',
653
+ callerClass: 'proactive-swap',
654
+ session: args.session,
655
+ machineId: this.machineId,
656
+ from: args.from,
657
+ to: args.to,
658
+ reason: 'deferral-ceiling-dropped',
659
+ deferralAgeMs: args.deferralAgeMs,
660
+ deferCount: args.deferCount,
661
+ inFlight: args.inFlight,
662
+ subagentLeg: args.subagentLeg,
663
+ ...(args.dryRun ? { dryRun: true } : {}),
664
+ });
665
+ }
666
+ recordInvalidated(args) {
667
+ this.invalidatedCount += 1;
668
+ this.appendRow({
669
+ ts: new Date(args.nowMs).toISOString(),
670
+ kind: 'proactive',
671
+ decision: 'invalidated',
672
+ callerClass: 'proactive-swap',
673
+ session: args.session,
674
+ machineId: this.machineId,
675
+ from: args.from,
676
+ ...(args.to ? { to: args.to } : {}),
677
+ reason: 'intent-stale',
678
+ ...(args.deferralAgeMs !== undefined ? { deferralAgeMs: args.deferralAgeMs } : {}),
679
+ ...(args.deferCount !== undefined ? { deferCount: args.deferCount } : {}),
680
+ ...(args.dryRun ? { dryRun: true } : {}),
681
+ });
682
+ }
683
+ /** Execute-time revalidation refusal (§3.3 R4-m4 — refuse, never re-select). */
684
+ recordRevalidationRefusal(args) {
685
+ if (args.reason === 'intent-stale') {
686
+ this.invalidatedCount += 1;
687
+ this.appendRow({
688
+ ts: new Date(args.nowMs).toISOString(),
689
+ kind: 'proactive',
690
+ decision: 'invalidated',
691
+ callerClass: 'proactive-swap',
692
+ session: args.session,
693
+ machineId: this.machineId,
694
+ from: args.from,
695
+ to: args.to,
696
+ reason: 'intent-stale',
697
+ });
698
+ return;
699
+ }
700
+ this.bumpRefusal(args.reason);
701
+ this.appendRow({
702
+ ts: new Date(args.nowMs).toISOString(),
703
+ kind: 'proactive',
704
+ decision: 'refused',
705
+ callerClass: 'proactive-swap',
706
+ session: args.session,
707
+ machineId: this.machineId,
708
+ from: args.from,
709
+ to: args.to,
710
+ reason: args.reason,
711
+ });
712
+ }
713
+ /** §4.5 interactive work-gate rows (kind 'interactive' — R6-m1). */
714
+ recordInteractiveRefusal(args) {
715
+ this.bumpRefusal('session-busy');
716
+ this.appendRow({
717
+ ts: new Date(args.nowMs).toISOString(),
718
+ kind: 'interactive',
719
+ decision: 'refused',
720
+ callerClass: 'interactive-refresh',
721
+ session: args.session,
722
+ machineId: this.machineId,
723
+ from: '',
724
+ reason: 'session-busy',
725
+ inFlight: args.inFlight,
726
+ subagentLeg: args.subagentLeg,
727
+ ...(args.dryRun ? { dryRun: true } : {}),
728
+ });
729
+ }
730
+ /** A forced kill proceeded over busy work — reactive-after-grace or force (§4.3). */
731
+ recordProceeded(args) {
732
+ this.proceededWithMitigations += 1;
733
+ if (args.kind === 'reactive' && args.to) {
734
+ // A reactive proceed IS an executed swap — dwell clock-start (R4-m1
735
+ // write-through, regardless of append outcome).
736
+ this.executions.push({ tsMs: args.nowMs, session: args.session, from: args.from, to: args.to, kind: 'reactive' });
737
+ this.lastSwapAtBySession.set(args.session, args.nowMs);
738
+ }
739
+ // Unreadable ≠ zero (R5-M1): when the subagent leg is 'absent' the killed-
740
+ // subagent fields are OMITTED, never an implicit empty list.
741
+ const legAbsent = args.subagentLeg === 'absent';
742
+ this.appendRow({
743
+ ts: new Date(args.nowMs).toISOString(),
744
+ kind: args.kind,
745
+ decision: 'proceeded',
746
+ callerClass: args.callerClass,
747
+ session: args.session,
748
+ machineId: this.machineId,
749
+ from: args.from,
750
+ ...(args.to ? { to: args.to } : {}),
751
+ reason: args.reason,
752
+ inFlight: args.inFlight,
753
+ subagentLeg: args.subagentLeg,
754
+ ...(!legAbsent && args.killedSubagents !== undefined ? { killedSubagents: args.killedSubagents } : {}),
755
+ ...(!legAbsent && args.killedSubagentList ? { killedSubagentList: args.killedSubagentList } : {}),
756
+ inbound: args.inbound,
757
+ ...(args.force ? { force: true, authLevel: 'bearer' } : {}),
758
+ ...(args.defaultAccountChanged ? { defaultAccountChanged: true } : {}),
759
+ ...(args.dryRun ? { dryRun: true } : {}),
760
+ });
761
+ }
762
+ // ─────────────────────────────────────────────────────────────────────────
763
+ // Status (§6.3)
764
+ // ─────────────────────────────────────────────────────────────────────────
765
+ breakerState() {
766
+ return this.breaker ? 'open' : 'closed';
767
+ }
768
+ /** In-memory count of `ledger-lost` refusals (the one refusal class that can
769
+ * never write its own row — the writer is what died, §3.5/I5). Supplied to
770
+ * the SwapLedger's outage-resume `outage-summary` row. */
771
+ ledgerLostRefusalCount() {
772
+ return this.ledgerLostRefusals;
773
+ }
774
+ isBreakerOpen() {
775
+ return this.breaker !== null;
776
+ }
777
+ status(nowMs) {
778
+ const ledgerStatus = this.ledger.status();
779
+ const byReason = {};
780
+ for (const [r, n] of this.refusalsByReason)
781
+ byReason[r] = n;
782
+ const streaks = {};
783
+ for (const [k, s] of this.failureStreaks)
784
+ streaks[k] = s.count;
785
+ const k = this.getKnobs();
786
+ const now = nowMs ?? this.now();
787
+ return {
788
+ refusals: { byReason },
789
+ thrash: {
790
+ reversalsDetected: this.reversalsDetected,
791
+ pairLevelDetections: this.pairLevelDetections,
792
+ frequencyCrossings: this.frequencyCrossings,
793
+ breakerState: this.breakerState(),
794
+ ...(this.breaker
795
+ ? {
796
+ breakerOpenedAt: new Date(this.breaker.openedAtMs).toISOString(),
797
+ episodes: [
798
+ {
799
+ episodeId: this.breaker.episodeId,
800
+ episodeKind: 'thrash-breaker',
801
+ openedAt: new Date(this.breaker.openedAtMs).toISOString(),
802
+ expiresAt: new Date(this.breaker.deadlineMs).toISOString(),
803
+ },
804
+ ],
805
+ }
806
+ : { episodes: [] }),
807
+ },
808
+ execFailures: { bySession: streaks, streaks },
809
+ deferrals: {
810
+ dropped: this.droppedCount,
811
+ invalidated: this.invalidatedCount,
812
+ proceededWithMitigations: this.proceededWithMitigations,
813
+ },
814
+ hydration: ledgerStatus.hydration,
815
+ corruptLinesSkipped: ledgerStatus.corruptLinesSkipped,
816
+ ledger: {
817
+ writable: ledgerStatus.writable,
818
+ ...(ledgerStatus.lostSince ? { lostSince: ledgerStatus.lostSince } : {}),
819
+ ledgerLostRefusals: this.ledgerLostRefusals,
820
+ rowsLostWhileDown: ledgerStatus.rowsLostWhileDown,
821
+ },
822
+ quotaValidity: {
823
+ freshnessMs: k.quotaFreshnessMs,
824
+ measurementBlindSince: this.blindSinceMs !== null ? new Date(this.blindSinceMs).toISOString() : null,
825
+ },
826
+ retentionBoundMs: retentionBoundMs(k),
827
+ scope: 'local',
828
+ _now: new Date(now).toISOString(),
829
+ };
830
+ }
831
+ // ─────────────────────────────────────────────────────────────────────────
832
+ // Internals
833
+ // ─────────────────────────────────────────────────────────────────────────
834
+ mostRecentExecution(session, nowMs, windowMs) {
835
+ let best = null;
836
+ for (const e of this.executions) {
837
+ if (e.session !== session)
838
+ continue;
839
+ if (nowMs - e.tsMs > windowMs)
840
+ continue;
841
+ if (!best || e.tsMs > best.tsMs)
842
+ best = e;
843
+ }
844
+ return best;
845
+ }
846
+ /** Add a T1 inversion-class increment; open the breaker when the pool-wide
847
+ * count inside reversalWindowMs reaches the threshold. The increment ROW is
848
+ * the open marker (R3-m2) — stamped with the episode fields at append time. */
849
+ addInversionIncrement(nowMs, k, row, sig) {
850
+ this.inversionIncrementsMs.push(nowMs);
851
+ this.inversionIncrementsMs = this.inversionIncrementsMs.filter((t) => nowMs - t <= k.reversalWindowMs);
852
+ if (this.inversionIncrementsMs.length >= k.thrashBreakerThreshold && !this.breaker) {
853
+ this.openBreaker(nowMs, k, row, sig);
854
+ }
855
+ }
856
+ openBreaker(nowMs, k, openMarkerRow, sig) {
857
+ const sigKey = signatureKey(sig);
858
+ const contWindow = sig.tier === 'T2' ? k.swapFrequencyWindowMs : k.reversalWindowMs;
859
+ const cont = this.continuationMemory.get(sigKey);
860
+ const isContinuation = cont !== undefined && nowMs - cont.closedAtMs <= contWindow;
861
+ const episodeId = isContinuation ? cont.episodeId : `thrash-${nowMs}-${Math.random().toString(36).slice(2, 8)}`;
862
+ const deadlineMs = nowMs + k.thrashBreakerBackoffMs;
863
+ this.breaker = { episodeId, openedAtMs: nowMs, deadlineMs, signature: sig };
864
+ // Stamp the open-marker row (the increment row that crossed the trigger).
865
+ openMarkerRow.episodeId = episodeId;
866
+ openMarkerRow.episodeKind = 'thrash-breaker';
867
+ openMarkerRow.breakerOpenedAt = new Date(nowMs).toISOString();
868
+ openMarkerRow.breakerDeadline = new Date(deadlineMs).toISOString();
869
+ openMarkerRow.triggerSignature = sig;
870
+ if (!this.alertedEpisodeIds.has(episodeId)) {
871
+ this.alertedEpisodeIds.add(episodeId);
872
+ this.raiseAttention(`swap-antithrash-${episodeId}`, 'Proactive account-swap is thrashing — suppressed', `The thrash breaker opened (${sig.tier === 'T2' ? `session "${sig.session}" hit the swap-frequency threshold` : `account pair ${sig.pair} reversed within the window`}). ` +
873
+ `Proactive swaps are suppressed for ${Math.round(k.thrashBreakerBackoffMs / 60000)} min; reactive rescue is unaffected.`);
874
+ }
875
+ this.logger.warn(`[SwapAntiThrash] thrash breaker OPEN (${sig.tier}, episode ${episodeId}) — proactive swaps suppressed until ${new Date(deadlineMs).toISOString()}`);
876
+ }
877
+ recordBreakerSuppression(input, nowMs, k) {
878
+ this.bumpRefusal('thrash-breaker');
879
+ if (!this.breaker)
880
+ return;
881
+ const key = `${this.breaker.episodeId}:${input.session}`;
882
+ const prev = this.breakerSuppressionRows.get(key);
883
+ let transition = null;
884
+ if (!prev)
885
+ transition = 'enter';
886
+ else if (nowMs - prev.lastRowMs >= k.allHotHeartbeatMs)
887
+ transition = 'heartbeat';
888
+ if (transition) {
889
+ this.breakerSuppressionRows.set(key, { lastRowMs: nowMs });
890
+ this.appendRow({
891
+ ts: new Date(nowMs).toISOString(),
892
+ kind: 'proactive',
893
+ decision: 'refused',
894
+ callerClass: 'proactive-swap',
895
+ session: input.session,
896
+ machineId: this.machineId,
897
+ from: input.fromAccountId,
898
+ reason: 'thrash-breaker',
899
+ transition,
900
+ episodeId: this.breaker.episodeId,
901
+ episodeKind: 'thrash-breaker',
902
+ breakerOpenedAt: new Date(this.breaker.openedAtMs).toISOString(),
903
+ breakerDeadline: new Date(this.breaker.deadlineMs).toISOString(),
904
+ triggerSignature: this.breaker.signature,
905
+ });
906
+ }
907
+ }
908
+ /** all-hot / target-unmeasured refusals use state-transition rows (§3.1). */
909
+ recordStateRefusal(input, reason, nowMs, k, unmeasuredAlternates) {
910
+ this.bumpRefusal(reason);
911
+ const prev = this.stateRows.get(input.session);
912
+ let transition = null;
913
+ let entry;
914
+ if (!prev || prev.reason !== reason) {
915
+ if (prev && prev.reason !== reason) {
916
+ // reason change: leave the old state first
917
+ this.appendRow({
918
+ ts: new Date(nowMs).toISOString(),
919
+ kind: 'proactive',
920
+ decision: 'refused',
921
+ callerClass: 'proactive-swap',
922
+ session: input.session,
923
+ machineId: this.machineId,
924
+ from: input.fromAccountId,
925
+ reason: prev.reason,
926
+ transition: 'leave',
927
+ episodeId: prev.episodeId,
928
+ episodeKind: prev.reason === 'all-hot' ? 'all-hot' : 'measurement-blind',
929
+ });
930
+ }
931
+ transition = 'enter';
932
+ entry = {
933
+ reason,
934
+ enteredAtMs: nowMs,
935
+ lastRowMs: nowMs,
936
+ episodeId: `${reason}-${input.session}-${nowMs}`,
937
+ touchedTick: this.tickSeq,
938
+ };
939
+ }
940
+ else {
941
+ entry = { ...prev, touchedTick: this.tickSeq };
942
+ if (nowMs - prev.lastRowMs >= k.allHotHeartbeatMs) {
943
+ transition = 'heartbeat';
944
+ entry.lastRowMs = nowMs;
945
+ }
946
+ }
947
+ this.stateRows.set(input.session, entry);
948
+ if (transition) {
949
+ this.appendRow({
950
+ ts: new Date(nowMs).toISOString(),
951
+ kind: 'proactive',
952
+ decision: 'refused',
953
+ callerClass: 'proactive-swap',
954
+ session: input.session,
955
+ machineId: this.machineId,
956
+ from: input.fromAccountId,
957
+ reason,
958
+ transition,
959
+ episodeId: entry.episodeId,
960
+ episodeKind: reason === 'all-hot' ? 'all-hot' : 'measurement-blind',
961
+ ...(reason === 'target-unmeasured' ? { unmeasuredAlternates } : {}),
962
+ ...(input.deferralAgeMs !== undefined ? { deferralAgeMs: input.deferralAgeMs } : {}),
963
+ });
964
+ }
965
+ }
966
+ recordSimpleRefusal(input, reason, nowMs, extra) {
967
+ this.bumpRefusal(reason);
968
+ this.appendRow(this.buildRefusalRow(input, reason, nowMs, extra));
969
+ }
970
+ buildRefusalRow(input, reason, nowMs, extra) {
971
+ return {
972
+ ts: new Date(nowMs).toISOString(),
973
+ kind: 'proactive',
974
+ decision: 'refused',
975
+ callerClass: 'proactive-swap',
976
+ session: input.session,
977
+ machineId: this.machineId,
978
+ from: input.fromAccountId,
979
+ reason,
980
+ ...extra,
981
+ };
982
+ }
983
+ appendRow(row) {
984
+ const k = this.getKnobs();
985
+ if (k.dryRun && row.dryRun === undefined && (row.decision === 'refused' || row.decision === 'deferred' || row.decision === 'dropped' || row.decision === 'invalidated')) {
986
+ // Rung-2 honesty: a would-decision under dryRun is marked as such.
987
+ row.dryRun = true;
988
+ }
989
+ this.ledger.append(row);
990
+ this.logRow(row);
991
+ }
992
+ logRow(row) {
993
+ if (row.decision === 'refused' && row.reason) {
994
+ this.logger.log(`[ProactiveSwap] ${row.dryRun ? 'WOULD-REFUSE' : 'REFUSED'} session=${row.session} from=${row.from} reason=${row.reason}` +
995
+ (row.fromUtilPct !== undefined ? ` fromUtil=${row.fromUtilPct}` : '') +
996
+ (row.toUtilPct !== undefined ? ` bestAltUtil=${row.toUtilPct}` : ''));
997
+ }
998
+ else if (row.decision === 'deferred') {
999
+ this.logger.log(`[SwapWorkGate] ${row.dryRun ? 'WOULD-DEFER' : 'DEFERRED'} session=${row.session} caller=proactive-swap reason=${row.reason}` +
1000
+ (row.inFlight ? `(${row.inFlight.subagents})` : '') +
1001
+ ` deferralAgeMs=${row.deferralAgeMs ?? 0}`);
1002
+ }
1003
+ else if (row.decision === 'proceeded') {
1004
+ this.logger.log(`[SwapWorkGate] PROCEEDED-WITH-MITIGATIONS session=${row.session} caller=${row.callerClass} killedSubagents=${row.killedSubagents ?? 'unreadable'} inbound=${row.inbound}`);
1005
+ }
1006
+ }
1007
+ bumpRefusal(reason) {
1008
+ this.refusalsByReason.set(reason, (this.refusalsByReason.get(reason) ?? 0) + 1);
1009
+ }
1010
+ ledgerLostAlerted = false;
1011
+ raiseLedgerLostAttention() {
1012
+ if (this.ledgerLostAlerted)
1013
+ return;
1014
+ this.ledgerLostAlerted = true;
1015
+ this.raiseAttention('swap-antithrash-ledger-lost', 'Swap ledger unwritable — proactive optimization paused', 'The swap-decision ledger cannot be written (disk error?). Every anti-thrash brake depends on it, so proactive account swapping is PAUSED until writes recover (level-triggered — no restart needed). Reactive rescue is unaffected.');
1016
+ }
1017
+ }
1018
+ // ── helpers ─────────────────────────────────────────────────────────────────
1019
+ export function pairKey(a, b) {
1020
+ return [a, b].sort().join('|');
1021
+ }
1022
+ export function signatureKey(sig) {
1023
+ return sig.tier === 'T2' ? `T2:${sig.session ?? ''}` : `T1:${sig.pair ?? ''}`;
1024
+ }
1025
+ //# sourceMappingURL=SwapAntiThrash.js.map