instar 1.3.728 → 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.
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +259 -2
- package/dist/commands/server.js.map +1 -1
- package/dist/config/ConfigDefaults.d.ts.map +1 -1
- package/dist/config/ConfigDefaults.js +11 -0
- package/dist/config/ConfigDefaults.js.map +1 -1
- package/dist/core/FileClassifier.d.ts.map +1 -1
- package/dist/core/FileClassifier.js +11 -0
- package/dist/core/FileClassifier.js.map +1 -1
- package/dist/core/LocalSessionOwnershipStore.d.ts +5 -0
- package/dist/core/LocalSessionOwnershipStore.d.ts.map +1 -1
- package/dist/core/LocalSessionOwnershipStore.js +13 -0
- package/dist/core/LocalSessionOwnershipStore.js.map +1 -1
- package/dist/core/ModelSwapService.d.ts +13 -0
- package/dist/core/ModelSwapService.d.ts.map +1 -1
- package/dist/core/ModelSwapService.js +11 -0
- package/dist/core/ModelSwapService.js.map +1 -1
- package/dist/core/ModelTierEscalation.d.ts +10 -0
- package/dist/core/ModelTierEscalation.d.ts.map +1 -1
- package/dist/core/ModelTierEscalation.js +4 -0
- package/dist/core/ModelTierEscalation.js.map +1 -1
- package/dist/core/PostUpdateMigrator.d.ts +1 -0
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +35 -0
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/ProactiveSwapMonitor.d.ts +74 -20
- package/dist/core/ProactiveSwapMonitor.d.ts.map +1 -1
- package/dist/core/ProactiveSwapMonitor.js +349 -28
- package/dist/core/ProactiveSwapMonitor.js.map +1 -1
- package/dist/core/QuotaAwareScheduler.d.ts +70 -3
- package/dist/core/QuotaAwareScheduler.d.ts.map +1 -1
- package/dist/core/QuotaAwareScheduler.js +100 -16
- package/dist/core/QuotaAwareScheduler.js.map +1 -1
- package/dist/core/SessionBuildContextStore.d.ts +20 -0
- package/dist/core/SessionBuildContextStore.d.ts.map +1 -1
- package/dist/core/SessionBuildContextStore.js +20 -3
- package/dist/core/SessionBuildContextStore.js.map +1 -1
- package/dist/core/SessionManager.d.ts +33 -0
- package/dist/core/SessionManager.d.ts.map +1 -1
- package/dist/core/SessionManager.js +105 -0
- package/dist/core/SessionManager.js.map +1 -1
- package/dist/core/SessionOwnershipRegistry.d.ts +16 -0
- package/dist/core/SessionOwnershipRegistry.d.ts.map +1 -1
- package/dist/core/SessionOwnershipRegistry.js +13 -0
- package/dist/core/SessionOwnershipRegistry.js.map +1 -1
- package/dist/core/SessionRefresh.d.ts +116 -1
- package/dist/core/SessionRefresh.d.ts.map +1 -1
- package/dist/core/SessionRefresh.js +280 -17
- package/dist/core/SessionRefresh.js.map +1 -1
- package/dist/core/StateManager.d.ts +20 -0
- package/dist/core/StateManager.d.ts.map +1 -1
- package/dist/core/StateManager.js +72 -4
- package/dist/core/StateManager.js.map +1 -1
- package/dist/core/SwapAntiThrash.d.ts +329 -0
- package/dist/core/SwapAntiThrash.d.ts.map +1 -0
- package/dist/core/SwapAntiThrash.js +1025 -0
- package/dist/core/SwapAntiThrash.js.map +1 -0
- package/dist/core/SwapLedger.d.ts +168 -0
- package/dist/core/SwapLedger.d.ts.map +1 -0
- package/dist/core/SwapLedger.js +275 -0
- package/dist/core/SwapLedger.js.map +1 -0
- package/dist/core/SwapWorkGate.d.ts +100 -0
- package/dist/core/SwapWorkGate.d.ts.map +1 -0
- package/dist/core/SwapWorkGate.js +175 -0
- package/dist/core/SwapWorkGate.js.map +1 -0
- package/dist/core/WriteAdmission.d.ts +277 -0
- package/dist/core/WriteAdmission.d.ts.map +1 -0
- package/dist/core/WriteAdmission.js +514 -0
- package/dist/core/WriteAdmission.js.map +1 -0
- package/dist/core/WriteDomainRegistry.d.ts +132 -0
- package/dist/core/WriteDomainRegistry.d.ts.map +1 -0
- package/dist/core/WriteDomainRegistry.js +186 -0
- package/dist/core/WriteDomainRegistry.js.map +1 -0
- package/dist/core/devGatedFeatures.d.ts.map +1 -1
- package/dist/core/devGatedFeatures.js +12 -0
- package/dist/core/devGatedFeatures.js.map +1 -1
- package/dist/core/types.d.ts +89 -1
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/monitoring/guardManifest.d.ts.map +1 -1
- package/dist/monitoring/guardManifest.js +58 -1
- package/dist/monitoring/guardManifest.js.map +1 -1
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +3 -1
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/server/AgentServer.d.ts +3 -0
- package/dist/server/AgentServer.d.ts.map +1 -1
- package/dist/server/AgentServer.js +20 -0
- package/dist/server/AgentServer.js.map +1 -1
- package/dist/server/CapabilityIndex.d.ts.map +1 -1
- package/dist/server/CapabilityIndex.js +1 -0
- package/dist/server/CapabilityIndex.js.map +1 -1
- package/dist/server/routes.d.ts +6 -0
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +115 -3
- package/dist/server/routes.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +65 -65
- package/src/scaffold/templates.ts +3 -1
- package/upgrades/1.3.729.md +92 -0
- package/upgrades/1.3.730.md +112 -0
- package/upgrades/side-effects/standby-write-reconciliation.md +62 -0
- 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
|