instar 1.3.714 → 1.3.715
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 +599 -4
- package/dist/commands/server.js.map +1 -1
- package/dist/config/ConfigDefaults.d.ts.map +1 -1
- package/dist/config/ConfigDefaults.js +35 -0
- package/dist/config/ConfigDefaults.js.map +1 -1
- package/dist/core/CoherenceJournal.d.ts +1 -1
- package/dist/core/CoherenceJournal.d.ts.map +1 -1
- package/dist/core/CoherenceJournal.js +9 -2
- package/dist/core/CoherenceJournal.js.map +1 -1
- package/dist/core/FencedLease.d.ts +47 -1
- package/dist/core/FencedLease.d.ts.map +1 -1
- package/dist/core/FencedLease.js +58 -1
- package/dist/core/FencedLease.js.map +1 -1
- package/dist/core/LeaseCoordinator.d.ts +25 -1
- package/dist/core/LeaseCoordinator.d.ts.map +1 -1
- package/dist/core/LeaseCoordinator.js +66 -0
- package/dist/core/LeaseCoordinator.js.map +1 -1
- package/dist/core/LeaseHandbackReconciler.d.ts +212 -0
- package/dist/core/LeaseHandbackReconciler.d.ts.map +1 -0
- package/dist/core/LeaseHandbackReconciler.js +416 -0
- package/dist/core/LeaseHandbackReconciler.js.map +1 -0
- package/dist/core/MeshRpc.d.ts +6 -1
- package/dist/core/MeshRpc.d.ts.map +1 -1
- package/dist/core/MeshRpc.js +9 -0
- package/dist/core/MeshRpc.js.map +1 -1
- package/dist/core/MultiMachineCoordinator.d.ts +20 -0
- package/dist/core/MultiMachineCoordinator.d.ts.map +1 -1
- package/dist/core/MultiMachineCoordinator.js +30 -0
- package/dist/core/MultiMachineCoordinator.js.map +1 -1
- package/dist/core/OwnershipReconciler.d.ts +42 -2
- package/dist/core/OwnershipReconciler.d.ts.map +1 -1
- package/dist/core/OwnershipReconciler.js +100 -2
- package/dist/core/OwnershipReconciler.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 +31 -0
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/StaleOwnerReleaseEngine.d.ts +290 -0
- package/dist/core/StaleOwnerReleaseEngine.d.ts.map +1 -0
- package/dist/core/StaleOwnerReleaseEngine.js +645 -0
- package/dist/core/StaleOwnerReleaseEngine.js.map +1 -0
- package/dist/core/TopicClaimAnnotationStore.d.ts +121 -0
- package/dist/core/TopicClaimAnnotationStore.d.ts.map +1 -0
- package/dist/core/TopicClaimAnnotationStore.js +234 -0
- package/dist/core/TopicClaimAnnotationStore.js.map +1 -0
- package/dist/core/devGatedFeatures.d.ts.map +1 -1
- package/dist/core/devGatedFeatures.js +11 -0
- package/dist/core/devGatedFeatures.js.map +1 -1
- package/dist/core/handbackLatch.d.ts +28 -0
- package/dist/core/handbackLatch.d.ts.map +1 -0
- package/dist/core/handbackLatch.js +73 -0
- package/dist/core/handbackLatch.js.map +1 -0
- package/dist/core/ropeHealth.d.ts +26 -0
- package/dist/core/ropeHealth.d.ts.map +1 -0
- package/dist/core/ropeHealth.js +32 -0
- package/dist/core/ropeHealth.js.map +1 -0
- package/dist/core/types.d.ts +51 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/monitoring/StrandedTopicSentinel.d.ts +5 -0
- package/dist/monitoring/StrandedTopicSentinel.d.ts.map +1 -1
- package/dist/monitoring/StrandedTopicSentinel.js +8 -0
- package/dist/monitoring/StrandedTopicSentinel.js.map +1 -1
- package/dist/monitoring/guardManifest.d.ts.map +1 -1
- package/dist/monitoring/guardManifest.js +45 -0
- package/dist/monitoring/guardManifest.js.map +1 -1
- package/dist/monitoring/strandedTopicDecision.d.ts +11 -0
- package/dist/monitoring/strandedTopicDecision.d.ts.map +1 -1
- package/dist/monitoring/strandedTopicDecision.js +12 -1
- package/dist/monitoring/strandedTopicDecision.js.map +1 -1
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +2 -1
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/server/AgentServer.d.ts +9 -0
- package/dist/server/AgentServer.d.ts.map +1 -1
- package/dist/server/AgentServer.js +2 -0
- package/dist/server/AgentServer.js.map +1 -1
- package/dist/server/routes.d.ts +9 -0
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +76 -0
- 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 +2 -1
- package/upgrades/1.3.715.md +92 -0
- package/upgrades/side-effects/u4-stale-owner-release-lease-handback.md +87 -0
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StaleOwnerReleaseEngine — U4.2 (docs/specs/u4-2-stale-owner-release.md): the
|
|
3
|
+
* CMT-1786 auto-failover, built as the EVIDENCE UPGRADE to OwnershipReconciler
|
|
4
|
+
* Case C. When a topic's owner machine is genuinely dead/dark, the serving-lease
|
|
5
|
+
* HOLDER force-claims its topics — but only after an evidence bar in which EVERY
|
|
6
|
+
* predicate is a mechanical check over authenticated state, and every ambiguity
|
|
7
|
+
* fails CLOSED (a brief strand beats a split-brain).
|
|
8
|
+
*
|
|
9
|
+
* One actor (spec §2.7.6): this engine is invoked FROM OwnershipReconciler.tick()
|
|
10
|
+
* and issues claims through the reconciler's single CAS funnel — it is Case C's
|
|
11
|
+
* evidence bar, not a second takeover authority. `ownershipEpoch` is the only
|
|
12
|
+
* fence; the §2.4 `topic-claim-annotation` kind is deliberately NOT ownership
|
|
13
|
+
* state (it can never answer or fence ownership).
|
|
14
|
+
*
|
|
15
|
+
* The evidence bar (§2.2 — ALL required; fail CLOSED on any ambiguity):
|
|
16
|
+
* 1. machine death: owner offline in the pool AND observer-stamped staleness
|
|
17
|
+
* ≥ deathEvidenceMs on the OBSERVER's monotonic clock (never the owner's
|
|
18
|
+
* self-reported wall clock — R-r2-5a). Never-observed-since-boot follows
|
|
19
|
+
* the bounded bootstrap rule (R-r2-2), never a silent forever-strand.
|
|
20
|
+
* 2. multi-transport disproof: unreachable via the authenticated probe on
|
|
21
|
+
* EVERY advertised transport; the advert set must be owner-authenticated
|
|
22
|
+
* (PeerEndpointRecorder provenance ONLY — R-r2-5b), non-empty, fresh, and
|
|
23
|
+
* multi-transport (a single-rope advert set is automatic ambiguity).
|
|
24
|
+
* 3. quorum (verbatim Case C): online × 2 > machines.
|
|
25
|
+
* 4. claimant self-connectivity proof (a claimer with a broken NIC sees
|
|
26
|
+
* everyone as dead and must never claim). 2-machine git-less mesh: the
|
|
27
|
+
* claim path is DISABLED outright (detection + escalation still run).
|
|
28
|
+
* 5. owner liveness disproof over a PROVABLY FRESH evidence mirror — a stale
|
|
29
|
+
* mirror classifies AMBIGUITY, never "no recent side-effects" (R-r2-3).
|
|
30
|
+
* 6. fresh re-read immediately before the claim (the caller's CAS funnel
|
|
31
|
+
* re-reads; the engine re-checks the record at claim time).
|
|
32
|
+
*
|
|
33
|
+
* Bounded blast radius (§2.5): maxClaimsPerTick; per-topic claim budget +
|
|
34
|
+
* widening backoff carried on the REPLICATED topic-claim-annotation kind
|
|
35
|
+
* (R-r2-4 — the claimer role moves with the lease, the budget must follow the
|
|
36
|
+
* topic); probe verdicts single-flight + TTL-memoized + backoff + a P19 breaker
|
|
37
|
+
* that degrades to ONE attention item. A declined demote (operator "no") rides
|
|
38
|
+
* the same replicated kind and blocks the episode's claims on ANY claimer.
|
|
39
|
+
*
|
|
40
|
+
* Honesty surfaces (§2.6): every stale-detect, probe verdict, would-claim
|
|
41
|
+
* (dry-run), claim and REFUSAL lands in the decision trace (state-change-gated
|
|
42
|
+
* per episode, never per-tick); ambiguity persisting past the ceiling escalates
|
|
43
|
+
* ONE per-episode deduped attention item — raisable by ANY quorum member
|
|
44
|
+
* (R-r2-1), while the CLAIM stays lease-holder-only.
|
|
45
|
+
*
|
|
46
|
+
* Supervision: Tier 0 by explicit argument (§2.8) — deterministic fencing path;
|
|
47
|
+
* LLM-free, synchronous verdicts, acquires NO spawn-cap slot (asserted by test).
|
|
48
|
+
*/
|
|
49
|
+
export const DEFAULT_STALE_OWNER_RELEASE_CONFIG = {
|
|
50
|
+
dryRun: true,
|
|
51
|
+
deathEvidenceMs: 180_000,
|
|
52
|
+
probeTimeoutMs: 8_000,
|
|
53
|
+
ambiguityCeilingMultiple: 3,
|
|
54
|
+
maxClaimsPerTick: 2,
|
|
55
|
+
bootstrapNonObservationMultiple: 3,
|
|
56
|
+
selfFenceTtlMs: 60_000,
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* §2.3 TTL-ordering invariant (R-r2-3, part 1): "expired implies self-fenced by
|
|
60
|
+
* construction" holds ONLY when
|
|
61
|
+
* deathEvidenceMs > selfFenceTtlMs + reconcilerTickMs + clockSkewSlackMs.
|
|
62
|
+
* This is an INVARIANT of the design, not a tuning suggestion — a violating
|
|
63
|
+
* combination is REJECTED at config validation (the existing multiMachine
|
|
64
|
+
* reject-nonsensical-combinations-at-startup pattern). Returns null when valid,
|
|
65
|
+
* else a human-readable rejection message.
|
|
66
|
+
*/
|
|
67
|
+
export function validateStaleOwnerReleaseInvariants(cfg, reconcilerTickMs, clockSkewSlackMs) {
|
|
68
|
+
const floor = cfg.selfFenceTtlMs + reconcilerTickMs + clockSkewSlackMs;
|
|
69
|
+
if (!(cfg.deathEvidenceMs > floor)) {
|
|
70
|
+
return (`multiMachine.sessionPool.staleOwnerRelease: deathEvidenceMs (${cfg.deathEvidenceMs}ms) must be STRICTLY greater than ` +
|
|
71
|
+
`selfFenceTtlMs + reconcilerTickMs + clockSkewSlackMs (${cfg.selfFenceTtlMs} + ${reconcilerTickMs} + ${clockSkewSlackMs} = ${floor}ms) — ` +
|
|
72
|
+
`otherwise a stale-owner claim can land while the owner is still legitimately emitting ` +
|
|
73
|
+
`("expired implies self-fenced" breaks; u4-2 spec §2.3). Raise deathEvidenceMs or lower selfFenceTtlMs.`);
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
/** 30 min of calm closes an episode (§2.6). */
|
|
78
|
+
const EPISODE_CALM_CLOSE_MS = 30 * 60_000;
|
|
79
|
+
/** Memoized probe verdict TTL (one reachability verdict per episode window). */
|
|
80
|
+
const PROBE_MEMO_TTL_MS = 60_000;
|
|
81
|
+
/** P19: consecutive probe errors that open the probe breaker. */
|
|
82
|
+
const PROBE_BREAKER_ERRORS = 5;
|
|
83
|
+
/** Per-topic claim budget before the loud P19 give-up. */
|
|
84
|
+
const PER_TOPIC_CLAIM_BUDGET = 5;
|
|
85
|
+
/** Widening backoff base between claim attempts on the same topic. */
|
|
86
|
+
const CLAIM_BACKOFF_BASE_MS = 60_000;
|
|
87
|
+
export class StaleOwnerReleaseEngine {
|
|
88
|
+
d;
|
|
89
|
+
episodes = new Map(); // by owner
|
|
90
|
+
/** Observer-clock liveness: mono-ms an owner's observer-stamped lastSeen last
|
|
91
|
+
* ADVANCED (the FencedLease-F2 verified fold-in pattern). Absent = never
|
|
92
|
+
* observed since claimant boot (the R-r2-2 bootstrap case). */
|
|
93
|
+
lastSeenMono = new Map();
|
|
94
|
+
lastSeenWallReading = new Map();
|
|
95
|
+
bootMono;
|
|
96
|
+
lastTickAtWall = 0;
|
|
97
|
+
lastEpisodeSummary = null;
|
|
98
|
+
counters = {
|
|
99
|
+
attempts: 0,
|
|
100
|
+
claims: 0,
|
|
101
|
+
wouldClaims: 0,
|
|
102
|
+
refusalsByReason: {
|
|
103
|
+
'transport-ambiguity': 0,
|
|
104
|
+
'not-expired': 0,
|
|
105
|
+
'quorum-fail': 0,
|
|
106
|
+
'self-proof-fail': 0,
|
|
107
|
+
'side-effect-fresh': 0,
|
|
108
|
+
},
|
|
109
|
+
ambiguities: 0,
|
|
110
|
+
escalations: 0,
|
|
111
|
+
p19GiveUps: 0,
|
|
112
|
+
};
|
|
113
|
+
evidenceClasses = {};
|
|
114
|
+
constructor(deps) {
|
|
115
|
+
this.d = deps;
|
|
116
|
+
this.bootMono = this.mono();
|
|
117
|
+
}
|
|
118
|
+
now() {
|
|
119
|
+
return (this.d.now ?? Date.now)();
|
|
120
|
+
}
|
|
121
|
+
mono() {
|
|
122
|
+
if (this.d.monotonicNow)
|
|
123
|
+
return this.d.monotonicNow();
|
|
124
|
+
return Number(process.hrtime.bigint() / 1000000n);
|
|
125
|
+
}
|
|
126
|
+
log(m) {
|
|
127
|
+
try {
|
|
128
|
+
this.d.logger?.(`[StaleOwnerRelease] ${m}`);
|
|
129
|
+
}
|
|
130
|
+
catch { /* @silent-fallback-ok — a logger fault must never gate an evidence verdict (observability only) */ }
|
|
131
|
+
}
|
|
132
|
+
trace(entry) {
|
|
133
|
+
try {
|
|
134
|
+
this.d.trace({ ts: new Date(this.now()).toISOString(), ...entry });
|
|
135
|
+
}
|
|
136
|
+
catch { /* @silent-fallback-ok — the decision trace is observability; a trace-write fault must never gate or flip a verdict */ }
|
|
137
|
+
}
|
|
138
|
+
bumpEvidenceClass(cls) {
|
|
139
|
+
this.evidenceClasses[cls] = (this.evidenceClasses[cls] ?? 0) + 1;
|
|
140
|
+
}
|
|
141
|
+
isActive() {
|
|
142
|
+
try {
|
|
143
|
+
return this.d.enabled();
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return false; /* @silent-fallback-ok — an unreadable feature gate reads as INACTIVE (fail dark, the safe direction) */
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* One evidence pass (invoked from OwnershipReconciler.tick()). Deterministic
|
|
151
|
+
* over injected state; probes are kicked off asynchronously and their
|
|
152
|
+
* verdicts consumed on LATER ticks (single-flight, memoized) — the tick
|
|
153
|
+
* itself never blocks on the network.
|
|
154
|
+
*/
|
|
155
|
+
tick() {
|
|
156
|
+
if (!this.isActive())
|
|
157
|
+
return;
|
|
158
|
+
const self = this.d.selfMachineId();
|
|
159
|
+
if (!self)
|
|
160
|
+
return;
|
|
161
|
+
const machines = this.d.machines();
|
|
162
|
+
if (machines.length < 2)
|
|
163
|
+
return; // single-machine strict no-op
|
|
164
|
+
const cfg = this.d.config();
|
|
165
|
+
const nowWall = this.now();
|
|
166
|
+
const nowMono = this.mono();
|
|
167
|
+
this.lastTickAtWall = nowWall;
|
|
168
|
+
// ── Fold observer-stamped liveness into the monotonic map (F2 pattern):
|
|
169
|
+
// stamp mono time whenever a machine's observer lastSeen ADVANCES.
|
|
170
|
+
const byId = new Map();
|
|
171
|
+
for (const m of machines) {
|
|
172
|
+
byId.set(m.machineId, m);
|
|
173
|
+
const prev = this.lastSeenWallReading.get(m.machineId);
|
|
174
|
+
if (m.observerLastSeenMs > 0 && (prev === undefined || m.observerLastSeenMs > prev)) {
|
|
175
|
+
this.lastSeenWallReading.set(m.machineId, m.observerLastSeenMs);
|
|
176
|
+
this.lastSeenMono.set(m.machineId, nowMono);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// ── Candidate owners: peers that own at least one live topic record.
|
|
180
|
+
const records = this.d.listOwnershipRecords().filter((r) => r.status !== 'released' && r.ownerMachineId && r.ownerMachineId !== self);
|
|
181
|
+
const topicsByOwner = new Map();
|
|
182
|
+
for (const r of records) {
|
|
183
|
+
const list = topicsByOwner.get(r.ownerMachineId) ?? [];
|
|
184
|
+
list.push(r);
|
|
185
|
+
topicsByOwner.set(r.ownerMachineId, list);
|
|
186
|
+
}
|
|
187
|
+
const holdsLease = this.d.holdsLease();
|
|
188
|
+
const online = machines.filter((m) => m.online).length;
|
|
189
|
+
const inQuorum = online * 2 > machines.length || machines.length <= 2;
|
|
190
|
+
let claimsThisTick = 0;
|
|
191
|
+
for (const [owner, topics] of topicsByOwner) {
|
|
192
|
+
const view = byId.get(owner);
|
|
193
|
+
const ownerOnline = !!view?.online;
|
|
194
|
+
// Healthy owner → close-episode bookkeeping and move on.
|
|
195
|
+
if (ownerOnline) {
|
|
196
|
+
const ep = this.episodes.get(owner);
|
|
197
|
+
if (ep) {
|
|
198
|
+
if (ep.healthySinceMono === null)
|
|
199
|
+
ep.healthySinceMono = nowMono;
|
|
200
|
+
if (nowMono - ep.healthySinceMono >= EPISODE_CALM_CLOSE_MS) {
|
|
201
|
+
this.episodes.delete(owner); // calm window elapsed → episode closed
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
// Owner offline → open (or continue) the episode.
|
|
207
|
+
const ep = this.openEpisode(owner, nowMono, nowWall);
|
|
208
|
+
ep.healthySinceMono = null;
|
|
209
|
+
const verdict = this.classifyOwner(owner, view, ep, cfg, nowWall, nowMono, machines.length, inQuorum);
|
|
210
|
+
this.recordVerdict(ep, owner, verdict, cfg, nowMono, nowWall);
|
|
211
|
+
if (verdict.verdict !== 'expired')
|
|
212
|
+
continue;
|
|
213
|
+
// ── Claims: lease-holder-only (§2.1 arbiter). Detection + escalation ran
|
|
214
|
+
// above regardless of the lease (R-r2-1 quorum-member hosting).
|
|
215
|
+
if (!holdsLease) {
|
|
216
|
+
this.trace({ type: 'refusal', owner, episodeId: ep.episodeId, reason: 'not-lease-holder', detail: 'claims are lease-holder-only; detection continues' });
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const annotations = this.d.claimAnnotations();
|
|
220
|
+
for (const rec of topics) {
|
|
221
|
+
if (claimsThisTick >= cfg.maxClaimsPerTick)
|
|
222
|
+
break; // P19 per-tick cap
|
|
223
|
+
// FSM respect (§2.1): a fresh drain-flow transferring record is held
|
|
224
|
+
// back — a mid-drain death rides the existing transferring-timeout
|
|
225
|
+
// recovery, never a raw CAS over it.
|
|
226
|
+
if (rec.status === 'transferring' && rec.drainInFlight === true && nowWall - (rec.timestamp ?? 0) < 45_000) {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const topicNum = Number(rec.sessionKey);
|
|
230
|
+
const ann = Number.isFinite(topicNum) ? annotations.get(topicNum) : undefined;
|
|
231
|
+
// Declined demote (§2.6): the operator's "no" durably pins the topic
|
|
232
|
+
// against claim for this episode — on ANY claimer (replicated read).
|
|
233
|
+
if (ann?.declinedDemote && ann.episodeId === ep.episodeId) {
|
|
234
|
+
this.trace({ type: 'refusal', owner, episodeId: ep.episodeId, topic: rec.sessionKey, reason: 'declined-demote' });
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
// Per-topic replicated claim budget + widening backoff (R-r2-4).
|
|
238
|
+
const claimCount = ann?.claimCount ?? 0;
|
|
239
|
+
if (claimCount >= PER_TOPIC_CLAIM_BUDGET) {
|
|
240
|
+
this.p19GiveUp(owner, ep, rec.sessionKey, `per-topic claim budget exhausted (${claimCount})`);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (typeof ann?.backoffUntilMs === 'number' && nowWall < ann.backoffUntilMs) {
|
|
244
|
+
continue; // inside the widening backoff window
|
|
245
|
+
}
|
|
246
|
+
this.counters.attempts++;
|
|
247
|
+
if (this.d.dryRun()) {
|
|
248
|
+
// State-change-gated would-claim: once per topic per episode (§2.6).
|
|
249
|
+
if (!ep.wouldClaimLogged.has(rec.sessionKey)) {
|
|
250
|
+
ep.wouldClaimLogged.add(rec.sessionKey);
|
|
251
|
+
this.counters.wouldClaims++;
|
|
252
|
+
this.trace({ type: 'would-claim', owner, episodeId: ep.episodeId, topic: rec.sessionKey });
|
|
253
|
+
this.lastEpisodeSummary = { episodeId: ep.episodeId, owner, verdict: 'would-claim', at: new Date(nowWall).toISOString() };
|
|
254
|
+
}
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
// Evidence 6: fresh re-read immediately before the claim.
|
|
258
|
+
const fresh = this.d.listOwnershipRecords().find((r) => r.sessionKey === rec.sessionKey);
|
|
259
|
+
if (!fresh || fresh.status === 'released' || fresh.ownerMachineId !== owner) {
|
|
260
|
+
continue; // the world moved — re-evaluate next tick
|
|
261
|
+
}
|
|
262
|
+
const landed = this.d.actForceClaim(rec.sessionKey, ep.episodeId);
|
|
263
|
+
const nextCount = claimCount + 1;
|
|
264
|
+
const backoffUntilMs = nowWall + CLAIM_BACKOFF_BASE_MS * Math.pow(2, Math.max(0, nextCount - 1));
|
|
265
|
+
if (landed) {
|
|
266
|
+
claimsThisTick++;
|
|
267
|
+
this.counters.claims++;
|
|
268
|
+
this.trace({ type: 'claim', owner, episodeId: ep.episodeId, topic: rec.sessionKey });
|
|
269
|
+
this.lastEpisodeSummary = { episodeId: ep.episodeId, owner, verdict: 'claimed', at: new Date(nowWall).toISOString() };
|
|
270
|
+
if (Number.isFinite(topicNum)) {
|
|
271
|
+
// §2.4/§2.7.4: the claim CAS and the suspension annotation are
|
|
272
|
+
// emitted in this single apply path, keyed to the same episode id.
|
|
273
|
+
// Level-reconciled: re-emitted idempotently if it didn't land.
|
|
274
|
+
try {
|
|
275
|
+
this.d.emitClaimAnnotation({
|
|
276
|
+
topic: topicNum,
|
|
277
|
+
episodeId: ep.episodeId,
|
|
278
|
+
suspended: true,
|
|
279
|
+
claimedBy: this.d.selfMachineId() ?? '',
|
|
280
|
+
claimCount: nextCount,
|
|
281
|
+
backoffUntilMs,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
catch { /* annotation is level-reconciled next tick */ }
|
|
285
|
+
try {
|
|
286
|
+
this.d.pullWorkingSet(topicNum);
|
|
287
|
+
}
|
|
288
|
+
catch { /* queued durably by the carrier */ }
|
|
289
|
+
try {
|
|
290
|
+
this.d.onClaimed?.(topicNum, ep.episodeId, owner);
|
|
291
|
+
}
|
|
292
|
+
catch { /* disclosure is best-effort */ }
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
else if (Number.isFinite(topicNum)) {
|
|
296
|
+
// A refused CAS still consumes budget (the flap-bounding purpose).
|
|
297
|
+
try {
|
|
298
|
+
this.d.emitClaimAnnotation({
|
|
299
|
+
topic: topicNum,
|
|
300
|
+
episodeId: ep.episodeId,
|
|
301
|
+
suspended: false,
|
|
302
|
+
claimedBy: this.d.selfMachineId() ?? '',
|
|
303
|
+
claimCount: nextCount,
|
|
304
|
+
backoffUntilMs,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
catch { /* level-reconciled */ }
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
// Prune episodes for owners that no longer own any live topic.
|
|
312
|
+
for (const owner of [...this.episodes.keys()]) {
|
|
313
|
+
if (!topicsByOwner.has(owner))
|
|
314
|
+
this.episodes.delete(owner);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/** Record the operator's durable "no" for an owner's current episode (§2.6). */
|
|
318
|
+
recordDeclinedDemote(topic, episodeId) {
|
|
319
|
+
const ann = this.d.claimAnnotations().get(topic);
|
|
320
|
+
this.d.emitClaimAnnotation({
|
|
321
|
+
topic,
|
|
322
|
+
episodeId,
|
|
323
|
+
suspended: ann?.suspended ?? false,
|
|
324
|
+
claimedBy: this.d.selfMachineId() ?? '',
|
|
325
|
+
claimCount: ann?.claimCount ?? 0,
|
|
326
|
+
declinedDemote: true,
|
|
327
|
+
});
|
|
328
|
+
this.trace({ type: 'declined-demote', topic: String(topic), episodeId });
|
|
329
|
+
}
|
|
330
|
+
openEpisode(owner, nowMono, nowWall) {
|
|
331
|
+
let ep = this.episodes.get(owner);
|
|
332
|
+
if (!ep) {
|
|
333
|
+
ep = {
|
|
334
|
+
episodeId: `${owner}-${nowWall}`.replace(/[^A-Za-z0-9._-]/g, '-').slice(0, 64),
|
|
335
|
+
owner,
|
|
336
|
+
openedAtMono: nowMono,
|
|
337
|
+
openedAtWall: nowWall,
|
|
338
|
+
lastVerdict: null,
|
|
339
|
+
ambiguitySinceMono: null,
|
|
340
|
+
escalated: false,
|
|
341
|
+
wouldClaimLogged: new Set(),
|
|
342
|
+
healthySinceMono: null,
|
|
343
|
+
probe: null,
|
|
344
|
+
probeInFlight: false,
|
|
345
|
+
probeErrors: 0,
|
|
346
|
+
probeBreakerOpen: false,
|
|
347
|
+
nextProbeAtMono: 0,
|
|
348
|
+
};
|
|
349
|
+
this.episodes.set(owner, ep);
|
|
350
|
+
this.trace({ type: 'stale-detect', owner, episodeId: ep.episodeId });
|
|
351
|
+
}
|
|
352
|
+
return ep;
|
|
353
|
+
}
|
|
354
|
+
/** The §2.2 evidence bar for ONE owner. Fail CLOSED on every uncertainty. */
|
|
355
|
+
classifyOwner(owner, view, ep, cfg, nowWall, nowMono, machineCount, inQuorum) {
|
|
356
|
+
// ── Evidence 1: machine death (observer monotonic clock; R-r2-5a input).
|
|
357
|
+
const seenMono = this.lastSeenMono.get(owner);
|
|
358
|
+
if (seenMono !== undefined) {
|
|
359
|
+
if (nowMono - seenMono < cfg.deathEvidenceMs) {
|
|
360
|
+
this.bumpEvidenceClass('not-expired');
|
|
361
|
+
return { verdict: 'refused', reason: 'not-expired' };
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
// Never observed since claimant boot — the R-r2-2 bounded bootstrap rule.
|
|
366
|
+
const nonObservedMs = nowMono - this.bootMono;
|
|
367
|
+
const bound = cfg.bootstrapNonObservationMultiple * cfg.deathEvidenceMs;
|
|
368
|
+
const durable = this.d.durableLastKnownHeartbeatMs(owner);
|
|
369
|
+
const durableOldEnough = durable === null || nowWall - durable >= cfg.deathEvidenceMs;
|
|
370
|
+
if (!(nonObservedMs >= bound && durableOldEnough)) {
|
|
371
|
+
this.bumpEvidenceClass('bootstrap-ambiguity');
|
|
372
|
+
return { verdict: 'ambiguity', reason: 'not-expired' };
|
|
373
|
+
}
|
|
374
|
+
this.bumpEvidenceClass('bootstrap-expired');
|
|
375
|
+
}
|
|
376
|
+
// ── Evidence 3: quorum (cheap — before the network evidence).
|
|
377
|
+
if (!inQuorum) {
|
|
378
|
+
this.bumpEvidenceClass('quorum-fail');
|
|
379
|
+
return { verdict: 'refused', reason: 'quorum-fail' };
|
|
380
|
+
}
|
|
381
|
+
// ── 2-machine git-less mesh: claim path DISABLED (§2.2.4 — fail closed;
|
|
382
|
+
// detection and escalation still run, which is why this is 'ambiguity').
|
|
383
|
+
if (machineCount === 2 && !this.d.hasDurableLeaseAuthority()) {
|
|
384
|
+
this.bumpEvidenceClass('two-machine-gitless');
|
|
385
|
+
return { verdict: 'ambiguity', reason: 'self-proof-fail' };
|
|
386
|
+
}
|
|
387
|
+
// ── Evidence 2: multi-transport disproof over an owner-authenticated,
|
|
388
|
+
// fresh, multi-transport advert set (R-r2-5b provenance).
|
|
389
|
+
let adverts;
|
|
390
|
+
try {
|
|
391
|
+
adverts = this.d.advertSet(owner);
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
this.bumpEvidenceClass('advert-read-error');
|
|
395
|
+
return { verdict: 'ambiguity', reason: 'transport-ambiguity' };
|
|
396
|
+
}
|
|
397
|
+
if (!adverts || adverts.endpoints.length === 0 || !adverts.fresh) {
|
|
398
|
+
this.bumpEvidenceClass('advert-empty-or-stale');
|
|
399
|
+
return { verdict: 'ambiguity', reason: 'transport-ambiguity' };
|
|
400
|
+
}
|
|
401
|
+
const kinds = new Set(adverts.endpoints.map((e) => e.kind));
|
|
402
|
+
if (kinds.size < 2) {
|
|
403
|
+
// Single-transport advertisement is automatic ambiguity — the single-rope
|
|
404
|
+
// false-death is the exact bug to avoid (surfaced as a U4.5 rope-health
|
|
405
|
+
// line, not claimed around).
|
|
406
|
+
this.bumpEvidenceClass('advert-single-transport');
|
|
407
|
+
return { verdict: 'ambiguity', reason: 'transport-ambiguity' };
|
|
408
|
+
}
|
|
409
|
+
const probe = this.probeVerdict(owner, ep, adverts, cfg, nowMono);
|
|
410
|
+
if (probe === 'reachable') {
|
|
411
|
+
// Alive on ≥1 transport — refresh the liveness stamp so evidence 1 stays
|
|
412
|
+
// honest, and refuse.
|
|
413
|
+
this.lastSeenMono.set(owner, nowMono);
|
|
414
|
+
this.bumpEvidenceClass('probe-reachable');
|
|
415
|
+
return { verdict: 'refused', reason: 'not-expired' };
|
|
416
|
+
}
|
|
417
|
+
if (probe !== 'unreachable') {
|
|
418
|
+
this.bumpEvidenceClass('probe-pending-or-error');
|
|
419
|
+
return { verdict: 'ambiguity', reason: 'transport-ambiguity' };
|
|
420
|
+
}
|
|
421
|
+
this.bumpEvidenceClass('probe-unreachable-all-transports');
|
|
422
|
+
// ── Evidence 4: claimant self-connectivity proof.
|
|
423
|
+
if (this.selfProofState !== 'ok') {
|
|
424
|
+
this.kickSelfProof();
|
|
425
|
+
if (this.selfProofState === 'failed') {
|
|
426
|
+
this.bumpEvidenceClass('self-proof-fail');
|
|
427
|
+
return { verdict: 'refused', reason: 'self-proof-fail' };
|
|
428
|
+
}
|
|
429
|
+
this.bumpEvidenceClass('self-proof-pending');
|
|
430
|
+
return { verdict: 'ambiguity', reason: 'self-proof-fail' };
|
|
431
|
+
}
|
|
432
|
+
// ── Evidence 5: owner liveness disproof over a PROVABLY FRESH mirror.
|
|
433
|
+
let mirror;
|
|
434
|
+
try {
|
|
435
|
+
mirror = this.d.evidenceMirror();
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
this.bumpEvidenceClass('mirror-read-error');
|
|
439
|
+
return { verdict: 'ambiguity', reason: 'side-effect-fresh' };
|
|
440
|
+
}
|
|
441
|
+
const syncOk = mirror.lastSyncOkMs;
|
|
442
|
+
if (syncOk === null || nowWall - syncOk > cfg.deathEvidenceMs) {
|
|
443
|
+
// A mirror not successfully synced inside the window classifies AMBIGUITY
|
|
444
|
+
// — a stale mirror must never read as "no recent side-effects" (R-r2-3).
|
|
445
|
+
this.bumpEvidenceClass('mirror-stale');
|
|
446
|
+
return { verdict: 'ambiguity', reason: 'side-effect-fresh' };
|
|
447
|
+
}
|
|
448
|
+
const lastEffect = mirror.lastOwnerSideEffectMs(owner);
|
|
449
|
+
if (lastEffect !== null && nowWall - lastEffect < cfg.deathEvidenceMs) {
|
|
450
|
+
this.bumpEvidenceClass('side-effect-fresh');
|
|
451
|
+
return { verdict: 'refused', reason: 'side-effect-fresh' };
|
|
452
|
+
}
|
|
453
|
+
this.bumpEvidenceClass('expired');
|
|
454
|
+
return { verdict: 'expired' };
|
|
455
|
+
}
|
|
456
|
+
// ── Evidence 4 (async, memoized per tick window) ──────────────────────
|
|
457
|
+
selfProofState = 'unknown';
|
|
458
|
+
selfProofAtMono = 0;
|
|
459
|
+
kickSelfProof() {
|
|
460
|
+
const nowMono = this.mono();
|
|
461
|
+
if (this.selfProofState === 'pending')
|
|
462
|
+
return;
|
|
463
|
+
if ((this.selfProofState === 'ok' || this.selfProofState === 'failed') && nowMono - this.selfProofAtMono < PROBE_MEMO_TTL_MS)
|
|
464
|
+
return;
|
|
465
|
+
this.selfProofState = 'pending';
|
|
466
|
+
void this.d
|
|
467
|
+
.selfConnectivityProof()
|
|
468
|
+
.then((ok) => {
|
|
469
|
+
this.selfProofState = ok ? 'ok' : 'failed';
|
|
470
|
+
this.selfProofAtMono = this.mono();
|
|
471
|
+
})
|
|
472
|
+
.catch(() => {
|
|
473
|
+
this.selfProofState = 'failed';
|
|
474
|
+
this.selfProofAtMono = this.mono();
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* ONE reachability verdict per (owner, episode) — single-flight, TTL-memoized,
|
|
479
|
+
* widening backoff between attempts, P19 breaker that degrades to the
|
|
480
|
+
* attention item. Returns the memoized verdict, or 'error' while pending /
|
|
481
|
+
* breaker-open (which classifies as ambiguity upstream — fail closed).
|
|
482
|
+
*/
|
|
483
|
+
probeVerdict(owner, ep, adverts, cfg, nowMono) {
|
|
484
|
+
if (ep.probe && nowMono - ep.probe.atMono < PROBE_MEMO_TTL_MS)
|
|
485
|
+
return ep.probe.verdict;
|
|
486
|
+
if (ep.probeBreakerOpen)
|
|
487
|
+
return 'error';
|
|
488
|
+
if (ep.probeInFlight)
|
|
489
|
+
return 'pending';
|
|
490
|
+
if (nowMono < ep.nextProbeAtMono)
|
|
491
|
+
return ep.probe?.verdict ?? 'pending';
|
|
492
|
+
ep.probeInFlight = true;
|
|
493
|
+
const endpoints = adverts.endpoints.slice(0, 8); // bounded fan-out
|
|
494
|
+
void Promise.all(endpoints.map((e) => this.d
|
|
495
|
+
.probeEndpoint(owner, e, cfg.probeTimeoutMs)
|
|
496
|
+
.then((ok) => (ok ? 'reachable' : 'unreachable'))
|
|
497
|
+
.catch(() => 'error')))
|
|
498
|
+
.then((results) => {
|
|
499
|
+
ep.probeInFlight = false;
|
|
500
|
+
const monoNow = this.mono();
|
|
501
|
+
let verdict;
|
|
502
|
+
if (results.some((r) => r === 'reachable')) {
|
|
503
|
+
verdict = 'reachable';
|
|
504
|
+
ep.probeErrors = 0;
|
|
505
|
+
}
|
|
506
|
+
else if (results.every((r) => r === 'unreachable')) {
|
|
507
|
+
verdict = 'unreachable';
|
|
508
|
+
ep.probeErrors = 0;
|
|
509
|
+
}
|
|
510
|
+
else {
|
|
511
|
+
verdict = 'error';
|
|
512
|
+
ep.probeErrors++;
|
|
513
|
+
if (ep.probeErrors >= PROBE_BREAKER_ERRORS) {
|
|
514
|
+
ep.probeBreakerOpen = true;
|
|
515
|
+
this.p19GiveUp(owner, ep, undefined, `probe breaker open after ${ep.probeErrors} consecutive errors`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
ep.probe = { verdict, atMono: monoNow };
|
|
519
|
+
// Widening backoff between probe episodes.
|
|
520
|
+
const attempt = Math.min(6, Math.max(1, ep.probeErrors + 1));
|
|
521
|
+
ep.nextProbeAtMono = monoNow + PROBE_MEMO_TTL_MS * attempt;
|
|
522
|
+
this.trace({ type: 'probe-verdict', owner, episodeId: ep.episodeId, detail: `${verdict} across ${endpoints.length} transport(s)` });
|
|
523
|
+
})
|
|
524
|
+
.catch(() => {
|
|
525
|
+
ep.probeInFlight = false;
|
|
526
|
+
});
|
|
527
|
+
return 'pending';
|
|
528
|
+
}
|
|
529
|
+
recordVerdict(ep, owner, v, cfg, nowMono, nowWall) {
|
|
530
|
+
const label = v.verdict === 'expired' || v.verdict === 'healthy' ? v.verdict : `${v.verdict}:${v.reason}`;
|
|
531
|
+
if (ep.lastVerdict !== label) {
|
|
532
|
+
// State-change-gated trace (first observation / verdict change — §2.6).
|
|
533
|
+
ep.lastVerdict = label;
|
|
534
|
+
if (v.verdict === 'refused') {
|
|
535
|
+
this.counters.refusalsByReason[v.reason]++;
|
|
536
|
+
this.trace({ type: 'refusal', owner, episodeId: ep.episodeId, reason: v.reason });
|
|
537
|
+
}
|
|
538
|
+
else if (v.verdict === 'ambiguity') {
|
|
539
|
+
this.counters.ambiguities++;
|
|
540
|
+
this.trace({ type: 'refusal', owner, episodeId: ep.episodeId, reason: v.reason, detail: 'ambiguity (fail closed)' });
|
|
541
|
+
}
|
|
542
|
+
this.lastEpisodeSummary = { episodeId: ep.episodeId, owner, verdict: label, at: new Date(nowWall).toISOString() };
|
|
543
|
+
}
|
|
544
|
+
// Bounded ambiguity → the per-episode deduped operator escalation (§2.6),
|
|
545
|
+
// hosted on ANY quorum member (R-r2-1) — this method runs on every member.
|
|
546
|
+
if (v.verdict === 'ambiguity') {
|
|
547
|
+
if (ep.ambiguitySinceMono === null)
|
|
548
|
+
ep.ambiguitySinceMono = nowMono;
|
|
549
|
+
const ceiling = cfg.ambiguityCeilingMultiple * cfg.deathEvidenceMs;
|
|
550
|
+
if (!ep.escalated && nowMono - ep.ambiguitySinceMono >= ceiling) {
|
|
551
|
+
ep.escalated = true;
|
|
552
|
+
this.counters.escalations++;
|
|
553
|
+
this.trace({ type: 'ambiguity-escalated', owner, episodeId: ep.episodeId });
|
|
554
|
+
try {
|
|
555
|
+
this.d.raiseAttention({
|
|
556
|
+
id: `stale-owner:${ep.episodeId}`,
|
|
557
|
+
title: `Topic(s) look stranded on ${owner} — I can't prove the owner's state`,
|
|
558
|
+
body: `Machine ${owner} has been offline past the evidence window but the death evidence stays ambiguous ` +
|
|
559
|
+
`(fail-closed). Topics it owns are not being served. Your call: demote it (I take its topics over) or wait. ` +
|
|
560
|
+
`Episode ${ep.episodeId}.`,
|
|
561
|
+
priority: 'high',
|
|
562
|
+
sourceContext: `stale-owner-release:${owner}`,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
catch { /* @silent-fallback-ok — attention raise is best-effort; the escalation is already durably recorded in the decision trace */ }
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
else {
|
|
569
|
+
ep.ambiguitySinceMono = null;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
p19GiveUp(owner, ep, topic, detail) {
|
|
573
|
+
// Loud give-up, ONE deduped attention item (the resurrection-cap mirror).
|
|
574
|
+
const key = `stale-owner-giveup:${ep.episodeId}${topic ? `:${topic}` : ''}`;
|
|
575
|
+
if (this.evidenceClasses[key])
|
|
576
|
+
return; // once per episode(+topic)
|
|
577
|
+
this.evidenceClasses[key] = 1;
|
|
578
|
+
this.counters.p19GiveUps++;
|
|
579
|
+
this.trace({ type: 'p19-giveup', owner, episodeId: ep.episodeId, topic, detail });
|
|
580
|
+
try {
|
|
581
|
+
this.d.raiseAttention({
|
|
582
|
+
id: key,
|
|
583
|
+
title: `Stale-owner release gave up${topic ? ` on topic ${topic}` : ''} (${owner})`,
|
|
584
|
+
body: `${detail}. No further automatic attempts this episode — manual demote or investigation needed.`,
|
|
585
|
+
priority: 'high',
|
|
586
|
+
sourceContext: `stale-owner-release:${owner}`,
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
catch { /* best-effort */ }
|
|
590
|
+
}
|
|
591
|
+
/** §2.9 status surface (assembled per tick, never stale on early return). */
|
|
592
|
+
status() {
|
|
593
|
+
let enabled = false;
|
|
594
|
+
let dryRun = true;
|
|
595
|
+
try {
|
|
596
|
+
enabled = this.d.enabled();
|
|
597
|
+
}
|
|
598
|
+
catch { /* fail dark */ }
|
|
599
|
+
try {
|
|
600
|
+
dryRun = this.d.dryRun();
|
|
601
|
+
}
|
|
602
|
+
catch { /* fail dry */ }
|
|
603
|
+
return {
|
|
604
|
+
enabled,
|
|
605
|
+
dryRun,
|
|
606
|
+
lastTickAt: this.lastTickAtWall ? new Date(this.lastTickAtWall).toISOString() : null,
|
|
607
|
+
counters: {
|
|
608
|
+
attempts: this.counters.attempts,
|
|
609
|
+
claims: this.counters.claims,
|
|
610
|
+
wouldClaims: this.counters.wouldClaims,
|
|
611
|
+
refusalsByReason: { ...this.counters.refusalsByReason },
|
|
612
|
+
ambiguities: this.counters.ambiguities,
|
|
613
|
+
escalations: this.counters.escalations,
|
|
614
|
+
p19GiveUps: this.counters.p19GiveUps,
|
|
615
|
+
},
|
|
616
|
+
evidenceClasses: Object.fromEntries(Object.entries(this.evidenceClasses).filter(([k]) => !k.startsWith('stale-owner-giveup:'))),
|
|
617
|
+
probeBreaker: {
|
|
618
|
+
openOwners: [...this.episodes.values()].filter((e) => e.probeBreakerOpen).map((e) => e.owner),
|
|
619
|
+
},
|
|
620
|
+
lastEpisode: this.lastEpisodeSummary,
|
|
621
|
+
openEpisodes: [...this.episodes.values()].map((e) => ({
|
|
622
|
+
episodeId: e.episodeId,
|
|
623
|
+
owner: e.owner,
|
|
624
|
+
openedAt: new Date(e.openedAtWall).toISOString(),
|
|
625
|
+
lastVerdict: e.lastVerdict,
|
|
626
|
+
escalated: e.escalated,
|
|
627
|
+
})),
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
export function deriveOwnershipLeaseState(record, opts) {
|
|
632
|
+
if (!record || record.status === 'released')
|
|
633
|
+
return null;
|
|
634
|
+
if (record.status === 'transferring')
|
|
635
|
+
return 'releasing';
|
|
636
|
+
// A stale-owner-release force-claim stamps `stale-owner-release` into the
|
|
637
|
+
// extended nonce grammar (§2.7.5) — visible here and in the reap-log.
|
|
638
|
+
const viaStaleOwnerClaim = typeof record.nonce === 'string' && record.nonce.includes(':stale-owner-release:');
|
|
639
|
+
if (record.status === 'active' && viaStaleOwnerClaim && opts.suspensionAnnotationPresent)
|
|
640
|
+
return 'claimed';
|
|
641
|
+
if (opts.evidenceEpisodeOpen)
|
|
642
|
+
return 'stale';
|
|
643
|
+
return 'held';
|
|
644
|
+
}
|
|
645
|
+
//# sourceMappingURL=StaleOwnerReleaseEngine.js.map
|