Skip to content

deck: two approval inboxes — tick/due_resumes read the checkpointer, pending() reads the log #120

Description

@sagi5060

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 HTTP
async def pending_interrupts(self, name: str | None = None) -> list[InterruptResult]:
    pending = await self.runtime.pending(run_context())
    return [_as_interrupt_result(run) for run in pending if name is None or run.invocable == name]

async def resume_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.
  • Out of scope: 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

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.
  • 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:surfacesTouches surfaceschoreMaintenance, CI, cleanup

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions