You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The half of #102 that PR #114 deliberately left, with the reason it left it.
Problem
There are two approval inboxes, and they answer differently.
PR #114 (issue #102) moved GET /workflows/{name}/pending and POST /workflows/{name}/{thread_id}/resume onto Runtime.pending() / Runtime.resume(), which project the event log. App.pending_interrupts(), App.due_resumes() and App.tick() still read the langgraph checkpointer through BaseWorkflow.pending(), and App.resume_workflow() still answers an interrupt through v1's runner without telling the log anything. Before #114 both read the checkpointer and agreed.
Two measured failures, both reachable by a deployment that uses HTTP for the UI and the Python API for a cron:
An interrupt created headlessly (await app.run_workflow("ApprovalFlow", state, thread_id="t")) is invisible to HTTP: GET /pending does not list it, and resuming it is a 404. The approval exists and nothing can answer it over HTTP.
An interrupt created over HTTP and then answered headlessly (await app.resume_workflow(...)) leaves a ghost in the HTTP listing: GET /pending still shows the thread, because the log's run was never closed. The ghost also holds the thread's session claim, so a new HTTP run on that thread answers 409 until stale_run_after expires.
PR #114 took the minimum fix for the second one — a resume whose graph ran nothing answers 404 instead of the stale final state a replayed thread hands back while silently discarding the caller's value — and stated the split in the CHANGELOG as a known limitation. It did not join the two inboxes, which is this issue.
Proposed shape
One inbox: the event log. The Python API's four inbox entry points go through the Runtime the same way agentdeck/serve.py already does, so the ghost cannot form and a headless pause is visible everywhere.
# agentdeck/app.py — the same three calls serve.py makes, minus the HTTPasyncdefpending_interrupts(self, name: str|None=None) ->list[InterruptResult]:
pending=awaitself.runtime.pending(run_context())
return [_as_interrupt_result(run) forruninpendingifnameisNoneorrun.invocable==name]
asyncdefresume_workflow(self, name: str, thread_id: str, value: Any) ->Any:
... # Runtime.resume under resume_context(paused), the paused run found as serve.py finds it
App.due_resumes() and App.tick() keep their current shape — they are already filtered views over pending_interrupts() and a resume_workflow() loop, so they follow for free once those two move.
Reuse agentdeck/surfaces/serve/compat.py's resume_context, resume_result and the PendingRun -> {"type","payload","thread_id"} projection rather than writing a second one. If that projection has to be shared with app.py, move it somewhere both may import — a surface importing app.py or the reverse is not it.
InterruptResult stays exactly what it is ({"type": "interrupt", "payload": ..., "thread_id": ...}): this changes where the listing comes from, not what a caller sees.
BaseWorkflow.pending() stays — it is the checkpointer's own view and tests/test_workflow_interrupts.py pins it — but nothing on the App path calls it any more.
App.resume_workflow has to move together with pending_interrupts. Leaving it on v1's runner is precisely what creates the ghost, so a change that only rewrites the listing fixes nothing.
run_context() in compat.py is v1's one-tenant context; App needs the same one, which is another argument for that projection living somewhere shared.
agentdeck/serve.py's resume handler is the reference implementation, including the "this caller's answer changed nothing" -> 404 rule; the Python API's equivalent is raising rather than returning a stale state.
Done when
An interrupt parked through App.run_workflow(..., thread_id=...) is listed by GET /workflows/{name}/pending and is resumable over HTTP.
An interrupt parked over HTTP and answered by App.resume_workflow(...) leaves GET /workflows/{name}/pending empty, and a new HTTP run on that thread is not a 409.
App.pending_interrupts() lists an interrupt parked over HTTP, with the same {"type","payload","thread_id"} shape it returns today.
App.due_resumes() and App.tick() still resume only threads whose sleep_until has passed, now sourced from the log (tests/test_workflow_timers.py green unchanged).
App.resume_workflow() on a thread with no paused run raises rather than returning a stale final state.
The half of #102 that PR #114 deliberately left, with the reason it left it.
Problem
There are two approval inboxes, and they answer differently.
PR #114 (issue #102) moved
GET /workflows/{name}/pendingandPOST /workflows/{name}/{thread_id}/resumeontoRuntime.pending()/Runtime.resume(), which project the event log.App.pending_interrupts(),App.due_resumes()andApp.tick()still read the langgraph checkpointer throughBaseWorkflow.pending(), andApp.resume_workflow()still answers an interrupt through v1's runner without telling the log anything. Before #114 both read the checkpointer and agreed.Two measured failures, both reachable by a deployment that uses HTTP for the UI and the Python API for a cron:
await app.run_workflow("ApprovalFlow", state, thread_id="t")) is invisible to HTTP:GET /pendingdoes not list it, and resuming it is a 404. The approval exists and nothing can answer it over HTTP.await app.resume_workflow(...)) leaves a ghost in the HTTP listing:GET /pendingstill shows the thread, because the log's run was never closed. The ghost also holds the thread's session claim, so a new HTTP run on that thread answers 409 untilstale_run_afterexpires.PR #114 took the minimum fix for the second one — a resume whose graph ran nothing answers 404 instead of the stale final state a replayed thread hands back while silently discarding the caller's
value— and stated the split in the CHANGELOG as a known limitation. It did not join the two inboxes, which is this issue.Proposed shape
One inbox: the event log. The Python API's four inbox entry points go through the Runtime the same way
agentdeck/serve.pyalready does, so the ghost cannot form and a headless pause is visible everywhere.App.due_resumes()andApp.tick()keep their current shape — they are already filtered views overpending_interrupts()and aresume_workflow()loop, so they follow for free once those two move.agentdeck/surfaces/serve/compat.py'sresume_context,resume_resultand thePendingRun->{"type","payload","thread_id"}projection rather than writing a second one. If that projection has to be shared withapp.py, move it somewhere both may import — a surface importingapp.pyor the reverse is not it.InterruptResultstays exactly what it is ({"type": "interrupt", "payload": ..., "thread_id": ...}): this changes where the listing comes from, not what a caller sees.BaseWorkflow.pending()stays — it is the checkpointer's own view andtests/test_workflow_interrupts.pypins it — but nothing on theApppath calls it any more.App.run_workflow()/run_workflow_stream()(the whole v1 workflow-running path, cleanup: remove legacy, dead code, and redundant files (Wave 6 gate) #71's business), and any change to the HTTP wire.Notes
App.resume_workflowhas to move together withpending_interrupts. Leaving it on v1's runner is precisely what creates the ghost, so a change that only rewrites the listing fixes nothing.run_context()incompat.pyis v1's one-tenant context;Appneeds the same one, which is another argument for that projection living somewhere shared.BaseWorkflow.pending()'s only remaining caller is its own test, which is exactly the kind of thing cleanup: remove legacy, dead code, and redundant files (Wave 6 gate) #71 exists to notice.agentdeck/serve.py's resume handler is the reference implementation, including the "this caller's answer changed nothing" -> 404 rule; the Python API's equivalent is raising rather than returning a stale state.Done when
App.run_workflow(..., thread_id=...)is listed byGET /workflows/{name}/pendingand is resumable over HTTP.App.resume_workflow(...)leavesGET /workflows/{name}/pendingempty, and a new HTTP run on that thread is not a 409.App.pending_interrupts()lists an interrupt parked over HTTP, with the same{"type","payload","thread_id"}shape it returns today.App.due_resumes()andApp.tick()still resume only threads whosesleep_untilhas passed, now sourced from the log (tests/test_workflow_timers.pygreen unchanged).App.resume_workflow()on a thread with no paused run raises rather than returning a stale final state.tests/golden/snapshots/untouched; the v1 suite green; CHANGELOG entry replacing feat(serve): run the workflow endpoints on the Runtime #114's "two separate sources of truth" note.