Skip to content

fix(omp): bound the post-agent_end completion gate (stacked on #3371) - #3667

Closed
joeshull wants to merge 5 commits into
getpaseo:mainfrom
ZGEnergy:omp-idle-budget-upstream
Closed

fix(omp): bound the post-agent_end completion gate (stacked on #3371)#3667
joeshull wants to merge 5 commits into
getpaseo:mainfrom
ZGEnergy:omp-idle-budget-upstream

Conversation

@joeshull

@joeshull joeshull commented Aug 21, 2026

Copy link
Copy Markdown

Draft, and not mine to merge. Do not merge before #3371. The first two commits are @jasonhnd's, unmodified — merging this as-is would land #3371 without its own review. If @jasonhnd would rather take the last three commits onto fix/2232-omp-idle-gate and carry them in #3371, that's my preference too; say the word and I'll close this.

Fixes #3654. This branch is #3371's head (fa9fc5e62) plus three commits. GitHub shows all five until #3371 merges; review only the last three.

I cannot push to this repository, so I cannot open this against a base branch holding #3371's commits. Once #3371 lands on main, this narrows to its own three commits automatically. Merges cleanly into current main (8f0c35e92); no omp drift since #3371's base.

I cannot push to this repository, so I cannot open this against a base branch holding #3371's commits. If a maintainer creates that branch here, or once #3371 lands on main, this PR narrows to its own three commits automatically. Merges cleanly into current main (8f0c35e92); no omp drift since #3371's base.

Problem

completeTurnAfterProviderIdle() polls get_state every 10 ms after an assistant-bearing agent_end, swallows every error, and has no deadline. If OMP reports a stale state or the state RPC keeps failing, the turn shows as running until the user cancels — and startTurn throws while a turn is active, so the session is wedged for new prompts. The only signal is a logger.debug line, and the daemon runs at info level, so nothing surfaces.

The loop has not changed since it shipped in #2067, and the two tests that pin the unbounded wait came in that same commit. #2261 and #2282 both fixed reaching the gate and explicitly kept it as-is ("the change does not synthesize idle or add a timeout"). #3371 adds a third reason to wait inside it — hence the stack.

can1357/oh-my-pi#6916 is a field occurrence of the symptom: a session held running for 25m44s after the final answer, cleared only by a forced cancel. It is still open, waiting on RPC evidence the host never emitted.

Change

Budgets. OmpProviderIdleScheduler.waitForRetry() takes { attempt, consecutiveFailures, elapsedMs, isCompacting, isWaitingOnSubagents } and returns {retry: true} | {retry: false, reason}. The default scheduler backs off 10 ms → 1 s and gives up after 3 consecutive get_state rejections, after 60 s of monotonic wall clock, or after 10 minutes if OMP reports compaction. Wall clock rather than a retry count is deliberate: each get_state carries the JSONL-RPC request timeout, so counting attempts would let a slow-but-answering state path stretch the wait to tens of minutes. Compaction gets its own budget because the gate waits for isCompacting to clear and compacting a large context routinely outlasts 60 s.

The subagent wait from #3371. A get_subagents reply listing running children is positive evidence OMP is working, and a fan-out has no bounded length, so the wait budget does not apply while that evidence is current. It resumes the moment get_subagents stops answering — which is the stuck-parent risk that closed #2245: an index still holding children it can no longer confirm no longer holds the turn open forever. The get_state failure budget applies throughout.

Structured failure. On abandon the turn fails with omp_provider_idle_timeout or omp_provider_state_unavailable; a gate that never observed any state reports state_unavailable whichever budget ran out, because a hanging get_state burns the wait budget before three rejections can land. The diagnostic carries the last observed isStreaming/isCompacting, the last RPC error, and whether subagents were still reported running, and it logs at warn. turn_failed already carries code and diagnostic, so there is no packages/protocol change and nothing to gate on server_info.features. The turn state is cleared, so recovery is an ordinary prompt.

One gate per turn. Every agent_end used to open another concurrent loop, and two loops that both observed idle each called completeTurn — two turn_completed for one turn, reproduced in the harness. Gates are keyed on the turn ID, with a shared key for autonomous cycles, which poll like any other gate. The gate holds the terminal payload and the newest agent_end overwrites it, unless that would discard an error the gate already holds.

Ownership. Ownership is re-checked before completing and before failing, not only at the top of each poll. Both sites sit after two awaits, and a get_state carries the 30 s RPC timeout, so a cancel-then-reprompt could otherwise let a stale gate fail the cancelled turn, cancel the new turn's tool calls, and clear its turn ID — after which its events go out with no turn ID and the manager back-fills whatever turn is active.

Terminalize on stall. A stall leaves OMP's state unknown, so tool calls and subagents still marked running are cancelled; otherwise they spin with no turn left to finish them.

The safety property holds: no ordinary turn_completed while OMP reports streaming, compacting, or running children, and a healthy state response before the budget still completes exactly once. #3371's tests and the two pre-existing gate tests are unchanged and pass.

Tests

Sixteen new tests in packages/server/src/server/agent/providers/omp/agent.test.ts, each written before its production change and watched fail — except the two that drive createOmpProviderIdleScheduler() directly, whose pre-change failure was an import error rather than a behavioral one.

  • exactly one completion when OMP repeats agent_end, foreground and autonomous
  • turn fails on wait-budget expiry and on repeated get_state failures, with the right code each time
  • a wait-budget expiry whose last check failed keeps both the state and the error in the diagnostic
  • a gate that never observed a state reports state_unavailable
  • newest agent_end error wins over the first snapshot, and an earlier error survives a later error-free cycle
  • a stale gate does not fail or clear a turn that started after it was abandoned
  • the session reports observed compaction, elapsed time, and the subagent wait to the scheduler
  • the subagent wait stops being trusted once get_subagents stops answering
  • in-flight tool calls are cancelled when the gate gives up
  • a new prompt is accepted after a stalled turn fails
  • the default scheduler's budget decisions, including compaction and subagent waits

QA

  • npx vitest run packages/server/src/server/agent/providers/omp — 19 files, 143 tests pass
  • npm run lint on the omp provider — 0 warnings, 0 errors
  • npm run format:check — clean

npm run typecheck in my checkout reports missing-module errors for @replit/codemirror-lang-csharp and @paseo/plugin at this base; those come from resolving my node_modules against a different lockfile, not from these commits. CI is the authority.

Not exercised against a live OMP process; the harness reproduces every failure mode deterministically. This surfaces a stall with the last state and RPC error but does not persist raw child RPC frames, which is the evidence oh-my-pi#6916 is still waiting on.

jasonhnd and others added 5 commits August 15, 2026 00:45
OMP can end the parent model loop while `task` children are still writing.
Do not complete the Paseo turn until the subagent index reports no runners.

Reconcile successful get_subagents replies so listed ids stay running, and
treat absence of a previously listed id as completed. Never-listed lifecycle
children stay running so an empty first snapshot cannot fake-idle the parent.

Closes getpaseo#2232
OMP emits tool_execution_end for `task` as a dispatch ack, then starts
children. Keep the parent call running and settle it from the subagent
index once a linked child exists and none remain running. completeTurn
force-settles a task that never produced a child.

Wire-order tests emit the result before subagent_lifecycle started.
After an assistant-bearing agent_end, the OMP provider polled get_state
every 10 ms waiting for a non-streaming, non-compacting state, swallowed
every error, and had no deadline. A stale or unreachable state path left
the turn showing as running until the user cancelled, and startTurn
throws while a turn is active, so the session was wedged for new prompts.
The only signal was a debug log, and the daemon runs at info.

Give the gate budgets. waitForRetry now backs off 10 ms to 1 s and
returns a decision: retry, or abandon because the 60 s wall-clock wait
ran out or three consecutive get_state calls failed. Wall clock rather
than a retry count, because each get_state carries the JSONL-RPC request
timeout and counting attempts would stretch the real wait to tens of
minutes. On abandon the turn fails with omp_provider_idle_timeout or
omp_provider_state_unavailable, and the diagnostic carries both the last
observed state and the last RPC error, so a stale provider is
distinguishable from a lost state path. turn_failed already carries
code and diagnostic, so no protocol change. The turn is cleared, so
recovery is an ordinary prompt.

Hold one gate per turn. Every agent_end opened another concurrent loop,
and two loops that both observed idle completed the same turn twice.
Autonomous cycles carry no turn ID and share a key of their own; they
poll like any other gate. The gate holds the terminal payload and the
newest agent_end overwrites it, so an error reported by a later cycle is
not dropped in favour of the first snapshot.

Terminalize in-flight work when the gate gives up. A stall leaves OMP's
state unknown, so a tool call or subagent still marked running has no
turn left to finish it.

Refs getpaseo#3654
The gate re-checked ownership at the top of each poll, but the abandon
path ran after two awaits without re-checking. A get_state carries the
30 s RPC timeout, so the window is wide: cancel a stuck turn, send
another one, and the stale gate could fail the cancelled turn, cancel
the new turn's tool calls, and null its turn ID. Events for the live
turn then went out with no turn ID, which the manager back-fills with
whatever turn is active. Check ownership before failing, and before
completing for the same reason.

Give compaction its own budget. The gate waits for isCompacting to
clear, and compacting a large context is a model call that outlasts 60 s
routinely, so a healthy provider could be failed mid-compaction. The
observed compaction state now reaches the scheduler, which allows ten
minutes while OMP reports it.

Report an unavailable state path whenever no state was ever observed. A
hanging get_state burns the 60 s wait budget before three rejections can
land, so the failure budget only fired on fast rejections and the
hanging case was labelled a timeout.

Measure the budget on performance.now(). Date.now() steps with NTP and
across suspend, either deferring the deadline or failing a healthy turn
on wake.

Keep an error the gate already holds when a later agent_end reports
none: the fallback payload is a single assistant message, so newest-wins
could drop the error the previous cycle reported.

Refs getpaseo#3654
getpaseo#3371 adds a third reason the gate can wait: OMP-internal task children
still running. That wait had the same shape as the two this branch
bounded, so the budget now covers it, with one difference. A get_subagents
reply that lists running children is positive evidence OMP is working, and
a fan-out has no bounded length, so the wait budget does not apply while
that evidence is current. It resumes the moment get_subagents stops
answering, which is the stuck-parent risk that closed getpaseo#2245: an index left
holding children it can no longer confirm no longer holds the turn open
forever. The failure budget for get_state applies throughout, and the
diagnostic records that subagents were still reported running.

Refs getpaseo#3654, getpaseo#2232
@joeshull

Copy link
Copy Markdown
Author

Closing this — on reflection it shouldn't carry #3371's commits. Merging it would have pulled that PR in without its own review, and the gate changes it builds on are @jasonhnd's to land.

The three commits on top are still available at ZGEnergy:omp-idle-budget-upstream if they're useful to anyone: they bound the post-agent_end idle gate (#3654) so a stale or unreachable get_state can't hold a finished turn in running forever, and they cover the subagent wait #3371 adds.

I'll reopen against main once #3371 lands, or happy for those commits to be taken into #3371 directly instead.

@joeshull joeshull closed this Aug 21, 2026
@joeshull joeshull reopened this Aug 21, 2026
@joeshull
joeshull marked this pull request as draft August 21, 2026 22:34
@joeshull

Copy link
Copy Markdown
Author

Reopening as a draft rather than sitting on this until #3371 lands — @jasonhnd got the notification already, and the findings are more useful to you now than after your PR merges.

Two of them touch #3371 directly:

  1. The gate you're adding a third wait to has no deadline at all. completeTurnAfterProviderIdle polls get_state every 10 ms forever and swallows every error, so a stale or unreachable state path holds a finished turn in running until the user cancels. It has been that way since feat(omp): add native OMP provider #2067, and fix(omp): complete turns when agent_end omits messages #2261/fix(omp): complete delayed model turns after local-only results #2282 both explicitly left it alone.

  2. hasRunningOmpSubagents() swallows a failing get_subagents the same way, so an index still holding children it can no longer confirm keeps the parent open indefinitely — the same shape as the objection that closed fix(omp): keep parent non-idle while internal task subagents run #2245. The last commit here treats a current snapshot listing running children as positive evidence (no budget, since a fan-out has no bounded length) and lets the budget resume once get_subagents stops answering.

Also worth knowing: every agent_end opens another concurrent gate today, and two that both observe idle emit two turn_completed for one turn. Reproduced in the harness.

Take any of it into #3371 if it's useful — I'd rather that than this stack.

@joeshull

Copy link
Copy Markdown
Author

Closing in favour of jasonhnd#1, which puts the same three commits into @jasonhnd's fix/2232-omp-idle-gate branch. That keeps #3371 as the single place this gets reviewed instead of asking anyone to review his commits under my PR.

The findings stand on their own if that PR isn't wanted: the post-agent_end gate has had no deadline since #2067, hasRunningOmpSubagents() swallows a failing get_subagents the same way get_state failures were swallowed, and concurrent gates can emit two turn_completed for one turn. #3654 has the detail.

@joeshull joeshull closed this Aug 21, 2026
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.

bug(omp): post-agent_end completion gate can spin forever on non-idle or unavailable get_state

2 participants