fix(cli): size the TUI V8 heap from this process's cgroup, not the hierarchy root (SCA-4627) - #65
Merged
Merged
Conversation
This was referenced Aug 9, 2026
pai-scaffolde
pushed a commit
that referenced
this pull request
Aug 10, 2026
…628) GitHub reported #65 and #66 both CLEAN. Both branch off #63 and diverge there, so each was measured against its own base and neither was ever measured against the other. A local merge simulation of the real queue shows they collide on agent/lsp/manager.py in either order, so no merge ordering alone lands both. The collision is add/delete, not semantic: #65 moved CGROUP_MEMORY_LIMIT_PATHS and _cgroup_memory_limit_bytes out of manager.py into the new agent/cgroup_memory.py, while #66 branched from #63 (where they still lived) and added EVICTION_HANDOFF_BUDGET beside them. Resolution keeps both contributions: EVICTION_HANDOFF_BUDGET stays, the relocated cgroup block is dropped in favour of the agent.cgroup_memory import #65 introduced. tests/agent/lsp/test_client_cap.py auto-merged onto #65's cgroup_mod form and carries no stale manager_mod references. Tuple stays imported (still used at 8 sites), so no orphaned import. Verified locally: no conflict markers, manager.py compiles, both EVICTION_HANDOFF_BUDGET call sites and the cgroup_memory import resolve. Test execution is left to CI — this host is at 93% disk with the self-hosted runner already disk-suspended (SCA-4625), so standing up a pinned uv env here would spend the exact resource that is scarce.
pai-scaffolde
pushed a commit
that referenced
this pull request
Aug 10, 2026
…(SCA-4633 class) Second instance of the same class as #66: GitHub reported #62 and #63 both CLEAN because each was measured only against main, never against each other. A merge simulation of the real queue shows they collide on agent/lsp/manager.py, so the queue could not land in any order. #62 is the cheaper side to absorb: it is a leaf with no dependents, while #63 is the base of a four-PR stack (#64, #65, #66), so resolving on #63 would force a re-merge and a fresh CI run on all four. Resolution takes both sides rather than either: _last_used keeps #62's _idle_clock() and the stack's second protected cap sweep is preserved. The conflicted hunk was not the whole risk. #63 added new _last_used write sites that git auto-merged with no conflict, and a merge that resolved only the marked hunk would have silently reinstated the wall clock on those paths and quietly undone #62. Audited the merged tree: all three _last_used writes (747, 809, 1090) and the reaper cutoff (1107) use _idle_clock(), and no time.time() remains in manager.py. The handoff deadline keeps time.monotonic() directly, which is correct for an elapsed-time budget. Verified locally: no conflict markers, manager.py compiles. Test execution is left to CI — this host is at 93% disk with the self-hosted runner disk-suspended (SCA-4625).
This was referenced Aug 10, 2026
Merged
pai-scaffolde
changed the base branch from
fix/sca-4623-cgroup-self-path
to
main
August 20, 2026 01:58
…erarchy root (SCA-4627) 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>
pai-scaffolde
force-pushed
the
fix/sca-4627-tui-heap-cgroup
branch
from
August 20, 2026 02:59
e7782f7 to
3f32737
Compare
pai-scaffolde
added a commit
that referenced
this pull request
Aug 20, 2026
… (#81) PR #64 was replayed onto main with the wrong merge base (its parent branch's TIP rather than the true fork point). Files main had gained after that branch forked therefore looked like deletions, so merging #64 silently reverted #79: * gateway/run.py — _run_state_db_maintenance_once() refactor undone * hermes_cli/config_defaults.py — housekeeping comment reverted * tests/gateway/test_state_db_periodic_maintenance.py — deleted This re-applies #79 verbatim onto current main. The LSP work from #63 and #64 (including sessions.max_clients) is untouched. Audited: #63 and #65 match their original diffstats exactly; #64 was the only bad replay. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #64 (which is stacked on #63). Review only the two commits above #64's tip; the base will collapse to
mainas the stack merges.The defect
Codex flagged this as P1 during review of #64.
hermes_cli/main.py_read_cgroup_memory_limit()read the identical two hierarchy roots that SCA-4623 just fixed inagent/lsp/manager.py:Those are this process's limit only when it sits in the root cgroup. Under a systemd unit with
MemoryMax=, or in a container without a private cgroup namespace, the root readsmaxand the function returnsNone.Why it is worse than the LSP cap
_resolve_tui_heap_mb()then returns the flatdefault_mb = 8192. V8 is not cgroup-aware, so inside a 4 GiB unit the Node TUI grows its heap toward 8 GB until the cgroup OOM killer SIGKILLs it. Per that function's own docstring, the kill runs no JS handler, writes no[tui-parent]breadcrumb, and the user sees only a bare gatewaystdin EOF.SCA-4623's defect degraded to pre-cap sizing. This one is a silent kill with no diagnostic.
The change
Two commits, deliberately split:
refactor(cgroup)— pure move of the resolver out ofagent/lsp/manager.pyinto a new stdlib-onlyagent/cgroup_memory.py, matching the flat single-purpose convention ofagent/battery.py.manager.pykeepshost_memory_bytes()and delegates. No behaviour change.fix(cli)—_read_cgroup_memory_limit()becomes a delegation to that module.hermes_clideliberately does not importagent.lsp.manager: LSP is unrelated to heap sizing and that module pulls asyncio plus the whole LSP client stack onto a CLI startup path. The new module imports in 9 ms with no asyncio, logging, or threading behind it (measured, not asserted).cgroup_memory_limit_bytes(no underscore) is the single name crossing a module boundary; the resolution helpers stay private.Positive control
Required, as in #64. Four new tests over a fabricated
/proc+ cgroupfs tree — hierarchy root atmax, the real limit on the unit's own cgroup — each shown failing against the fixed-path implementation before the fix:test_unit_limit_is_read_not_the_hierarchy_rootassert None == 4 GiBtest_heap_is_sized_below_the_unit_limit_not_the_flat_defaultassert 8192 == 3072test_an_ancestor_limit_binds_the_unitassert None == 2 GiBtest_absent_proc_still_falls_back_to_the_fixed_pathsassert None == 6 GiBassert 8192 == 3072is the defect itself: the heap V8 would have been handed inside a unit that cannot afford it.Test trap closed
tests/hermes_cli/test_tui_heap_sizing.pymockedbuiltins.openand passed unrecognised paths through to the realopen. Once resolution consults/proc, those fixed-path tests would have read the real/procon Linux CI and started reporting the runner's own cgroup._readnow pinsCGROUP_FS_ROOTat an empty root first — the same guardno_proc()gives the LSP suite intests/agent/lsp/test_client_cap.py.The cgroup tests in
test_client_cap.pymove their patch targets to the new module alongside the code. Same tests, same assertions, no coverage lost.Class sweep
git grep -n "sys/fs/cgroup" -- '*.py' '*.ts' '*.js' '*.sh'— 3 readers; 2 fixed, 1 tombstoned:agent/lsp/manager.pyhermes_cli/main.pygateway/cgroup_cleanup.py/sys/fs/cgroup{cgroup_path}/cgroup.procsfrom a caller-supplied path rather than assuming the root. Tombstoned.Verification
tests/hermes_cli/test_tui_heap_sizing.py— 9 passedtests/agent/lsp/— 104 passed (extraction is behaviour-preserving)tests/tools/test_docker_environment.py+tests/hermes_cli/test_agent_import.py— 99 passedruff checkclean;ty checkclean on both changed source modulesCloses SCA-4627.