Skip to content

fix(services): resume a run when its approval is answered outside the playground (#5593) - #5598

Merged
mmabrouk merged 2 commits into
release/v0.107.0from
fix/approval-resume
Aug 1, 2026
Merged

fix(services): resume a run when its approval is answered outside the playground (#5593)#5598
mmabrouk merged 2 commits into
release/v0.107.0from
fix/approval-resume

Conversation

@mmabrouk

Copy link
Copy Markdown
Member

Context

An agent on a schedule stops and waits when it hits a tool that needs approval. Answering that approval from anywhere other than a live playground session did nothing. The row was marked answered, the agent service returned 200 with an empty body, and the run stayed stopped forever.

That blocks every out-of-band approval surface equally: an inbox, a webhook, a Slack button, a script.

Three separate defects, each reproduced against the running runner.

The resumed run was refused for carrying no user message. An approval reply has no new user text, and buildRunPlan refused any request without it. The runner can rebuild the earlier turns from its own record log, but that happens after the plan is built, so the request died before the conversation could be supplied.

{"kind":"result","result":{"ok":false,"error":"No user message to send (prompt/messages empty)."}}

The agent service raises after the response headers are already committed, which is why the caller saw a successful status and nothing in the body, and then reported Workflow service closed the stream before emitting a started record.

Answering evicted the live session parked on the gate. The approval branch compares the conversation the request carries against the parked one. A caller answering from a durable row carries none, so the runner concluded the transcript had been edited and tore the session down:

[keepalive] approval-mismatch (history) key=<project>:<session>; evict + cold

The ordinary continuation branch already had an escape hatch for callers that send no history. The approval branch never got one.

The durable interaction row never stored the tool call id. A parked gate has two ids, the permission gate id and the tool call id, and only the first was persisted. The playground works because it receives both on the live event stream. So an answer built from the stored row alone named the wrong call, and the resume path matches strictly on tool call id.

Changes

buildRunPlan still refuses an empty prompt, except when the request is an approval reply and nothing else. A new predicate, carriesApprovalReplyOnly, decides that. History reconstruction accepts the same shape, so the earlier turns come from the record log.

The keep-alive approval branch now skips its history comparison for a caller that asserts no conversation, the same way the ordinary continuation branch already did. It needed its own condition rather than reusing the existing helper, because that one requires the last message to be a fresh user turn and an approval reply never is.

The tool call id is persisted when the gate is raised, so a caller can name the right call. Rows written before this still read back, and the frontend falls back to the old id for them.

Tests

Verified end to end on a running deployment. An approval was answered out of band on a session whose park had already expired, so it took the cold path. The runner rebuilt two prior turns from the record log, recovered the stored decision, allowed the gate, and finished the turn. The file the agent had asked to write appeared on the mount with the approved contents, and the row moved to resolved carrying the real tool call id.

[HITL] cold replay: ... resumeFrame=approval
[reconstruct] session=... records=10 prior=10 priorMessages=2 inbound=1
[HITL] gate toolName="Write" permission=ask outcome=allow
complete OK session=... turn=1

Runner tests 1308 passing, six of them new. API session tests 144 passing, three of them new.

Two reviewers went at this adversarially. Neither could construct a request that gains anything through the loosened checks: the live resume answers the parked gate with the gate's own original arguments and never shows the model the incoming messages, any user text at all re-arms the history comparison, and gate binding stays strict on tool call id.

Follow-ups this does not fix

An answer that matches no parked gate still evicts the live session instead of being refused. That policy is old, but it almost never fired while the playground was the only client answering approvals. A retry or a double click will now hit it. Filed as #5596.

Answering an approval marks the row as used before the resumed run is known to have started, so a failed resume loses the approval with no retry. Filed as #5592.

What to QA

  • Put an agent on a schedule so it stops for approval unattended. Answer the approval outside the playground. The run continues and the tool actually executes.
  • Check the interaction row afterwards. It reads resolved and carries a tool_call_id.
  • Regression: approve and deny in the playground as usual. Both still work, and denying still ends the call as declined rather than hanging.
  • Regression: send an ordinary message to an agent mid-conversation. It continues from the live session rather than rebuilding from scratch.
  • An approval created before this change carries no tool call id. Answering it still falls back to the old identifier.

Closes #5593

@dosubot dosubot Bot added the size:L This PR changes 100-499 lines, ignoring generated files. label Jul 30, 2026
@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agenta-documentation Ready Ready Preview Aug 1, 2026 4:36pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5f0c0bf4-0733-4d25-89e7-6e264cac3e7d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Railway Preview Environment

Status Destroyed (PR closed)

Updated at 2026-08-01T18:13:39.092Z

@mmabrouk
mmabrouk force-pushed the fix/approval-resume branch from 7189a20 to cbd3ed8 Compare July 31, 2026 20:07
@dosubot dosubot Bot removed the size:L This PR changes 100-499 lines, ignoring generated files. label Jul 31, 2026
@mmabrouk
mmabrouk changed the base branch from fix/pi-default-builtins to wp1-api-attachments July 31, 2026 20:07
@dosubot dosubot Bot added size:XXL This PR changes 1000+ lines, ignoring generated files. size:L This PR changes 100-499 lines, ignoring generated files. labels Jul 31, 2026
@mmabrouk

mmabrouk commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Flagging a behavior found during the multi-modality end-to-end QA that looks like it belongs to this lane: answering an approval gate with "Deny & send note" came back to the model as {"status":"not_handled","message":"Not handled by this client."} rather than a denial with the note, so the model kept retrying variants of the same command instead of changing course. Reproduced on the dev stack (session window 2026-08-01, bash approval gates during a zip-reading turn). Worth checking whether the deny-with-note path maps to the right interaction resolution.

@mmabrouk
mmabrouk force-pushed the wp1-api-attachments branch from 19b70b0 to 3e34770 Compare August 1, 2026 11:14
@mmabrouk
mmabrouk force-pushed the fix/approval-resume branch from cbd3ed8 to c794326 Compare August 1, 2026 11:14
@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. and removed size:L This PR changes 100-499 lines, ignoring generated files. size:XXL This PR changes 1000+ lines, ignoring generated files. labels Aug 1, 2026
@mmabrouk
mmabrouk force-pushed the wp1-api-attachments branch from 3e34770 to 973ade3 Compare August 1, 2026 15:14
@mmabrouk
mmabrouk force-pushed the fix/approval-resume branch from c794326 to 76cd766 Compare August 1, 2026 15:14
@mmabrouk

mmabrouk commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

🤖 The AI agent says:

Root cause found, and it is on the frontend rather than in this lane's transport.

The denied part state output-denied was missing from the client-tool dispatcher's settled set. Because the dispatcher did not recognize the part as already settled, an unhandled-tool fallback ran over it and auto-overwrote the denial with not_handled before the next turn shipped. The model therefore saw the fallback status instead of the note the user wrote.

Fixed with tests in PR #5630, stacked on wp4-frontend-transport.

Worth noting that plain deny carried the same latent bug. It only surfaced on a follow-up message, since that is when the dispatcher re-scanned the parts and clobbered the denial.

… the run

Answering a parked approval from anywhere but a live playground session never
resumed anything. Three defects, each reproduced against the runner:

The resumed run was refused for carrying no user message. An approval reply has
no new user text, and the runner rebuilds the prior turns from the record log
only AFTER the plan is built, so the request died before the conversation could
be supplied. The check now spares an approval-reply-only request.

Answering also evicted the live session parked on that gate. The approval branch
compared the conversation the request carries against the parked one and found
nothing, so it treated the transcript as edited. It now has the same
no-history escape hatch the ordinary continuation branch already had.

The durable interaction row stored only the permission gate id, never the tool
call id, so a caller building an answer from the row named the wrong call. The
tool call id is now persisted when the gate is raised; rows written before this
still read back.

Closes #5593
@mmabrouk
mmabrouk force-pushed the wp1-api-attachments branch from 973ade3 to 97d4367 Compare August 1, 2026 16:35
@mmabrouk
mmabrouk force-pushed the fix/approval-resume branch from 76cd766 to f9f3de9 Compare August 1, 2026 16:35
@mmabrouk
mmabrouk changed the base branch from wp1-api-attachments to release/v0.107.0 August 1, 2026 18:12
@mmabrouk
mmabrouk merged commit d658c4f into release/v0.107.0 Aug 1, 2026
40 of 41 checks passed
@mmabrouk
mmabrouk deleted the fix/approval-resume branch August 1, 2026 18:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant