fix(lsp): guard a non-finite idle_timeout and report the bounds in force (SCA-4721) - #76
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bd55c5a875
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…or-byte Both #62 and #76 add contributors/emails/engineer@scaffolde.ai as a NEW file with different bytes (#62 carries a provenance comment, #76 did not). That is an add/add conflict: the two PRs merge clean against main individually and collide with each other, which GitHub's per-PR mergeStateStatus cannot see. Detected by scripts/pr_merge_order.py (SCA-4638). Aligning #76 to #62's exact bytes makes the add/add resolve trivially in either merge order. The mapped login is identical (pai-scaffolde) either way, so attribution is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes the Codex P2 on #76: `bool` subclasses `int`, so a boolean `lsp.idle_timeout` never raised on its way through `float()`. YAML 1.1 resolves `off`/`no` to `False` and `on`/`yes` to `True`, so this is reachable from a config an operator would plausibly write: idle_timeout: off -> 0.0 -> read as the documented "0", which disables the reaper outright idle_timeout: on -> 1.0 -> clamped up to MIN_IDLE_TIMEOUT (30) max_clients: on -> 1 -> a one-server fleet `off` reaching `0.0` is the same disarmed reaper this PR exists to prevent, arrived at through a different door — and it was silent, because `_bound_was_overridden` compares numerically and `float(False)` is exactly the `0.0` it resolved to. The sweep found the sibling Codex did not flag: `resolve_max_clients` has the identical hole. The two resolvers are documented as siblings precisely so they cannot drift, so both are fixed together. `_bound_was_overridden` now treats a bool as an override before the numeric comparison can excuse it: `MIN_CLIENT_CAP` is 1, so on a small host `max_clients: on` resolves to a default of 1 and would compare equal — the one case where a rejected value was corrected in silence. Rejecting `bool` before coercion matches the idiom already used in `agent/retry_utils.py:67` and `agent/image_routing.py:165`. Verified: `tests/agent/lsp/test_client_bounds.py` + `test_client_cap.py` 76 passed. The two new tests fail on the parent commit — the factory one reaching a live service with `_idle_timeout == 0.0`. `0` still disables as documented and `"4"` still does not warn, so neither existing contract regressed.
…rce (SCA-4721) Replayed onto current main. This branch was stacked on PRs that landed as squashes, so its original history conflicted with itself; only this PR's own delta is kept. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
7c82a2b to
87cb145
Compare
|
Not merged — blocked on a real defect surfaced by landing #62 first.
Cause. #62 ( now = time.time()
...
"idle_seconds": max(0.0, now - self._last_used.get(k, now)),Wall clock minus a monotonic stamp — hence the epoch-sized Fix. I did not apply that change: it is a behavioural fix to the live LSP reaper rather #77 stacks on this PR and is therefore also left open. 🤖 Generated with Claude Code |
get_status() computed each client's age with time.time() while _last_used is stamped by _idle_clock() (CLOCK_BOOTTIME since #62), so every reported idle_seconds was the gap between the two epochs (~1.79e9) rather than an elapsed time. The comment directly above the line already required both to read the same clock; this makes the code match it. Surfaced by this PR's own guard test once #62 landed on main: test_status_idle_seconds_reads_the_same_clock_as_last_used. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Pushed a one-line fix to this branch while landing the LSP stack.
The comment directly above the line already required both to read the This branch was also replayed onto current |
Ports the only two things
#50carries that the live LSP stack does not, so#50can be closed as fully superseded and the hermes-agent queue stops being hostage to it (SCA-4721).Based on the stack tip
#67(6a3d95b100), not opened as a competing branch onagent/lsp/manager.py.1. A non-finite
lsp.idle_timeoutsilently disarmed the reaperresolve_max_clients()rejects zero, negatives, strings and the non-finite floats.idle_timeoutwas coerced inline with a barefloat()and a0 < x < MIN_IDLE_TIMEOUTclamp — and.nan/.infare valid YAML that survivefloat()without raising and slip that clamp. Driving the realcreate_from_configat the tip:The count cap still binds, so this was never unbounded accumulation. It is the reaper's own axis switched off: idle servers never drain and the fleet pins at
max_clientsindefinitely.The fix is a named
resolve_idle_timeout()besideresolve_max_clients(), not a second inlineisfinitecheck — coercing the two knobs in two places is how they drifted apart to begin with.0is the one non-positive value kept as written (the user guide documents it as "hold every server's index warm"); a negative reaches that same disabled state by accident and derives the default instead.2.
get_status()now reports the bounds in forcehermes lsp statusis how the cap gets audited without reading source — the surface that made SCA-4621 and SCA-4583 diagnosable. It now carriesmax_clients,idle_timeout, and per-clientidle_seconds/inflight, in both the JSON and human-readable paths:3. Rejected bounds log at WARNING, not debug
An operator setting
max_clients: 0reads it as "no limit" and got no visible signal the host default was substituted. The warning fires only on an actual substitution ("4"and4compare numerically equal), so it stays a signal rather than startup noise.Explicitly not in scope
The monotonic-clock fix.
#62already carries it and goes further (CLOCK_BOOTTIME, suspend and wall-clock-step coverage). Not re-ported.#62and this branch merge clean textually (git merge-tree --write-treeexits 0, no conflict) and are still semantically incompatible until one line changes.idle_secondsand_last_usedare only meaningful relative to each other;#62moves_last_usedonto_idle_clock()whileget_statushere readstime.time().Rather than leave that as a comment, it is pinned by a test. Probed by actually performing the merge locally:
test_status_idle_seconds_reads_the_same_clock_as_last_usedbounds the age on both sides for exactly this reason — a bare>= 5is satisfied by an epoch gap of 1e9 and would have shipped that number to the operator's status output.The fix is one line in
get_status, verified on the merged tree:now = time.time()→now = _idle_clock(), after which all 24 pass. Whichever of#62/ this lands second should make that change.Verification
RED first, against tip
6a3d95b100—max_clientsalready passed,idle_timeoutfailed, exactly as SCA-4719's eviction-coverage comment predicted:Then green:
The 5 failures are pre-existing and environmental, not from this change —
git stashon the same commit reproduces the identical 5 (tests/conftest.pylive-system guard blockingos.killon a PID outside the test subtree, intest_client_e2e,test_diagnostics_field,test_stale_diagnostics).Merge-order: this branch descends directly from
origin/fix/sca-4688-type-aware-footprint(git merge-base --is-ancestorconfirms), so it is clean onto the stack tip by construction; the sibling probe against#62is above.scripts/pr_merge_order.pylives only on#75and is not on this stack — the equivalentmerge-treeprobe was run by hand instead.Ported from
#50:test_non_finite_bounds_fall_back_instead_of_crashing_the_lsp_path,test_status_reports_the_bounds,test_human_readable_status_renders_the_bounds(driven through the real_cmd_statusrather than a re-rendered copy of its format string),test_config_max_clients_zero_is_rejected_loudly, andtest_user_guide_does_not_promise_a_disableable_capas a pure regression guard — the guide was already correct.Merge authority stays with the operator.