instar 1.3.652 → 1.3.653
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 +283 -0
- package/dist/commands/server.js.map +1 -1
- package/dist/core/devGatedFeatures.d.ts.map +1 -1
- package/dist/core/devGatedFeatures.js +6 -0
- package/dist/core/devGatedFeatures.js.map +1 -1
- package/dist/core/ownershipFollowsLiveWork.d.ts +68 -0
- package/dist/core/ownershipFollowsLiveWork.d.ts.map +1 -0
- package/dist/core/ownershipFollowsLiveWork.js +95 -0
- package/dist/core/ownershipFollowsLiveWork.js.map +1 -0
- package/dist/core/types.d.ts +15 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/messaging/stuckMessageRecovery.d.ts +12 -0
- package/dist/messaging/stuckMessageRecovery.d.ts.map +1 -1
- package/dist/messaging/stuckMessageRecovery.js +12 -0
- package/dist/messaging/stuckMessageRecovery.js.map +1 -1
- package/dist/monitoring/SessionRecovery.d.ts +73 -0
- package/dist/monitoring/SessionRecovery.d.ts.map +1 -1
- package/dist/monitoring/SessionRecovery.js +113 -0
- package/dist/monitoring/SessionRecovery.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +2 -2
- package/upgrades/1.3.653.md +85 -0
- package/upgrades/side-effects/ownership-follows-live-work.md +137 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/commands/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/commands/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AA2CH,OAAO,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAU3D,OAAO,EAAE,eAAe,EAAiC,MAAM,iCAAiC,CAAC;AAwBjG,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAGvD,OAAO,EAAE,YAAY,EAAE,MAAM,+BAA+B,CAAC;AAwH7D,OAAO,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AA+EtD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,yBAAyB,CACvC,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,EACf,gBAAgB,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAC1C,OAAO,CAUT;AAyID,UAAU,YAAY;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;2DACuD;IACvD,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAy4CD,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,eAAe,EACzB,cAAc,EAAE,cAAc,EAC9B,YAAY,CAAC,EAAE,YAAY,EAC3B,WAAW,CAAC,EAAE,WAAW,EACzB,WAAW,CAAC,EAAE,WAAW,EACzB,iBAAiB,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,EAGvE,UAAU,CAAC,EAAE,MAAM,OAAO,8BAA8B,EAAE,WAAW,GAAG,IAAI,EAK5E,qBAAqB,CAAC,EAAE,MAAM,OAAO,gCAAgC,EAAE,kBAAkB,GAAG,IAAI,EAKhG,mBAAmB,CAAC,EAAE,MAAM,MAAM,GAAG,IAAI,GAAG,SAAS,GACpD,IAAI,CA8eN;AA2lBD,wBAAsB,WAAW,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAgvgBtE;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
|
@@ -23,6 +23,7 @@ import { handleProcessLevelError } from '../core/uncaughtExceptionPolicy.js';
|
|
|
23
23
|
import { configureHostSpawnSemaphore } from '../core/hostSpawnSemaphore.js';
|
|
24
24
|
import { SingleInstanceLock, installReleaseHandlers } from '../core/SingleInstanceLock.js';
|
|
25
25
|
import { resolveDevAgentGate, resolveStateSyncStores } from '../core/devAgentGate.js';
|
|
26
|
+
import { shouldReleaseOnComplete, planClaimOnSpawn, ownershipNonce } from '../core/ownershipFollowsLiveWork.js';
|
|
26
27
|
import { PrHandLease } from '../core/PrHandLease.js';
|
|
27
28
|
import { parseProfileTrigger, platformMessageIdFrom } from '../core/topicProfileIngress.js';
|
|
28
29
|
import { slugifyChannelName } from '../messaging/slack/sanitize.js';
|
|
@@ -7333,6 +7334,13 @@ export async function startServer(options) {
|
|
|
7333
7334
|
}
|
|
7334
7335
|
}
|
|
7335
7336
|
catch { /* @silent-fallback-ok: best-effort midWork tag; a failure must never undo the successful respawn (the run is alive, which is the goal). */ }
|
|
7337
|
+
// Ownership Follows Live Work — Part B (claim-on-spawn, FD2): this
|
|
7338
|
+
// autonomous respawn bypasses route() (which is what normally claims),
|
|
7339
|
+
// so ownership would stay on whatever machine last held it. Issue the
|
|
7340
|
+
// fenced-epoch place→claim onto THIS machine. The helper is a strict
|
|
7341
|
+
// no-op when the flag is OFF / single-machine, and best-effort (a lost
|
|
7342
|
+
// CAS / owned-by-peer race withholds — never undoes the live spawn).
|
|
7343
|
+
_claimOwnershipForAutonomousSpawn?.(topicId);
|
|
7336
7344
|
},
|
|
7337
7345
|
claimInflight: (topicId) => {
|
|
7338
7346
|
if (livenessInflight.has(topicId))
|
|
@@ -8468,6 +8476,67 @@ export async function startServer(options) {
|
|
|
8468
8476
|
}
|
|
8469
8477
|
return { cleared: false, reason: 'timeout' };
|
|
8470
8478
|
},
|
|
8479
|
+
// ── Part D: double-dispatch recovery gate (ownership-follows-live-work) ──
|
|
8480
|
+
// Injected primitives so checkAndRecover consults per-topic ownership
|
|
8481
|
+
// before re-running locally. All read the late-bound registry/pool deps
|
|
8482
|
+
// (sessionOwnershipRegistry @ ~15976, _meshSelfId, machinePoolRegistry @
|
|
8483
|
+
// ~15703) — these closures fire at runtime, after those are assigned.
|
|
8484
|
+
// Strict no-op when the flag resolves OFF / single-machine.
|
|
8485
|
+
ownershipFollowsLiveWork: () => resolveDevAgentGate(config.multiMachine?.ownershipFollowsLiveWork, config),
|
|
8486
|
+
ownerOfTopic: (topicId) => {
|
|
8487
|
+
// ownReg.ownerOf — MAY THROW (the gate's fail-OPEN registry-unknown
|
|
8488
|
+
// branch). NO catch here: a throw must propagate so the gate takes its
|
|
8489
|
+
// labeled fail-open path (catching here would mask it).
|
|
8490
|
+
const reg = sessionOwnershipRegistry;
|
|
8491
|
+
if (!reg)
|
|
8492
|
+
return null; // pool dark / single-machine → no peer can own it
|
|
8493
|
+
return reg.ownerOf(String(topicId));
|
|
8494
|
+
},
|
|
8495
|
+
selfMachineId: () => _meshSelfId,
|
|
8496
|
+
// Owner-reachability — the SAME shared helper the third Part-D site
|
|
8497
|
+
// (recoverStuckMessages) uses, so the two gates can never diverge. MAY
|
|
8498
|
+
// THROW: the gate treats a throw as the unreachable-peer branch (the
|
|
8499
|
+
// record names a peer). NO catch here (propagate to the gate's handler).
|
|
8500
|
+
isOwnerReachable: (owner) => isOwnerReachableShared(owner),
|
|
8501
|
+
forwardPendingInboundViaRoute: async (topicId) => {
|
|
8502
|
+
// Forward the topic's ALREADY-DURABLE pending inbound through the
|
|
8503
|
+
// existing durable-inbound-queue drain (which routes via route() +
|
|
8504
|
+
// applies isRemotelyHandled at dispatch). When the durable queue is not
|
|
8505
|
+
// wired (the common case today), there is no durable pending inbound to
|
|
8506
|
+
// forward → nonePending:true (the gate withholds the local respawn; the
|
|
8507
|
+
// leftover idle session is converged by the existing reaper/reconciler).
|
|
8508
|
+
const q = _inboundQueue;
|
|
8509
|
+
if (!q)
|
|
8510
|
+
return { forwarded: 0, nonePending: true };
|
|
8511
|
+
const sk = String(topicId);
|
|
8512
|
+
let hasPending = false;
|
|
8513
|
+
try {
|
|
8514
|
+
hasPending = q.hasQueued(sk);
|
|
8515
|
+
}
|
|
8516
|
+
catch {
|
|
8517
|
+
hasPending = false;
|
|
8518
|
+
}
|
|
8519
|
+
if (!hasPending)
|
|
8520
|
+
return { forwarded: 0, nonePending: true };
|
|
8521
|
+
try {
|
|
8522
|
+
const summary = await q.onOwnershipTransition(sk);
|
|
8523
|
+
return { forwarded: summary?.dispatched ?? 0, nonePending: false };
|
|
8524
|
+
}
|
|
8525
|
+
catch {
|
|
8526
|
+
// @silent-fallback-ok — the drain is best-effort; route()'s own ledger
|
|
8527
|
+
// owns delivery + exactly-once. There WAS pending, so nonePending:false.
|
|
8528
|
+
return { forwarded: 0, nonePending: false };
|
|
8529
|
+
}
|
|
8530
|
+
},
|
|
8531
|
+
emitRecoveryGateTelemetry: (row) => {
|
|
8532
|
+
// ONE neutral observational row to the machine-local sentinel audit so
|
|
8533
|
+
// the fail-OPEN tradeoff is MEASURED (the fleet-promotion gate reads it).
|
|
8534
|
+
try {
|
|
8535
|
+
const auditPath = path.join(config.stateDir, 'logs', 'sentinel-events.jsonl');
|
|
8536
|
+
fs.appendFileSync(auditPath, JSON.stringify({ ts: new Date().toISOString(), ...row }) + '\n');
|
|
8537
|
+
}
|
|
8538
|
+
catch { /* @silent-fallback-ok — telemetry is observability; never affects the recovery decision */ }
|
|
8539
|
+
},
|
|
8471
8540
|
});
|
|
8472
8541
|
// Track context exhaustion kills to prevent beforeSessionKill from re-saving
|
|
8473
8542
|
// resume UUIDs (which would cause an infinite death loop)
|
|
@@ -14830,6 +14899,21 @@ export async function startServer(options) {
|
|
|
14830
14899
|
// Self-attests hardware + records a self-heartbeat so GET /pool + the Machines tab show
|
|
14831
14900
|
// this machine online with its specs; peer liveness is fed from MachineHeartbeat.
|
|
14832
14901
|
let machinePoolRegistry;
|
|
14902
|
+
// Ownership Follows Live Work — Part D shared owner-reachability helper (FD/
|
|
14903
|
+
// decision-completeness #4 unification). "reachable peer" = the SAME signal the
|
|
14904
|
+
// router uses (`machinePoolRegistry.getCapacity(owner)?.online === true`, the
|
|
14905
|
+
// isMachineAlive seam). Used by BOTH Part-D gates — the SessionRecovery deps
|
|
14906
|
+
// (checkAndRecover) and the recoverStuckMessages site — so the two can NEVER
|
|
14907
|
+
// make divergent reachability calls. A function declaration (hoisted) so both
|
|
14908
|
+
// callsites — one textually before this line — resolve it; it reads
|
|
14909
|
+
// machinePoolRegistry by closure at call time. THROWS-by-propagation are the
|
|
14910
|
+
// caller's unreachable-peer branch; here we only return the boolean (an absent
|
|
14911
|
+
// registry / unknown machine → false = treat as unreachable, the safe side).
|
|
14912
|
+
function isOwnerReachableShared(owner) {
|
|
14913
|
+
const cap = machinePoolRegistry?.getCapacity(owner);
|
|
14914
|
+
return cap?.online === true;
|
|
14915
|
+
}
|
|
14916
|
+
void isOwnerReachableShared; // referenced from the late deps closures (hoisted)
|
|
14833
14917
|
try {
|
|
14834
14918
|
const poolMod = await import('../core/MachinePoolRegistry.js');
|
|
14835
14919
|
const osMod = await import('node:os');
|
|
@@ -15112,6 +15196,18 @@ export async function startServer(options) {
|
|
|
15112
15196
|
// git-backed store swaps in for the Track-H proof (the registry is store-agnostic).
|
|
15113
15197
|
let meshRpcDispatcher;
|
|
15114
15198
|
let sessionOwnershipRegistry;
|
|
15199
|
+
/**
|
|
15200
|
+
* Ownership Follows Live Work — Part B (claim-on-spawn). Assigned inside the
|
|
15201
|
+
* durable-ownership wiring block (where `ownReg`/`emitPlacement`/`_meshSelfId`
|
|
15202
|
+
* are in lexical scope) and CALLED from the AutonomousLivenessReconciler
|
|
15203
|
+
* `respawn` closure (defined ~7900, invoked at runtime AFTER this is wired).
|
|
15204
|
+
* Null until wired and a strict no-op when the flag resolves OFF or
|
|
15205
|
+
* `_meshSelfId` is null (single-machine). The closure issues a fenced-epoch
|
|
15206
|
+
* `place → claim` so ownership follows the live autonomous session onto THIS
|
|
15207
|
+
* machine; it NEVER force-claims a peer-owned topic (FD3) and a lost CAS is a
|
|
15208
|
+
* no-op (the spawn still runs; the reconciler/applier converge — FD11).
|
|
15209
|
+
*/
|
|
15210
|
+
let _claimOwnershipForAutonomousSpawn = null;
|
|
15115
15211
|
try {
|
|
15116
15212
|
const meshMod = await import('../core/MeshRpc.js');
|
|
15117
15213
|
const idMod = await import('../core/MachineIdentity.js');
|
|
@@ -15491,6 +15587,167 @@ export async function startServer(options) {
|
|
|
15491
15587
|
}
|
|
15492
15588
|
catch { /* observability never endangers the observed */ }
|
|
15493
15589
|
};
|
|
15590
|
+
// ── Ownership Follows Live Work (docs/specs/ownership-follows-live-work.md) ──
|
|
15591
|
+
// Parts A + B wire HERE, inside the durable-ownership block, because this is
|
|
15592
|
+
// the only scope where `ownReg`, `emitPlacement`, and `_meshSelfId` are all in
|
|
15593
|
+
// lexical view. The spec's anchor (server.ts:13247, "alongside the existing
|
|
15594
|
+
// sessionComplete handler") assumed `ownReg`/`_meshSelfId` were in scope there
|
|
15595
|
+
// — they are NOT (they are declared in this nested try-block, textually after).
|
|
15596
|
+
// Registering Part A's `sessionComplete` listener here (a session listener may
|
|
15597
|
+
// be added at any point during init) keeps the release logic where its deps
|
|
15598
|
+
// live; Part B is exposed via the hoisted `_claimOwnershipForAutonomousSpawn`
|
|
15599
|
+
// so the reconciler `respawn` closure (defined ~7900, before this block) can
|
|
15600
|
+
// reach it. (Anchor correction recorded in the implementation summary.)
|
|
15601
|
+
{
|
|
15602
|
+
const ownershipFollowsLiveWork = resolveDevAgentGate(config.multiMachine?.ownershipFollowsLiveWork, config);
|
|
15603
|
+
// ── Part A: release-on-complete ──────────────────────────────────────
|
|
15604
|
+
// On the SessionManager 'sessionComplete' event, if THIS machine is the
|
|
15605
|
+
// topic's `active` owner AND no DIFFERENT live session is bound to the
|
|
15606
|
+
// topic, issue a `release` CAS so the record advances to `released` instead
|
|
15607
|
+
// of being stuck `active` (the stale label PR #1258's closeout defends
|
|
15608
|
+
// against). Fail-closed/withhold on EVERY uncertainty. The flag gate is
|
|
15609
|
+
// re-read here so a session listener registered once at boot is inert when
|
|
15610
|
+
// the feature resolves OFF (byte-identical legacy behavior).
|
|
15611
|
+
sessionManager.on('sessionComplete', (session) => {
|
|
15612
|
+
try {
|
|
15613
|
+
if (!ownershipFollowsLiveWork || !_meshSelfId)
|
|
15614
|
+
return; // single-machine / dark = strict no-op
|
|
15615
|
+
const tmux = session.tmuxSession || session.name;
|
|
15616
|
+
if (!tmux)
|
|
15617
|
+
return;
|
|
15618
|
+
// Resolve the topicId from the completing session's tmux name. A throw
|
|
15619
|
+
// or empty result → SKIP (fail-closed toward NOT releasing): a topicId
|
|
15620
|
+
// we cannot resolve is a record we cannot prove is ours (adversarial X2).
|
|
15621
|
+
let topicId = null;
|
|
15622
|
+
try {
|
|
15623
|
+
const t = telegram?.getTopicForSession(tmux);
|
|
15624
|
+
if (t != null) {
|
|
15625
|
+
const n = typeof t === 'number' ? t : Number(t);
|
|
15626
|
+
topicId = Number.isFinite(n) ? n : null;
|
|
15627
|
+
}
|
|
15628
|
+
}
|
|
15629
|
+
catch {
|
|
15630
|
+
topicId = null; /* @silent-fallback-ok — unresolvable topic → unbound → skip (no release), the deliberate fail-closed direction (adversarial X2) */
|
|
15631
|
+
}
|
|
15632
|
+
if (topicId == null)
|
|
15633
|
+
return; // no topic binding → not topic-owned → skip
|
|
15634
|
+
const sk = String(topicId);
|
|
15635
|
+
// Resolve the CURRENT live session's stable instance key for the
|
|
15636
|
+
// session-identity guard (FD9). `telegram.getSessionForTopic` returns
|
|
15637
|
+
// the live session NAME (an anchor correction vs the spec's pseudocode,
|
|
15638
|
+
// which assumed it returned a Session); resolve that name to the
|
|
15639
|
+
// running Session and read its `startedAt` (the stable per-instance
|
|
15640
|
+
// key — tmux NAMES are reused across respawn). `undefined` means
|
|
15641
|
+
// "couldn't enumerate / no live session" → handled fail-closed below.
|
|
15642
|
+
let liveStartedAt = null; // null = no live session bound
|
|
15643
|
+
try {
|
|
15644
|
+
const liveName = telegram?.getSessionForTopic?.(topicId) ?? null;
|
|
15645
|
+
if (liveName) {
|
|
15646
|
+
const liveSess = sessionManager
|
|
15647
|
+
.listRunningSessions()
|
|
15648
|
+
.find((s) => s.tmuxSession === liveName || s.name === liveName);
|
|
15649
|
+
// A bound name with no running Session (already gone) → no DIFFERENT
|
|
15650
|
+
// live session (null). A running Session → its startedAt (may be
|
|
15651
|
+
// missing on a legacy record → unprovable → withhold via the helper).
|
|
15652
|
+
liveStartedAt = liveSess ? (liveSess.startedAt ?? '') : null;
|
|
15653
|
+
}
|
|
15654
|
+
}
|
|
15655
|
+
catch {
|
|
15656
|
+
// @silent-fallback-ok — cannot enumerate live sessions for the
|
|
15657
|
+
// identity guard → FAIL-CLOSED. Pass an empty-string key so the
|
|
15658
|
+
// helper's unprovable-identity branch withholds the release.
|
|
15659
|
+
liveStartedAt = '';
|
|
15660
|
+
}
|
|
15661
|
+
// (a) owner === self AND (b) status === active AND (c) the session-
|
|
15662
|
+
// identity guard — all resolved by the single-sourced pure helper.
|
|
15663
|
+
const rec = (() => { try {
|
|
15664
|
+
return ownReg.read(sk);
|
|
15665
|
+
}
|
|
15666
|
+
catch {
|
|
15667
|
+
return undefined; /* @silent-fallback-ok — registry unreadable → caller withholds the release (fail-closed); a read error must never break the completion path */
|
|
15668
|
+
} })();
|
|
15669
|
+
if (rec === undefined)
|
|
15670
|
+
return; // registry unreadable → withhold (fail-closed)
|
|
15671
|
+
if (!shouldReleaseOnComplete({
|
|
15672
|
+
enabled: ownershipFollowsLiveWork,
|
|
15673
|
+
selfMachineId: _meshSelfId,
|
|
15674
|
+
record: rec,
|
|
15675
|
+
completingStartedAt: session.startedAt,
|
|
15676
|
+
liveStartedAt,
|
|
15677
|
+
}))
|
|
15678
|
+
return;
|
|
15679
|
+
const prevOwner = rec.ownerMachineId;
|
|
15680
|
+
const r = ownReg.cas({ type: 'release', machineId: _meshSelfId }, { sessionKey: sk, sender: _meshSelfId, nonce: ownershipNonce(_meshSelfId, 'rel-complete', sk) });
|
|
15681
|
+
// MANDATORY pairing — scripts/lint-cas-emit-placement.js fails CI on an
|
|
15682
|
+
// unpaired cas(). Records 'released' into the coherence journal so the
|
|
15683
|
+
// release replicates exactly like the user-move release does. A lost CAS
|
|
15684
|
+
// (peer advanced the epoch first) is a no-op here — we never retry/force.
|
|
15685
|
+
emitPlacement(sk, r, 'released', prevOwner);
|
|
15686
|
+
}
|
|
15687
|
+
catch {
|
|
15688
|
+
// @silent-fallback-ok — release-on-complete is best-effort housekeeping
|
|
15689
|
+
// (FD1); a throw must never break the completion path. The existing
|
|
15690
|
+
// reconciler/applier + PR #1258 closeout still handle a missed release.
|
|
15691
|
+
}
|
|
15692
|
+
});
|
|
15693
|
+
// ── Part B: claim-on-spawn (assign the hoisted helper) ───────────────
|
|
15694
|
+
// After an autonomous respawn (the path that genuinely bypasses route()),
|
|
15695
|
+
// issue a fenced-epoch `place → claim` so ownership follows the live session
|
|
15696
|
+
// onto THIS machine. Owned-by-peer is NOT force-claimed (FD3): the
|
|
15697
|
+
// reconciler already gates respawn on topicOwnerElsewhere, so the
|
|
15698
|
+
// owned-by-peer case should not reach here; if a post-gate race does, the
|
|
15699
|
+
// claim is withheld and ONE neutral audit row records the divergence.
|
|
15700
|
+
_claimOwnershipForAutonomousSpawn = (topicId) => {
|
|
15701
|
+
try {
|
|
15702
|
+
if (!ownershipFollowsLiveWork || !_meshSelfId)
|
|
15703
|
+
return; // single-machine / dark = no-op
|
|
15704
|
+
const sk = String(topicId);
|
|
15705
|
+
let cur;
|
|
15706
|
+
try {
|
|
15707
|
+
cur = ownReg.read(sk);
|
|
15708
|
+
}
|
|
15709
|
+
catch {
|
|
15710
|
+
return; /* registry unreadable → withhold (fail-closed) */
|
|
15711
|
+
}
|
|
15712
|
+
const plan = planClaimOnSpawn({ enabled: ownershipFollowsLiveWork, selfMachineId: _meshSelfId, record: cur });
|
|
15713
|
+
if (plan.action === 'noop')
|
|
15714
|
+
return; // already ours / dark → nothing to do
|
|
15715
|
+
if (plan.action === 'place-then-claim') {
|
|
15716
|
+
// never-seen / released → place then claim onto self.
|
|
15717
|
+
const prev0 = cur?.ownerMachineId;
|
|
15718
|
+
const rp = ownReg.cas({ type: 'place', machineId: _meshSelfId }, { sessionKey: sk, sender: _meshSelfId, nonce: ownershipNonce(_meshSelfId, 'auto-place', sk) });
|
|
15719
|
+
emitPlacement(sk, rp, 'placed', prev0);
|
|
15720
|
+
if (rp.ok) {
|
|
15721
|
+
const rc = ownReg.cas({ type: 'claim', machineId: _meshSelfId }, { sessionKey: sk, sender: _meshSelfId, nonce: ownershipNonce(_meshSelfId, 'auto-claim', sk) });
|
|
15722
|
+
emitPlacement(sk, rc, 'placed', _meshSelfId);
|
|
15723
|
+
}
|
|
15724
|
+
// place lost (a peer advanced first) → claim NOT attempted; the
|
|
15725
|
+
// session still runs locally and the reconciler/applier converge (FD11).
|
|
15726
|
+
return;
|
|
15727
|
+
}
|
|
15728
|
+
// plan.action === 'audit-owned-elsewhere': owned by a PEER → do NOT
|
|
15729
|
+
// force-claim (FD3, fail-closed). This should not occur on the
|
|
15730
|
+
// reconciler path (its topicOwnerElsewhere gate already filters it); a
|
|
15731
|
+
// post-gate race lands ONE neutral, non-directional audit row to the
|
|
15732
|
+
// machine-local liveness log.
|
|
15733
|
+
try {
|
|
15734
|
+
const auditPath = path.join(config.stateDir, 'logs', 'autonomous-liveness.jsonl');
|
|
15735
|
+
fs.appendFileSync(auditPath, JSON.stringify({
|
|
15736
|
+
ts: new Date().toISOString(),
|
|
15737
|
+
kind: 'auto-spawn-owned-elsewhere',
|
|
15738
|
+
topicId,
|
|
15739
|
+
owner: plan.owner,
|
|
15740
|
+
status: plan.status,
|
|
15741
|
+
}) + '\n');
|
|
15742
|
+
}
|
|
15743
|
+
catch { /* @silent-fallback-ok — observational audit; never break the spawn */ }
|
|
15744
|
+
}
|
|
15745
|
+
catch {
|
|
15746
|
+
// @silent-fallback-ok — claim-on-spawn is best-effort (FD1/FD11); a throw
|
|
15747
|
+
// must never undo the successful spawn (the run is alive, which is the goal).
|
|
15748
|
+
}
|
|
15749
|
+
};
|
|
15750
|
+
}
|
|
15494
15751
|
// The §L3 ownership commands, routed from MeshRpc to the registry CAS.
|
|
15495
15752
|
const ownAction = (cmd, sender, env) => {
|
|
15496
15753
|
if (cmd.type === 'place') {
|
|
@@ -18024,6 +18281,32 @@ export async function startServer(options) {
|
|
|
18024
18281
|
epoch: coordinator.getLeaseEpoch(),
|
|
18025
18282
|
maxProcessingMs: seamlessness.maxProcessingMs,
|
|
18026
18283
|
reinject: reinjectStuck,
|
|
18284
|
+
// Part D, third site (ownership-follows-live-work): per-topic owner check
|
|
18285
|
+
// ON TOP of the existing machine-level holdsLease() gate. SKIP a topic
|
|
18286
|
+
// owned by a REACHABLE peer (leave its stuck messages in the ledger for
|
|
18287
|
+
// the owner to drain). Flag-gated; every uncertainty resolves to the SAFE
|
|
18288
|
+
// side (not-owned-elsewhere → re-feed as today). Uses the SAME shared
|
|
18289
|
+
// reachability helper as the checkAndRecover Part-D gate.
|
|
18290
|
+
ownerElsewhereReachable: (topic) => {
|
|
18291
|
+
try {
|
|
18292
|
+
const on = resolveDevAgentGate(config.multiMachine?.ownershipFollowsLiveWork, config);
|
|
18293
|
+
if (!on)
|
|
18294
|
+
return false; // dark / legacy → no ownership check
|
|
18295
|
+
const reg = sessionOwnershipRegistry;
|
|
18296
|
+
const self = _meshSelfId;
|
|
18297
|
+
if (!reg || !self)
|
|
18298
|
+
return false; // single-machine → no peer can own it
|
|
18299
|
+
const owner = reg.ownerOf(String(topic));
|
|
18300
|
+
if (!owner || owner === self)
|
|
18301
|
+
return false; // unowned / ours → re-feed as today
|
|
18302
|
+
return isOwnerReachableShared(owner); // peer + reachable → skip (owner drains it)
|
|
18303
|
+
}
|
|
18304
|
+
catch {
|
|
18305
|
+
// @silent-fallback-ok — any uncertainty → SAFE side (re-feed as
|
|
18306
|
+
// today). Never WITHHOLD a re-feed on a registry/pool blip.
|
|
18307
|
+
return false;
|
|
18308
|
+
}
|
|
18309
|
+
},
|
|
18027
18310
|
logger: (m) => console.log(pc.dim(` ${m}`)),
|
|
18028
18311
|
});
|
|
18029
18312
|
// Gap #2 (wedge-recovery-drops-messages): an entry whose re-run budget is
|