instar 1.3.729 → 1.3.731

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 (107) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +254 -2
  3. package/dist/commands/server.js.map +1 -1
  4. package/dist/core/CoherenceGate.d.ts +119 -0
  5. package/dist/core/CoherenceGate.d.ts.map +1 -1
  6. package/dist/core/CoherenceGate.js +342 -19
  7. package/dist/core/CoherenceGate.js.map +1 -1
  8. package/dist/core/CoherenceReviewer.d.ts +30 -0
  9. package/dist/core/CoherenceReviewer.d.ts.map +1 -1
  10. package/dist/core/CoherenceReviewer.js +14 -0
  11. package/dist/core/CoherenceReviewer.js.map +1 -1
  12. package/dist/core/FileClassifier.d.ts.map +1 -1
  13. package/dist/core/FileClassifier.js +7 -0
  14. package/dist/core/FileClassifier.js.map +1 -1
  15. package/dist/core/ModelSwapService.d.ts +13 -0
  16. package/dist/core/ModelSwapService.d.ts.map +1 -1
  17. package/dist/core/ModelSwapService.js +11 -0
  18. package/dist/core/ModelSwapService.js.map +1 -1
  19. package/dist/core/ModelTierEscalation.d.ts +10 -0
  20. package/dist/core/ModelTierEscalation.d.ts.map +1 -1
  21. package/dist/core/ModelTierEscalation.js +4 -0
  22. package/dist/core/ModelTierEscalation.js.map +1 -1
  23. package/dist/core/PostUpdateMigrator.d.ts +1 -0
  24. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  25. package/dist/core/PostUpdateMigrator.js +43 -0
  26. package/dist/core/PostUpdateMigrator.js.map +1 -1
  27. package/dist/core/ProactiveSwapMonitor.d.ts +74 -20
  28. package/dist/core/ProactiveSwapMonitor.d.ts.map +1 -1
  29. package/dist/core/ProactiveSwapMonitor.js +349 -28
  30. package/dist/core/ProactiveSwapMonitor.js.map +1 -1
  31. package/dist/core/QuotaAwareScheduler.d.ts +70 -3
  32. package/dist/core/QuotaAwareScheduler.d.ts.map +1 -1
  33. package/dist/core/QuotaAwareScheduler.js +100 -16
  34. package/dist/core/QuotaAwareScheduler.js.map +1 -1
  35. package/dist/core/ResponseReviewDecisionLog.d.ts +45 -0
  36. package/dist/core/ResponseReviewDecisionLog.d.ts.map +1 -0
  37. package/dist/core/ResponseReviewDecisionLog.js +85 -0
  38. package/dist/core/ResponseReviewDecisionLog.js.map +1 -0
  39. package/dist/core/SessionManager.d.ts +28 -0
  40. package/dist/core/SessionManager.d.ts.map +1 -1
  41. package/dist/core/SessionManager.js +102 -0
  42. package/dist/core/SessionManager.js.map +1 -1
  43. package/dist/core/SessionRefresh.d.ts +116 -1
  44. package/dist/core/SessionRefresh.d.ts.map +1 -1
  45. package/dist/core/SessionRefresh.js +280 -17
  46. package/dist/core/SessionRefresh.js.map +1 -1
  47. package/dist/core/SwapAntiThrash.d.ts +329 -0
  48. package/dist/core/SwapAntiThrash.d.ts.map +1 -0
  49. package/dist/core/SwapAntiThrash.js +1025 -0
  50. package/dist/core/SwapAntiThrash.js.map +1 -0
  51. package/dist/core/SwapLedger.d.ts +168 -0
  52. package/dist/core/SwapLedger.d.ts.map +1 -0
  53. package/dist/core/SwapLedger.js +275 -0
  54. package/dist/core/SwapLedger.js.map +1 -0
  55. package/dist/core/SwapWorkGate.d.ts +100 -0
  56. package/dist/core/SwapWorkGate.d.ts.map +1 -0
  57. package/dist/core/SwapWorkGate.js +175 -0
  58. package/dist/core/SwapWorkGate.js.map +1 -0
  59. package/dist/core/WriteDomainRegistry.d.ts.map +1 -1
  60. package/dist/core/WriteDomainRegistry.js +21 -0
  61. package/dist/core/WriteDomainRegistry.js.map +1 -1
  62. package/dist/core/conversationContextWiring.d.ts +51 -0
  63. package/dist/core/conversationContextWiring.d.ts.map +1 -0
  64. package/dist/core/conversationContextWiring.js +78 -0
  65. package/dist/core/conversationContextWiring.js.map +1 -0
  66. package/dist/core/devGatedFeatures.d.ts.map +1 -1
  67. package/dist/core/devGatedFeatures.js +6 -0
  68. package/dist/core/devGatedFeatures.js.map +1 -1
  69. package/dist/core/reviewers/conversational-tone.d.ts.map +1 -1
  70. package/dist/core/reviewers/conversational-tone.js +15 -1
  71. package/dist/core/reviewers/conversational-tone.js.map +1 -1
  72. package/dist/core/types.d.ts +102 -1
  73. package/dist/core/types.d.ts.map +1 -1
  74. package/dist/core/types.js.map +1 -1
  75. package/dist/core/untrustedConversationContext.d.ts +77 -0
  76. package/dist/core/untrustedConversationContext.d.ts.map +1 -0
  77. package/dist/core/untrustedConversationContext.js +134 -0
  78. package/dist/core/untrustedConversationContext.js.map +1 -0
  79. package/dist/monitoring/ReviewCanaryBattery.d.ts +160 -0
  80. package/dist/monitoring/ReviewCanaryBattery.d.ts.map +1 -0
  81. package/dist/monitoring/ReviewCanaryBattery.js +416 -0
  82. package/dist/monitoring/ReviewCanaryBattery.js.map +1 -0
  83. package/dist/monitoring/guardManifest.d.ts.map +1 -1
  84. package/dist/monitoring/guardManifest.js +40 -1
  85. package/dist/monitoring/guardManifest.js.map +1 -1
  86. package/dist/scaffold/templates.d.ts.map +1 -1
  87. package/dist/scaffold/templates.js +6 -0
  88. package/dist/scaffold/templates.js.map +1 -1
  89. package/dist/server/AgentServer.d.ts +4 -0
  90. package/dist/server/AgentServer.d.ts.map +1 -1
  91. package/dist/server/AgentServer.js +20 -0
  92. package/dist/server/AgentServer.js.map +1 -1
  93. package/dist/server/CapabilityIndex.d.ts.map +1 -1
  94. package/dist/server/CapabilityIndex.js +1 -0
  95. package/dist/server/CapabilityIndex.js.map +1 -1
  96. package/dist/server/routes.d.ts +5 -0
  97. package/dist/server/routes.d.ts.map +1 -1
  98. package/dist/server/routes.js +72 -4
  99. package/dist/server/routes.js.map +1 -1
  100. package/package.json +1 -1
  101. package/src/data/builtin-manifest.json +65 -65
  102. package/src/scaffold/templates/jobs/instar/review-canary-battery.md +31 -0
  103. package/src/scaffold/templates.ts +6 -0
  104. package/upgrades/1.3.730.md +112 -0
  105. package/upgrades/1.3.731.md +97 -0
  106. package/upgrades/side-effects/context-aware-outbound-review.md +62 -0
  107. package/upgrades/side-effects/swap-continuity-antithrash.md +118 -0
@@ -13,25 +13,32 @@
13
13
  * This monitor moves a session OFF an account BEFORE it walls, at a lag-aware
14
14
  * measured threshold below the real limit.
15
15
  *
16
- * ── Why a separate, lower threshold (lag-aware) ──
17
- * The QuotaPoller reads utilization periodically, so our reading TRAILS the real
18
- * usage (measured live: our ~90% == Anthropic's ~95% on the same account). A
19
- * pre-emptive swap therefore triggers at a LOWER measured threshold (default 80)
20
- * to leave margin for the lag by the time we read 80%, real is higher, and the
21
- * swap completes before the wall.
16
+ * ── Anti-thrash brakes (swap-continuity-antithrash §3) ──
17
+ * The 2026-07-02 thrash day (36 executed proactive swaps in 8 waves, repeated
18
+ * kills of six parallel build subagents) proved the bare threshold loop
19
+ * oscillates. When an SwapAntiThrashEngine is wired (`cfg.antiThrash`), every
20
+ * proactive decision runs the brake pipeline at THIS chokepoint:
21
+ * ledger-lost pause thrash breaker → dwell → validity gate + all-hot →
22
+ * filter→score→verify target selection → reversal refusal. In dryRun the
23
+ * legacy decision path stays byte-identical to v1.3.722 while the engine logs
24
+ * would-refuse rows (the rung-2 soak); live, the engine's verdict binds and
25
+ * UNTAGGED sessions leave the proactive candidate set entirely (Q3 — a
26
+ * background optimizer must never mutate the default-slot binding).
22
27
  *
23
- * ── Effective-account resolution (covers the session you actually use) ──
24
- * A session carries `subscriptionAccountId` only if it was pinned at spawn. The
25
- * primary interactive session usually runs on the DEFAULT config (untagged), so
26
- * the monitor resolves an untagged session's effective account from the default
27
- * config's live login (InUseAccountResolver). Without this, the session a user
28
- * is actively in would be invisible to the swap engine and wedge at the wall.
28
+ * ── In-flight work deferral (swap-continuity-antithrash §4) ──
29
+ * When a SwapWorkGate is wired (`cfg.workGate`) and swapContinuity is live, a
30
+ * braked proactive swap whose session is BUSY (mid-turn or carrying live
31
+ * subagents) is DEFERRED the intent is retried each tick through the FULL
32
+ * brake pipeline (I9), bounded by `deferralCeilingMs`; at the ceiling the
33
+ * intent is DROPPED (the wall wins; the reactive floor exists) and the
34
+ * session enters re-intent backoff. The monitor owns the deferral lifecycle
35
+ * (the gate is a stateless predicate).
29
36
  *
30
37
  * ── Bounded, non-storming ──
31
- * Per evaluation: only accounts AT pressure that have a sub-threshold ALTERNATE
32
- * are sources; candidates are sorted newest-first (the just-(re)started
33
- * interactive session ranks first) and capped per cycle; each swapped session
34
- * enters a cooldown so a slow restart isn't double-swapped. Near the wall the
38
+ * Per evaluation: candidates are sorted newest-first and capped per cycle
39
+ * (executed swaps only deferrals never consume the budget); at most one
40
+ * executed swap per target account per tick (pile-on cap); each swapped
41
+ * session enters dwell (ledger-backed, restart-safe). Near the wall the
35
42
  * monitor triggers a fresh poll so a fast burn isn't missed between the
36
43
  * low-frequency baseline polls.
37
44
  *
@@ -40,6 +47,8 @@
40
47
  * (injected deps) so it tests with zero sessions and zero network.
41
48
  */
42
49
  import type { SubscriptionAccount } from './SubscriptionPool.js';
50
+ import type { SwapAntiThrashEngine, AntiThrashKnobs } from './SwapAntiThrash.js';
51
+ import type { WorkProbeResult } from './SwapWorkGate.js';
43
52
  /** A running, swap-eligible session as the monitor sees it. */
44
53
  export interface ProactiveSwapSession {
45
54
  /** tmux session name (what the swap path keys on). */
@@ -54,6 +63,15 @@ export interface ProactiveSwapSession {
54
63
  export interface ProactiveSwapOutcome {
55
64
  swapped: boolean;
56
65
  toAccountId: string | null;
66
+ reason?: string;
67
+ }
68
+ /** swapContinuity knobs as the monitor consumes them (resolved by the wiring). */
69
+ export interface SwapContinuityKnobs {
70
+ enabled: boolean;
71
+ dryRun: boolean;
72
+ deferralCeilingMs: number;
73
+ reactiveGraceMs: number;
74
+ recheckMs: number;
57
75
  }
58
76
  export interface ProactiveSwapMonitorConfig {
59
77
  /** Current pool accounts (e.g. () => pool.list()). */
@@ -63,11 +81,16 @@ export interface ProactiveSwapMonitorConfig {
63
81
  /** The pool account the DEFAULT config is logged into right now (or null).
64
82
  * Untagged sessions run here; from InUseAccountResolver in production. */
65
83
  resolveDefaultAccountId: () => Promise<string | null>;
66
- /** Performs the actual swap (wraps QuotaAwareScheduler.onQuotaPressure). */
84
+ /** Performs the actual swap (wraps QuotaAwareScheduler.onQuotaPressure).
85
+ * When the brakes are LIVE the monitor passes the authoritative
86
+ * `targetAccountId` through (§3.3 — the checked target IS the executed
87
+ * target, I1) plus `callerClass: 'proactive-swap'`. */
67
88
  swap: (args: {
68
89
  sessionName: string;
69
90
  exhaustedAccountId: string;
70
91
  nowMs: number;
92
+ targetAccountId?: string;
93
+ callerClass?: 'proactive-swap';
71
94
  }) => Promise<ProactiveSwapOutcome>;
72
95
  /** Optional fresh-poll trigger, awaited when an account is in the watch zone. */
73
96
  triggerPoll?: () => Promise<unknown>;
@@ -80,10 +103,21 @@ export interface ProactiveSwapMonitorConfig {
80
103
  /** Max sessions swapped per evaluation cycle (storm guard). Default 3. */
81
104
  maxSwapsPerCycle?: number;
82
105
  /** Per-session cooldown after a successful swap before it's eligible again.
83
- * Default 600000 (10m) — must exceed the swap+restart time. */
106
+ * Default 600000 (10m) — must exceed the swap+restart time. SUBSUMED by
107
+ * antiThrash dwell when the brakes are live (§9). */
84
108
  cooldownMs?: number;
85
109
  /** Monitor tick cadence. Default 180000 (3m). */
86
110
  tickMs?: number;
111
+ /** Anti-thrash brakes (Piece 1). Knobs read LIVE per tick (§7.1). */
112
+ antiThrash?: {
113
+ engine: SwapAntiThrashEngine;
114
+ getKnobs: () => AntiThrashKnobs;
115
+ };
116
+ /** In-flight work deferral (Piece 2). Knobs read live per evaluation. */
117
+ workGate?: {
118
+ probe: (sessionName: string) => Promise<WorkProbeResult>;
119
+ getContinuity: () => SwapContinuityKnobs;
120
+ };
87
121
  /** Injected for tests. */
88
122
  now?: () => number;
89
123
  logger?: {
@@ -108,15 +142,18 @@ export declare class ProactiveSwapMonitor {
108
142
  private readonly tickMs;
109
143
  private readonly now;
110
144
  private readonly logger;
111
- /** Last successful-swap timestamp per session (cooldown bookkeeping). */
145
+ /** Last successful-swap timestamp per session (legacy cooldown bookkeeping). */
112
146
  private readonly lastSwapAt;
147
+ /** Pending deferred proactive intents (Piece 2 — monitor-owned lifecycle). */
148
+ private readonly deferrals;
113
149
  private timer;
114
150
  private ticking;
115
151
  private lastResult;
116
152
  constructor(cfg: ProactiveSwapMonitorConfig);
117
153
  start(): void;
118
154
  stop(): void;
119
- /** Status for the read route (never throws). */
155
+ /** Status for the read route (never throws). Additive `brakes`/`deferrals`
156
+ * blocks per swap-continuity-antithrash §6.3 (all fields LOCAL-SCOPE). */
120
157
  status(): {
121
158
  thresholdPct: number;
122
159
  watchPct: number;
@@ -125,6 +162,15 @@ export declare class ProactiveSwapMonitor {
125
162
  tickMs: number;
126
163
  running: boolean;
127
164
  lastResult: ProactiveSwapTickResult | null;
165
+ antiThrash?: {
166
+ enabled: boolean;
167
+ dryRun: boolean;
168
+ };
169
+ brakes?: Record<string, unknown>;
170
+ deferrals?: {
171
+ active: number;
172
+ sessions: string[];
173
+ };
128
174
  };
129
175
  /**
130
176
  * One monitor pass: if any at-risk account is in the watch zone, refresh the
@@ -134,11 +180,19 @@ export declare class ProactiveSwapMonitor {
134
180
  /**
135
181
  * Pure-ish decision + swap on the CURRENT snapshots (no poll refresh). Exposed
136
182
  * for tests and the on-demand check route.
183
+ *
184
+ * With the anti-thrash brakes LIVE the braked pipeline binds; in dryRun (or
185
+ * with no engine wired) the legacy v1.3.722 decision path is byte-identical
186
+ * while the engine (when present) logs would-decisions for the soak.
137
187
  */
138
188
  evaluate(): Promise<{
139
189
  swapped: string[];
140
190
  considered: number;
141
191
  }>;
192
+ private evaluateBraked;
193
+ private evaluateLegacy;
194
+ private safeKnobs;
195
+ private safeContinuity;
142
196
  /**
143
197
  * Map running sessions to candidates whose EFFECTIVE account is at/over minPct.
144
198
  * Effective account = the session's tag, else the default-config login (so the
@@ -1 +1 @@
1
- {"version":3,"file":"ProactiveSwapMonitor.d.ts","sourceRoot":"","sources":["../../src/core/ProactiveSwapMonitor.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"ProactiveSwapMonitor.d.ts","sourceRoot":"","sources":["../../src/core/ProactiveSwapMonitor.ts"],"names":[],"mappings":"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"}
@@ -13,25 +13,32 @@
13
13
  * This monitor moves a session OFF an account BEFORE it walls, at a lag-aware
14
14
  * measured threshold below the real limit.
15
15
  *
16
- * ── Why a separate, lower threshold (lag-aware) ──
17
- * The QuotaPoller reads utilization periodically, so our reading TRAILS the real
18
- * usage (measured live: our ~90% == Anthropic's ~95% on the same account). A
19
- * pre-emptive swap therefore triggers at a LOWER measured threshold (default 80)
20
- * to leave margin for the lag by the time we read 80%, real is higher, and the
21
- * swap completes before the wall.
16
+ * ── Anti-thrash brakes (swap-continuity-antithrash §3) ──
17
+ * The 2026-07-02 thrash day (36 executed proactive swaps in 8 waves, repeated
18
+ * kills of six parallel build subagents) proved the bare threshold loop
19
+ * oscillates. When an SwapAntiThrashEngine is wired (`cfg.antiThrash`), every
20
+ * proactive decision runs the brake pipeline at THIS chokepoint:
21
+ * ledger-lost pause thrash breaker → dwell → validity gate + all-hot →
22
+ * filter→score→verify target selection → reversal refusal. In dryRun the
23
+ * legacy decision path stays byte-identical to v1.3.722 while the engine logs
24
+ * would-refuse rows (the rung-2 soak); live, the engine's verdict binds and
25
+ * UNTAGGED sessions leave the proactive candidate set entirely (Q3 — a
26
+ * background optimizer must never mutate the default-slot binding).
22
27
  *
23
- * ── Effective-account resolution (covers the session you actually use) ──
24
- * A session carries `subscriptionAccountId` only if it was pinned at spawn. The
25
- * primary interactive session usually runs on the DEFAULT config (untagged), so
26
- * the monitor resolves an untagged session's effective account from the default
27
- * config's live login (InUseAccountResolver). Without this, the session a user
28
- * is actively in would be invisible to the swap engine and wedge at the wall.
28
+ * ── In-flight work deferral (swap-continuity-antithrash §4) ──
29
+ * When a SwapWorkGate is wired (`cfg.workGate`) and swapContinuity is live, a
30
+ * braked proactive swap whose session is BUSY (mid-turn or carrying live
31
+ * subagents) is DEFERRED the intent is retried each tick through the FULL
32
+ * brake pipeline (I9), bounded by `deferralCeilingMs`; at the ceiling the
33
+ * intent is DROPPED (the wall wins; the reactive floor exists) and the
34
+ * session enters re-intent backoff. The monitor owns the deferral lifecycle
35
+ * (the gate is a stateless predicate).
29
36
  *
30
37
  * ── Bounded, non-storming ──
31
- * Per evaluation: only accounts AT pressure that have a sub-threshold ALTERNATE
32
- * are sources; candidates are sorted newest-first (the just-(re)started
33
- * interactive session ranks first) and capped per cycle; each swapped session
34
- * enters a cooldown so a slow restart isn't double-swapped. Near the wall the
38
+ * Per evaluation: candidates are sorted newest-first and capped per cycle
39
+ * (executed swaps only deferrals never consume the budget); at most one
40
+ * executed swap per target account per tick (pile-on cap); each swapped
41
+ * session enters dwell (ledger-backed, restart-safe). Near the wall the
35
42
  * monitor triggers a fresh poll so a fast burn isn't missed between the
36
43
  * low-frequency baseline polls.
37
44
  *
@@ -49,8 +56,10 @@ export class ProactiveSwapMonitor {
49
56
  tickMs;
50
57
  now;
51
58
  logger;
52
- /** Last successful-swap timestamp per session (cooldown bookkeeping). */
59
+ /** Last successful-swap timestamp per session (legacy cooldown bookkeeping). */
53
60
  lastSwapAt = new Map();
61
+ /** Pending deferred proactive intents (Piece 2 — monitor-owned lifecycle). */
62
+ deferrals = new Map();
54
63
  timer = null;
55
64
  ticking = false;
56
65
  lastResult = null;
@@ -80,9 +89,10 @@ export class ProactiveSwapMonitor {
80
89
  this.timer = null;
81
90
  }
82
91
  }
83
- /** Status for the read route (never throws). */
92
+ /** Status for the read route (never throws). Additive `brakes`/`deferrals`
93
+ * blocks per swap-continuity-antithrash §6.3 (all fields LOCAL-SCOPE). */
84
94
  status() {
85
- return {
95
+ const base = {
86
96
  thresholdPct: this.thresholdPct,
87
97
  watchPct: Math.max(0, this.thresholdPct - this.watchMarginPct),
88
98
  maxSwapsPerCycle: this.maxSwapsPerCycle,
@@ -91,6 +101,15 @@ export class ProactiveSwapMonitor {
91
101
  running: this.timer !== null,
92
102
  lastResult: this.lastResult,
93
103
  };
104
+ if (!this.cfg.antiThrash)
105
+ return base;
106
+ const knobs = this.safeKnobs();
107
+ return {
108
+ ...base,
109
+ ...(knobs ? { antiThrash: { enabled: knobs.enabled, dryRun: knobs.dryRun } } : {}),
110
+ brakes: this.cfg.antiThrash.engine.status(this.now()),
111
+ deferrals: { active: this.deferrals.size, sessions: [...this.deferrals.keys()] },
112
+ };
94
113
  }
95
114
  /**
96
115
  * One monitor pass: if any at-risk account is in the watch zone, refresh the
@@ -128,10 +147,260 @@ export class ProactiveSwapMonitor {
128
147
  /**
129
148
  * Pure-ish decision + swap on the CURRENT snapshots (no poll refresh). Exposed
130
149
  * for tests and the on-demand check route.
150
+ *
151
+ * With the anti-thrash brakes LIVE the braked pipeline binds; in dryRun (or
152
+ * with no engine wired) the legacy v1.3.722 decision path is byte-identical
153
+ * while the engine (when present) logs would-decisions for the soak.
131
154
  */
132
155
  async evaluate() {
133
156
  const nowMs = this.now();
134
157
  const accounts = this.cfg.listAccounts();
158
+ const knobs = this.safeKnobs();
159
+ const engine = this.cfg.antiThrash?.engine ?? null;
160
+ const engineActive = !!(engine && knobs?.enabled);
161
+ const live = !!(engineActive && knobs && !knobs.dryRun);
162
+ if (engine && knobs?.enabled)
163
+ engine.beginTick(accounts, nowMs, true);
164
+ try {
165
+ if (live && engine && knobs) {
166
+ return await this.evaluateBraked(engine, accounts, nowMs);
167
+ }
168
+ return await this.evaluateLegacy(accounts, nowMs, engineActive ? engine : null);
169
+ }
170
+ finally {
171
+ if (engine && knobs?.enabled)
172
+ engine.endTick(nowMs);
173
+ }
174
+ }
175
+ // ── The LIVE braked pipeline (§3 + §4 proactive arm) ─────────────────────
176
+ async evaluateBraked(engine, accounts, nowMs) {
177
+ const byId = new Map(accounts.map((a) => [a.id, a]));
178
+ // Candidate set: TAGGED sessions only (Q3 — untagged sessions are outside
179
+ // the proactive candidate set by construction, I10), whose source account
180
+ // carries a VALID fresh reading at/over the threshold (§3.3 source leg).
181
+ const candidates = [];
182
+ const currentAccountBySession = new Map();
183
+ for (const s of this.cfg.listRunningSessions()) {
184
+ currentAccountBySession.set(s.sessionName, s.accountId);
185
+ if (!s.accountId)
186
+ continue;
187
+ const acct = byId.get(s.accountId);
188
+ if (!acct)
189
+ continue;
190
+ if (!engine.sourceEligible(acct, nowMs))
191
+ continue;
192
+ const startedMs = s.startedAt ? Date.parse(s.startedAt) : NaN;
193
+ candidates.push({
194
+ sessionName: s.sessionName,
195
+ accountId: s.accountId,
196
+ startedMs: Number.isFinite(startedMs) ? startedMs : 0,
197
+ untagged: false,
198
+ });
199
+ }
200
+ candidates.sort((a, b) => b.startedMs - a.startedMs);
201
+ // Deferral invalidation sweep (I9): an intent whose session's account
202
+ // changed underneath it (a reactive swap moved it), or whose session left
203
+ // candidacy (wave subsided / session gone), is INVALIDATED — never
204
+ // executed as a second kill inside the dwell window.
205
+ for (const [session, d] of [...this.deferrals]) {
206
+ const stillCandidate = candidates.some((c) => c.sessionName === session);
207
+ const currentAcct = currentAccountBySession.get(session);
208
+ if (!stillCandidate || currentAcct !== d.from) {
209
+ engine.recordInvalidated({
210
+ session,
211
+ from: d.from,
212
+ to: d.to,
213
+ nowMs,
214
+ deferralAgeMs: nowMs - d.firstAtMs,
215
+ deferCount: d.count,
216
+ });
217
+ this.deferrals.delete(session);
218
+ }
219
+ }
220
+ const targetsUsedThisTick = new Set();
221
+ const swapped = [];
222
+ let considered = 0;
223
+ const continuity = this.safeContinuity();
224
+ for (const c of candidates) {
225
+ if (swapped.length >= this.maxSwapsPerCycle)
226
+ break;
227
+ considered += 1;
228
+ const deferral = this.deferrals.get(c.sessionName);
229
+ const deferralAgeMs = deferral ? nowMs - deferral.firstAtMs : undefined;
230
+ // Full brake pipeline — re-run on every deferred retry too (I9: the
231
+ // intent that finally fires is one that would have been approved fresh).
232
+ const verdict = engine.evaluateIntent({
233
+ session: c.sessionName,
234
+ fromAccountId: c.accountId,
235
+ accounts,
236
+ nowMs,
237
+ targetsUsedThisTick,
238
+ ...(deferralAgeMs !== undefined ? { deferralAgeMs } : {}),
239
+ ...(deferral ? { deferCount: deferral.count } : {}),
240
+ });
241
+ if (verdict.action !== 'execute')
242
+ continue; // refusal rows already written by the engine
243
+ // Piece 2: the work gate (proactive arm — defer, ceiling-drop).
244
+ if (this.cfg.workGate && continuity?.enabled) {
245
+ let probe = null;
246
+ try {
247
+ probe = await this.cfg.workGate.probe(c.sessionName);
248
+ }
249
+ catch {
250
+ // @silent-fallback-ok: probe machinery itself failed → indeterminate,
251
+ // which resolves BUSY (I7) — the swap is DEFERRED, the safe direction;
252
+ // the deferral is recorded on the anti-thrash engine below.
253
+ probe = null;
254
+ }
255
+ const busy = probe ? probe.busy : true;
256
+ if (busy) {
257
+ const reason = probe?.reason ?? 'busy-indeterminate';
258
+ const inFlight = { turn: probe?.turnInFlight ?? false, subagents: probe?.subagents?.length ?? 0 };
259
+ const subagentLeg = probe?.subagentLeg ?? 'indeterminate';
260
+ if (continuity.dryRun) {
261
+ // Would-defer (rung-2 soak): log the row, change nothing.
262
+ engine.recordDeferred({
263
+ session: c.sessionName,
264
+ from: c.accountId,
265
+ to: verdict.targetAccountId,
266
+ nowMs,
267
+ reason,
268
+ inFlight,
269
+ subagentLeg,
270
+ deferralAgeMs: 0,
271
+ deferCount: 1,
272
+ dryRun: true,
273
+ rowKind: 'first',
274
+ });
275
+ // fall through to execute (dryRun changes nothing)
276
+ }
277
+ else {
278
+ const d = deferral ?? {
279
+ firstAtMs: nowMs,
280
+ count: 0,
281
+ from: c.accountId,
282
+ to: verdict.targetAccountId,
283
+ };
284
+ d.count += 1;
285
+ d.to = verdict.targetAccountId; // ceiling clock carries across target re-selection (§4.2)
286
+ this.deferrals.set(c.sessionName, d);
287
+ const age = nowMs - d.firstAtMs;
288
+ if (age >= continuity.deferralCeilingMs) {
289
+ // At the ceiling: the wall wins — DROP the intent; the session
290
+ // keeps working and the reactive floor absorbs a genuine wall.
291
+ engine.recordDropped({
292
+ session: c.sessionName,
293
+ from: d.from,
294
+ to: d.to,
295
+ nowMs,
296
+ deferralAgeMs: age,
297
+ deferCount: d.count,
298
+ inFlight,
299
+ subagentLeg,
300
+ });
301
+ this.deferrals.delete(c.sessionName);
302
+ }
303
+ else if (d.count === 1) {
304
+ // Dedup (§4.2): FIRST row only; the final row is the eventual
305
+ // swapped/dropped/invalidated row carrying deferCount.
306
+ engine.recordDeferred({
307
+ session: c.sessionName,
308
+ from: d.from,
309
+ to: d.to,
310
+ nowMs,
311
+ reason,
312
+ inFlight,
313
+ subagentLeg,
314
+ deferralAgeMs: age,
315
+ deferCount: d.count,
316
+ rowKind: 'first',
317
+ });
318
+ }
319
+ continue;
320
+ }
321
+ }
322
+ }
323
+ // Execute — the checked target IS the executed target (I1); the
324
+ // scheduler revalidates the WHOLE decision at execute time (§3.3).
325
+ try {
326
+ const outcome = await this.cfg.swap({
327
+ sessionName: c.sessionName,
328
+ exhaustedAccountId: c.accountId,
329
+ nowMs,
330
+ targetAccountId: verdict.targetAccountId,
331
+ callerClass: 'proactive-swap',
332
+ });
333
+ if (outcome.swapped) {
334
+ engine.recordProactiveExecuted({
335
+ session: c.sessionName,
336
+ from: c.accountId,
337
+ to: outcome.toAccountId ?? verdict.targetAccountId,
338
+ nowMs,
339
+ fromUtilPct: verdict.fromUtilPct,
340
+ toUtilPct: verdict.toUtilPct,
341
+ ...(deferral ? { deferralAgeMs: nowMs - deferral.firstAtMs, deferCount: deferral.count } : {}),
342
+ });
343
+ this.lastSwapAt.set(c.sessionName, nowMs);
344
+ this.deferrals.delete(c.sessionName);
345
+ targetsUsedThisTick.add(verdict.targetAccountId);
346
+ swapped.push(c.sessionName);
347
+ this.logger.log(`[ProactiveSwap] ${c.sessionName}: pre-emptively swapped off ${c.accountId} → ${outcome.toAccountId ?? verdict.targetAccountId} ` +
348
+ `(account ≥${this.thresholdPct}% measured — moved before the wall, conversation preserved)`);
349
+ }
350
+ else if (outcome.reason === 'target-revalidation-failed') {
351
+ engine.recordRevalidationRefusal({
352
+ session: c.sessionName,
353
+ from: c.accountId,
354
+ to: verdict.targetAccountId,
355
+ nowMs,
356
+ reason: 'target-revalidation-failed',
357
+ });
358
+ }
359
+ else if (outcome.reason === 'intent-stale') {
360
+ engine.recordRevalidationRefusal({
361
+ session: c.sessionName,
362
+ from: c.accountId,
363
+ to: verdict.targetAccountId,
364
+ nowMs,
365
+ reason: 'intent-stale',
366
+ });
367
+ this.deferrals.delete(c.sessionName);
368
+ }
369
+ else if (outcome.reason === 'session-busy') {
370
+ // Funnel-gate race: the SessionRefresh work gate saw busy after our
371
+ // probe. Treat exactly like a busy probe (deferral bookkeeping).
372
+ const d = deferral ?? { firstAtMs: nowMs, count: 0, from: c.accountId, to: verdict.targetAccountId };
373
+ d.count += 1;
374
+ this.deferrals.set(c.sessionName, d);
375
+ }
376
+ else {
377
+ engine.recordExecFailure({
378
+ session: c.sessionName,
379
+ from: c.accountId,
380
+ to: verdict.targetAccountId,
381
+ kind: 'proactive',
382
+ errorClass: outcome.reason ?? 'refresh-failed',
383
+ nowMs,
384
+ });
385
+ }
386
+ }
387
+ catch (err) {
388
+ // §3.6: a swap execution that THROWS is a failed row + backoff — never
389
+ // a silent every-tick retry.
390
+ engine.recordExecFailure({
391
+ session: c.sessionName,
392
+ from: c.accountId,
393
+ to: verdict.targetAccountId,
394
+ kind: 'proactive',
395
+ errorClass: err instanceof Error ? err.constructor.name : 'Error',
396
+ nowMs,
397
+ });
398
+ }
399
+ }
400
+ return { swapped, considered };
401
+ }
402
+ // ── The legacy v1.3.722 decision path (dark / dryRun — byte-identical) ───
403
+ async evaluateLegacy(accounts, nowMs, shadowEngine) {
135
404
  const atPressure = await this.mapCandidates(this.thresholdPct);
136
405
  const eligible = atPressure.filter((c) => {
137
406
  const last = this.lastSwapAt.get(c.sessionName);
@@ -146,13 +415,28 @@ export class ProactiveSwapMonitor {
146
415
  // (it just restarted on compaction/recovery) ranks ahead of idle background
147
416
  // sessions, so under the per-cycle cap it is rescued first.
148
417
  eligible.sort((a, b) => b.startedMs - a.startedMs);
149
- // TODO(follow-up, 2026-06-09 incident): a proactive cycle moving MANY
150
- // sessions at once is itself disruptive every swap is a kill+respawn
151
- // ("Session respawned" + interruption) even when it succeeds. Beyond the
152
- // per-cycle cap + cooldown, consider gating on session ACTIVITY: only
153
- // swap sessions that are actually burning quota (recent pane activity),
154
- // and let idle sessions wall reactively instead of being preemptively
155
- // interrupted in bulk.
418
+ // Rung-2 dry-run shadow (§10): the engine evaluates the LIVE rule's
419
+ // candidate set (tagged only) and writes would-refuse/would-defer rows
420
+ // observability with ZERO decision change.
421
+ if (shadowEngine) {
422
+ const shadowTargets = new Set();
423
+ for (const c of atPressure) {
424
+ if (c.untagged)
425
+ continue; // Q3 — untagged is outside the live candidate set
426
+ try {
427
+ shadowEngine.evaluateIntent({
428
+ session: c.sessionName,
429
+ fromAccountId: c.accountId,
430
+ accounts,
431
+ nowMs,
432
+ targetsUsedThisTick: shadowTargets,
433
+ });
434
+ }
435
+ catch {
436
+ // @silent-fallback-ok: the shadow must never affect the legacy path
437
+ }
438
+ }
439
+ }
156
440
  const toSwap = eligible.slice(0, this.maxSwapsPerCycle);
157
441
  const swapped = [];
158
442
  for (const c of toSwap) {
@@ -162,21 +446,57 @@ export class ProactiveSwapMonitor {
162
446
  sessionName: c.sessionName,
163
447
  exhaustedAccountId: c.accountId,
164
448
  nowMs,
449
+ callerClass: 'proactive-swap',
165
450
  });
166
451
  }
167
- catch {
168
- // @silent-fallback-ok: a swap failure is retried next cycle (no cooldown set)
452
+ catch (err) {
453
+ // Legacy behavior: retried next cycle (no cooldown set). With the
454
+ // engine present the failure is at least RECORDED (§3.6 observability;
455
+ // decision behavior unchanged in dryRun — no backoff binds here).
456
+ shadowEngine?.recordExecFailure({
457
+ session: c.sessionName,
458
+ from: c.accountId,
459
+ kind: 'proactive',
460
+ errorClass: err instanceof Error ? err.constructor.name : 'Error',
461
+ nowMs,
462
+ });
169
463
  continue;
170
464
  }
171
465
  if (outcome.swapped) {
172
466
  this.lastSwapAt.set(c.sessionName, nowMs);
173
467
  swapped.push(c.sessionName);
468
+ shadowEngine?.recordProactiveExecuted({
469
+ session: c.sessionName,
470
+ from: c.accountId,
471
+ to: outcome.toAccountId ?? '',
472
+ nowMs,
473
+ ...(c.untagged ? { defaultAccountChanged: true } : {}),
474
+ });
174
475
  this.logger.log(`[ProactiveSwap] ${c.sessionName}: pre-emptively swapped off ${c.accountId} → ${outcome.toAccountId} ` +
175
476
  `(account ≥${this.thresholdPct}% measured — moved before the wall, conversation preserved)`);
176
477
  }
177
478
  }
178
479
  return { swapped, considered: eligible.length };
179
480
  }
481
+ safeKnobs() {
482
+ try {
483
+ return this.cfg.antiThrash?.getKnobs() ?? null;
484
+ }
485
+ catch {
486
+ // @silent-fallback-ok: a broken knob getter reads as feature-dark (null)
487
+ // — the brakes simply stay out of the path; legacy swap behavior holds.
488
+ return null;
489
+ }
490
+ }
491
+ safeContinuity() {
492
+ try {
493
+ return this.cfg.workGate?.getContinuity() ?? null;
494
+ }
495
+ catch {
496
+ // @silent-fallback-ok: same feature-dark degrade as safeKnobs above.
497
+ return null;
498
+ }
499
+ }
180
500
  /**
181
501
  * Map running sessions to candidates whose EFFECTIVE account is at/over minPct.
182
502
  * Effective account = the session's tag, else the default-config login (so the
@@ -207,6 +527,7 @@ export class ProactiveSwapMonitor {
207
527
  sessionName: s.sessionName,
208
528
  accountId: eff,
209
529
  startedMs: Number.isFinite(startedMs) ? startedMs : 0,
530
+ untagged: s.accountId === null,
210
531
  });
211
532
  }
212
533
  return out;