instar 1.3.651 → 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.
Files changed (47) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +403 -1
  3. package/dist/commands/server.js.map +1 -1
  4. package/dist/core/ReapGuard.d.ts +13 -1
  5. package/dist/core/ReapGuard.d.ts.map +1 -1
  6. package/dist/core/ReapGuard.js +86 -30
  7. package/dist/core/ReapGuard.js.map +1 -1
  8. package/dist/core/SessionManager.d.ts +11 -0
  9. package/dist/core/SessionManager.d.ts.map +1 -1
  10. package/dist/core/SessionManager.js +30 -4
  11. package/dist/core/SessionManager.js.map +1 -1
  12. package/dist/core/devGatedFeatures.d.ts.map +1 -1
  13. package/dist/core/devGatedFeatures.js +12 -0
  14. package/dist/core/devGatedFeatures.js.map +1 -1
  15. package/dist/core/gapBCommitmentEvidence.d.ts +9 -0
  16. package/dist/core/gapBCommitmentEvidence.d.ts.map +1 -1
  17. package/dist/core/gapBCommitmentEvidence.js +20 -0
  18. package/dist/core/gapBCommitmentEvidence.js.map +1 -1
  19. package/dist/core/ownershipFollowsLiveWork.d.ts +68 -0
  20. package/dist/core/ownershipFollowsLiveWork.d.ts.map +1 -0
  21. package/dist/core/ownershipFollowsLiveWork.js +95 -0
  22. package/dist/core/ownershipFollowsLiveWork.js.map +1 -0
  23. package/dist/core/types.d.ts +26 -0
  24. package/dist/core/types.d.ts.map +1 -1
  25. package/dist/core/types.js.map +1 -1
  26. package/dist/messaging/stuckMessageRecovery.d.ts +12 -0
  27. package/dist/messaging/stuckMessageRecovery.d.ts.map +1 -1
  28. package/dist/messaging/stuckMessageRecovery.js +12 -0
  29. package/dist/messaging/stuckMessageRecovery.js.map +1 -1
  30. package/dist/monitoring/SessionReaper.d.ts +118 -7
  31. package/dist/monitoring/SessionReaper.d.ts.map +1 -1
  32. package/dist/monitoring/SessionReaper.js +302 -101
  33. package/dist/monitoring/SessionReaper.js.map +1 -1
  34. package/dist/monitoring/SessionRecovery.d.ts +73 -0
  35. package/dist/monitoring/SessionRecovery.d.ts.map +1 -1
  36. package/dist/monitoring/SessionRecovery.js +113 -0
  37. package/dist/monitoring/SessionRecovery.js.map +1 -1
  38. package/dist/monitoring/closeoutLivenessSnapshot.d.ts +143 -0
  39. package/dist/monitoring/closeoutLivenessSnapshot.d.ts.map +1 -0
  40. package/dist/monitoring/closeoutLivenessSnapshot.js +207 -0
  41. package/dist/monitoring/closeoutLivenessSnapshot.js.map +1 -0
  42. package/package.json +1 -1
  43. package/src/data/builtin-manifest.json +3 -3
  44. package/upgrades/1.3.652.md +18 -0
  45. package/upgrades/1.3.653.md +85 -0
  46. package/upgrades/side-effects/ownership-follows-live-work.md +137 -0
  47. package/upgrades/side-effects/post-transfer-closeout-correctness.md +43 -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,CAi3ftE;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';
@@ -32,7 +33,7 @@ import { paneIdleWithEmptyInput } from '../core/ModelSwapService.js';
32
33
  import { escalatedModelIds, normalizeTierEscalationConfig } from '../core/ModelTierEscalation.js';
33
34
  import { activeAutonomousJobs, autonomousRunRemainingForTopic, listAutonomousJobs } from '../core/AutonomousSessions.js';
34
35
  import { AGE_LIMIT_ACTIVE_RUN_REASON, COMMITMENT_ACTIVE_RUN_REASON } from '../core/WorkEvidence.js';
35
- import { gapBEligibleForTopic, recentUserMessageFromHistory, resolveGapBInjectionGate, decideGapBInjection } from '../core/gapBCommitmentEvidence.js';
36
+ import { gapBEligibleForTopic, recentUserMessageFromHistory, recentUserMessageAtFromHistory, resolveGapBInjectionGate, decideGapBInjection } from '../core/gapBCommitmentEvidence.js';
36
37
  import { TopicProfileTransferCarrier, createTopicProfilePullHandler } from '../core/TopicProfileTransferCarrier.js';
37
38
  import { closeAllSqlite } from '../core/SqliteRegistry.js';
38
39
  import { SessionManager } from '../core/SessionManager.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)
@@ -14087,6 +14156,96 @@ export async function startServer(options) {
14087
14156
  config: { residueDenylist: ysCfg.residueDenylist, cacheTtlMs: ysCfg.dirtyCheckCacheTtlMs },
14088
14157
  });
14089
14158
  }
14159
+ // ── Post-transfer closeout correctness (F1) ───────────────────────────────
14160
+ // closeoutLivenessGate: when on, the post-transfer closeout no longer
14161
+ // terminates the live local session on a stale/unverified ownership record —
14162
+ // it verifies the owning machine ACTUALLY has a live session for the topic via
14163
+ // a machine-local liveness snapshot before shedding the leftover. Ships dark
14164
+ // fleet-wide, LIVE on a dev agent via resolveDevAgentGate. When OFF the snapshot
14165
+ // is never built and the new deps are left absent (closeout byte-identical).
14166
+ // Spec: docs/specs/post-transfer-closeout-correctness.md.
14167
+ const _closeoutLivenessGate = resolveDevAgentGate(config.monitoring?.sessionReaper?.closeoutLivenessGate, config);
14168
+ let _closeoutSnapshot;
14169
+ // The combined ATOMIC owner read the gated closeout consumes — ONE registry
14170
+ // read returning the STABLE machineId (liveness key) AND the display label
14171
+ // (audit text), guaranteeing they describe the same owner from one instant.
14172
+ const _topicOwnerElsewhereInfo = (topicId) => {
14173
+ try {
14174
+ const reg = sessionOwnershipRegistry;
14175
+ const self = _meshSelfId;
14176
+ if (!reg || !self)
14177
+ return null;
14178
+ const machineId = reg.ownerOf(String(topicId));
14179
+ if (!machineId || machineId === self)
14180
+ return null;
14181
+ const displayName = machinePoolRegistry?.getCapacity(machineId)?.nickname ?? machineId;
14182
+ return { machineId, displayName };
14183
+ }
14184
+ catch {
14185
+ return null; /* @silent-fallback-ok — pool not wired yet → rule inert (withhold) */
14186
+ }
14187
+ };
14188
+ if (_closeoutLivenessGate) {
14189
+ const { CloseoutLivenessSnapshot } = await import('../monitoring/closeoutLivenessSnapshot.js');
14190
+ const _reaperTick = config.monitoring?.sessionReaper?.tickIntervalSec ?? 120;
14191
+ _closeoutSnapshot = new CloseoutLivenessSnapshot({
14192
+ resolvePeerUrls: () => _resolvePeerUrls?.() ?? [],
14193
+ // Reuse the proven per-peer GET /sessions fetch primitive (5s timeout,
14194
+ // same shape as the pool route's fan-out). Rejects on any failure so the
14195
+ // entry ages to stale → 'unknown' (fail-closed).
14196
+ fetchPeerSessions: async (peer) => {
14197
+ const r = await fetch(`${peer.url}/sessions`, {
14198
+ headers: { Authorization: `Bearer ${config.authToken}` },
14199
+ signal: AbortSignal.timeout(5000),
14200
+ });
14201
+ if (!r.ok)
14202
+ throw new Error(`HTTP ${r.status}`);
14203
+ return (await r.json());
14204
+ },
14205
+ // Owner set = distinct machineId of every owned-elsewhere topic that has
14206
+ // a live LOCAL leftover here (the exact set the closeout could act on).
14207
+ ownerSet: () => {
14208
+ const owners = new Set();
14209
+ try {
14210
+ for (const s of sessionManager.listRunningSessions()) {
14211
+ const t = _resolveTopic(s.tmuxSession);
14212
+ if (t == null)
14213
+ continue;
14214
+ const info = _topicOwnerElsewhereInfo(t);
14215
+ if (info)
14216
+ owners.add(info.machineId);
14217
+ }
14218
+ }
14219
+ catch { /* @silent-fallback-ok — degrade to whatever was already pending */ }
14220
+ return [...owners];
14221
+ },
14222
+ now: () => Date.now(),
14223
+ raiseAttention: (item) => {
14224
+ try {
14225
+ if (!telegram)
14226
+ return;
14227
+ void telegram.createAttentionItem({
14228
+ id: item.id, title: item.title, summary: item.summary, description: item.description,
14229
+ category: 'sessions', priority: 'NORMAL',
14230
+ sourceContext: 'session-reaper:closeout-snapshot-breaker',
14231
+ }).catch(() => { });
14232
+ }
14233
+ catch { /* @silent-fallback-ok — telegram not wired (TDZ) → audit-only */ }
14234
+ },
14235
+ audit: reaperAuditSink(config.stateDir),
14236
+ }, { tickIntervalSec: _reaperTick });
14237
+ // Level-triggered on the reaper cadence with ±10% jitter (anti-herd). Async
14238
+ // refresh; the closeout always reads the PREVIOUS snapshot, never a fetch it
14239
+ // triggers this tick. A thrown pass is swallowed (previous snapshot ages to
14240
+ // stale → 'unknown'); the loop is never wedged.
14241
+ const _jitter = 1 + (Math.random() * 0.2 - 0.1);
14242
+ const _snapshotTimer = setInterval(() => {
14243
+ void _closeoutSnapshot.refresh().catch(() => { });
14244
+ }, Math.round(_reaperTick * 1000 * _jitter));
14245
+ if (typeof _snapshotTimer.unref === 'function')
14246
+ _snapshotTimer.unref();
14247
+ console.log(pc.green(' SessionReaper closeoutLivenessGate ON (post-transfer closeout liveness-gated — live)'));
14248
+ }
14090
14249
  const sessionReaper = new SessionReaper({
14091
14250
  ...reapGuardDeps,
14092
14251
  dirtyCheck: _yieldSafetyDirtyCheck,
@@ -14133,6 +14292,8 @@ export async function startServer(options) {
14133
14292
  },
14134
14293
  terminate: (id, reason, opts) => sessionManager.terminateSession(id, reason, {
14135
14294
  bypassActiveProcessKeep: opts?.bypassActiveProcessKeep,
14295
+ bypassRecentUserMessageForConfirmedMove: opts?.bypassRecentUserMessageForConfirmedMove,
14296
+ workEvidence: opts?.workEvidence,
14136
14297
  }),
14137
14298
  markReaping: (id) => sessionManager.markReaping(id),
14138
14299
  clearReaping: (id) => sessionManager.clearReaping(id),
@@ -14161,6 +14322,28 @@ export async function startServer(options) {
14161
14322
  return null; /* @silent-fallback-ok — pool not wired yet → rule inert */
14162
14323
  }
14163
14324
  },
14325
+ // Post-transfer closeout correctness (F1): the ATOMIC combined owner read
14326
+ // the gated closeout uses (stable machineId + display label from ONE
14327
+ // registry read). Always constructed (a pure read helper); the closeout
14328
+ // only consults it on the gated path. Returns null when the pool is dark.
14329
+ topicOwnerElsewhereInfo: _topicOwnerElsewhereInfo,
14330
+ // Post-transfer closeout correctness (F1): the liveness verdict, read from
14331
+ // the machine-local snapshot (NOT a live per-tick fetch). Injected ONLY
14332
+ // when the gate resolved on (the snapshot exists); absent ⇒ gate inert.
14333
+ remoteOwnerHasLiveSession: _closeoutSnapshot?.remoteOwnerHasLiveSession,
14334
+ // Post-transfer closeout correctness (F1, Part E): the LOCAL-receipt ts of
14335
+ // the bound topic's newest user message — same history source the
14336
+ // recent-user-message KEEP-guard reads. Backs the freshest-interaction veto.
14337
+ recentUserMessageAt: (topicId) => {
14338
+ try {
14339
+ if (!telegram)
14340
+ return null;
14341
+ return recentUserMessageAtFromHistory(telegram.getTopicHistory(topicId, 50));
14342
+ }
14343
+ catch {
14344
+ return null; /* @silent-fallback-ok — no history → withhold the bypass (safe) */
14345
+ }
14346
+ },
14164
14347
  // WS1.3: pin-conflict do-not-act — a pin naming THIS machine while the
14165
14348
  // owner is elsewhere means the reconciler is bringing the topic back;
14166
14349
  // the closeout holds instead of attacking the session the pin wants here.
@@ -14210,6 +14393,11 @@ export async function startServer(options) {
14210
14393
  // Observe-only busy-orphan detection rides the same dev-gate (dark fleet,
14211
14394
  // live on dev agents). Zero risk — it never changes a keep/kill verdict.
14212
14395
  busyOrphanDetection: resolveDevAgentGate(rcfg.busyOrphanDetection, config),
14396
+ // Post-transfer closeout correctness (F1): the liveness gate rides the
14397
+ // SAME dev-gate (dark fleet, live on dev agents). Resolved once here so
14398
+ // the reaper's gated/legacy branch matches the snapshot construction
14399
+ // above (both read resolveDevAgentGate(...closeoutLivenessGate)).
14400
+ closeoutLivenessGate: _closeoutLivenessGate,
14213
14401
  };
14214
14402
  })());
14215
14403
  sessionReaper.start();
@@ -14711,6 +14899,21 @@ export async function startServer(options) {
14711
14899
  // Self-attests hardware + records a self-heartbeat so GET /pool + the Machines tab show
14712
14900
  // this machine online with its specs; peer liveness is fed from MachineHeartbeat.
14713
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)
14714
14917
  try {
14715
14918
  const poolMod = await import('../core/MachinePoolRegistry.js');
14716
14919
  const osMod = await import('node:os');
@@ -14993,6 +15196,18 @@ export async function startServer(options) {
14993
15196
  // git-backed store swaps in for the Track-H proof (the registry is store-agnostic).
14994
15197
  let meshRpcDispatcher;
14995
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;
14996
15211
  try {
14997
15212
  const meshMod = await import('../core/MeshRpc.js');
14998
15213
  const idMod = await import('../core/MachineIdentity.js');
@@ -15372,6 +15587,167 @@ export async function startServer(options) {
15372
15587
  }
15373
15588
  catch { /* observability never endangers the observed */ }
15374
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
+ }
15375
15751
  // The §L3 ownership commands, routed from MeshRpc to the registry CAS.
15376
15752
  const ownAction = (cmd, sender, env) => {
15377
15753
  if (cmd.type === 'place') {
@@ -17905,6 +18281,32 @@ export async function startServer(options) {
17905
18281
  epoch: coordinator.getLeaseEpoch(),
17906
18282
  maxProcessingMs: seamlessness.maxProcessingMs,
17907
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
+ },
17908
18310
  logger: (m) => console.log(pc.dim(` ${m}`)),
17909
18311
  });
17910
18312
  // Gap #2 (wedge-recovery-drops-messages): an entry whose re-run budget is