Per-invocation isolation: concurrent invoke() on one assistant (WorkflowRun) - #98
Merged
Merged
Conversation
…lowRun) One EventDrivenWorkflow instance can now handle many invoke() calls concurrently. Previously all runtime state (quiescence tracker, ready-queue, stop flag, topic queues) lived on the shared definition and was reset on every invoke, so two concurrent invocations corrupted each other -- the second reset the first mid-flight and a single stop() halted both (Defect #3). Introduce WorkflowRun, a per-invocation object that owns all mutable runtime state. The definition is read-only during a run; each invoke() allocates a WorkflowRun with its own tracker, ready-queue, stop flag, and per-run topic queues (shallow topic copies with fresh queues, preserving queue kind). Tools, LLM clients, and topology are shared by reference -- no graph cloning. - event_driven_workflow.py: slim immutable definition/facade; invoke() runs on a WorkflowRun tracked in _active_runs; stop() forwards to in-flight runs. - workflow_run.py: WorkflowRun state + shared topology/commit/progress helpers. - run_recovery.py / sequential_engine.py / parallel_engine.py: execution split into focused modules (free functions taking the run; WorkflowRun referenced only under TYPE_CHECKING, so no import cycle). - utils.py: publish_events/get_node_input route I/O through the run's topics. Tests: - tests/workflow/test_invocation_isolation.py: concurrent invokes are isolated (parallel + sequential); fails on the pre-change runtime. - tests/workflow/test_workflow_run.py: 22 unit tests for WorkflowRun internals. - tests_integration: 3 concurrent invoke() examples (parallel, sequential, and a 3-node function-call workflow), plus .env.example. Verified against real providers. - Retargeted 4 coupled unit tests from definition-private fields to WorkflowRun. 692 unit tests pass; mypy grafi clean; pre-commit (black/isort/flake8) clean; full integration suite green (54 passed / 4 skipped). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
One
EventDrivenWorkflowinstance can now handle manyinvoke()calls concurrently.Before this change, all runtime state — the quiescence tracker, the ready-queue, the stop flag, and the topic queues — lived on the shared workflow definition and was
reset()at the start of everyinvoke(). Two concurrent invocations on the same instance therefore corrupted each other: the second invoke's reset drained the first's queues mid-flight, and a singlestop()halted both. This is Defect #3 / runtime Gap 00 from the design notes.Approach — "share by reference, isolate by allocation"
A new
WorkflowRunowns everything that mutates during one invocation. The definition stays read-only during a run; eachinvoke()allocates aWorkflowRunwith its own tracker, ready-queue, stop flag, and per-run topic queues (shallow topic copies with fresh queues, so the queue kind is preserved). Tools, LLM clients, and topology are shared by reference — there is no cloning of the node/tool graph, so concurrency stays cheap (allocation isO(nodes + topics)).Changes
event_driven_workflow.pyinvoke()runs on aWorkflowRunregistered in_active_runs;stop()forwards to in-flight runs.workflow_run.py(new)WorkflowRunstate + shared topology/quiescence/commit helpers + thininit/rundispatch.run_recovery.py(new)init_run).parallel_engine.py/sequential_engine.py(new)run(WorkflowRunreferenced only underTYPE_CHECKING→ no import cycle).utils.pypublish_events/get_node_inputroute I/O through the run's topics-by-name.Tests
tests/workflow/test_invocation_isolation.py— the characterization test: concurrent invokes on one instance complete independently (parallel + sequential). Fails on the pre-change runtime.tests/workflow/test_workflow_run.py— 22 unit tests forWorkflowRuninternals (isolation invariants, helpers, error wrapping, stop-forwarding, recovery seeding).tests_integration/— 3 concurrentinvoke()examples (parallel single-node, sequential single-node, 3-node function-call workflow), each asserting per-request isolation with no cross-talk. Verified against real providers..env.example— documents required API keys for the integration tests (read viadotenv).WorkflowRun.Verification
mypy graficleanNotes / scope
Assistant,EventDrivenWorkflow,Node, builders) are unchanged.simple_function_call_assistant_async_example.pyis a pre-existing flaky integration test (asserts a literal"bad"substring on free-form LLM output); its event-count accounting is unchanged by this PR (stable at 24). Not modified here.🤖 Generated with Claude Code