Skip to content

Durable orchestrators: a pure-Python run() cannot resume across a process boundary πŸ€–πŸ€–πŸ€–Β #144

Description

@shsagnik

What I was doing

Exercising the persistence API to see whether a NOOA agent could drive a long-running workflow that pauses for a human β€” an operator supplying a value partway through β€” and then finishes later, possibly in a different process.

Two things came out of that, one small and one structural. The small one I've already opened a PR for (#139, the StorageManager docstring documents agent.save() / MyAgent.load(...), neither of which exists). This issue is about the structural one, which I'd rather ask about than assume.

The gap

AGENTS.md:32 is explicit that orchestrators are pure Python:

Orchestrators are pure Python. Workflow sequence methods have real bodies (no ...), calling generation methods for each step. If a class has no ... methods it doesn't need to subclass Agent at all.

That works well, but a Python method's progress lives on the call stack, and nothing captures the stack:

  • AgentSnapshot.from_agent walks agent.__dict__ (src/nooa/storage/snapshot.py:100) β€” it captures attributes, not the frame, the local variables, or the program counter.
  • CodeActStrategy runs a live in-process loop (while not session.is_exhausted(), src/nooa/strategies/codeact.py:827) with no suspension point.
  • Grepping src/nooa/ for suspend / pause / resume_from finds nothing.

So an orchestrator that is three steps in and waiting on a human cannot be resumed after the process exits. InteractiveAgent (src/nooa/interactive.py:310) does give durable progress between turns, but that's a chat-shaped loop β€” it doesn't help a run() method that is halfway down its own body.

The workaround, and why I'm not sure it's the intended one

Keep the orchestrator's progress in a snapshotted field rather than in locals, so re-entering the method after restore_snapshot() skips whatever is already done:

class PipelineAgent(Agent, llm=llm):
    steps_done: dict[str, str]      # snapshotted β€” this is the resumable part
    inputs: dict[str, str]

    async def run(self, payload: str) -> str:
        if "summary" not in self.steps_done:
            self.steps_done["summary"] = await self.summarise(payload)
        if "threshold" not in self.inputs:
            raise SuspendForInput("what risk threshold should apply?")
        ...

This works today with no library changes. I've put a runnable version up as #140 β€” it runs a step, suspends, snapshots, re-launches itself as a genuinely separate process, restores, and finishes. The orchestrator prints which steps it actually executes, so the child visibly runs only the remaining one; the earlier step comes back from the snapshot rather than being repeated.

But every user who wants this has to independently invent the ledger, remember that locals aren't durable, and get the re-entrancy right. That felt like something worth asking about rather than quietly working around.

The question

Is the field-ledger pattern the intended answer, or is durable orchestration something the framework should carry?

Concretely, any of these is a useful reply:

  1. "That's the pattern" β€” then examples: durable human-in-the-loop suspend and resume πŸ€–πŸ€–πŸ€–Β #140 is probably the whole fix, and it just needs documenting.
  2. "feat: add shared interactive session foundationΒ #82 covers this" β€” feat: add shared interactive session foundation adds durable sessions with persistence and resumption. It touches sqlite.py rather than the orchestrator layer, so my read is that they're complementary, but you'd know better. If feat: add shared interactive session foundationΒ #82 is heading somewhere that makes examples: durable human-in-the-loop suspend and resume πŸ€–πŸ€–πŸ€–Β #140 redundant, I'd rather hear it now than have you carry a stale example.
  3. "In scope, we'd take a PR" β€” happy to work it up, in whatever shape you prefer (an optional workspace package alongside nooa-memory / nooa-bench, or something smaller). I'd want a design agreed here first rather than arriving with a large PR.
  4. "Out of scope" β€” equally useful, and examples: durable human-in-the-loop suspend and resume πŸ€–πŸ€–πŸ€–Β #140 can stand alone as an example or be closed.

No urgency on my end. Mostly I wanted the gap written down somewhere, since I couldn't find it discussed in any existing issue or PR.

Metadata

Metadata

Assignees

Labels

durable executionFigure out how to do per-turn durable execution in nooa

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions