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.
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/commands/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AA0CH,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,CA89ftE;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"}
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"}
@@ -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