instar 1.3.635 → 1.3.637

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.
@@ -0,0 +1,102 @@
1
+ # Side-Effects Review — SleepWakeDetector: per-process CPU check separates event-loop block from sleep
2
+
3
+ **Version / slug:** `sleepwake-stall-not-sleep`
4
+ **Tier:** 1 (surgical bug-fix, one core file + tests; no new route, config contract, or persistence schema)
5
+
6
+ ## Summary of the change
7
+
8
+ `SleepWakeDetector` inferred "sleep" from timer drift + **system loadavg**. A single
9
+ Node thread blocking its own event loop for tens of seconds does NOT move a 16-core
10
+ `loadavg` above `maxLoadRatio` (1.5), so an isolated event-loop block under normal
11
+ system load emitted a FALSE `wake` ("Wake detected after ~14s sleep") — even on a
12
+ caffeinated host where sleep is physically impossible. That laundered real wedges into
13
+ "sleep" and masked the actual fault.
14
+
15
+ The fix adds a per-PROCESS discriminator: `process.cpuUsage()` sampled across the drift
16
+ gap. A suspended (sleeping) process burns ~0 CPU; a blocked event loop burns CPU through
17
+ most of the gap. When `cpuBusyRatio >= cpuBlockBusyRatio` (default 0.5) the drift is a
18
+ BLOCK — emit a new signal-only `stall` event and SUPPRESS the false `wake`. The check
19
+ runs ahead of the existing load/burst/cooldown guards and, unlike the old `isLongSleep`
20
+ exemption, applies to LONG drifts too (a multi-minute CPU-busy drift is the wedge, never
21
+ sleep).
22
+
23
+ ## Decision-point inventory
24
+
25
+ - **Threshold `cpuBlockBusyRatio` = 0.5.** A real wake burns ~0 CPU over the gap (ratio
26
+ ~0); a CPU-bound block burns ~1 core (ratio ~1.0). 0.5 is the safe midpoint; set 0 to
27
+ disable. Tunable, defaults on.
28
+ - **Ordering:** the CPU check is FIRST (most authoritative). A real long sleep (≈0 CPU)
29
+ falls through it and still emits a wake via the existing `isLongSleep` exemption.
30
+ - **New `stall` event:** signal-only. No consumer = harmless (the value is the suppressed
31
+ false wake). Wedge watchers MAY consume it later.
32
+
33
+ ## 1. Over-block (false positive — suppressing a REAL wake)
34
+
35
+ Could a genuine wake be misread as a block? On real OS suspend the process is frozen →
36
+ ~0 CPU over the gap → ratio ~0 → NOT suppressed → wake emits correctly. A wake immediately
37
+ followed by heavy CPU still reads low, because the drift tick fires once at wake and the
38
+ gap's CPU (the frozen span) is ~0. Verified by the `genuine sleep → WAKE` test.
39
+
40
+ ## 2. Under-block (missing a block)
41
+
42
+ A low-CPU stall (IO-wait, not CPU-bound) reads ratio ~0 and is NOT flagged by this check —
43
+ by design; it falls through to the existing load/burst/recurring guards exactly as before.
44
+ This change only ADDS detection for the CPU-bound case the load heuristics were blind to;
45
+ it removes no existing suppression path.
46
+
47
+ ## 4. Signal vs authority compliance
48
+
49
+ The detector only decides whether to emit `wake` vs `stall` — both are signals to other
50
+ watchers; it gates nothing and takes no destructive action. Recovery authority stays with
51
+ the consumers (ServerSupervisor / wedge watchers), unchanged.
52
+
53
+ ## 5. Interactions
54
+
55
+ `getCumulativeSleepMsBetween` (the wake-reaper's sleep-credit source) reads only EMITTED
56
+ wakes; a suppressed block is never credited as sleep — so a wedge can no longer inflate a
57
+ job's sleep credit and cause an early reap. `getStats().suppressedByReason` gains an
58
+ `event-loop-block` counter (additive; existing keys unchanged).
59
+
60
+ ## 6. External surfaces
61
+
62
+ None. No new route, no config-file contract change (the two new config fields are optional
63
+ with safe defaults), no persistence, no messaging. `GET /sleep/stats` (routes.ts) returns
64
+ the same `SleepWakeStats` shape plus the additive reason key.
65
+
66
+ ## 6b. Operator-surface quality
67
+
68
+ N/A — no operator/dashboard/approval surface is touched. Change is internal to a core
69
+ detector.
70
+
71
+ ## Framework generality
72
+
73
+ N/A — `SleepWakeDetector` is a framework-agnostic core monitor (not part of the session
74
+ launch/inject abstraction). It runs per-process regardless of which agentic framework the
75
+ session uses.
76
+
77
+ ## 7. Multi-machine posture
78
+
79
+ Per-process and per-machine by nature; each machine's detector watches its own loop. No
80
+ replicated state, no cross-machine contract. Safe on single- and multi-machine installs.
81
+
82
+ ## 8. Rollback cost
83
+
84
+ Trivial and safe: set `cpuBlockBusyRatio: 0` to disable the new branch (reverts to the
85
+ prior load-only behavior), or revert the commit. The defensive CPU read fails toward the
86
+ old behavior, so even a provider error degrades to pre-change semantics rather than
87
+ breaking.
88
+
89
+ ## Evidence pointers
90
+
91
+ - New tests: `tests/unit/SleepWakeDetector-cpu-block.test.ts` (5 tests — CPU-busy short
92
+ drift → stall; genuine sleep → wake; long CPU-busy drift → stall; stats record; throwing
93
+ provider never crashes the tick). Existing `SleepWakeDetector.test.ts` (10 tests) still
94
+ green — 15/15. `tsc --noEmit` clean.
95
+ - Live root cause (2026-06-21): a caffeinated, lid-open, plugged-in host logged "Wake
96
+ detected after ~Ns sleep" for what were event-loop blocks — the misdiagnosis this fixes.
97
+
98
+ ## Conclusion
99
+
100
+ A correctness fix that makes drift classification honest: CPU-bound event-loop blocks are
101
+ flagged as `stall`, not laundered into a false `wake`. Defaults on, fails safe, trivially
102
+ reversible. Ship.