instar 1.3.339 → 1.3.341
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 +178 -10
- package/dist/commands/server.js.map +1 -1
- package/dist/config/ConfigDefaults.d.ts.map +1 -1
- package/dist/config/ConfigDefaults.js +17 -0
- package/dist/config/ConfigDefaults.js.map +1 -1
- package/dist/core/AutonomousSessions.d.ts +17 -2
- package/dist/core/AutonomousSessions.d.ts.map +1 -1
- package/dist/core/AutonomousSessions.js +30 -4
- package/dist/core/AutonomousSessions.js.map +1 -1
- package/dist/core/CoherenceJournal.d.ts +326 -0
- package/dist/core/CoherenceJournal.d.ts.map +1 -0
- package/dist/core/CoherenceJournal.js +999 -0
- package/dist/core/CoherenceJournal.js.map +1 -0
- package/dist/core/CoherenceJournalReader.d.ts +158 -0
- package/dist/core/CoherenceJournalReader.d.ts.map +1 -0
- package/dist/core/CoherenceJournalReader.js +450 -0
- package/dist/core/CoherenceJournalReader.js.map +1 -0
- package/dist/core/JournalSyncApplier.d.ts +272 -0
- package/dist/core/JournalSyncApplier.d.ts.map +1 -0
- package/dist/core/JournalSyncApplier.js +835 -0
- package/dist/core/JournalSyncApplier.js.map +1 -0
- package/dist/core/PendingInjectStore.d.ts +54 -0
- package/dist/core/PendingInjectStore.d.ts.map +1 -0
- package/dist/core/PendingInjectStore.js +152 -0
- package/dist/core/PendingInjectStore.js.map +1 -0
- package/dist/core/SessionManager.d.ts +17 -0
- package/dist/core/SessionManager.d.ts.map +1 -1
- package/dist/core/SessionManager.js +70 -1
- package/dist/core/SessionManager.js.map +1 -1
- package/dist/core/StateManager.d.ts +45 -0
- package/dist/core/StateManager.d.ts.map +1 -1
- package/dist/core/StateManager.js +106 -0
- package/dist/core/StateManager.js.map +1 -1
- package/dist/core/types.d.ts +35 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/messaging/TelegramAdapter.d.ts +14 -0
- package/dist/messaging/TelegramAdapter.d.ts.map +1 -1
- package/dist/messaging/TelegramAdapter.js +16 -1
- package/dist/messaging/TelegramAdapter.js.map +1 -1
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +7 -0
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +54 -3
- package/dist/server/routes.js.map +1 -1
- package/package.json +2 -2
- package/scripts/lint-cas-emit-placement.js +87 -0
- package/scripts/lint-journal-actuation-ban.js +63 -0
- package/scripts/lint-state-registry.js +313 -0
- package/src/data/builtin-manifest.json +47 -47
- package/src/data/state-coherence-registry.json +880 -0
- package/src/scaffold/templates.ts +7 -0
- package/upgrades/1.3.340.md +132 -0
- package/upgrades/1.3.341.md +41 -0
- package/upgrades/coherence-journal-p1-1.eli16.md +11 -0
- package/upgrades/coherence-journal-p1-2.eli16.md +11 -0
- package/upgrades/coherence-journal-p1-3.eli16.md +13 -0
- package/upgrades/side-effects/coherence-journal-p1-1.md +68 -0
- package/upgrades/side-effects/coherence-journal-p1-2.md +54 -0
- package/upgrades/side-effects/coherence-journal-p1-3.md +49 -0
- package/upgrades/side-effects/pending-inject-durability.md +58 -0
- package/upgrades/1.3.339.md +0 -48
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/commands/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AA6SH,UAAU,YAAY;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;2DACuD;IACvD,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAuxDD,wBAAsB,WAAW,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/commands/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AA6SH,UAAU,YAAY;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;2DACuD;IACvD,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAuxDD,wBAAsB,WAAW,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CA06RtE;AAED,wBAAsB,UAAU,CAAC,OAAO,EAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAsDzE;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,aAAa,CAAC,OAAO,EAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAuD5E"}
|
package/dist/commands/server.js
CHANGED
|
@@ -2304,6 +2304,94 @@ export async function startServer(options) {
|
|
|
2304
2304
|
console.log(pc.yellow(' Standby mode — processing gated, writes disabled'));
|
|
2305
2305
|
}
|
|
2306
2306
|
}
|
|
2307
|
+
// ── Coherence Journal (COHERENCE-JOURNAL-SPEC §3.1/§3.3, P1.1) ─────────
|
|
2308
|
+
// Per-machine append-only event streams (topic-placement / session-
|
|
2309
|
+
// lifecycle / autonomous-run). Dark-ship: `enabled ?? !!developmentAgent`.
|
|
2310
|
+
// Constructed HERE — after the coordinator (machineId available), before
|
|
2311
|
+
// the reaper/ownership wiring that emits into it. Writer runs even on a
|
|
2312
|
+
// single-machine agent (locally useful); replication is P1.3.
|
|
2313
|
+
let coherenceJournal;
|
|
2314
|
+
{
|
|
2315
|
+
const cjCfg = config.multiMachine?.coherenceJournal;
|
|
2316
|
+
const cjEnabled = cjCfg?.enabled ?? !!config.developmentAgent;
|
|
2317
|
+
if (cjEnabled) {
|
|
2318
|
+
try {
|
|
2319
|
+
const cjMod = await import('../core/CoherenceJournal.js');
|
|
2320
|
+
// Stable machine id: mesh identity when present; deterministic
|
|
2321
|
+
// hostname-derived fallback for single-machine agents (sanitized by
|
|
2322
|
+
// the journal's own percent-encode rule either way).
|
|
2323
|
+
const cjMachineId = coordinator.identity?.machineId ?? `m_host_${os.hostname()}`;
|
|
2324
|
+
coherenceJournal = new cjMod.CoherenceJournal({
|
|
2325
|
+
stateDir: config.stateDir,
|
|
2326
|
+
machineId: cjMachineId,
|
|
2327
|
+
flushIntervalMs: cjCfg?.flushIntervalMs,
|
|
2328
|
+
retention: cjCfg?.retention,
|
|
2329
|
+
// The §3.1 standby-safe seam: the flusher asks StateManager before
|
|
2330
|
+
// each append batch; the prefix allowlist lives there.
|
|
2331
|
+
guardWrite: (p) => state.guardJournalWrite(p),
|
|
2332
|
+
logger: (m) => console.log(pc.dim(` [coherence-journal] ${m}`)),
|
|
2333
|
+
});
|
|
2334
|
+
coherenceJournal.open();
|
|
2335
|
+
// Lifecycle funnel (§3.3): every session status transition flows
|
|
2336
|
+
// through StateManager.saveSession; the diff-derived emit lives there.
|
|
2337
|
+
state.setCoherenceJournal(coherenceJournal);
|
|
2338
|
+
// §3.3 autonomous-run journal scanner — observation-based start/stop
|
|
2339
|
+
// (no single .local.md write funnel exists; polling is the structural
|
|
2340
|
+
// choice). P19 brakes, declared: constant per-tick cost (bounded by
|
|
2341
|
+
// maxConcurrent active runs); a throwing read skips the tick and
|
|
2342
|
+
// never compounds; the seen-set evicts a run after its stopped emit
|
|
2343
|
+
// (bounded by active runs, not history); emits inherit the writer's
|
|
2344
|
+
// rate cap. Sub-scan-interval runs are not observed (stated spec
|
|
2345
|
+
// limitation). Op-key dedupe collapses scanner + stop-funnel emits.
|
|
2346
|
+
{
|
|
2347
|
+
const journal = coherenceJournal;
|
|
2348
|
+
const cjScannerMs = cjCfg?.scannerIntervalMs ?? 60000;
|
|
2349
|
+
const seenRuns = new Map();
|
|
2350
|
+
const cjScan = async () => {
|
|
2351
|
+
try {
|
|
2352
|
+
const { activeAutonomousJobs, autonomousRunId } = await import('../core/AutonomousSessions.js');
|
|
2353
|
+
const active = activeAutonomousJobs(config.stateDir);
|
|
2354
|
+
const liveTopics = new Set();
|
|
2355
|
+
for (const j of active) {
|
|
2356
|
+
if (j.topic == null)
|
|
2357
|
+
continue; // legacy single-file job: not topic-scoped
|
|
2358
|
+
liveTopics.add(j.topic);
|
|
2359
|
+
const topicNum = Number(j.topic);
|
|
2360
|
+
if (!Number.isFinite(topicNum))
|
|
2361
|
+
continue;
|
|
2362
|
+
if (!seenRuns.has(j.topic)) {
|
|
2363
|
+
const runId = autonomousRunId(j.startedAt, j.topic);
|
|
2364
|
+
seenRuns.set(j.topic, { runId, file: j.file });
|
|
2365
|
+
journal.emitAutonomousRun(topicNum, { action: 'started', runId, artifactPaths: [j.file] });
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
for (const [topicKey, run] of [...seenRuns]) {
|
|
2369
|
+
if (liveTopics.has(topicKey))
|
|
2370
|
+
continue;
|
|
2371
|
+
const topicNum = Number(topicKey);
|
|
2372
|
+
if (Number.isFinite(topicNum)) {
|
|
2373
|
+
// observed-stopped: covers deaths outside the stop funnels
|
|
2374
|
+
// (crash / reboot / reaper kill) — no phantom-live runs.
|
|
2375
|
+
journal.emitAutonomousRun(topicNum, { action: 'stopped', runId: run.runId, artifactPaths: [run.file] });
|
|
2376
|
+
}
|
|
2377
|
+
seenRuns.delete(topicKey);
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
catch { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */ /* skip-tick; never compounds (P19) */ }
|
|
2381
|
+
};
|
|
2382
|
+
const cjScanTimer = setInterval(() => { void cjScan(); }, cjScannerMs);
|
|
2383
|
+
cjScanTimer.unref?.();
|
|
2384
|
+
void cjScan(); // prime at boot (re-emits dedupe via op keys)
|
|
2385
|
+
}
|
|
2386
|
+
console.log(pc.dim(` Coherence journal: writer active (${cjMachineId.slice(0, 16)}…)`));
|
|
2387
|
+
}
|
|
2388
|
+
catch (err) { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
|
|
2389
|
+
// The journal must never endanger boot (§3.1 inverted at startup).
|
|
2390
|
+
console.warn(pc.yellow(` Coherence journal failed to start (continuing without): ${err instanceof Error ? err.message : String(err)}`));
|
|
2391
|
+
coherenceJournal = undefined;
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2307
2395
|
// Cross-Machine Seamlessness (spec §9) — resolve + validate the tunable
|
|
2308
2396
|
// knobs at startup. A violating config (e.g. a widened ingressHeartbeatMs
|
|
2309
2397
|
// that breaks the RPO bound) is REJECTED here with a clear message rather
|
|
@@ -3577,6 +3665,16 @@ export async function startServer(options) {
|
|
|
3577
3665
|
console.error(`[server] Failed to ensure Agent Updates topic: ${err}`);
|
|
3578
3666
|
});
|
|
3579
3667
|
}
|
|
3668
|
+
// ── Coherence Journal × Telegram emergency-stop seam (COHERENCE-JOURNAL §3.3)
|
|
3669
|
+
// The adapter's sentinel emergency-stop path clears a topic's autonomous job
|
|
3670
|
+
// via stopAutonomousTopic but holds no StateManager, so it cannot reach the
|
|
3671
|
+
// wired journal on its own. Inject the journal as the AutonomousJournalSeam so
|
|
3672
|
+
// a sentinel-driven stop emits the autonomous-run `stopped` event like every
|
|
3673
|
+
// other stop funnel. Placed AFTER both adapter-setup blocks (so `telegram` is
|
|
3674
|
+
// assigned in either mode) and only when the journal is wired.
|
|
3675
|
+
if (telegram && coherenceJournal) {
|
|
3676
|
+
telegram.setCoherenceJournalSeam(coherenceJournal);
|
|
3677
|
+
}
|
|
3580
3678
|
// Agent worktree convention (Layer 4) — lifeline detector.
|
|
3581
3679
|
//
|
|
3582
3680
|
// Runs once per server boot. Inspects the canonical instar repo's
|
|
@@ -4301,6 +4399,19 @@ export async function startServer(options) {
|
|
|
4301
4399
|
origin: e.origin,
|
|
4302
4400
|
});
|
|
4303
4401
|
reapNotifier.onReaped({ session: e.session, reason: e.reason, disposition: e.disposition, origin: e.origin });
|
|
4402
|
+
// Coherence journal 'reaped' (§3.3): emitted HERE, alongside the
|
|
4403
|
+
// reap-log append it references — never derived in the saveSession
|
|
4404
|
+
// funnel (which records the plain killed/completed transition).
|
|
4405
|
+
try {
|
|
4406
|
+
const m = /(?:^|[-_])(?:topic|telegram)[-_]?(\d+)(?:$|[-_])/.exec(e.session.name ?? '');
|
|
4407
|
+
coherenceJournal?.emitLifecycle({
|
|
4408
|
+
sessionId: e.session.id,
|
|
4409
|
+
status: 'reaped',
|
|
4410
|
+
reapReason: e.reason,
|
|
4411
|
+
reapLogRef: `logs/reap-log.jsonl:${e.session.name}`,
|
|
4412
|
+
}, m ? Number(m[1]) : undefined);
|
|
4413
|
+
}
|
|
4414
|
+
catch { /* observability never endangers the observed */ }
|
|
4304
4415
|
});
|
|
4305
4416
|
sessionManager.on('reapBlocked', (e) => {
|
|
4306
4417
|
reapLog.recordSkipped({
|
|
@@ -4317,6 +4428,18 @@ export async function startServer(options) {
|
|
|
4317
4428
|
// lifeline to restart the server in a tight loop.
|
|
4318
4429
|
await sessionManager.purgeDeadSessions();
|
|
4319
4430
|
sessionManager.startMonitoring();
|
|
4431
|
+
// Pending-inject recovery (finding 8d300555): re-deliver initial messages
|
|
4432
|
+
// orphaned by the previous server process dying in the spawn→ready→inject
|
|
4433
|
+
// window (the auto-updater restart race). Runs AFTER the purge so dead
|
|
4434
|
+
// sessions are already settled, and in the background — the ready-waits
|
|
4435
|
+
// inside can take up to 90s per session and must not block boot.
|
|
4436
|
+
void sessionManager.recoverPendingInjects().catch((err) => {
|
|
4437
|
+
// @silent-fallback-ok boot recovery is a backstop that must NEVER crash
|
|
4438
|
+
// boot — its own internal failures already route to DegradationReporter
|
|
4439
|
+
// (sweepPendingInjects.reportLoss); this outer guard only catches an
|
|
4440
|
+
// unexpected throw from the sweep harness itself.
|
|
4441
|
+
console.error(`[server] Pending-inject recovery failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
|
|
4442
|
+
});
|
|
4320
4443
|
// StuckInputSentinel — persistent, restart-safe recovery for tmux prompts
|
|
4321
4444
|
// that hold text but never submitted Enter. Complements the in-process
|
|
4322
4445
|
// verifyInjection timers (PR #159) which die when the server crashes.
|
|
@@ -9307,16 +9430,55 @@ export async function startServer(options) {
|
|
|
9307
9430
|
logger: (m) => console.log(pc.dim(` ${m}`)),
|
|
9308
9431
|
});
|
|
9309
9432
|
const ownReg = sessionOwnershipRegistry;
|
|
9433
|
+
// Coherence journal §3.3: the emit is a thin wrapper at every CAS call
|
|
9434
|
+
// site — `reason` is caller knowledge (cas() is a storage primitive and
|
|
9435
|
+
// cannot know WHY). A mesh-applied action records the coarse reason;
|
|
9436
|
+
// the ORIGINATING machine's own emit carries the precise one. The
|
|
9437
|
+
// cas-pairing lint (scripts/lint-cas-emit-placement.js) fails CI on
|
|
9438
|
+
// any cas( call site missing this pairing.
|
|
9439
|
+
const emitPlacement = (sessionKey, r, reason, prevOwner) => {
|
|
9440
|
+
try {
|
|
9441
|
+
if (!coherenceJournal || !r?.ok)
|
|
9442
|
+
return;
|
|
9443
|
+
const rec = r.record;
|
|
9444
|
+
const topicNum = Number(sessionKey);
|
|
9445
|
+
if (!Number.isFinite(topicNum) || rec?.ownershipEpoch == null)
|
|
9446
|
+
return;
|
|
9447
|
+
coherenceJournal.emitPlacement(topicNum, {
|
|
9448
|
+
owner: rec.ownerMachineId ?? '',
|
|
9449
|
+
...(prevOwner ? { prevOwner } : {}),
|
|
9450
|
+
epoch: rec.ownershipEpoch,
|
|
9451
|
+
reason,
|
|
9452
|
+
});
|
|
9453
|
+
}
|
|
9454
|
+
catch { /* observability never endangers the observed */ }
|
|
9455
|
+
};
|
|
9310
9456
|
// The §L3 ownership commands, routed from MeshRpc to the registry CAS.
|
|
9311
9457
|
const ownAction = (cmd, sender, env) => {
|
|
9312
|
-
if (cmd.type === 'place')
|
|
9313
|
-
|
|
9314
|
-
|
|
9315
|
-
|
|
9316
|
-
|
|
9317
|
-
|
|
9318
|
-
if (cmd.type === '
|
|
9319
|
-
|
|
9458
|
+
if (cmd.type === 'place') {
|
|
9459
|
+
const prev = ownReg.read(cmd.session)?.ownerMachineId;
|
|
9460
|
+
const r = ownReg.cas({ type: 'place', machineId: cmd.machine }, { sessionKey: cmd.session, sender, nonce: env.nonce });
|
|
9461
|
+
emitPlacement(cmd.session, r, 'placed', prev);
|
|
9462
|
+
return r;
|
|
9463
|
+
}
|
|
9464
|
+
if (cmd.type === 'claim') {
|
|
9465
|
+
const prev = ownReg.read(cmd.session)?.ownerMachineId;
|
|
9466
|
+
const r = ownReg.cas({ type: 'claim', machineId: sender }, { sessionKey: cmd.session, sender, nonce: env.nonce });
|
|
9467
|
+
emitPlacement(cmd.session, r, cmd.failover ? 'failover' : 'placed', prev);
|
|
9468
|
+
return r;
|
|
9469
|
+
}
|
|
9470
|
+
if (cmd.type === 'transfer') {
|
|
9471
|
+
const prev = ownReg.read(cmd.session)?.ownerMachineId;
|
|
9472
|
+
const r = ownReg.cas({ type: 'transfer', to: cmd.target }, { sessionKey: cmd.session, sender, nonce: env.nonce });
|
|
9473
|
+
emitPlacement(cmd.session, r, 'user-move', prev);
|
|
9474
|
+
return r;
|
|
9475
|
+
}
|
|
9476
|
+
if (cmd.type === 'release') {
|
|
9477
|
+
const prev = ownReg.read(cmd.session)?.ownerMachineId;
|
|
9478
|
+
const r = ownReg.cas({ type: 'release', machineId: sender }, { sessionKey: cmd.session, sender, nonce: env.nonce });
|
|
9479
|
+
emitPlacement(cmd.session, r, 'released', prev);
|
|
9480
|
+
return r;
|
|
9481
|
+
}
|
|
9320
9482
|
return { ok: false, reason: 'unsupported' };
|
|
9321
9483
|
};
|
|
9322
9484
|
// The §L4 owner-side deliverMessage receive handler (shared factory — same
|
|
@@ -9702,7 +9864,9 @@ export async function startServer(options) {
|
|
|
9702
9864
|
markOwnerSuspect: (m) => ownerSuspectBreaker.markSuspect(m),
|
|
9703
9865
|
onOwnerResponsive: (m) => ownerSuspectBreaker.recordSuccess(m),
|
|
9704
9866
|
casClaimOwnership: (sk, machineId) => {
|
|
9867
|
+
const prevOwner = ownReg.read(sk)?.ownerMachineId;
|
|
9705
9868
|
const r = ownReg.cas({ type: 'place', machineId }, { sessionKey: sk, sender: meshSelfId, nonce: `${meshSelfId}:c:${++routerNonce}` });
|
|
9869
|
+
emitPlacement(sk, r, 'placed', prevOwner);
|
|
9706
9870
|
return { ok: r.ok, epoch: ownReg.read(sk)?.ownershipEpoch ?? 0 };
|
|
9707
9871
|
},
|
|
9708
9872
|
// bug #11: confirm the remote owner (placing → active) after the spawn is
|
|
@@ -9711,7 +9875,9 @@ export async function startServer(options) {
|
|
|
9711
9875
|
// owner, so the router confirms on the target's behalf. Without this the
|
|
9712
9876
|
// record stays 'placing' and every later message for the session queues.
|
|
9713
9877
|
confirmClaim: (sk, machineId) => {
|
|
9714
|
-
ownReg.
|
|
9878
|
+
const prevOwner = ownReg.read(sk)?.ownerMachineId;
|
|
9879
|
+
const r = ownReg.cas({ type: 'claim', machineId }, { sessionKey: sk, sender: meshSelfId, nonce: `${meshSelfId}:cl:${++routerNonce}` });
|
|
9880
|
+
emitPlacement(sk, r, 'placed', prevOwner); // placing→active confirmation: a real epoch bump in the registry
|
|
9715
9881
|
},
|
|
9716
9882
|
deliverMessage: async (target, env) => {
|
|
9717
9883
|
const url = peerUrl(target);
|
|
@@ -9879,7 +10045,9 @@ export async function startServer(options) {
|
|
|
9879
10045
|
// If THIS machine actively owns the topic, release so the next message re-places to the pin.
|
|
9880
10046
|
try {
|
|
9881
10047
|
if (ownReg.ownerOf(sessionKey) === meshSelfId) {
|
|
9882
|
-
|
|
10048
|
+
const prevOwner = ownReg.read(sessionKey)?.ownerMachineId;
|
|
10049
|
+
const r = ownReg.cas({ type: 'release', machineId: meshSelfId }, { sessionKey, sender: meshSelfId, nonce: `${meshSelfId}:rel:${sessionKey}:${Math.round(performance.now())}` });
|
|
10050
|
+
emitPlacement(sessionKey, r, 'user-move', prevOwner); // the explicit move's release half
|
|
9883
10051
|
}
|
|
9884
10052
|
}
|
|
9885
10053
|
catch { /* best-effort; route() re-places regardless once the owner is cleared */ }
|