Skip to content

Budget the editor-handoff pause sleep, and note why main looked green without it - #6243

Merged
Hmbown merged 1 commit into
mainfrom
fix/blocking-budget-terminal-input-20260915
Sep 15, 2026
Merged

Hmbown merged 1 commit into
mainfrom
fix/blocking-budget-terminal-input-20260915

Conversation

@Hmbown

@Hmbown Hmbown commented Sep 15, 2026

Copy link
Copy Markdown
Owner

One line of JSON, plus something about the gate that is worth knowing.

No-Issue: ratchet bookkeeping for a site #6165 landed deliberately; the underlying convention is #6149.

What

#6239 (which closed #6165) added a bounded thread::sleep to
tui/ui/terminal_input.rs::pause_for_child_terminal. The blocking-calls ratchet
has no entry for that file, so it now reports:

crates/tui/src/tui/ui/terminal_input.rs: thread_sleep sites 1 > budget 0

This adds the one entry. Nothing else in the file changes.

Why budgeted and not rewritten

This is the case check-blocking-calls-budget.py's own message names — "raise
the budget with --update if the site can only run on synchronous code" — and
its author had already written the justification at the call site:

a bounded retry, capped by TERMINAL_INPUT_CHILD_PAUSE_TIMEOUT, in a
synchronous API whose caller is about to block this very thread on a
foreground editor for as long as the user keeps it open. tokio::time is not
reachable from here and would not change what the thread does.

That reads correctly to me. The caller is about to hand the terminal to
$EDITOR and block until the user quits it; a few polled milliseconds before
that is not what parks the thread. The sibling sleep at :192 is already inside
a thread::Builder::spawn, which is why the scanner never counted it.

The part worth reading

Main is currently green on this, and that is not evidence.

The budget steps are gated on if: needs.changes.outputs.heavy == 'true'. On
main's run for e8a226552 all four budget steps report skipped:

skipped  Check dead-code budget
skipped  Check blocking-calls budget
skipped  Check runtime-contract budget
skipped  Check persistence-backlog budget

Combined with continue-on-error on pull requests, a violation can cross a PR
advisory-only and then land on a main push that never runs the check. That is
exactly the path this took: green PR, green main, real violation, fires on
whatever heavy push comes next — at which point it looks like that unrelated
push's fault.

I am not changing the gating here; narrowing heavy or making these steps
unconditional is a CI-cost tradeoff someone should make deliberately. But the
current shape means a green main does not tell you the ratchets passed, and
that seemed worth stating where someone will read it.

Evidence

check-blocking-calls-budget.py   625 sites across 181 files, within budget

Diff is one added object:

+  "crates/tui/src/tui/ui/terminal_input.rs": {
+    "thread_sleep": 1
+  },

🤖 Generated with Claude Code

#6239 (#6165) added a bounded `thread::sleep` to
`tui/ui/terminal_input.rs::pause_for_child_terminal`, which the blocking-calls
ratchet has no entry for. The gate is advisory on pull requests and blocking on
pushes to main, so the PR was green and main is only green because the budget
steps SKIPPED - they are gated on `needs.changes.outputs.heavy == 'true'`, and
that merge did not trip it. The violation is real and fires on the next heavy
push.

Budgeted rather than rewritten, because the site is the case the script's own
message names. Its author already wrote the justification at the call site: a
bounded retry capped by `TERMINAL_INPUT_CHILD_PAUSE_TIMEOUT`, in a synchronous
API whose caller is about to block that very thread on a foreground editor for
as long as the user keeps it open. `tokio::time` is not reachable from there and
would not change what the thread does. The sibling sleep at :192 is already
inside a `thread::Builder::spawn`, so the scanner never counted it.

One entry added; the rest of the file is unchanged.

  check-blocking-calls-budget.py  625 sites across 181 files, within budget

Worth a follow-up someone should own: a budget step that skips on a
not-heavy-enough diff means a green main is not evidence the ratchet passed.
That is how this reached main in the first place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AJENKJ2smviQW4FVGzUTk9
Copilot AI lite review requested due to automatic review settings September 15, 2026 22:43
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Hmbown's task in 1m 52s —— View job


Review complete

  • Read PR context and diff
  • Inspect terminal_input.rs::pause_for_child_terminal and the sibling sleep at :192
  • Verify check-blocking-calls-budget.py scanner behavior (why one site is counted, the other isn't)
  • Verify the CI gating claim (heavy condition + continue-on-error)
  • Post review findings

Summary

This is a correctly-scoped, data-only ratchet update: one JSON entry, no code changes. I traced the scanner logic by hand against the file and the numbers check out.

Verified correct:

  • crates/tui/src/tui/ui/terminal_input.rs:132 (thread::sleep(TERMINAL_INPUT_CHILD_PAUSE_POLL_INTERVAL)) sits in a plain fn scope (pause_terminal_input_for_child), not inside spawn_blocking/thread::Builder/test — so check-blocking-calls-budget.py's scope-stack scanner counts it once. That matches the added "thread_sleep": 1 entry.
  • The sibling sleep at :192 is inside the closure passed to thread::Builder::new().spawn(...) (line 185), which pushes a "blocking" scope onto the scanner's stack — correctly excluded, as the PR description says.
  • Alphabetical placement is correct (provider_setup.rs < terminal_input.rs < tests.rs), matching the script's sort_keys=True output.
  • The CI-gating claim checks out against .github/workflows/ci.yml: the blocking-calls-budget step is gated on needs.changes.outputs.heavy == 'true' (line 435) and uses continue-on-error: ${{ github.event_name == 'pull_request' }} (line 436). A push whose changed paths don't hit the heavy classifier skips the step entirely; a PR run makes it advisory-only. Also confirmed scripts/check-blocking-calls-budget.json itself isn't in the light-path allowlist in the changes job, so this PR's own run is classified heavy and will actually exercise the check (as the pasted "within budget" evidence shows) rather than skip it.
  • No provider/model/route logic, no reuse/dead-code concern, no security surface — appropriately no new tests for a pure ratchet-bookkeeping change, consistent with the repo's "don't add tests by default" guidance.

One nit (description only, not the diff): the PR body attributes the budgeted sleep to pause_for_child_terminal (terminal_input.rs:302), but that function is async and already awaits tokio::time::sleep (line 320) — it has no thread::sleep and needs no budget entry. The quoted justification comment and the actual thread::sleep call are in the similarly-named but distinct synchronous function pause_terminal_input_for_child (terminal_input.rs:108-135). Doesn't affect correctness of the shipped JSON change, but worth fixing in the PR description so a future reader matching function name to call site doesn't get pointed at the wrong (and already-correct) async function.

@codewhale-agent codewhale-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codewhale review

Complete review coverage: 1/1 passes, 1 file patches, sha256:e3aac133bcff3abde64e4ff3d74310f4763537031748db482abc0ecc2b2d3fd8.

Pass 1: The PR adds a single budget entry to scripts/check-blocking-calls-budget.json for crates/tui/src/tui/ui/terminal_input.rs with thread_sleep = 1. The hunk is syntactically valid JSON, correctly indented (2 spaces for the key, 4 for the inner count), and inserted in the file's existing alphabetical position between provider_setup.rs and tests.rs. No other content changes. The change is minimal and matches the scanner's reported violation.

Findings

  • [INFO] Verify the key is not already present elsewhere in the budget file (scripts/check-blocking-calls-budget.json:454)
    The diff shows only the insertion context around line 451, so a pre-existing entry for "crates/tui/src/tui/ui/terminal_input.rs" elsewhere in the JSON cannot be ruled out from the patch alone. JSON parsers silently keep the last duplicate key while the generator (--update) may emit only one, so a duplicate would produce confusing future diffs and a budget that does not match what the author intended. Since the file is sorted alphabetically, a duplicate should be adjacent (or would break the ordering assumption), which makes this cheap to confirm with a quick grep before merge.
  • [INFO] This file is not validated by CI on main, so the entry cannot be machine-verified here (scripts/check-blocking-calls-budget.json)
    As the PR description states, the four budget steps are gated on needs.changes.outputs.heavy == 'true' and were skipped on main's run for e8a2265; combined with continue-on-error on pull requests, a malformed or wrong-count entry can land without ever being exercised by check-blocking-calls-budget.py. The author explicitly declares this gating change out of scope, which is reasonable, but it means the only verification for this diff is manual: the key must byte-match the path printed by the scanner and the count must equal the site count the scanner reports for that file. Worth confirming locally with the script rather than relying on the green main signal.
  • [INFO] Rationale for the budgeted exception lives only outside the repository (scripts/check-blocking-calls-budget.json:455)
    The justification quoted in the PR (bounded retry capped by TERMINAL_INPUT_CHILD_PAUSE_TIMEOUT, synchronous API whose caller blocks the thread on a foreground editor) is the reason the budget is raised instead of the call being rewritten, but it will not be discoverable from the budget file, which is plain JSON with no comment support. A reader investigating later why this file carries a non-zero thread_sleep budget will have to dig through git history to find it. Nothing to fix in this diff, but noting it so the tradeoff is at least acknowledged.

Assessment

Pass 1: Correct and minimal. The added object is valid JSON, uses the file's existing 2/4-space indentation, and is placed in the correct alphabetical slot (provider_setup.rs < terminal_input.rs < tests.rs), so it will not churn under a future --update. The budgeted value of 1 matches the single site the scanner reports, and the sibling sleep at terminal_input.rs:192 is inside a thread::Builder::spawn so it is legitimately uncounted. The main risk is not in the patch but in its verification path: because the budget steps are gated behind the heavy output and are advisory-only on PRs, this entry is not actually exercised by CI, so the path/key match and absence of a duplicate key should be confirmed manually. Approve once that is checked.


Advisory review by Codewhale (codewhale review --pr 6243 --post, head fbf715889e0763ac7f5ad126e5c20e66ea6f71f2). Line-specific findings are also posted as inline review comments; mechanical fixes arrive as committable suggestions you can apply from the Files tab. CODEOWNERS approval still governs merge.

"crates/tui/src/tui/ui/provider_setup.rs": {
"std_fs": 2
},
"crates/tui/src/tui/ui/terminal_input.rs": {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[INFO] Verify the key is not already present elsewhere in the budget file

The diff shows only the insertion context around line 451, so a pre-existing entry for "crates/tui/src/tui/ui/terminal_input.rs" elsewhere in the JSON cannot be ruled out from the patch alone. JSON parsers silently keep the last duplicate key while the generator (--update) may emit only one, so a duplicate would produce confusing future diffs and a budget that does not match what the author intended. Since the file is sorted alphabetically, a duplicate should be adjacent (or would break the ordering assumption), which makes this cheap to confirm with a quick grep before merge.

"std_fs": 2
},
"crates/tui/src/tui/ui/terminal_input.rs": {
"thread_sleep": 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[INFO] Rationale for the budgeted exception lives only outside the repository

The justification quoted in the PR (bounded retry capped by TERMINAL_INPUT_CHILD_PAUSE_TIMEOUT, synchronous API whose caller blocks the thread on a foreground editor) is the reason the budget is raised instead of the call being rewritten, but it will not be discoverable from the budget file, which is plain JSON with no comment support. A reader investigating later why this file carries a non-zero thread_sleep budget will have to dig through git history to find it. Nothing to fix in this diff, but noting it so the tradeoff is at least acknowledged.

@Hmbown

Hmbown commented Sep 15, 2026

Copy link
Copy Markdown
Owner Author

A second, larger blind spot in this same gate — partly confirmed, partly not

(Reposted: the first version of this comment lost several filenames to shell backtick expansion.)

A multi-agent review of the guard surface flagged scripts/check-blocking-calls-budget.py for a defect beyond the one this PR patches. I verified the mechanism and could not reproduce the headline number, so both are below.

Confirmed: the pattern anchors on the literal std::fs::

"std_fs": re.compile(
    r"\bstd::fs::(?:read|read_to_string|write|create_dir|…)\b"
),

A file that does use std::fs; and then calls fs::read_to_string(...) — the more idiomatic of the two spellings — is invisible to this gate. Same call, same blocking syscall, not counted.

Measured on origin/main (raw occurrences, no scope filtering):

std::fs:: sites the gate can see : 3253
bare  fs:: sites it cannot see   :  476  across 390 files

Largest uncounted: crates/tui/src/runtime_api/tests.rs 298, crates/tui/src/config/tests.rs 296, crates/tui/src/project_context.rs 184, crates/tui/src/session_manager.rs 154, crates/tui/src/plugins/tests.rs 121, crates/config/src/tests.rs 120.

Not confirmed: "the gate sees 45% of sites"

The review reported 624 recorded sites against 1403 under a widened pattern. I could not reproduce that ratio. The two figures measure different things — the budget's 624 is after scope filtering (tests, spawn_blocking and dedicated-thread scopes are excluded), while a raw pattern count is not. My raw numbers put the blind spot nearer 13% of occurrences, and a large share of those sit in tests.rs files the gate deliberately ignores anyway.

So: the blind spot is real, its size is unconfirmed, and the honest production-only number is the one nobody has measured yet. Widening the regex and re-running --update would produce it in one command; I did not do that here because it would bury a one-line fix under a re-baseline.

Why it belongs in its own issue, not this PR

Widening the pattern moves the budget number for reasons unrelated to any code change. That deserves its own commit whose message says the number rose because the measurement got honest, not because the tree got worse. Folding it in here would make this PR's one-line intent unreviewable.

It is also another instance of the class in #6242 — a gate that asserts a spelling rather than a property. That issue is the archetype; this is the third confirmed instance, after the dead-code ratchet (#6241) and the skipped-steps problem described in this PR's own description.

One fact for whoever picks it up: there are zero use tokio::fs sites in the tree, so a widened pattern needs no import-gating to avoid false positives.

@Hmbown
Hmbown merged commit 9a4d69e into main Sep 15, 2026
32 checks passed
@Hmbown
Hmbown deleted the fix/blocking-budget-terminal-input-20260915 branch September 15, 2026 23:57
timothybrush pushed a commit to timothybrush/DeepSeek-TUI that referenced this pull request Sep 16, 2026
…mbown#6247)

`build_approval_grouping_key` is the scope of an "approve for the session"
decision: two patches share that grant exactly when they share the key. For
apply_patch the key was built by a second, weaker parser than the one the
executor uses - it read paths only from `+++ b/` headers and the
`replace`/`changes` array, never from the tool's own top-level `path`
argument, and discarded the normalizer's error with `Err(_) => {}`. When it
found nothing it returned the literal constant "no_files".

Four supported shapes hit that constant: the documented
`apply_patch{path, patch}` override (a bare hunk has no `+++` line, and
apply_patch.rs itself tells the model "Ensure the patch includes ---/+++
headers or provide `path`"), the same shape through `File{action:"patch"}`,
any `--no-prefix` diff (the executor's normalize_diff_path accepts
`+++ src/x.rs` while the fingerprint demanded the literal `b/`), and any
delete-only diff.

So approving a card that read "patch .env.example" also approved every later
path-less patch - to `.env`, to `.codewhale/settings.json` which decides what
runs on the machine, to any absolute path outside the workspace, and in a
non-git workspace to every file in the tree, since the Hmbown#5185 carve-out is
disabled there. No second card, no notice, no receipt.

is_session_approved_for_tool is a bare set lookup on this key, and its own
comment names this exact class: "approving one shell command used to
auto-approve the entire shell tool for the session. The contains(tool_name)
clause was the escalation (ops R2)." That escalation was closed for shell. It
was open for patches, through a constant instead of a tool name.

The fix is reuse, not new parsing: hash `preflight_apply_patch(input)`'s
touched_files - already `pub`, already what core/engine.rs and auto_review.rs
call for the permission path - which folds the path override, prefix-less
headers and tab timestamps. Both empty arms now fail closed to a digest of the
input rather than to a shared constant, so an unparseable patch is its own
family and matches nothing but a byte-identical repeat.

Three regression tests, each confirmed failing without the fix by reverting
the resolver and keeping the tests:

  grouping_key_scopes_a_path_override_to_its_own_file    FAILED -> ok
  grouping_key_reads_prefix_less_diff_headers            FAILED -> ok
  grouping_key_fails_closed_on_an_unresolvable_patch     FAILED -> ok

  tools::approval_cache + tools::apply_patch + approval
    323 passed; 0 failed  (--test-threads=1)
  cargo fmt --all -- --check  clean
  cargo clippy --workspace --all-targets --all-features --locked
    (CI's allow list)  clean

One note for the reviewer: at --test-threads=2 this set reported
task_manager::tests::pending_approval_suspends_idle_and_timeout_denial_settles_failed
as failing. It passes isolated and passes at --test-threads=1; it is the same
load-sensitive timeout class as the compatibility_stream tests, not this
change.

check-blocking-calls-budget.py still fails on terminal_input.rs - that is
main's existing red, fixed in Hmbown#6243, not introduced here.

Closes Hmbown#6247

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AJENKJ2smviQW4FVGzUTk9
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

/hooks edit hands the terminal to $EDITOR without pausing the TUI input thread — keystrokes get split between the editor and the composer

2 participants