Skip to content

steer gate: read the session busy flag, not the agent's - #58

Merged
frolic merged 4 commits into
frolic:mainfrom
hello-pollux:pollux/steer-gate-busy-flag
Oct 17, 2026
Merged

frolic merged 4 commits into
frolic:mainfrom
hello-pollux:pollux/steer-gate-busy-flag

Conversation

@hello-pollux

@hello-pollux hello-pollux commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Fixes a steering bug that the pi 1.3.0 bump widened. Base merged, so this is now a clean single-concern PR on main — the diff is the fix commit plus its test, nothing else.

Main has moved since this branch was cut (#59, "presence: show the model name only"), which touches createAgentPool.ts in the same file. Merged rather than rebased, per the repo's convention; the merge was clean and #59's change is intact.

The bug

pi has two busy flags and they flip at different points:

  • session.isStreaming — pi's _isAgentRunActive
  • session.agent.state.isStreaming — the agent loop's flag, which guards activeRun

createAgentPool.handle() gated steering on the agent flag. But pi 1.2.0 moved the session flag to the first line of its run-start sequence, which then awaits the compaction check and the before_agent_start handlers; the agent flag only becomes true inside the later agent.prompt() call.

So during that window a message arriving from Discord found the agent flag false, the harness concluded nothing was in flight, and it submitted a plain prompt(text) with no streamingBehavior. pi checks its flag, sees busy, and throws:

Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.

handle() catches that and posts it via sender.sendError — so the user gets an error line and their message is never processed. On 0.84.4 the two flags flipped one statement apart, so this was effectively unreachable; on 1.3.0 the window spans the whole run-preparation, which is exactly when someone fires off a quick correction.

evict() read the same flag for its busy check. Benign there, but wrong for the same reason — a just-started session could be evicted.

Reproducing it

The window is normally sub-second, so I widened it deterministically by registering a ~2s before_agent_start handler in a throwaway agent home, then probing both flags while a run was starting:

PASS  observed the gap: session busy while agent.state.isStreaming is false
PASS  session.isStreaming === true during the gap
PASS  agent.state.isStreaming === false during the gap
PASS  a plain prompt during the gap is REJECTED (today's harness path)
PASS  the rejection is the 'already processing' error
PASS  a steer during the gap is ACCEPTED (the fixed path)
PASS  the steered message landed in the session

Both flags read and both prompt paths exercised in the same window, so this is the bug and the fix landing on the same measurement, not two separate runs.

The fix

Read session.isStreaming at all three sites, via a small isSessionBusy() helper that carries the explanation — this is a trap someone will otherwise walk back into. handle() also now reads the flag once and uses that value for both the option it builds and the branch it takes; those must agree or the prompt carries the wrong disposition.

Why this can't regress anything: the session flag is cleared in _emitAgentSettled(), which runs after agent.prompt() and the post-run loop finish — so it is strictly wider than the agent flag, never narrower. Every case that previously steered still steers, and the only newly-steered case is the window that previously errored.

What I deliberately didn't do

  • Catch-and-resubmit-as-steer. Tempting as a belt-and-braces, but pi has no typed error class for this — all five "already processing" throws are bare new Error() with different messages, so it would be a string match against prose. That is brittle and would mask genuine "already processing" bugs. Reading the authoritative flag makes the error unreachable on this path instead.
  • wakeUp() keeps a plain prompt(). Its three callers (thread.ts on a brand-new thread, recoverActive on a fresh process, handleClear after dropEntry) all target a session that is fresh or just dropped, so it can't be busy. If a caller is ever added that can hit a warm busy session, it needs the same gate — noted in the code.

Tests

Added src/agent/buildPromptOptions.test.ts (4 cases) pinning the isBusy → streamingBehavior: "steer" pairing, which is the contract this bug violated. buildPromptOptions was the one piece of this change with a testable seam — the pool itself needs a live discord.js client and a full pi session to construct.

bun run typecheck clean, bun test 106/106.

pi 1.2.0 made a session count as busy from the first line of its run-start
sequence, which awaits the compaction check and the before_agent_start handlers.
agent.state.isStreaming only becomes true inside the later agent.prompt() call,
so gating steering on it left a window where an arriving message was submitted
as a plain prompt into a run that was already starting. pi rejects that with
"Agent is already processing", which surfaced as a channel error and dropped the
message. Before 1.2.0 the two flags flipped one statement apart.

Read session.isStreaming instead (pi's _isAgentRunActive) at all three sites, and
read it once per message so the steer option and the branch agree.
@frolic
frolic merged commit c232688 into frolic:main Oct 17, 2026
1 check passed
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.

2 participants