Stop the gate from ending every Thread at its first tool call - #36
Merged
Conversation
The gate was killing every Thread it protected. An agent would state its plan, request one tool, and the Thread would report Completed having done nothing — twice in a row, on two different prompts, in under two seconds each. `defer` is Tervin's word for "no objection". It is not a word the runtime knows. The protocol accepts `allow`, `deny` and `ask`; handed anything else it ends the turn immediately and reports success. No tool result, no continuation, no error. Because this gate matches every tool call, that landed on the *first* one, so the agent stopped at its first action while the panel above it said Tervin Rules were protecting the session. The hook now says nothing at all when Rules do not object, which is how this protocol spells "no opinion". Denials are unchanged: a reason on stderr, exit 2. Established by elimination rather than inspection, because every plausible culprit had an alibi. The same prompt, profile, model and flags driven straight at the CLI worked. With a deliberately dead gate socket it worked. Driven through Tervin's own runtime with no arbiter wired, it worked. The audit trail showed the action allowed, "no policy rule matched and the action is low risk", 87ms before the Thread ended. What all the working cases had in common was a hook that printed nothing; substituting a hook that printed only the `defer` line reproduced it exactly. Why the suite missed it, and the same reason as the last gate bug: the tests that drive the real client over the real socket captured its exit code and its stderr and threw its stdout away. Every assertion about this path was correct. The fatal byte was in the stream nobody looked at. `run_client_capturing` now returns stdout and two tests assert it is empty, verified by reintroducing the `println!` and watching them fail with the exact payload from the timeline. Two things found while chasing it, both kept: - **Raw payloads were never stored.** `take_raw_stream` was defined and called from nowhere, so every event wrote a pointer to a body that was never saved: `raw_payloads` held zero rows while the channel grew unbounded for the life of each session. It is now drained alongside the events it belongs to, which is what made this bug diagnosable at all — "what did the runtime actually send?" had no answer when it was first asked. - **Every protocol line is traceable** behind `TERVIN_LOG=trace`. Needing a code change to see the stream is how a one-off becomes unreproducible. rust 691 to 692. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
QuintinBotes
force-pushed
the
gate-defer-fix
branch
from
August 3, 2026 15:56
ed396a5 to
f3bf969
Compare
QuintinBotes
enabled auto-merge (squash)
August 3, 2026 15:56
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 #35. Retarget to
mainonce that lands.The gate was killing every Thread it protected. An agent states its plan, requests one tool, and the Thread reports
Completedhaving done nothing — in under two seconds, every time.Cause
deferis Tervin's word for "no objection". It is not a word the runtime knows. The protocol acceptsallow,denyandask; handed anything else it ends the turn immediately and reports success — no tool result, no continuation, no error. Because this gate matches every tool call, that landed on the first one, so the agent stopped at its first action while the panel above it said Tervin Rules were protecting the session.The hook now says nothing at all when Rules do not object, which is how this protocol spells "no opinion". Denials are unchanged: a reason on stderr, exit 2.
Established by elimination, because every suspect had an alibi
allowed— "no policy rule matched and the action is low risk", 87ms before the Thread endedWhat every working case had in common was a hook that printed nothing. Substituting a hook that printed only the
deferline reproduced it exactly.Why the suite missed it
The same reason as the last gate bug. The tests that drive the real client over the real socket captured its exit code and its stderr and threw its stdout away. Every assertion about this path was correct; the fatal byte was in the stream nobody looked at.
run_client_capturingnow returns stdout and two tests assert it is empty — verified by reintroducing theprintln!and watching them fail with the exact payload from the timeline.Two things found while chasing it, both kept
take_raw_streamwas defined and called from nowhere, so every event wrote a pointer to a body that was never saved:raw_payloadsheld zero rows while the channel grew unbounded for the life of each session. It is now drained alongside the events it belongs to. This is what made the bug diagnosable — "what did the runtime actually send?" had no answer when it was first asked.TERVIN_LOG=trace. Needing a code change to see the stream is how a one-off becomes unreproducible.Also in here: Block output is stripped of escape sequences server-side, using the same routine as the preview and the search index. Three views of one output that previously disagreed — the one you actually read was showing
[1m[7m%[27m.rust 682 to 683 on this branch, clippy and fmt clean.