instar 1.3.652 → 1.3.654

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.
Files changed (31) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +388 -3
  3. package/dist/commands/server.js.map +1 -1
  4. package/dist/core/configCoherence.d.ts +46 -5
  5. package/dist/core/configCoherence.d.ts.map +1 -1
  6. package/dist/core/configCoherence.js +101 -12
  7. package/dist/core/configCoherence.js.map +1 -1
  8. package/dist/core/devGatedFeatures.d.ts.map +1 -1
  9. package/dist/core/devGatedFeatures.js +12 -0
  10. package/dist/core/devGatedFeatures.js.map +1 -1
  11. package/dist/core/ownershipFollowsLiveWork.d.ts +68 -0
  12. package/dist/core/ownershipFollowsLiveWork.d.ts.map +1 -0
  13. package/dist/core/ownershipFollowsLiveWork.js +95 -0
  14. package/dist/core/ownershipFollowsLiveWork.js.map +1 -0
  15. package/dist/core/types.d.ts +26 -0
  16. package/dist/core/types.d.ts.map +1 -1
  17. package/dist/core/types.js.map +1 -1
  18. package/dist/messaging/stuckMessageRecovery.d.ts +12 -0
  19. package/dist/messaging/stuckMessageRecovery.d.ts.map +1 -1
  20. package/dist/messaging/stuckMessageRecovery.js +12 -0
  21. package/dist/messaging/stuckMessageRecovery.js.map +1 -1
  22. package/dist/monitoring/SessionRecovery.d.ts +73 -0
  23. package/dist/monitoring/SessionRecovery.d.ts.map +1 -1
  24. package/dist/monitoring/SessionRecovery.js +113 -0
  25. package/dist/monitoring/SessionRecovery.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/data/builtin-manifest.json +2 -2
  28. package/upgrades/1.3.653.md +85 -0
  29. package/upgrades/1.3.654.md +26 -0
  30. package/upgrades/side-effects/mesh-coherence-live-state-honesty.md +53 -0
  31. 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;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,CAq1gBtE;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';
@@ -48,6 +49,7 @@ import { BootHealthBeacon } from '../server/BootHealthBeacon.js';
48
49
  import { TelegramAdapter, TOPIC_STYLE, selectTopicEmoji } from '../messaging/TelegramAdapter.js';
49
50
  import { getTelegramInboundDir } from '../messaging/shared/telegramInboundFiles.js';
50
51
  import { RelationshipManager } from '../core/RelationshipManager.js';
52
+ import { getFeatureMetricsRecorder } from '../core/CircuitBreakingIntelligenceProvider.js';
51
53
  import { configureLlmCircuitBreaker, llmCircuitAvailable } from '../core/LlmCircuitBreaker.js';
52
54
  import { computeSelfQuotaState } from '../core/selfQuotaState.js';
53
55
  import { isClaudeForbidden } from '../core/claudeForbiddenGuard.js';
@@ -92,7 +94,7 @@ import { MultiMachineCoordinator } from '../core/MultiMachineCoordinator.js';
92
94
  import { isRemotelyHandled } from '../core/SessionRouter.js';
93
95
  import { isSlackSessionKey, reconstructSlackMessage } from '../core/SlackForwardBridge.js';
94
96
  import { formatForwardedTopicContext } from '../core/ForwardedTopicContext.js';
95
- import { resolveAdvertisedMeshUrl, advertiseSelfMeshUrl, detectTailscaleIp, pickPrimaryLanIp, computeSelfMeshEndpoints, advertiseSelfMeshEndpoints } from '../core/MeshUrlAdvertiser.js';
97
+ import { resolveAdvertisedMeshUrl, advertiseSelfMeshUrl, detectTailscaleIp, pickPrimaryLanIp, computeSelfMeshEndpoints, advertiseSelfMeshEndpoints, resolveMeshBindHost } from '../core/MeshUrlAdvertiser.js';
96
98
  import { PeerEndpointResolver } from '../core/PeerEndpointResolver.js';
97
99
  import { relayOutbound } from '../core/TelegramRelay.js';
98
100
  import { GitSyncManager } from '../core/GitSync.js';
@@ -110,7 +112,7 @@ import { LocalLeaseStore } from '../core/LocalLeaseStore.js';
110
112
  import { LeaseCoordinator } from '../core/LeaseCoordinator.js';
111
113
  import { isPeerPresumedDead } from '../core/leaseLiveness.js';
112
114
  import { readPollActive, pidAlive as pollPidAlive } from '../core/pollIntent.js';
113
- import { checkMultiMachineConfigCoherence } from '../core/configCoherence.js';
115
+ import { checkMultiMachineConfigCoherence, checkMeshLiveStateCoherence, MESH_WARMUP_GRACE_MS } from '../core/configCoherence.js';
114
116
  import { HttpLeaseTransport } from '../core/HttpLeaseTransport.js';
115
117
  import { PeerEndpointRecorder } from '../core/PeerEndpointRecorder.js';
116
118
  import { HttpLiveTailTransport } from '../core/HttpLiveTailTransport.js';
@@ -3880,6 +3882,33 @@ export async function startServer(options) {
3880
3882
  // construction (far below) can wire the /api/lease[/pull] receivers to real registry fns.
3881
3883
  let peerEndpointRecorder;
3882
3884
  let getSelfMeshEndpoints;
3885
+ // mesh-coherence-live-state-honesty (Fix (b) / Decision #5): the resolved server bind host,
3886
+ // a BOOT CONSTANT. Declared `let` at OUTER scope (the same scope as getSelfMeshEndpoints)
3887
+ // and assigned inside the mesh-init block so the peerPresenceTimer closure (far below) can
3888
+ // reach it — a const inside the block would be UNREACHABLE from the timer.
3889
+ let meshResolvedBindHost;
3890
+ // mesh-coherence-live-state-honesty (Fix (b) / Decision #6): transition-only emit state +
3891
+ // capped-backoff failure state for the periodic live-coherence check, at outer scope so the
3892
+ // peerPresenceTimer closure persists them across ticks.
3893
+ // _meshCoherenceLastCodes: the SET of codes emitted on the previous tick — a code logs
3894
+ // ONLY on absent→present; a LEVEL-TRIGGERED reset clears a code when its divergence
3895
+ // resolves, so a later recurrence logs fresh (one line per divergence episode).
3896
+ // _meshCoherenceEmitCounts: per-code re-emit tally enforcing the optional emitCap ceiling.
3897
+ // _meshCoherenceConsecFailures/_meshCoherenceTicksSinceAttempt: capped consecutive-failure
3898
+ // backoff so a sustained-corrupt registry does not re-throw-and-swallow every 30s.
3899
+ // _meshCoherenceFailing: a healthy→failing LATCH so the 'error' metric is TRANSITION-GATED
3900
+ // (one row when the read STARTS failing, not one per attempt).
3901
+ let _meshCoherenceLastCodes = new Set();
3902
+ const _meshCoherenceEmitCounts = new Map();
3903
+ let _meshCoherenceConsecFailures = 0;
3904
+ let _meshCoherenceTicksSinceAttempt = 0;
3905
+ let _meshCoherenceFailing = false;
3906
+ // Half-open breaker (all three brakes): BACKOFF (a read failure trips it to ≤1 attempt per
3907
+ // MAX_BACKOFF_TICKS×30s ≈ 10min), BREAKER (the failing-latch + backoff; a successful read
3908
+ // CLOSES it), CAP (MAX_BACKOFF_TICKS floors the re-probe rate; the 'error' metric is
3909
+ // transition-gated). Deliberately HALF-OPEN (re-probing), NOT hard-STOP: a signal-only
3910
+ // honesty observer that STOPS attempting goes BLIND if the registry recovers.
3911
+ const MAX_BACKOFF_TICKS = 20;
3883
3912
  let leaseCoordinatorRef;
3884
3913
  let liveTailBuffer;
3885
3914
  let liveTailSendTransport;
@@ -4039,6 +4068,20 @@ export async function startServer(options) {
4039
4068
  // and the LIVE meshTransport gate. Shared by the receiver routes (sender/puller
4040
4069
  // direction) and the transport's pull-RESPONSE path (holder direction).
4041
4070
  getSelfMeshEndpoints = () => idMgr.getMachineEndpoints(selfMachineId);
4071
+ // mesh-coherence-live-state-honesty (Fix (b) / Decision #5): assign the resolved bind
4072
+ // host from the SAME meshBindActive expression the AgentServer bind callsite uses
4073
+ // (server.ts construction → AgentServer.ts:3469). boundHost is a BOOT CONSTANT (the bind
4074
+ // cannot change without a restart — which is exactly the b.1 divergence), so a single
4075
+ // boot-scope assignment is the live truth.
4076
+ {
4077
+ const meshBindActiveForCoherence = coordinator.managers.identityManager.hasIdentity() &&
4078
+ config.multiMachine?.meshTransport?.enabled !== false;
4079
+ meshResolvedBindHost = resolveMeshBindHost({
4080
+ configHost: config.host,
4081
+ meshBindActive: meshBindActiveForCoherence,
4082
+ meshBindHostOverride: config.multiMachine?.meshTransport?.bindHost,
4083
+ });
4084
+ }
4042
4085
  peerEndpointRecorder = new PeerEndpointRecorder({
4043
4086
  getPeerEndpoints: (id) => idMgr.getMachineEndpoints(id),
4044
4087
  updateMachineEndpoints: (id, eps) => idMgr.updateMachineEndpoints(id, eps),
@@ -7333,6 +7376,13 @@ export async function startServer(options) {
7333
7376
  }
7334
7377
  }
7335
7378
  catch { /* @silent-fallback-ok: best-effort midWork tag; a failure must never undo the successful respawn (the run is alive, which is the goal). */ }
7379
+ // Ownership Follows Live Work — Part B (claim-on-spawn, FD2): this
7380
+ // autonomous respawn bypasses route() (which is what normally claims),
7381
+ // so ownership would stay on whatever machine last held it. Issue the
7382
+ // fenced-epoch place→claim onto THIS machine. The helper is a strict
7383
+ // no-op when the flag is OFF / single-machine, and best-effort (a lost
7384
+ // CAS / owned-by-peer race withholds — never undoes the live spawn).
7385
+ _claimOwnershipForAutonomousSpawn?.(topicId);
7336
7386
  },
7337
7387
  claimInflight: (topicId) => {
7338
7388
  if (livenessInflight.has(topicId))
@@ -8468,6 +8518,67 @@ export async function startServer(options) {
8468
8518
  }
8469
8519
  return { cleared: false, reason: 'timeout' };
8470
8520
  },
8521
+ // ── Part D: double-dispatch recovery gate (ownership-follows-live-work) ──
8522
+ // Injected primitives so checkAndRecover consults per-topic ownership
8523
+ // before re-running locally. All read the late-bound registry/pool deps
8524
+ // (sessionOwnershipRegistry @ ~15976, _meshSelfId, machinePoolRegistry @
8525
+ // ~15703) — these closures fire at runtime, after those are assigned.
8526
+ // Strict no-op when the flag resolves OFF / single-machine.
8527
+ ownershipFollowsLiveWork: () => resolveDevAgentGate(config.multiMachine?.ownershipFollowsLiveWork, config),
8528
+ ownerOfTopic: (topicId) => {
8529
+ // ownReg.ownerOf — MAY THROW (the gate's fail-OPEN registry-unknown
8530
+ // branch). NO catch here: a throw must propagate so the gate takes its
8531
+ // labeled fail-open path (catching here would mask it).
8532
+ const reg = sessionOwnershipRegistry;
8533
+ if (!reg)
8534
+ return null; // pool dark / single-machine → no peer can own it
8535
+ return reg.ownerOf(String(topicId));
8536
+ },
8537
+ selfMachineId: () => _meshSelfId,
8538
+ // Owner-reachability — the SAME shared helper the third Part-D site
8539
+ // (recoverStuckMessages) uses, so the two gates can never diverge. MAY
8540
+ // THROW: the gate treats a throw as the unreachable-peer branch (the
8541
+ // record names a peer). NO catch here (propagate to the gate's handler).
8542
+ isOwnerReachable: (owner) => isOwnerReachableShared(owner),
8543
+ forwardPendingInboundViaRoute: async (topicId) => {
8544
+ // Forward the topic's ALREADY-DURABLE pending inbound through the
8545
+ // existing durable-inbound-queue drain (which routes via route() +
8546
+ // applies isRemotelyHandled at dispatch). When the durable queue is not
8547
+ // wired (the common case today), there is no durable pending inbound to
8548
+ // forward → nonePending:true (the gate withholds the local respawn; the
8549
+ // leftover idle session is converged by the existing reaper/reconciler).
8550
+ const q = _inboundQueue;
8551
+ if (!q)
8552
+ return { forwarded: 0, nonePending: true };
8553
+ const sk = String(topicId);
8554
+ let hasPending = false;
8555
+ try {
8556
+ hasPending = q.hasQueued(sk);
8557
+ }
8558
+ catch {
8559
+ hasPending = false;
8560
+ }
8561
+ if (!hasPending)
8562
+ return { forwarded: 0, nonePending: true };
8563
+ try {
8564
+ const summary = await q.onOwnershipTransition(sk);
8565
+ return { forwarded: summary?.dispatched ?? 0, nonePending: false };
8566
+ }
8567
+ catch {
8568
+ // @silent-fallback-ok — the drain is best-effort; route()'s own ledger
8569
+ // owns delivery + exactly-once. There WAS pending, so nonePending:false.
8570
+ return { forwarded: 0, nonePending: false };
8571
+ }
8572
+ },
8573
+ emitRecoveryGateTelemetry: (row) => {
8574
+ // ONE neutral observational row to the machine-local sentinel audit so
8575
+ // the fail-OPEN tradeoff is MEASURED (the fleet-promotion gate reads it).
8576
+ try {
8577
+ const auditPath = path.join(config.stateDir, 'logs', 'sentinel-events.jsonl');
8578
+ fs.appendFileSync(auditPath, JSON.stringify({ ts: new Date().toISOString(), ...row }) + '\n');
8579
+ }
8580
+ catch { /* @silent-fallback-ok — telemetry is observability; never affects the recovery decision */ }
8581
+ },
8471
8582
  });
8472
8583
  // Track context exhaustion kills to prevent beforeSessionKill from re-saving
8473
8584
  // resume UUIDs (which would cause an infinite death loop)
@@ -14830,6 +14941,21 @@ export async function startServer(options) {
14830
14941
  // Self-attests hardware + records a self-heartbeat so GET /pool + the Machines tab show
14831
14942
  // this machine online with its specs; peer liveness is fed from MachineHeartbeat.
14832
14943
  let machinePoolRegistry;
14944
+ // Ownership Follows Live Work — Part D shared owner-reachability helper (FD/
14945
+ // decision-completeness #4 unification). "reachable peer" = the SAME signal the
14946
+ // router uses (`machinePoolRegistry.getCapacity(owner)?.online === true`, the
14947
+ // isMachineAlive seam). Used by BOTH Part-D gates — the SessionRecovery deps
14948
+ // (checkAndRecover) and the recoverStuckMessages site — so the two can NEVER
14949
+ // make divergent reachability calls. A function declaration (hoisted) so both
14950
+ // callsites — one textually before this line — resolve it; it reads
14951
+ // machinePoolRegistry by closure at call time. THROWS-by-propagation are the
14952
+ // caller's unreachable-peer branch; here we only return the boolean (an absent
14953
+ // registry / unknown machine → false = treat as unreachable, the safe side).
14954
+ function isOwnerReachableShared(owner) {
14955
+ const cap = machinePoolRegistry?.getCapacity(owner);
14956
+ return cap?.online === true;
14957
+ }
14958
+ void isOwnerReachableShared; // referenced from the late deps closures (hoisted)
14833
14959
  try {
14834
14960
  const poolMod = await import('../core/MachinePoolRegistry.js');
14835
14961
  const osMod = await import('node:os');
@@ -15112,6 +15238,18 @@ export async function startServer(options) {
15112
15238
  // git-backed store swaps in for the Track-H proof (the registry is store-agnostic).
15113
15239
  let meshRpcDispatcher;
15114
15240
  let sessionOwnershipRegistry;
15241
+ /**
15242
+ * Ownership Follows Live Work — Part B (claim-on-spawn). Assigned inside the
15243
+ * durable-ownership wiring block (where `ownReg`/`emitPlacement`/`_meshSelfId`
15244
+ * are in lexical scope) and CALLED from the AutonomousLivenessReconciler
15245
+ * `respawn` closure (defined ~7900, invoked at runtime AFTER this is wired).
15246
+ * Null until wired and a strict no-op when the flag resolves OFF or
15247
+ * `_meshSelfId` is null (single-machine). The closure issues a fenced-epoch
15248
+ * `place → claim` so ownership follows the live autonomous session onto THIS
15249
+ * machine; it NEVER force-claims a peer-owned topic (FD3) and a lost CAS is a
15250
+ * no-op (the spawn still runs; the reconciler/applier converge — FD11).
15251
+ */
15252
+ let _claimOwnershipForAutonomousSpawn = null;
15115
15253
  try {
15116
15254
  const meshMod = await import('../core/MeshRpc.js');
15117
15255
  const idMod = await import('../core/MachineIdentity.js');
@@ -15491,6 +15629,167 @@ export async function startServer(options) {
15491
15629
  }
15492
15630
  catch { /* observability never endangers the observed */ }
15493
15631
  };
15632
+ // ── Ownership Follows Live Work (docs/specs/ownership-follows-live-work.md) ──
15633
+ // Parts A + B wire HERE, inside the durable-ownership block, because this is
15634
+ // the only scope where `ownReg`, `emitPlacement`, and `_meshSelfId` are all in
15635
+ // lexical view. The spec's anchor (server.ts:13247, "alongside the existing
15636
+ // sessionComplete handler") assumed `ownReg`/`_meshSelfId` were in scope there
15637
+ // — they are NOT (they are declared in this nested try-block, textually after).
15638
+ // Registering Part A's `sessionComplete` listener here (a session listener may
15639
+ // be added at any point during init) keeps the release logic where its deps
15640
+ // live; Part B is exposed via the hoisted `_claimOwnershipForAutonomousSpawn`
15641
+ // so the reconciler `respawn` closure (defined ~7900, before this block) can
15642
+ // reach it. (Anchor correction recorded in the implementation summary.)
15643
+ {
15644
+ const ownershipFollowsLiveWork = resolveDevAgentGate(config.multiMachine?.ownershipFollowsLiveWork, config);
15645
+ // ── Part A: release-on-complete ──────────────────────────────────────
15646
+ // On the SessionManager 'sessionComplete' event, if THIS machine is the
15647
+ // topic's `active` owner AND no DIFFERENT live session is bound to the
15648
+ // topic, issue a `release` CAS so the record advances to `released` instead
15649
+ // of being stuck `active` (the stale label PR #1258's closeout defends
15650
+ // against). Fail-closed/withhold on EVERY uncertainty. The flag gate is
15651
+ // re-read here so a session listener registered once at boot is inert when
15652
+ // the feature resolves OFF (byte-identical legacy behavior).
15653
+ sessionManager.on('sessionComplete', (session) => {
15654
+ try {
15655
+ if (!ownershipFollowsLiveWork || !_meshSelfId)
15656
+ return; // single-machine / dark = strict no-op
15657
+ const tmux = session.tmuxSession || session.name;
15658
+ if (!tmux)
15659
+ return;
15660
+ // Resolve the topicId from the completing session's tmux name. A throw
15661
+ // or empty result → SKIP (fail-closed toward NOT releasing): a topicId
15662
+ // we cannot resolve is a record we cannot prove is ours (adversarial X2).
15663
+ let topicId = null;
15664
+ try {
15665
+ const t = telegram?.getTopicForSession(tmux);
15666
+ if (t != null) {
15667
+ const n = typeof t === 'number' ? t : Number(t);
15668
+ topicId = Number.isFinite(n) ? n : null;
15669
+ }
15670
+ }
15671
+ catch {
15672
+ topicId = null; /* @silent-fallback-ok — unresolvable topic → unbound → skip (no release), the deliberate fail-closed direction (adversarial X2) */
15673
+ }
15674
+ if (topicId == null)
15675
+ return; // no topic binding → not topic-owned → skip
15676
+ const sk = String(topicId);
15677
+ // Resolve the CURRENT live session's stable instance key for the
15678
+ // session-identity guard (FD9). `telegram.getSessionForTopic` returns
15679
+ // the live session NAME (an anchor correction vs the spec's pseudocode,
15680
+ // which assumed it returned a Session); resolve that name to the
15681
+ // running Session and read its `startedAt` (the stable per-instance
15682
+ // key — tmux NAMES are reused across respawn). `undefined` means
15683
+ // "couldn't enumerate / no live session" → handled fail-closed below.
15684
+ let liveStartedAt = null; // null = no live session bound
15685
+ try {
15686
+ const liveName = telegram?.getSessionForTopic?.(topicId) ?? null;
15687
+ if (liveName) {
15688
+ const liveSess = sessionManager
15689
+ .listRunningSessions()
15690
+ .find((s) => s.tmuxSession === liveName || s.name === liveName);
15691
+ // A bound name with no running Session (already gone) → no DIFFERENT
15692
+ // live session (null). A running Session → its startedAt (may be
15693
+ // missing on a legacy record → unprovable → withhold via the helper).
15694
+ liveStartedAt = liveSess ? (liveSess.startedAt ?? '') : null;
15695
+ }
15696
+ }
15697
+ catch {
15698
+ // @silent-fallback-ok — cannot enumerate live sessions for the
15699
+ // identity guard → FAIL-CLOSED. Pass an empty-string key so the
15700
+ // helper's unprovable-identity branch withholds the release.
15701
+ liveStartedAt = '';
15702
+ }
15703
+ // (a) owner === self AND (b) status === active AND (c) the session-
15704
+ // identity guard — all resolved by the single-sourced pure helper.
15705
+ const rec = (() => { try {
15706
+ return ownReg.read(sk);
15707
+ }
15708
+ catch {
15709
+ return undefined; /* @silent-fallback-ok — registry unreadable → caller withholds the release (fail-closed); a read error must never break the completion path */
15710
+ } })();
15711
+ if (rec === undefined)
15712
+ return; // registry unreadable → withhold (fail-closed)
15713
+ if (!shouldReleaseOnComplete({
15714
+ enabled: ownershipFollowsLiveWork,
15715
+ selfMachineId: _meshSelfId,
15716
+ record: rec,
15717
+ completingStartedAt: session.startedAt,
15718
+ liveStartedAt,
15719
+ }))
15720
+ return;
15721
+ const prevOwner = rec.ownerMachineId;
15722
+ const r = ownReg.cas({ type: 'release', machineId: _meshSelfId }, { sessionKey: sk, sender: _meshSelfId, nonce: ownershipNonce(_meshSelfId, 'rel-complete', sk) });
15723
+ // MANDATORY pairing — scripts/lint-cas-emit-placement.js fails CI on an
15724
+ // unpaired cas(). Records 'released' into the coherence journal so the
15725
+ // release replicates exactly like the user-move release does. A lost CAS
15726
+ // (peer advanced the epoch first) is a no-op here — we never retry/force.
15727
+ emitPlacement(sk, r, 'released', prevOwner);
15728
+ }
15729
+ catch {
15730
+ // @silent-fallback-ok — release-on-complete is best-effort housekeeping
15731
+ // (FD1); a throw must never break the completion path. The existing
15732
+ // reconciler/applier + PR #1258 closeout still handle a missed release.
15733
+ }
15734
+ });
15735
+ // ── Part B: claim-on-spawn (assign the hoisted helper) ───────────────
15736
+ // After an autonomous respawn (the path that genuinely bypasses route()),
15737
+ // issue a fenced-epoch `place → claim` so ownership follows the live session
15738
+ // onto THIS machine. Owned-by-peer is NOT force-claimed (FD3): the
15739
+ // reconciler already gates respawn on topicOwnerElsewhere, so the
15740
+ // owned-by-peer case should not reach here; if a post-gate race does, the
15741
+ // claim is withheld and ONE neutral audit row records the divergence.
15742
+ _claimOwnershipForAutonomousSpawn = (topicId) => {
15743
+ try {
15744
+ if (!ownershipFollowsLiveWork || !_meshSelfId)
15745
+ return; // single-machine / dark = no-op
15746
+ const sk = String(topicId);
15747
+ let cur;
15748
+ try {
15749
+ cur = ownReg.read(sk);
15750
+ }
15751
+ catch {
15752
+ return; /* registry unreadable → withhold (fail-closed) */
15753
+ }
15754
+ const plan = planClaimOnSpawn({ enabled: ownershipFollowsLiveWork, selfMachineId: _meshSelfId, record: cur });
15755
+ if (plan.action === 'noop')
15756
+ return; // already ours / dark → nothing to do
15757
+ if (plan.action === 'place-then-claim') {
15758
+ // never-seen / released → place then claim onto self.
15759
+ const prev0 = cur?.ownerMachineId;
15760
+ const rp = ownReg.cas({ type: 'place', machineId: _meshSelfId }, { sessionKey: sk, sender: _meshSelfId, nonce: ownershipNonce(_meshSelfId, 'auto-place', sk) });
15761
+ emitPlacement(sk, rp, 'placed', prev0);
15762
+ if (rp.ok) {
15763
+ const rc = ownReg.cas({ type: 'claim', machineId: _meshSelfId }, { sessionKey: sk, sender: _meshSelfId, nonce: ownershipNonce(_meshSelfId, 'auto-claim', sk) });
15764
+ emitPlacement(sk, rc, 'placed', _meshSelfId);
15765
+ }
15766
+ // place lost (a peer advanced first) → claim NOT attempted; the
15767
+ // session still runs locally and the reconciler/applier converge (FD11).
15768
+ return;
15769
+ }
15770
+ // plan.action === 'audit-owned-elsewhere': owned by a PEER → do NOT
15771
+ // force-claim (FD3, fail-closed). This should not occur on the
15772
+ // reconciler path (its topicOwnerElsewhere gate already filters it); a
15773
+ // post-gate race lands ONE neutral, non-directional audit row to the
15774
+ // machine-local liveness log.
15775
+ try {
15776
+ const auditPath = path.join(config.stateDir, 'logs', 'autonomous-liveness.jsonl');
15777
+ fs.appendFileSync(auditPath, JSON.stringify({
15778
+ ts: new Date().toISOString(),
15779
+ kind: 'auto-spawn-owned-elsewhere',
15780
+ topicId,
15781
+ owner: plan.owner,
15782
+ status: plan.status,
15783
+ }) + '\n');
15784
+ }
15785
+ catch { /* @silent-fallback-ok — observational audit; never break the spawn */ }
15786
+ }
15787
+ catch {
15788
+ // @silent-fallback-ok — claim-on-spawn is best-effort (FD1/FD11); a throw
15789
+ // must never undo the successful spawn (the run is alive, which is the goal).
15790
+ }
15791
+ };
15792
+ }
15494
15793
  // The §L3 ownership commands, routed from MeshRpc to the registry CAS.
15495
15794
  const ownAction = (cmd, sender, env) => {
15496
15795
  if (cmd.type === 'place') {
@@ -17354,7 +17653,67 @@ export async function startServer(options) {
17354
17653
  },
17355
17654
  });
17356
17655
  void peerPresencePuller.pullOnce();
17357
- const peerPresenceTimer = setInterval(() => { void peerPresencePuller.pullOnce(); }, 30_000);
17656
+ const peerPresenceTick = () => {
17657
+ void peerPresencePuller.pullOnce();
17658
+ // mesh-coherence-live-state-honesty (Fix (b)): the periodic, signal-only live-vs-config
17659
+ // mesh coherence recheck. Dev-gated dark (resolveDevAgentGate on the nested ?.enabled).
17660
+ if (resolveDevAgentGate(config.monitoring?.meshCoherenceLiveCheck?.enabled, config)) {
17661
+ // Backoff: after a failure, skip up to min(consecFailures, MAX_BACKOFF_TICKS) ticks
17662
+ // before retrying. A SUCCESSFUL tick resets the counters (auto-recover).
17663
+ const backoffTicks = Math.min(_meshCoherenceConsecFailures, MAX_BACKOFF_TICKS);
17664
+ if (_meshCoherenceConsecFailures > 0 && _meshCoherenceTicksSinceAttempt < backoffTicks) {
17665
+ _meshCoherenceTicksSinceAttempt += 1;
17666
+ }
17667
+ else {
17668
+ _meshCoherenceTicksSinceAttempt = 0;
17669
+ // Resolve the tuning knobs (both are actually READ): warmupGraceMs overrides the
17670
+ // b.2 grace; emitCap (if set) hard-caps re-emits per code per process.
17671
+ const warmupGraceMs = config.monitoring?.meshCoherenceLiveCheck?.warmupGraceMs ?? MESH_WARMUP_GRACE_MS;
17672
+ const emitCap = config.monitoring?.meshCoherenceLiveCheck?.emitCap; // undefined ⇒ unbounded
17673
+ const recorder = getFeatureMetricsRecorder();
17674
+ // THROW-SAFETY (Decision #11): the live registry read can throw on a corrupt /
17675
+ // mid-write registry. A signal-only check must NEVER crash the tick. On any read
17676
+ // failure emit a TRANSITION-GATED 'error' metric row, advance the backoff, and
17677
+ // do NOT touch the transition state (fail toward silence).
17678
+ try {
17679
+ const live = {
17680
+ boundHost: meshResolvedBindHost,
17681
+ selfEndpoints: getSelfMeshEndpoints?.() ?? [],
17682
+ uptimeMs: process.uptime() * 1000,
17683
+ };
17684
+ const warnings = checkMeshLiveStateCoherence(config.multiMachine, true, live, warmupGraceMs);
17685
+ const nowCodes = new Set(warnings.map((w) => w.code));
17686
+ let firedThisTick = false;
17687
+ for (const w of warnings) {
17688
+ if (!_meshCoherenceLastCodes.has(w.code)) { // TRANSITION-ONLY (absent→present)
17689
+ const count = _meshCoherenceEmitCounts.get(w.code) ?? 0;
17690
+ if (emitCap === undefined || count < emitCap) { // emitCap is READ here
17691
+ console.log(pc.yellow(` ⚠ mesh-live-coherence [${w.code}]: ${w.message}`));
17692
+ _meshCoherenceEmitCounts.set(w.code, count + 1);
17693
+ firedThisTick = true;
17694
+ }
17695
+ }
17696
+ }
17697
+ recorder?.record({ feature: 'mesh-coherence-live', kind: 'event', outcome: firedThisTick ? 'fired' : 'noop' });
17698
+ _meshCoherenceLastCodes = nowCodes; // LEVEL-TRIGGERED reset
17699
+ _meshCoherenceConsecFailures = 0; // SUCCESS resets the backoff
17700
+ _meshCoherenceFailing = false; // clear the latch
17701
+ }
17702
+ catch {
17703
+ // @silent-fallback-ok: signal-only honesty observer; a registry read error must
17704
+ // never crash the tick. TRANSITION-GATED observability — emit ONE 'error' row on
17705
+ // the healthy→failing transition (not every attempt), leave the divergence
17706
+ // transition state untouched (fail toward silence), and advance the backoff.
17707
+ if (!_meshCoherenceFailing) {
17708
+ recorder?.record({ feature: 'mesh-coherence-live', kind: 'event', outcome: 'error' });
17709
+ _meshCoherenceFailing = true;
17710
+ }
17711
+ _meshCoherenceConsecFailures += 1;
17712
+ }
17713
+ }
17714
+ }
17715
+ };
17716
+ const peerPresenceTimer = setInterval(peerPresenceTick, 30_000);
17358
17717
  if (typeof peerPresenceTimer.unref === 'function')
17359
17718
  peerPresenceTimer.unref();
17360
17719
  const { resolveSessionPoolStage: _resolveSessionPoolStage } = await import('../core/inboundQueueConfig.js');
@@ -18024,6 +18383,32 @@ export async function startServer(options) {
18024
18383
  epoch: coordinator.getLeaseEpoch(),
18025
18384
  maxProcessingMs: seamlessness.maxProcessingMs,
18026
18385
  reinject: reinjectStuck,
18386
+ // Part D, third site (ownership-follows-live-work): per-topic owner check
18387
+ // ON TOP of the existing machine-level holdsLease() gate. SKIP a topic
18388
+ // owned by a REACHABLE peer (leave its stuck messages in the ledger for
18389
+ // the owner to drain). Flag-gated; every uncertainty resolves to the SAFE
18390
+ // side (not-owned-elsewhere → re-feed as today). Uses the SAME shared
18391
+ // reachability helper as the checkAndRecover Part-D gate.
18392
+ ownerElsewhereReachable: (topic) => {
18393
+ try {
18394
+ const on = resolveDevAgentGate(config.multiMachine?.ownershipFollowsLiveWork, config);
18395
+ if (!on)
18396
+ return false; // dark / legacy → no ownership check
18397
+ const reg = sessionOwnershipRegistry;
18398
+ const self = _meshSelfId;
18399
+ if (!reg || !self)
18400
+ return false; // single-machine → no peer can own it
18401
+ const owner = reg.ownerOf(String(topic));
18402
+ if (!owner || owner === self)
18403
+ return false; // unowned / ours → re-feed as today
18404
+ return isOwnerReachableShared(owner); // peer + reachable → skip (owner drains it)
18405
+ }
18406
+ catch {
18407
+ // @silent-fallback-ok — any uncertainty → SAFE side (re-feed as
18408
+ // today). Never WITHHOLD a re-feed on a registry/pool blip.
18409
+ return false;
18410
+ }
18411
+ },
18027
18412
  logger: (m) => console.log(pc.dim(` ${m}`)),
18028
18413
  });
18029
18414
  // Gap #2 (wedge-recovery-drops-messages): an entry whose re-run budget is