instar 1.3.651 → 1.3.652
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 +120 -1
- package/dist/commands/server.js.map +1 -1
- package/dist/core/ReapGuard.d.ts +13 -1
- package/dist/core/ReapGuard.d.ts.map +1 -1
- package/dist/core/ReapGuard.js +86 -30
- package/dist/core/ReapGuard.js.map +1 -1
- package/dist/core/SessionManager.d.ts +11 -0
- package/dist/core/SessionManager.d.ts.map +1 -1
- package/dist/core/SessionManager.js +30 -4
- package/dist/core/SessionManager.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/gapBCommitmentEvidence.d.ts +9 -0
- package/dist/core/gapBCommitmentEvidence.d.ts.map +1 -1
- package/dist/core/gapBCommitmentEvidence.js +20 -0
- package/dist/core/gapBCommitmentEvidence.js.map +1 -1
- package/dist/core/types.d.ts +11 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/monitoring/SessionReaper.d.ts +118 -7
- package/dist/monitoring/SessionReaper.d.ts.map +1 -1
- package/dist/monitoring/SessionReaper.js +302 -101
- package/dist/monitoring/SessionReaper.js.map +1 -1
- package/dist/monitoring/closeoutLivenessSnapshot.d.ts +143 -0
- package/dist/monitoring/closeoutLivenessSnapshot.d.ts.map +1 -0
- package/dist/monitoring/closeoutLivenessSnapshot.js +207 -0
- package/dist/monitoring/closeoutLivenessSnapshot.js.map +1 -0
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +3 -3
- package/upgrades/1.3.652.md +18 -0
- package/upgrades/side-effects/post-transfer-closeout-correctness.md +43 -0
|
@@ -103,6 +103,20 @@ export interface SessionReaperConfig {
|
|
|
103
103
|
* breaker resets when the topic returns home / a pin-conflict hold engages /
|
|
104
104
|
* the session ends. Default 5 (~10 min of vetoes at the default 120s tick). */
|
|
105
105
|
topicMovedVetoBreakerAttempts: number;
|
|
106
|
+
/** Post-transfer closeout CORRECTNESS gate (F1,
|
|
107
|
+
* docs/specs/post-transfer-closeout-correctness.md). When true, the closeout
|
|
108
|
+
* no longer terminates the live local session on a stale/unverified ownership
|
|
109
|
+
* record: it consults `remoteOwnerHasLiveSession` and proceeds ONLY when the
|
|
110
|
+
* owning machine genuinely HAS a live session for the topic (a real
|
|
111
|
+
* duplicate). `false` (owner has no live session) / `'unknown'` / dep-absent →
|
|
112
|
+
* WITHHOLD (fail-closed — never kill the sole live worker). It also re-keys the
|
|
113
|
+
* closeout breaker counters on the stable TOPIC id (so a session-id churn
|
|
114
|
+
* across respawn no longer resets the veto count) and passes the narrow
|
|
115
|
+
* `bypassRecentUserMessageForConfirmedMove` on a liveness-CONFIRMED move so a
|
|
116
|
+
* genuine leftover with a pre-move "recent" message can actually shed.
|
|
117
|
+
* Ships DARK fleet-wide, LIVE on a dev agent via `resolveDevAgentGate`. When
|
|
118
|
+
* OFF the closeout's observable behavior is byte-identical to today. */
|
|
119
|
+
closeoutLivenessGate: boolean;
|
|
106
120
|
}
|
|
107
121
|
export declare const DEFAULT_SESSION_REAPER_CONFIG: SessionReaperConfig;
|
|
108
122
|
/**
|
|
@@ -183,6 +197,43 @@ export interface SessionReaperDeps {
|
|
|
183
197
|
* is unowned / owned by this machine / the pool is dark. Absent ⇒ the
|
|
184
198
|
* topic-moved closeout rule is inert. */
|
|
185
199
|
topicOwnerElsewhere?: (topicId: number) => string | null;
|
|
200
|
+
/** Post-transfer closeout correctness (F1): the combined, ATOMIC owner read
|
|
201
|
+
* the liveness-gated closeout uses — ONE registry read returning BOTH the
|
|
202
|
+
* STABLE `machineId` (the un-nicknamed `reg.ownerOf(...)` value — the
|
|
203
|
+
* liveness/snapshot key, immune to nickname rename/duplication) and the
|
|
204
|
+
* `displayName` (= nickname ?? machineId — audit/operator text). Returning
|
|
205
|
+
* both from one read guarantees the liveness key and the display text describe
|
|
206
|
+
* the SAME owner from the SAME instant (no straddle across an ownership change).
|
|
207
|
+
* null ⇒ the topic is not owned elsewhere. Consulted ONLY when
|
|
208
|
+
* `closeoutLivenessGate` is on; absent-under-gate ⇒ the closeout fail-closes to
|
|
209
|
+
* WITHHOLD (it never silently falls back to the display-only dep). */
|
|
210
|
+
topicOwnerElsewhereInfo?: (topicId: number) => {
|
|
211
|
+
machineId: string;
|
|
212
|
+
displayName: string;
|
|
213
|
+
} | null;
|
|
214
|
+
/** Post-transfer closeout correctness (F1): does the machine that OWNS this
|
|
215
|
+
* topic (per the ownership registry) actually have a LIVE session for it right
|
|
216
|
+
* now? Reads the machine-local liveness snapshot (NOT a live per-tick fetch).
|
|
217
|
+
* state:true → owner genuinely serving → local is a duplicate → may shed.
|
|
218
|
+
* state:false → owner has NO live session → local is the sole worker →
|
|
219
|
+
* WITHHOLD (never terminate it).
|
|
220
|
+
* state:'unknown'→ liveness undeterminable (snapshot missing/stale, peer
|
|
221
|
+
* unreachable) → WITHHOLD (fail-closed; UNKNOWN must NEVER act).
|
|
222
|
+
* `reachableAt` (ms) = the snapshot pass that produced the answer; backs the
|
|
223
|
+
* true-side dwell-advancement (a confirm tick only counts when `reachableAt`
|
|
224
|
+
* ADVANCED). Present on true/false, absent on 'unknown'. Absent dep ⇒ the
|
|
225
|
+
* liveness gate is inert. */
|
|
226
|
+
remoteOwnerHasLiveSession?: (topicId: number, ownerMachineId: string) => {
|
|
227
|
+
state: boolean | 'unknown';
|
|
228
|
+
reachableAt?: number;
|
|
229
|
+
};
|
|
230
|
+
/** Post-transfer closeout correctness (F1, Part E freshest-interaction veto):
|
|
231
|
+
* the LOCAL-RECEIPT timestamp (ms) of the bound topic's most-recent user
|
|
232
|
+
* message, or null. SAME source the existing `recent-user-message` KEEP-guard
|
|
233
|
+
* reads, so the basis is local wall-clock at message receipt. Used to withhold
|
|
234
|
+
* the recent-message bypass when a user message arrived AFTER the liveness
|
|
235
|
+
* snapshot (the local session the user is actively talking to is kept). */
|
|
236
|
+
recentUserMessageAt?: (topicId: number) => number | null;
|
|
186
237
|
/** WS1.3: does the topic's placement PIN name THIS machine? A pin-conflict
|
|
187
238
|
* (pin=here, owner=elsewhere) means the divergence is reconciling TOWARD us —
|
|
188
239
|
* the closeout holds (do-not-act) instead of attacking the session the pin
|
|
@@ -210,6 +261,11 @@ export interface SessionReaperDeps {
|
|
|
210
261
|
* KEEP-guard is re-checked by the authority and still vetoes. */
|
|
211
262
|
terminate: (sessionId: string, reason: string, opts?: {
|
|
212
263
|
bypassActiveProcessKeep?: boolean;
|
|
264
|
+
/** Post-transfer closeout correctness (F1, Part E): lifts ONLY the
|
|
265
|
+
* `recent-user-message` keep-reason (every other guard re-checked), passed
|
|
266
|
+
* ONLY on a liveness-CONFIRMED genuine move so a duplicate leftover with a
|
|
267
|
+
* pre-move "recent" message can shed. */
|
|
268
|
+
bypassRecentUserMessageForConfirmedMove?: boolean;
|
|
213
269
|
workEvidence?: string[];
|
|
214
270
|
}) => Promise<{
|
|
215
271
|
terminated: boolean;
|
|
@@ -255,6 +311,27 @@ export interface Obs {
|
|
|
255
311
|
/** When this session entered reap-pending (two-phase), if it has. */
|
|
256
312
|
reapPendingSince?: number;
|
|
257
313
|
}
|
|
314
|
+
/**
|
|
315
|
+
* The closeout dwell-streak value. OFF mode uses the legacy `number | -1` (-1 =
|
|
316
|
+
* held sentinel). ON (gated) mode uses the richer struct so the dwell advances
|
|
317
|
+
* only across DISTINCT snapshot generations and the GC grace window applies to
|
|
318
|
+
* held + counting episodes uniformly:
|
|
319
|
+
* - `{ kind: 'counting'; count; lastTrueReachableAt; lastSeenAt }` — `count` is
|
|
320
|
+
* the consecutive-distinct-generation dwell; a `true` tick advances it only
|
|
321
|
+
* when `reachableAt > lastTrueReachableAt`.
|
|
322
|
+
* - `{ kind: 'held'; lastSeenAt }` — REPLACES the legacy `-1` sentinel for the
|
|
323
|
+
* WITHHOLD / pin-conflict episodes (audited once; never confirms terminate);
|
|
324
|
+
* `lastSeenAt` lets the GC grace window evict a held topic uniformly.
|
|
325
|
+
*/
|
|
326
|
+
export type TopicMovedStreakValue = number | {
|
|
327
|
+
kind: 'counting';
|
|
328
|
+
count: number;
|
|
329
|
+
lastTrueReachableAt: number;
|
|
330
|
+
lastSeenAt: number;
|
|
331
|
+
} | {
|
|
332
|
+
kind: 'held';
|
|
333
|
+
lastSeenAt: number;
|
|
334
|
+
};
|
|
258
335
|
export declare class SessionReaper extends EventEmitter {
|
|
259
336
|
private readonly cfg;
|
|
260
337
|
private readonly deps;
|
|
@@ -268,14 +345,22 @@ export declare class SessionReaper extends EventEmitter {
|
|
|
268
345
|
/** Consecutive busy-orphan-suspect ticks per session (observe-only dwell for
|
|
269
346
|
* `busyOrphanDetection`). Resets to 0 on any non-suspect tick. GC'd with obs. */
|
|
270
347
|
private busyOrphanStreak;
|
|
271
|
-
/** Consecutive VETOED closeout terminate attempts
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
348
|
+
/** Consecutive VETOED closeout terminate attempts (the WS1.2 P19 breaker
|
|
349
|
+
* counter). Distinct from the dwell streak: this counts only real terminate()
|
|
350
|
+
* calls that came back vetoed. At `topicMovedVetoBreakerAttempts` the closeout
|
|
351
|
+
* stops retrying for the episode. Reset on success / topic-home / pin-conflict
|
|
352
|
+
* hold. GC'd with obs.
|
|
353
|
+
* Keyed on session.id when `closeoutLivenessGate` is OFF (legacy); on the
|
|
354
|
+
* STABLE topic id when ON (so a session-id churn across respawn no longer
|
|
355
|
+
* resets the count — the Secondary fix). Value is always a plain number. */
|
|
276
356
|
private topicMovedVetoes;
|
|
277
|
-
/** Consecutive owned-elsewhere ticks
|
|
278
|
-
*
|
|
357
|
+
/** Consecutive owned-elsewhere ticks (the topicMovedCloseout dwell). Resets
|
|
358
|
+
* when the topic returns home / unowned. GC'd with obs.
|
|
359
|
+
* OFF (legacy): keyed on session.id, value `number | -1` (-1 = held sentinel).
|
|
360
|
+
* ON (gated): keyed on the stable topic id, value the richer struct below —
|
|
361
|
+
* `lastTrueReachableAt` enforces dwell advancement (a confirm tick counts only
|
|
362
|
+
* when the snapshot `reachableAt` advanced), `lastSeenAt` backs the GC grace
|
|
363
|
+
* window so a held topic participates in eviction uniformly. */
|
|
279
364
|
private topicMovedStreak;
|
|
280
365
|
/** Last audited `verdict:keptBy` per session — so the decision audit logs only
|
|
281
366
|
* on a CHANGE, not every tick (auditability without per-tick log spam). */
|
|
@@ -334,6 +419,32 @@ export declare class SessionReaper extends EventEmitter {
|
|
|
334
419
|
/** Active idle threshold (ms) for the current pressure tier. */
|
|
335
420
|
private thresholdMs;
|
|
336
421
|
private hourlyBudgetRemaining;
|
|
422
|
+
/**
|
|
423
|
+
* Post-transfer closeout — LEGACY path (closeoutLivenessGate OFF). Byte-identical
|
|
424
|
+
* observable behavior to the pre-correctness-fix code: session-id-keyed maps, no
|
|
425
|
+
* liveness check, no Part E bypass. Returns whether the session was terminated +
|
|
426
|
+
* the running reap count.
|
|
427
|
+
*/
|
|
428
|
+
private runCloseoutLegacy;
|
|
429
|
+
/**
|
|
430
|
+
* Post-transfer closeout — GATED path (closeoutLivenessGate ON). The
|
|
431
|
+
* correctness fix: NEVER terminate the live local session on a stale/unverified
|
|
432
|
+
* ownership record. Resolves the owner ATOMICALLY (machineId + display), then
|
|
433
|
+
* consults `remoteOwnerHasLiveSession`:
|
|
434
|
+
* true → genuine move (real duplicate) → dwell→terminate (with Part E bypass).
|
|
435
|
+
* false → owner has no live session → WITHHOLD (neutral once-per-episode audit).
|
|
436
|
+
* 'unknown'/throw/dep-absent → WITHHOLD (fail-closed; UNKNOWN must NEVER act).
|
|
437
|
+
* Maps are keyed on the stable TOPIC id (Secondary fix) with the richer streak
|
|
438
|
+
* struct so the dwell advances only across DISTINCT snapshot generations.
|
|
439
|
+
*/
|
|
440
|
+
private runCloseoutGated;
|
|
441
|
+
/**
|
|
442
|
+
* Shared closeout terminate machinery — the dwell has been met. Handles the P19
|
|
443
|
+
* breaker, dry-run, budget caps, the guarded terminate, and the veto/breaker
|
|
444
|
+
* audit. `key` is session.id (legacy) or the topic id (gated); `bypassRecentForMove`
|
|
445
|
+
* is passed through to terminate ONLY on a liveness-confirmed genuine move.
|
|
446
|
+
*/
|
|
447
|
+
private attemptCloseoutTerminate;
|
|
337
448
|
tick(): Promise<void>;
|
|
338
449
|
private performReap;
|
|
339
450
|
/** Emit a `decision` audit row only when a session's (verdict, keptBy) differs
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SessionReaper.d.ts","sourceRoot":"","sources":["../../src/monitoring/SessionReaper.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAG3C,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAGhD,OAAO,EAAoC,KAAK,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAE/F,MAAM,MAAM,YAAY,GAAG,QAAQ,GAAG,UAAU,GAAG,UAAU,CAAC;AAC9D,MAAM,MAAM,OAAO,GAAG,MAAM,GAAG,eAAe,CAAC;AAC/C,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,KAAK,CAAC;AAExC,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC;IAChB,eAAe,EAAE,MAAM,CAAC;IACxB,aAAa,EAAE,MAAM,CAAC;IACtB,mBAAmB,EAAE,MAAM,CAAC;IAC5B,oBAAoB,EAAE,MAAM,CAAC;IAC7B,gBAAgB,EAAE,MAAM,CAAC;IACzB,uBAAuB,EAAE,MAAM,CAAC;IAChC,4BAA4B,EAAE,MAAM,CAAC;IACrC,4BAA4B,EAAE,MAAM,CAAC;IACrC,eAAe,EAAE,OAAO,CAAC;IACzB,eAAe,EAAE,MAAM,CAAC;IACxB,eAAe,EAAE,MAAM,CAAC;IACxB,aAAa,EAAE,MAAM,CAAC;IACtB,sBAAsB,EAAE,OAAO,CAAC;IAChC;gEAC4D;IAC5D,4BAA4B,EAAE,MAAM,CAAC;IACrC;;;;;4FAKwF;IACxF,+BAA+B,EAAE,OAAO,CAAC;IACzC;;uEAEmE;IACnE,sBAAsB,EAAE,MAAM,CAAC;IAC/B,yEAAyE;IACzE,sBAAsB,EAAE,MAAM,CAAC;IAC/B;;;;;;;;+EAQ2E;IAC3E,yBAAyB,EAAE,OAAO,CAAC;IACnC;;;;+EAI2E;IAC3E,sBAAsB,EAAE,MAAM,CAAC;IAC/B;;;;;;;;sFAQkF;IAClF,mBAAmB,EAAE,OAAO,CAAC;IAC7B;;8CAE0C;IAC1C,sBAAsB,EAAE,MAAM,CAAC;IAC/B;;;;;oFAKgF;IAChF,kBAAkB,EAAE,OAAO,CAAC;IAC5B;;uDAEmD;IACnD,sBAAsB,EAAE,MAAM,CAAC;IAC/B;;;;;;;oFAOgF;IAChF,6BAA6B,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"SessionReaper.d.ts","sourceRoot":"","sources":["../../src/monitoring/SessionReaper.ts"],"names":[],"mappings":"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"}
|
|
@@ -48,6 +48,7 @@ export const DEFAULT_SESSION_REAPER_CONFIG = {
|
|
|
48
48
|
topicMovedCloseout: true,
|
|
49
49
|
topicMovedConfirmTicks: 2,
|
|
50
50
|
topicMovedVetoBreakerAttempts: 5,
|
|
51
|
+
closeoutLivenessGate: false,
|
|
51
52
|
};
|
|
52
53
|
/** Memory-pressure thresholds (freePct). Kept as constants — the existing
|
|
53
54
|
* behavior surface; CPU thresholds are the configurable addition. */
|
|
@@ -96,14 +97,22 @@ export class SessionReaper extends EventEmitter {
|
|
|
96
97
|
/** Consecutive busy-orphan-suspect ticks per session (observe-only dwell for
|
|
97
98
|
* `busyOrphanDetection`). Resets to 0 on any non-suspect tick. GC'd with obs. */
|
|
98
99
|
busyOrphanStreak = new Map();
|
|
99
|
-
/** Consecutive VETOED closeout terminate attempts
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
100
|
+
/** Consecutive VETOED closeout terminate attempts (the WS1.2 P19 breaker
|
|
101
|
+
* counter). Distinct from the dwell streak: this counts only real terminate()
|
|
102
|
+
* calls that came back vetoed. At `topicMovedVetoBreakerAttempts` the closeout
|
|
103
|
+
* stops retrying for the episode. Reset on success / topic-home / pin-conflict
|
|
104
|
+
* hold. GC'd with obs.
|
|
105
|
+
* Keyed on session.id when `closeoutLivenessGate` is OFF (legacy); on the
|
|
106
|
+
* STABLE topic id when ON (so a session-id churn across respawn no longer
|
|
107
|
+
* resets the count — the Secondary fix). Value is always a plain number. */
|
|
104
108
|
topicMovedVetoes = new Map();
|
|
105
|
-
/** Consecutive owned-elsewhere ticks
|
|
106
|
-
*
|
|
109
|
+
/** Consecutive owned-elsewhere ticks (the topicMovedCloseout dwell). Resets
|
|
110
|
+
* when the topic returns home / unowned. GC'd with obs.
|
|
111
|
+
* OFF (legacy): keyed on session.id, value `number | -1` (-1 = held sentinel).
|
|
112
|
+
* ON (gated): keyed on the stable topic id, value the richer struct below —
|
|
113
|
+
* `lastTrueReachableAt` enforces dwell advancement (a confirm tick counts only
|
|
114
|
+
* when the snapshot `reachableAt` advanced), `lastSeenAt` backs the GC grace
|
|
115
|
+
* window so a held topic participates in eviction uniformly. */
|
|
107
116
|
topicMovedStreak = new Map();
|
|
108
117
|
/** Last audited `verdict:keptBy` per session — so the decision audit logs only
|
|
109
118
|
* on a CHANGE, not every tick (auditability without per-tick log spam). */
|
|
@@ -383,6 +392,237 @@ export class SessionReaper extends EventEmitter {
|
|
|
383
392
|
this.reapTimestamps = this.reapTimestamps.filter(t => t >= cutoff);
|
|
384
393
|
return this.cfg.maxReapsPerHour - this.reapTimestamps.length;
|
|
385
394
|
}
|
|
395
|
+
/**
|
|
396
|
+
* Post-transfer closeout — LEGACY path (closeoutLivenessGate OFF). Byte-identical
|
|
397
|
+
* observable behavior to the pre-correctness-fix code: session-id-keyed maps, no
|
|
398
|
+
* liveness check, no Part E bypass. Returns whether the session was terminated +
|
|
399
|
+
* the running reap count.
|
|
400
|
+
*/
|
|
401
|
+
async runCloseoutLegacy(session, reapedThisTick) {
|
|
402
|
+
let otherOwner = null;
|
|
403
|
+
let pinnedHere = false;
|
|
404
|
+
try {
|
|
405
|
+
const topicId = this.deps.topicBinding(session.tmuxSession);
|
|
406
|
+
otherOwner = topicId != null ? (this.deps.topicOwnerElsewhere?.(topicId) ?? null) : null;
|
|
407
|
+
// WS1.3: pin-conflict = do-not-act (reconcile mid-flight TOWARD us).
|
|
408
|
+
pinnedHere = topicId != null && (this.deps.topicPinnedHere?.(topicId) ?? false);
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
otherOwner = null; /* @silent-fallback-ok — ownership signal failed → cannot reason → skip rule (safe withhold direction) */
|
|
412
|
+
}
|
|
413
|
+
if (otherOwner && pinnedHere) {
|
|
414
|
+
const prior = this.topicMovedStreak.get(session.id) ?? 0;
|
|
415
|
+
if (prior !== -1) {
|
|
416
|
+
this.audit('reap-skipped-topic-moved', session, { rule: 'topic-moved-away', otherOwner, skipped: 'pin-conflict-pending-reconcile' });
|
|
417
|
+
this.topicMovedStreak.set(session.id, -1);
|
|
418
|
+
}
|
|
419
|
+
this.topicMovedVetoes.delete(session.id);
|
|
420
|
+
return { terminated: false, reapedThisTick };
|
|
421
|
+
}
|
|
422
|
+
if (otherOwner) {
|
|
423
|
+
const streak = (this.topicMovedStreak.get(session.id) ?? 0) + 1;
|
|
424
|
+
this.topicMovedStreak.set(session.id, streak);
|
|
425
|
+
if (streak >= this.cfg.topicMovedConfirmTicks) {
|
|
426
|
+
const reason = `topic moved to ${otherOwner} — closing the leftover session on this machine (post-transfer closeout)`;
|
|
427
|
+
return this.attemptCloseoutTerminate({
|
|
428
|
+
session, otherOwner, reason, streak,
|
|
429
|
+
key: session.id, reapedThisTick, bypassRecentForMove: false,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
return { terminated: false, reapedThisTick };
|
|
433
|
+
}
|
|
434
|
+
// owned-by-self / unowned — clear the episode (counting streak AND -1 sentinel).
|
|
435
|
+
if ((this.topicMovedStreak.get(session.id) ?? 0) !== 0) {
|
|
436
|
+
this.topicMovedStreak.set(session.id, 0);
|
|
437
|
+
this.topicMovedVetoes.delete(session.id);
|
|
438
|
+
}
|
|
439
|
+
return { terminated: false, reapedThisTick };
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Post-transfer closeout — GATED path (closeoutLivenessGate ON). The
|
|
443
|
+
* correctness fix: NEVER terminate the live local session on a stale/unverified
|
|
444
|
+
* ownership record. Resolves the owner ATOMICALLY (machineId + display), then
|
|
445
|
+
* consults `remoteOwnerHasLiveSession`:
|
|
446
|
+
* true → genuine move (real duplicate) → dwell→terminate (with Part E bypass).
|
|
447
|
+
* false → owner has no live session → WITHHOLD (neutral once-per-episode audit).
|
|
448
|
+
* 'unknown'/throw/dep-absent → WITHHOLD (fail-closed; UNKNOWN must NEVER act).
|
|
449
|
+
* Maps are keyed on the stable TOPIC id (Secondary fix) with the richer streak
|
|
450
|
+
* struct so the dwell advances only across DISTINCT snapshot generations.
|
|
451
|
+
*/
|
|
452
|
+
async runCloseoutGated(session, reapedThisTick) {
|
|
453
|
+
let topicId = null;
|
|
454
|
+
let owner = null;
|
|
455
|
+
let pinnedHere = false;
|
|
456
|
+
try {
|
|
457
|
+
topicId = this.deps.topicBinding(session.tmuxSession);
|
|
458
|
+
// The gated path REQUIRES the combined dep — it never falls back to the
|
|
459
|
+
// display-only one. Absent ⇒ owner stays null ⇒ withhold (fail-closed below).
|
|
460
|
+
owner = topicId != null ? (this.deps.topicOwnerElsewhereInfo?.(topicId) ?? null) : null;
|
|
461
|
+
pinnedHere = topicId != null && (this.deps.topicPinnedHere?.(topicId) ?? false);
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
owner = null; /* @silent-fallback-ok — ownership signal failed → cannot reason → skip rule (safe withhold direction) */
|
|
465
|
+
}
|
|
466
|
+
// topicId null → no participation (also covers the topicOwnerElsewhereInfo-absent
|
|
467
|
+
// dep window: owner is null → the "no owner-elsewhere" reset arm below runs).
|
|
468
|
+
if (topicId == null)
|
|
469
|
+
return { terminated: false, reapedThisTick };
|
|
470
|
+
const key = topicId; // stable topic-keyed breaker state (Secondary fix)
|
|
471
|
+
const now = this.now();
|
|
472
|
+
if (owner && pinnedHere) {
|
|
473
|
+
// Pin-conflict hold WINS ahead of the liveness gate (unchanged WS1.3 rule).
|
|
474
|
+
const prior = this.topicMovedStreak.get(key);
|
|
475
|
+
const isHeld = typeof prior === 'object' && prior.kind === 'held';
|
|
476
|
+
if (!isHeld) {
|
|
477
|
+
this.audit('reap-skipped-topic-moved', session, { rule: 'topic-moved-away', otherOwner: owner.displayName, skipped: 'pin-conflict-pending-reconcile' });
|
|
478
|
+
}
|
|
479
|
+
this.topicMovedStreak.set(key, { kind: 'held', lastSeenAt: now });
|
|
480
|
+
this.topicMovedVetoes.delete(key);
|
|
481
|
+
return { terminated: false, reapedThisTick };
|
|
482
|
+
}
|
|
483
|
+
if (owner) {
|
|
484
|
+
// ── Part C: liveness gate ──────────────────────────────────────────────
|
|
485
|
+
let liveness;
|
|
486
|
+
try {
|
|
487
|
+
liveness = this.deps.remoteOwnerHasLiveSession
|
|
488
|
+
? this.deps.remoteOwnerHasLiveSession(topicId, owner.machineId)
|
|
489
|
+
: { state: 'unknown' }; // dep absent under gate → fail-closed
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
liveness = { state: 'unknown' }; // a throw is treated as 'unknown'
|
|
493
|
+
}
|
|
494
|
+
if (liveness.state !== true) {
|
|
495
|
+
// false / 'unknown' → WITHHOLD. Once-per-episode neutral audit + held
|
|
496
|
+
// sentinel; a withheld episode never accrues breaker vetoes.
|
|
497
|
+
const prior = this.topicMovedStreak.get(key);
|
|
498
|
+
const isHeld = typeof prior === 'object' && prior.kind === 'held';
|
|
499
|
+
if (!isHeld) {
|
|
500
|
+
const ownerEntry = liveness.reachableAt;
|
|
501
|
+
this.audit('reap-skipped-topic-moved', session, {
|
|
502
|
+
rule: 'topic-moved-away',
|
|
503
|
+
otherOwner: owner.displayName,
|
|
504
|
+
ownerMachineId: owner.machineId,
|
|
505
|
+
skipped: liveness.state === false ? 'no-live-remote-session' : 'remote-liveness-unknown',
|
|
506
|
+
// Neutral, non-directional observational evidence (NO reconcileToward).
|
|
507
|
+
remoteOwnerListedSession: false,
|
|
508
|
+
withheldCloseout: true,
|
|
509
|
+
possibleStaleOwner: true,
|
|
510
|
+
snapshotAgeMs: ownerEntry != null ? now - ownerEntry : undefined,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
this.topicMovedStreak.set(key, { kind: 'held', lastSeenAt: now });
|
|
514
|
+
this.topicMovedVetoes.delete(key);
|
|
515
|
+
return { terminated: false, reapedThisTick };
|
|
516
|
+
}
|
|
517
|
+
// state === true: genuine move. Advance the dwell ONLY across a DISTINCT
|
|
518
|
+
// snapshot generation (reachableAt strictly newer than lastTrueReachableAt).
|
|
519
|
+
const reachableAt = liveness.reachableAt ?? 0;
|
|
520
|
+
const prior = this.topicMovedStreak.get(key);
|
|
521
|
+
let count;
|
|
522
|
+
let lastTrueReachableAt;
|
|
523
|
+
if (typeof prior === 'object' && prior.kind === 'counting') {
|
|
524
|
+
if (reachableAt > prior.lastTrueReachableAt) {
|
|
525
|
+
count = prior.count + 1;
|
|
526
|
+
lastTrueReachableAt = reachableAt;
|
|
527
|
+
}
|
|
528
|
+
else {
|
|
529
|
+
// Same generation re-read — the tick does not advance the streak.
|
|
530
|
+
count = prior.count;
|
|
531
|
+
lastTrueReachableAt = prior.lastTrueReachableAt;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
535
|
+
// First counting tick OR transition out of a held (-1) episode → start at 1.
|
|
536
|
+
count = 1;
|
|
537
|
+
lastTrueReachableAt = reachableAt;
|
|
538
|
+
}
|
|
539
|
+
this.topicMovedStreak.set(key, { kind: 'counting', count, lastTrueReachableAt, lastSeenAt: now });
|
|
540
|
+
if (count >= this.cfg.topicMovedConfirmTicks) {
|
|
541
|
+
const reason = `topic moved to ${owner.displayName} — closing the leftover session on this machine (post-transfer closeout)`;
|
|
542
|
+
// Part E: pass the narrow bypass ONLY when the topic's freshest LOCAL user
|
|
543
|
+
// message is OLDER than the snapshot reachableAt (freshest-interaction veto).
|
|
544
|
+
const lastUserMsgAt = (() => {
|
|
545
|
+
try {
|
|
546
|
+
return this.deps.recentUserMessageAt?.(topicId) ?? null;
|
|
547
|
+
}
|
|
548
|
+
catch {
|
|
549
|
+
return null; /* @silent-fallback-ok — recent-msg signal failed → treat as no recent message → bypass not granted (safe) */
|
|
550
|
+
}
|
|
551
|
+
})();
|
|
552
|
+
const bypassRecentForMove = lastUserMsgAt == null || lastUserMsgAt <= reachableAt;
|
|
553
|
+
return this.attemptCloseoutTerminate({
|
|
554
|
+
session, otherOwner: owner.displayName, reason, streak: count,
|
|
555
|
+
key, reapedThisTick, bypassRecentForMove,
|
|
556
|
+
confirmedMove: true, snapshotReachableAt: reachableAt,
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
return { terminated: false, reapedThisTick };
|
|
560
|
+
}
|
|
561
|
+
// No owner-elsewhere — clear the episode (counting OR held), so a FUTURE move
|
|
562
|
+
// starts clean. Mirrors the legacy reset arm under the topic key.
|
|
563
|
+
if (this.topicMovedStreak.has(key)) {
|
|
564
|
+
this.topicMovedStreak.delete(key);
|
|
565
|
+
this.topicMovedVetoes.delete(key);
|
|
566
|
+
}
|
|
567
|
+
return { terminated: false, reapedThisTick };
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Shared closeout terminate machinery — the dwell has been met. Handles the P19
|
|
571
|
+
* breaker, dry-run, budget caps, the guarded terminate, and the veto/breaker
|
|
572
|
+
* audit. `key` is session.id (legacy) or the topic id (gated); `bypassRecentForMove`
|
|
573
|
+
* is passed through to terminate ONLY on a liveness-confirmed genuine move.
|
|
574
|
+
*/
|
|
575
|
+
async attemptCloseoutTerminate(args) {
|
|
576
|
+
const { session, otherOwner, reason, streak, key, bypassRecentForMove } = args;
|
|
577
|
+
let { reapedThisTick } = args;
|
|
578
|
+
const vetoes = this.topicMovedVetoes.get(key) ?? 0;
|
|
579
|
+
if (vetoes >= this.cfg.topicMovedVetoBreakerAttempts) {
|
|
580
|
+
// P19 breaker OPEN — stop retrying for the episode (audited+escalated once).
|
|
581
|
+
return { terminated: false, reapedThisTick };
|
|
582
|
+
}
|
|
583
|
+
if (!this.killsEnabled) {
|
|
584
|
+
if (streak === this.cfg.topicMovedConfirmTicks) {
|
|
585
|
+
this.audit('would-reap', session, { rule: 'topic-moved-away', otherOwner, dryRun: true });
|
|
586
|
+
}
|
|
587
|
+
return { terminated: false, reapedThisTick };
|
|
588
|
+
}
|
|
589
|
+
if (reapedThisTick < this.cfg.maxReapsPerTick && this.hourlyBudgetRemaining() > 0) {
|
|
590
|
+
const res = await this.deps.terminate(session.id, reason, bypassRecentForMove
|
|
591
|
+
? { bypassRecentUserMessageForConfirmedMove: true, workEvidence: [] }
|
|
592
|
+
: undefined);
|
|
593
|
+
if (res.terminated) {
|
|
594
|
+
reapedThisTick++;
|
|
595
|
+
this.reapTimestamps.push(this.now());
|
|
596
|
+
this.audit('reaped', session, {
|
|
597
|
+
rule: 'topic-moved-away', otherOwner,
|
|
598
|
+
...(args.confirmedMove ? { confirmedMove: true, snapshotReachableAt: args.snapshotReachableAt } : {}),
|
|
599
|
+
});
|
|
600
|
+
this.topicMovedStreak.delete(key);
|
|
601
|
+
this.topicMovedVetoes.delete(key);
|
|
602
|
+
return { terminated: true, reapedThisTick };
|
|
603
|
+
}
|
|
604
|
+
// Guard veto / already-terminal — audit once per streak crossing, keep the
|
|
605
|
+
// streak so next tick retries (bounded by the breaker).
|
|
606
|
+
const v = vetoes + 1;
|
|
607
|
+
this.topicMovedVetoes.set(key, v);
|
|
608
|
+
if (streak === this.cfg.topicMovedConfirmTicks) {
|
|
609
|
+
this.audit('reap-skipped-topic-moved', session, { rule: 'topic-moved-away', otherOwner, skipped: res.skipped });
|
|
610
|
+
}
|
|
611
|
+
if (v === this.cfg.topicMovedVetoBreakerAttempts) {
|
|
612
|
+
this.audit('closeout-breaker-open', session, {
|
|
613
|
+
rule: 'topic-moved-away', otherOwner, vetoedAttempts: v, lastSkipped: res.skipped ?? 'keep-guard',
|
|
614
|
+
});
|
|
615
|
+
const topicId = this.deps.topicBinding(session.tmuxSession);
|
|
616
|
+
this.deps.raiseAttention?.({
|
|
617
|
+
id: `closeout-breaker:${session.id}`,
|
|
618
|
+
title: `Topic ${topicId ?? '?'} moved to ${otherOwner}, but the old session won't close`,
|
|
619
|
+
summary: `Post-transfer closeout gave up after ${v} vetoed attempts (held by: ${res.skipped ?? 'keep-guard'}).`,
|
|
620
|
+
description: `The conversation for topic ${topicId ?? '?'} now lives on ${otherOwner}, but the leftover session on this machine (${session.tmuxSession}) refused to close ${v} times in a row — a KEEP-guard reports it is still working (${res.skipped ?? 'keep-guard'}). Closeout retries have stopped (P19 breaker); the session will close via the normal idle path when it finishes, or you can close it from the dashboard.`,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return { terminated: false, reapedThisTick };
|
|
625
|
+
}
|
|
386
626
|
async tick() {
|
|
387
627
|
if (!this.cfg.enabled || this.running)
|
|
388
628
|
return;
|
|
@@ -408,12 +648,54 @@ export class SessionReaper extends EventEmitter {
|
|
|
408
648
|
for (const id of [...this.busyOrphanStreak.keys()])
|
|
409
649
|
if (!live.has(id))
|
|
410
650
|
this.busyOrphanStreak.delete(id);
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
651
|
+
if (this.cfg.closeoutLivenessGate) {
|
|
652
|
+
// Gated: the closeout maps are keyed on the stable TOPIC id, so a naive
|
|
653
|
+
// "GC any topic without a live session THIS tick" would erase the count
|
|
654
|
+
// during the brief gap between a session dying and its same-topic respawn
|
|
655
|
+
// appearing — defeating the topic key. Instead evict a topic entry only
|
|
656
|
+
// when it has had no live binding AND was not owned-elsewhere for a grace
|
|
657
|
+
// window (2× tickIntervalSec — survives one full respawn gap). `lastSeenAt`
|
|
658
|
+
// is stamped on every entry whenever its topic is bound/owned-elsewhere.
|
|
659
|
+
const liveTopics = new Set();
|
|
660
|
+
for (const s of sessions) {
|
|
661
|
+
const t = this.deps.topicBinding(s.tmuxSession);
|
|
662
|
+
if (t != null)
|
|
663
|
+
liveTopics.add(t);
|
|
664
|
+
}
|
|
665
|
+
const graceMs = 2 * this.cfg.tickIntervalSec * 1000;
|
|
666
|
+
const now = this.now();
|
|
667
|
+
for (const [key, val] of [...this.topicMovedStreak.entries()]) {
|
|
668
|
+
if (typeof key !== 'number') {
|
|
669
|
+
this.topicMovedStreak.delete(key);
|
|
670
|
+
continue;
|
|
671
|
+
} // stale OFF-mode key
|
|
672
|
+
if (liveTopics.has(key))
|
|
673
|
+
continue; // still bound — keep
|
|
674
|
+
const lastSeen = typeof val === 'object' ? val.lastSeenAt : 0;
|
|
675
|
+
if (now - lastSeen > graceMs) {
|
|
676
|
+
this.topicMovedStreak.delete(key);
|
|
677
|
+
this.topicMovedVetoes.delete(key);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
// topicMovedVetoes with no streak entry left (and not a live topic) → drop.
|
|
681
|
+
for (const key of [...this.topicMovedVetoes.keys()]) {
|
|
682
|
+
if (typeof key !== 'number') {
|
|
683
|
+
this.topicMovedVetoes.delete(key);
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
if (!liveTopics.has(key) && !this.topicMovedStreak.has(key))
|
|
687
|
+
this.topicMovedVetoes.delete(key);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
else {
|
|
691
|
+
// OFF (legacy): keyed on session.id — GC any id not in the live set.
|
|
692
|
+
for (const id of [...this.topicMovedStreak.keys()])
|
|
693
|
+
if (typeof id === 'number' || !live.has(id))
|
|
694
|
+
this.topicMovedStreak.delete(id);
|
|
695
|
+
for (const id of [...this.topicMovedVetoes.keys()])
|
|
696
|
+
if (typeof id === 'number' || !live.has(id))
|
|
697
|
+
this.topicMovedVetoes.delete(id);
|
|
698
|
+
}
|
|
417
699
|
let reapedThisTick = 0;
|
|
418
700
|
for (const session of sessions) {
|
|
419
701
|
// ── Post-transfer closeout (operator-named issue, 2026-06-05) ──────
|
|
@@ -425,94 +707,13 @@ export class SessionReaper extends EventEmitter {
|
|
|
425
707
|
// veto is audited and retried next tick (eventual closeout, never a
|
|
426
708
|
// forced kill). Dwell of `topicMovedConfirmTicks` absorbs ownership
|
|
427
709
|
// churn mid-transfer.
|
|
428
|
-
if (this.cfg.topicMovedCloseout && this.deps.topicOwnerElsewhere) {
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
//
|
|
435
|
-
// When the topic's PIN names THIS machine while ownership still says
|
|
436
|
-
// another, the divergence is mid-reconcile TOWARD us — the
|
|
437
|
-
// OwnershipReconciler is bringing the record back, and closing the
|
|
438
|
-
// local session now would kill the exact session the pin wants here
|
|
439
|
-
// (the 2026-06-12 incident: the closeout attacked the working laptop
|
|
440
|
-
// session every 2 minutes for hours during a stuck transfer-back).
|
|
441
|
-
pinnedHere = topicId != null && (this.deps.topicPinnedHere?.(topicId) ?? false);
|
|
442
|
-
}
|
|
443
|
-
catch {
|
|
444
|
-
otherOwner = null; /* signal failed → cannot reason → skip rule */
|
|
445
|
-
}
|
|
446
|
-
if (otherOwner && pinnedHere) {
|
|
447
|
-
// -1 is the held-and-audited sentinel: audit ONCE per conflict
|
|
448
|
-
// episode, hold (never act) for as long as the pin names us.
|
|
449
|
-
const prior = this.topicMovedStreak.get(session.id) ?? 0;
|
|
450
|
-
if (prior !== -1) {
|
|
451
|
-
this.audit('reap-skipped-topic-moved', session, { rule: 'topic-moved-away', otherOwner, skipped: 'pin-conflict-pending-reconcile' });
|
|
452
|
-
this.topicMovedStreak.set(session.id, -1);
|
|
453
|
-
}
|
|
454
|
-
// The closeout intent is withdrawn while the pin holds — a fresh
|
|
455
|
-
// episode after reconcile starts with a clean breaker.
|
|
456
|
-
this.topicMovedVetoes.delete(session.id);
|
|
457
|
-
}
|
|
458
|
-
else if (otherOwner) {
|
|
459
|
-
const streak = (this.topicMovedStreak.get(session.id) ?? 0) + 1;
|
|
460
|
-
this.topicMovedStreak.set(session.id, streak);
|
|
461
|
-
if (streak >= this.cfg.topicMovedConfirmTicks) {
|
|
462
|
-
const reason = `topic moved to ${otherOwner} — closing the leftover session on this machine (post-transfer closeout)`;
|
|
463
|
-
const vetoes = this.topicMovedVetoes.get(session.id) ?? 0;
|
|
464
|
-
if (vetoes >= this.cfg.topicMovedVetoBreakerAttempts) {
|
|
465
|
-
// P19 breaker OPEN (WS1.2): this session vetoed the closeout
|
|
466
|
-
// `topicMovedVetoBreakerAttempts` times in a row — stop
|
|
467
|
-
// retrying for the episode. NOT a stranded session: it falls
|
|
468
|
-
// through to the idle pipeline below every tick, so it is
|
|
469
|
-
// still reaped normally when its work actually finishes. The
|
|
470
|
-
// open transition was audited + escalated exactly once.
|
|
471
|
-
}
|
|
472
|
-
else if (!this.killsEnabled) {
|
|
473
|
-
if (streak === this.cfg.topicMovedConfirmTicks) {
|
|
474
|
-
this.audit('would-reap', session, { rule: 'topic-moved-away', otherOwner, dryRun: true });
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
else if (reapedThisTick < this.cfg.maxReapsPerTick && this.hourlyBudgetRemaining() > 0) {
|
|
478
|
-
const res = await this.deps.terminate(session.id, reason);
|
|
479
|
-
if (res.terminated) {
|
|
480
|
-
reapedThisTick++;
|
|
481
|
-
this.reapTimestamps.push(this.now());
|
|
482
|
-
this.audit('reaped', session, { rule: 'topic-moved-away', otherOwner });
|
|
483
|
-
this.topicMovedStreak.delete(session.id);
|
|
484
|
-
this.topicMovedVetoes.delete(session.id);
|
|
485
|
-
continue; // session is gone — skip the idle pipeline
|
|
486
|
-
}
|
|
487
|
-
// Guard veto / already-terminal — audit once per streak crossing,
|
|
488
|
-
// keep the streak so next tick retries (bounded by the breaker).
|
|
489
|
-
const v = vetoes + 1;
|
|
490
|
-
this.topicMovedVetoes.set(session.id, v);
|
|
491
|
-
if (streak === this.cfg.topicMovedConfirmTicks) {
|
|
492
|
-
this.audit('reap-skipped-topic-moved', session, { rule: 'topic-moved-away', otherOwner, skipped: res.skipped });
|
|
493
|
-
}
|
|
494
|
-
if (v === this.cfg.topicMovedVetoBreakerAttempts) {
|
|
495
|
-
// Breaker opens NOW: one audit row + one deduped escalation.
|
|
496
|
-
this.audit('closeout-breaker-open', session, {
|
|
497
|
-
rule: 'topic-moved-away', otherOwner, vetoedAttempts: v, lastSkipped: res.skipped ?? 'keep-guard',
|
|
498
|
-
});
|
|
499
|
-
const topicId = this.deps.topicBinding(session.tmuxSession);
|
|
500
|
-
this.deps.raiseAttention?.({
|
|
501
|
-
id: `closeout-breaker:${session.id}`,
|
|
502
|
-
title: `Topic ${topicId ?? '?'} moved to ${otherOwner}, but the old session won't close`,
|
|
503
|
-
summary: `Post-transfer closeout gave up after ${v} vetoed attempts (held by: ${res.skipped ?? 'keep-guard'}).`,
|
|
504
|
-
description: `The conversation for topic ${topicId ?? '?'} now lives on ${otherOwner}, but the leftover session on this machine (${session.tmuxSession}) refused to close ${v} times in a row — a KEEP-guard reports it is still working (${res.skipped ?? 'keep-guard'}). Closeout retries have stopped (P19 breaker); the session will close via the normal idle path when it finishes, or you can close it from the dashboard.`,
|
|
505
|
-
});
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
else if ((this.topicMovedStreak.get(session.id) ?? 0) !== 0) {
|
|
511
|
-
// Clears both a counting streak AND the -1 pin-conflict sentinel,
|
|
512
|
-
// so a FUTURE genuine move starts its dwell from a clean slate.
|
|
513
|
-
this.topicMovedStreak.set(session.id, 0);
|
|
514
|
-
this.topicMovedVetoes.delete(session.id);
|
|
515
|
-
}
|
|
710
|
+
if (this.cfg.topicMovedCloseout && (this.deps.topicOwnerElsewhere || this.deps.topicOwnerElsewhereInfo)) {
|
|
711
|
+
const outcome = this.cfg.closeoutLivenessGate
|
|
712
|
+
? await this.runCloseoutGated(session, reapedThisTick)
|
|
713
|
+
: await this.runCloseoutLegacy(session, reapedThisTick);
|
|
714
|
+
reapedThisTick = outcome.reapedThisTick;
|
|
715
|
+
if (outcome.terminated)
|
|
716
|
+
continue; // session is gone — skip the idle pipeline
|
|
516
717
|
}
|
|
517
718
|
// CPU-progress probe for the active-process keep-tightening. Sampled here
|
|
518
719
|
// (once per session per tick) so the cross-tick delta lives in one place;
|