Repository navigation
fix(workbuddy): keep prompt hooks from stalling on scope reads - #1729
Merged
Teingi merged 2 commits intoSep 23, 2026
Merged
Conversation
WorkBuddy prompt submission should fail open within the scope binding HTTP budget even when a server response stalls after headers arrive. Constraint: WorkBuddy enforces an outer UserPromptSubmit hook timeout Scope-risk: narrow Tested: uv run pytest tests/workbuddy_plugin/test_scope_binding.py -q Tested: uv run ruff check integrations/workbuddy/plugins/powercontext/scripts/workspace_scope.py tests/workbuddy_plugin/conftest.py tests/workbuddy_plugin/test_scope_binding.py Tested: uv run ruff format --check integrations/workbuddy/plugins/powercontext/scripts/workspace_scope.py tests/workbuddy_plugin/conftest.py tests/workbuddy_plugin/test_scope_binding.py Co-authored-by: OmX <omx@oh-my-codex.dev>
AlexStocks
force-pushed
the
codex/workbuddy-scope-read-deadline
branch
from
September 23, 2026 10:04
29e784b to
f42a2bf
Compare
f42a2bf added an absolute deadline around the scope resolver's response body, but it still tightened urllib's socket timeout only once per 64 KiB block read. http.client consumes many socket reads inside a single read call, so a server that trickles a byte faster than that timeout keeps the caller inside one read and past its deadline; the observed worst case is the host's 30s UserPromptSubmit timeout, which is exactly what the change set out to remove. Reuse the primitives the claude-code and codex scope modules already ship: - open_bounded runs the request in a daemon worker, so connect and header exchange stay inside the caller's budget. - bind_response_deadline recomputes the remaining time before every receive, framing included. Both now cover the scope resolver and the hook's own recall, capture, and flush reads. The hook shares them through the installed powercontext_scope_binding shim instead of carrying its own opener, so the shim forwards them too. _set_response_timeout reads the socket through a guarded getattr chain, so a missing fp can no longer raise AttributeError outside the scope client's error contract. The deadline test replaces its fake socket with real loopback properties: body drip, header drip, a large healthy response, an expired deadline, and the hook's own recall read. The three drip cases fail on f42a2bf (3.19s, 3.17s, 3.20s against a 1.0s assertion) and pass here.
Contributor
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Response reads can still outlast the request deadline, and the regression test does not exercise multiple socket receives.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
What changed in this PR
Updates WorkBuddy scope binding to bound response-body reads within the configured HTTP deadline.
Changes:
- Applies socket timeouts during response reads.
- Adds test fixtures and stalled-response regression coverage.
| File | Summary |
|---|---|
tests/workbuddy_plugin/test_scope_binding.py |
Tests stalled response handling. |
tests/workbuddy_plugin/conftest.py |
Loads the WorkBuddy scope module for tests. |
integrations/workbuddy/plugins/powercontext/scripts/workspace_scope.py |
Applies request deadlines while reading responses. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+53
to
+55
| def read(self, amount: int = -1) -> bytes: | ||
| assert self.sock.timeouts | ||
| raise TimeoutError |
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.

Which issue or RFC does this PR close?
N/A - follow-up from local WorkBuddy hook timeout diagnosis.
Rationale for this change
The WorkBuddy scope client bounded the size of a response body but not the time it could take, and only one of the hook's four read paths was covered. Two gaps remain in the first revision of this PR:
The deadline was not enforced inside a single
readcall.HTTPResponse.read(n)delegates toio.BufferedReader.read(n), which keeps issuing underlying raw reads until it hasnbytes or hits EOF. Recomputing the socket timeout once per 64 KiB block read therefore cannot stop a server that trickles data faster than that timeout: the caller stays inside oneread()call and the absolute deadline is never re-evaluated. Measured against a loopback server dripping one byte per 50 ms with a 0.3 s per-request timeout, the first revision aborted after 3.19 s (body drip) and 3.17 s (header drip) instead of 0.3 s, i.e. it was bounded by the drip length, not by the deadline. With production defaults the same shape of server reaches the host'sUserPromptSubmittimeout — theHook timed out after 30000mssymptom this PR set out to remove.Note on the original description: a body read that stalls was already bounded (urllib applies its timeout to each individual socket read, so a hard stall trips it), so "response body reads were not bounded" overstated the gap. The genuinely unbounded case is a trickling response, at the body and at the header exchange, since the socket is unreachable until
open()returns.Only
resolve_scope_idwas covered. The hook's own recall, capture, and flush reads (workbuddy_powercontext_hook._read_response, used by/v1/context/prepare,/v1/sources/content, and/v1/memory/flush) still used the pre-feat(integrations): show token savings in Claude Code, OpenCode, and Codex #1580 pattern, so the PR title's promise ("keep prompt hooks from stalling") applied to one of four read paths.The primitives needed to close both gaps already exist in this repository.
claude-codeandcodexintroducedopen_bounded+_DeadlineSocket/bind_response_deadlinein #1580;_read_boundedin the codex plugin even has a test asserting the drip case today. This PR reuses them instead of inventing a workbuddy-specific variant.What changes are included in this PR?
integrations/workbuddy/plugins/powercontext/scripts/workspace_scope.pyopen_bounded, which runs one request in a daemon worker so connect and the header exchange stay inside the caller's budget._DeadlineSocket+bind_response_deadline, which recompute the remaining time before everyrecv/recv_into, framing included.bind_response_deadlineat the top of_read_boundedand keep the 64 KiB block reads (block size and deadline enforcement are orthogonal; one byte per call would cap a 1 MiB body at roughly 0.4 MB/s)._set_response_timeoutthrough a guardedgetattrchain, and drop thefpmember the first revision added to the_Responseprotocol.integrations/workbuddy/plugins/powercontext/hooks/powercontext_scope_binding.pybind_response_deadlineandopen_boundednext toresolve_scope_id. The installer replaces this file with a full copy ofscripts/workspace_scope.py, so both layouts (source tree shim and installed copy) expose the same names to the hook.integrations/workbuddy/plugins/powercontext/hooks/workbuddy_powercontext_hook.py_read_response, and route_post_jsonthroughopen_bounded._RejectRedirects/_URL_OPENERand the now-unusedoverrideshim: the bounded opener is shared from the scope module, which keeps the redirect refusal the hook relies on._set_response_timeoutuses the guardedgetattrchain, so a missingfpcan no longer raiseAttributeErroroutside the hook's error contract.tests/workbuddy_plugin/__init__.py, matching thetests/codex_pluginandtests/claude_code_pluginlayout.resolve_scope_id: body drip, header drip, a large healthy response inside its budget, and an expired deadline that must not contact the server./v1/context/preparekeepsmain()inside the shared HTTP budget, plus a redirect-refusal guard for the shared opener.Are there any user-facing changes?
Yes. A WorkBuddy prompt hook now fails open inside its configured HTTP budget when the PowerContext Server trickles response headers or a response body, instead of waiting for WorkBuddy's outer
UserPromptSubmittimeout. Healthy responses are unaffected, and a slow but healthy response is still allowed to finish inside its budget (covered by a test).No public API, persisted format, or configuration semantics change. The behaviour is now identical to the
claude-codeandcodexintegrations.Known residual, unchanged from the other hosts:
bind_response_deadlineand_set_response_timeoutreach the socket through CPython'sresponse.fp.raw._sockchain. If a future transport drops a link in that chain the deadline degrades to the per-socket-op timeout instead of failing loudly; that case is not observable in the loopback tests and is left as parity with the existing hosts.How was this change tested?
Red/green, with the implementation reverted to the first revision of this PR and the new tests left in place (
git applyof the fix patch afterwards):The two tests that pass in both revisions are the reverse guards (a 1 MB healthy response must still succeed; an expired deadline must not issue a request) and are documented as such.
Commands run on this branch:
uv run pytest tests/workbuddy_plugin tests/test_cli_workbuddy.py tests/test_integration_manifest.py tests/test_doctor_integrations.py tests/codex_plugin tests/claude_code_plugin -q→ 350 passed, 4 skippeduv run ruff check ./uv run ruff format --check .→ clean, 844 files formatteduv run ty check→ no diagnostics in the changed filesuv lock --check→ resolvedscripts/workspace_scope.pycopied topowercontext_scope_binding.pyand noworkspace_scope.pypresent (what the installer produces), the hook importsbind_response_deadline/open_boundedfrompowercontext_scope_binding.Not covered here: a cross-host contract test that asserts the same drip property for the
claude-codeandcodexread paths. Their scope modules still read one byte per call, which caps a large body at roughly 0.4 MB/s; that is a separate, low-priority alignment.AI usage statement
The initial revision and its diagnosis were prepared by Codex with GPT-5. The follow-up commit was prepared by WorkBuddy (Claude) after an independent review of the first revision: it reproduced the remaining drip case with real loopback servers, ported the existing
open_bounded/_DeadlineSocketprimitives instead of the workbuddy-specific variant, extended coverage to the hook's own read path, replaced the mock-based test with wall-clock property tests, and verified the red/green transition reported above. A human reviewed the diff and the test evidence.