Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
177 commits
Select commit Hold shift + click to select a range
cfdd57e
chore(nodes): remove unused import
senamakel Aug 13, 2026
dcfeacb
chore(nodes): remove unused import
senamakel Aug 13, 2026
5bd157a
chore(nodes): remove unused import
senamakel Aug 13, 2026
37fee9e
chore(engine): remove unused import
senamakel Aug 13, 2026
4d0e7e7
chore(engine): remove unused import
senamakel Aug 13, 2026
416163b
chore(engine): remove unused import
senamakel Aug 13, 2026
604187a
chore(engine): remove unused import
senamakel Aug 13, 2026
0477f0e
chore(engine): remove unused import
senamakel Aug 13, 2026
33f6d41
fix(nodes): add lane and step fields to test execution contexts
senamakel Aug 13, 2026
83ebb40
chore(engine): remove unused import
senamakel Aug 13, 2026
ebf5425
chore(tests): add support module for shared test utilities
senamakel Aug 13, 2026
531b37f
chore(tests): add support module for test generation
senamakel Aug 13, 2026
37908a7
chore(tests): add support module for test generation
senamakel Aug 13, 2026
8212267
chore: add fuzz test for graph module
senamakel Aug 13, 2026
146c95a
chore(tests): rename support module gen to graphgen
senamakel Aug 13, 2026
65eaf1a
chore(tests): add diag_gen test file
senamakel Aug 13, 2026
6f97e5e
test(diag_gen): import ValueTree for proptest usage
senamakel Aug 13, 2026
1e19f95
test: remove obsolete diagnostic generator test
senamakel Aug 13, 2026
bcf0837
chore: add fuzz test for graph module
senamakel Aug 13, 2026
869750f
chore(tests): add graph generation support for tests
senamakel Aug 13, 2026
09fc233
chore(tests): add graph generation support for tests
senamakel Aug 13, 2026
0d2b90e
chore(tests): add graph generation support
senamakel Aug 13, 2026
52bbd06
chore(tests): add graph generation support for tests
senamakel Aug 13, 2026
4115bb4
chore(tests): add graph generation support
senamakel Aug 13, 2026
657d03d
chore(tests): add graph generation support for tests
senamakel Aug 13, 2026
a0add93
fix(test): add fuzz test for resume functionality
senamakel Aug 13, 2026
3c204fe
chore: add fuzz regression for resume
senamakel Aug 13, 2026
c7efff7
fix(test): add fuzz test for resume functionality
senamakel Aug 13, 2026
55ef5d5
fix(test): add fuzz test for resume functionality
senamakel Aug 13, 2026
a006a46
fix(test): add fuzz test for resume functionality
senamakel Aug 13, 2026
8424f29
chore(tests): remove stale proptest regression seeds
senamakel Aug 13, 2026
f63078b
fix(test): add fuzz test for resume functionality
senamakel Aug 13, 2026
f080679
chore(tests): simplify formatting in fuzz and graphgen tests
senamakel Aug 13, 2026
433e37e
fix(routing): restore fallback for missing compiled routes
senamakel Aug 13, 2026
30efa0f
fix(validate): restore missing null check in validator
senamakel Aug 13, 2026
3602d36
fix(tests): add loop e2e test
senamakel Aug 13, 2026
bb5471a
fix(tests): add loop e2e test
senamakel Aug 13, 2026
ef4f097
fix(validate): restore missing null check in validator
senamakel Aug 13, 2026
f914eee
test(loop): cover diamond and off-cycle merge inside loop body
senamakel Aug 13, 2026
21242e4
test: add diamond dependency debug test
senamakel Aug 13, 2026
0c84eda
test(dbg_diamond): add max_node_visits to trigger config
senamakel Aug 13, 2026
1cc722d
test(dbg_diamond): add execution trace observer
senamakel Aug 13, 2026
ed3d9cd
fix(validate): restore missing null check in validator
senamakel Aug 13, 2026
4f650df
test(loop): refuse diamond inside loop body
senamakel Aug 13, 2026
06eeb18
test(loop_e2e): format node definitions for readability
senamakel Aug 13, 2026
d4ba001
chore: files changed src/graph/builder/types.rs
senamakel Aug 13, 2026
3f8cf33
fix(graph): restore missing edge deduplication
senamakel Aug 13, 2026
76c40d7
fix(routing): restore fallback for missing compiled routes
senamakel Aug 13, 2026
213797e
chore(engine): remove unused import
senamakel Aug 13, 2026
e0e5ee1
fix(validate): restore missing null check in validator
senamakel Aug 13, 2026
e158862
fix(tests): update diamond-in-loop test to assert iteration ordering
senamakel Aug 13, 2026
d37d03c
test: add diamond dependency debug test
senamakel Aug 13, 2026
625cf73
fix(routing): restore fallback for missing compiled routes
senamakel Aug 13, 2026
6c86cd7
fix(routing): restore fallback for missing compiled routes
senamakel Aug 13, 2026
895063b
chore: reformat code and remove debug test
senamakel Aug 13, 2026
fd8362a
test(eng5): add regression for conditional predecessor behind fan-out
senamakel Aug 13, 2026
398a9ad
fix(routing): disable unconditional fan-out path
senamakel Aug 13, 2026
700bf1e
fix(routing): enable unconditional fan-out routing
senamakel Aug 13, 2026
b610e41
chore(graph): update compiled module
senamakel Aug 13, 2026
ab257f3
fix(executor): collect all interrupts in parallel step fold
senamakel Aug 13, 2026
f8cfee2
fix(executor): restore missing graph node validation
senamakel Aug 13, 2026
30be8bf
fix(executor): restore missing graph node validation
senamakel Aug 13, 2026
020d725
fix(executor): restore missing graph node validation
senamakel Aug 13, 2026
1b1fc21
fix(executor): persist all pending interrupts on interruption
senamakel Aug 13, 2026
cd0ca28
fix(executor): restore missing graph node execution
senamakel Aug 13, 2026
88425a4
fix(executor): pass active by reference to partition_completed
senamakel Aug 13, 2026
dae1c2d
chore(graph): remove unused compiled module
senamakel Aug 13, 2026
e6e592c
feat(executor): track completed step indices
senamakel Aug 13, 2026
261f240
fix(executor): restore missing graph node validation
senamakel Aug 13, 2026
8c911a8
fix(executor): restore missing graph node validation
senamakel Aug 13, 2026
989ff72
fix(executor): restore missing graph node validation
senamakel Aug 13, 2026
b92e805
test(graph): correct pending-send expectations for parallel execution
senamakel Aug 13, 2026
b292870
test(engine): expect both parallel gates to pend together
senamakel Aug 13, 2026
1add63c
test(hitl_e2e): add regression test for sibling branch re-run on resume
senamakel Aug 13, 2026
a254a04
test(hitl_e2e): update mock invoke signature to match trait
senamakel Aug 13, 2026
e1e7c44
fix(executor): stop interrupt search after first match
senamakel Aug 13, 2026
63d6d23
fix(executor): continue scanning for interrupts after first hit
senamakel Aug 13, 2026
01819f4
feat(graph): add per-node concurrency limit
senamakel Aug 13, 2026
d1f316b
feat(graph): add per-node concurrency limits
senamakel Aug 13, 2026
4e4a62c
feat(graph): add per-node concurrency limits
senamakel Aug 13, 2026
60f3f4f
fix(executor): restore missing graph node validation
senamakel Aug 13, 2026
1e805f4
test(graph): add test for per-node concurrency cap
senamakel Aug 13, 2026
2f7bdfd
fix(test): update graph input constructors in cap bounds test
senamakel Aug 13, 2026
5466e80
chore(graph): reformat executor conditionals
senamakel Aug 13, 2026
3312c58
chore(integration): update sub-workflow node handling
senamakel Aug 13, 2026
0c7b632
chore(integration): update sub-workflow node handling
senamakel Aug 13, 2026
41481f9
chore(integration): update sub-workflow node handling
senamakel Aug 13, 2026
83aa4d1
chore(integration): add json macro import to sub_workflow
senamakel Aug 13, 2026
4c80417
fix(sub-workflow): distinguish child pause from completion in run result
senamakel Aug 13, 2026
9613d3b
refactor(integration): return ChildOutcome from run_child
senamakel Aug 13, 2026
66ede24
feat(nodes): propagate child approval pauses to the parent workflow
senamakel Aug 13, 2026
3e0245d
refactor(sub_workflow): remove redundant pause logging
senamakel Aug 13, 2026
1da618b
test(hitl_e2e): cover child gate pausing parent
senamakel Aug 13, 2026
fbca0a2
fix(engine): key pending approvals by interrupt id
senamakel Aug 13, 2026
6d0ecf3
feat(nodes): add resume value to node context
senamakel Aug 13, 2026
3a4c34b
fix(integration): read child approvals from both resume paths
senamakel Aug 13, 2026
57a9687
chore(sub_workflow): add debug output to approvals_for_child
senamakel Aug 13, 2026
52e2289
feat(engine): add explicit run-level approvals channel
senamakel Aug 13, 2026
cac29a7
fix(engine): destructure approvals from run input
senamakel Aug 13, 2026
51e44ec
fix(engine): preserve prior approvals when merging
senamakel Aug 13, 2026
cde1b38
fix(engine): carry forward explicit approvals on resume
senamakel Aug 13, 2026
9e7b6a8
chore: files changed src/engine.rs,src/nodes/integration/sub_workflow.rs
senamakel Aug 13, 2026
cc3c664
chore(sub_workflow): format child outcome construction
senamakel Aug 13, 2026
5ff9f41
test(hitl_e2e): add test for per_item fan-out reporting all paused ch…
senamakel Aug 13, 2026
2d20e09
feat(map): add run-level per-item concurrency cap
senamakel Aug 13, 2026
423c8a4
fix(nodes): update map_options test call signature
senamakel Aug 13, 2026
0a510e2
feat(engine): clamp trigger max_concurrency to a ceiling
senamakel Aug 13, 2026
49b05f7
chore(engine): apply max concurrency limit to graph builder
senamakel Aug 13, 2026
95ab63a
feat(catalog): add max_concurrency and max_item_concurrency config fi…
senamakel Aug 13, 2026
a7e011d
test(map): cover run-level concurrency cap behaviour
senamakel Aug 13, 2026
73c2e2c
test: verify max_concurrency bounds parallel branch execution
senamakel Aug 13, 2026
291072f
chore(nodes): reformat map node code
senamakel Aug 13, 2026
fb0202c
fix(nodes): remove duplicate test attribute
senamakel Aug 13, 2026
64ad06c
fix(release): restore release notes for patch releases
senamakel Aug 13, 2026
4d2aa44
chore(nodes): register release module
senamakel Aug 13, 2026
9b71855
chore(caps): update task list
senamakel Aug 13, 2026
0891390
feat(caps): add task runner capability for spawn/gate nodes
senamakel Aug 13, 2026
bbdce9e
feat(caps): wire default task runner in mock capabilities
senamakel Aug 13, 2026
2db39bf
chore: add tasks capability to stub binary
senamakel Aug 13, 2026
ff46d74
feat(model): add Spawn and Gate node kinds
senamakel Aug 13, 2026
2077d5f
chore(nodes): update spawn integration docs
senamakel Aug 13, 2026
94e25e3
fix(integration): restore missing gate node fields
senamakel Aug 13, 2026
ab0c24d
feat(nodes): add gate and spawn node executors
senamakel Aug 13, 2026
51aba3f
refactor(integration): rename with_paired_item to paired_with
senamakel Aug 13, 2026
8d33342
fix(catalog): update NODE_KINDS array size and add two new node kinds
senamakel Aug 13, 2026
ce4c88f
feat(catalog): add spawn and gate node kind contracts
senamakel Aug 13, 2026
d4ed982
feat(catalog): add descriptions for spawn and gate contracts
senamakel Aug 13, 2026
99f8d7c
test(catalog): update node kind count for async pair
senamakel Aug 13, 2026
734c104
fix(tests): add async gate end-to-end coverage
senamakel Aug 13, 2026
1384c98
chore: format code with rustfmt
senamakel Aug 13, 2026
85db7fd
refactor(gate): drop unused awaiting parameter from emit_items
senamakel Aug 13, 2026
e763147
refactor(gate): simplify timeout branch formatting and update test docs
senamakel Aug 13, 2026
9e7f7b2
test(smoke): add smoke tests for spawn and gate nodes
senamakel Aug 13, 2026
b81fedc
feat(tests): add spawned and nested shapes to graph generator
senamakel Aug 13, 2026
8ae75c8
test(support): add spawned shapes to the test strategy and a dedicate…
senamakel Aug 13, 2026
6546885
fix(tests): add async fuzz test for concurrent state handling
senamakel Aug 13, 2026
2ce046e
test: drop redundant release-rule property test
senamakel Aug 13, 2026
a649b3a
feat(engine): add $replace sentinel for wholesale object assignment
senamakel Aug 13, 2026
3240b1a
feat(loop): fold body state before until check
senamakel Aug 13, 2026
291580e
feat(loop_node): add accumulator state and emit modes to loop node
senamakel Aug 13, 2026
8af7f9f
feat(nodes): promote all node meta keys into scope
senamakel Aug 13, 2026
1d44b0a
test(loop_node): check iteration field instead of full meta object
senamakel Aug 13, 2026
404fea6
style: format long assert_eq calls in loop node tests
senamakel Aug 13, 2026
4e67ead
test(engine): add merge sentinel tests
senamakel Aug 13, 2026
31bad6b
test(loop): add end-to-end coverage for loop accumulator and exit sem…
senamakel Aug 13, 2026
ac4806c
test(loop_e2e): update expression syntax in loop tests
senamakel Aug 13, 2026
b663450
fix(validate): accept max_node_visits as a loop bound
senamakel Aug 13, 2026
10e35ce
test(loop): cover cycle bounds and loop config validation
senamakel Aug 13, 2026
915ac6c
feat(catalog): document loop state, until, and success port
senamakel Aug 13, 2026
00b07f0
fix(loop): document accumulator semantics in run state
senamakel Aug 13, 2026
8725356
feat(nodes): add scatter control for parallel downstream lanes
senamakel Aug 13, 2026
9a4ff1c
feat(engine): add lane activation state update helpers
senamakel Aug 13, 2026
a86625e
fix(engine): use lane argument for lane activations
senamakel Aug 13, 2026
18ad61d
feat(engine): route lane activations through sends until gather
senamakel Aug 13, 2026
22647e9
fix(engine): pass routed items into lane envelope construction
senamakel Aug 13, 2026
8c373ef
fix(engine): pass emitted items to error and continue handlers
senamakel Aug 13, 2026
821efa9
chore(engine): add lane routing metadata for plain and gather successors
senamakel Aug 13, 2026
ce3bfb7
chore(engine): add RouteTarget to imports
senamakel Aug 13, 2026
183355a
feat(graph): add route command and scatter/gather node kinds
senamakel Aug 13, 2026
0eed9d4
feat(engine): support scatter routing with per-lane sends
senamakel Aug 13, 2026
aace2f4
fix(engine): tolerate missing lane envelope in graph build
senamakel Aug 13, 2026
c261dd1
fix(control_flow): restore scatter node output ordering
senamakel Aug 13, 2026
83e3396
fix(control_flow): restore gather node output ordering
senamakel Aug 13, 2026
1fd2f93
chore(control_flow): register gather and scatter modules
senamakel Aug 13, 2026
6205f8a
feat(control_flow): export Gather and Scatter nodes
senamakel Aug 13, 2026
3ecb4a7
fix(engine): use from_ref for single-item update
senamakel Aug 13, 2026
1e8fc5f
chore(tests): add scatter/gather end-to-end test coverage
senamakel Aug 13, 2026
f06f7c8
fix(engine): stop lane activations from stamping the node's slot
senamakel Aug 13, 2026
cac3d91
refactor(engine): use graph result type for lane serialization
senamakel Aug 13, 2026
6c6d27f
feat(validate): add structural checks for scatter/gather regions
senamakel Aug 13, 2026
6a83e4c
test(scatter_gather): add validation tests for lane region invariants
senamakel Aug 13, 2026
550f4cd
fix(validate): detect stranded lane members by reachability
senamakel Aug 13, 2026
2d2c9f4
feat(catalog): add scatter and gather node kind contracts
senamakel Aug 13, 2026
504037a
fix(control-flow): add smoke test for scatter-gather pair
senamakel Aug 13, 2026
a503ef0
fix(scatter): keep lane_count in node slot for gather
senamakel Aug 13, 2026
88c0c36
Merge remote-tracking branch 'origin/main' into parallel-gates-loops
senamakel Aug 13, 2026
4611964
chore(visualization): remove unused import
senamakel Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/caps/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,9 @@ pub fn mock_capabilities_with_resolver(resolver: impl WorkflowResolver + 'static
agent: None,
// Wired by default (unlike `agent`) — see the doc comment above.
memory: Some(Arc::new(MockMemory)),
// The real tokio-backed runner, so `spawn`/`gate` behave under test the
// way they behave for a host that wires nothing.
tasks: Some(Arc::new(crate::caps::TokioTaskRunner::new())),
}
}

Expand Down
12 changes: 12 additions & 0 deletions src/caps/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub mod host;
#[cfg(any(test, feature = "mock"))]
pub mod mock;
pub mod shell;
pub mod tasks;

use std::sync::Arc;

Expand All @@ -24,6 +25,7 @@ pub use self::agent::{
AgentRunner, AgentUsage, ContextBlock, StopReason, ToolDescriptor,
};
pub use self::shell::{ShellInterpreter, ShellOutcome, ShellRequest, ShellRunner, ShellScript};
pub use self::tasks::{TaskRunner, TaskSpec, TaskState, TokioTaskRunner};

/// A chat / LLM provider used by `agent` and `output_parser` nodes.
#[async_trait]
Expand Down Expand Up @@ -235,6 +237,16 @@ pub struct Capabilities {
/// read/write memory call). See [`MemoryProvider`] for the `scope`
/// contract and the `remember`/`forget` write restriction.
pub memory: Option<Arc<dyn MemoryProvider>>,
/// Runner for background work a `spawn` node starts and a `gate` node
/// collects.
///
/// Defaults to [`TokioTaskRunner`], so spawn/gate genuinely overlap without
/// a host wiring anything. `None` makes `spawn` run its work **inline** and
/// hand back a ticket that is already settled: such a graph still produces
/// the right answer, it just loses the overlap. That is a performance cliff
/// rather than a correctness one, which is exactly why it is worth saying
/// out loud here and in the node catalog.
pub tasks: Option<Arc<dyn TaskRunner>>,
}

#[cfg(test)]
Expand Down
300 changes: 300 additions & 0 deletions src/caps/tasks.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
//! Background work a run starts but does not wait for: the [`TaskRunner`]
//! capability and the tokio-backed implementation the crate ships by default.
//!
//! # Why this is a capability at all
//!
//! A `spawn` node starts work and hands back a ticket instead of a result, so
//! the run can carry on and a later `gate` can collect it. Something has to own
//! that work while the run is elsewhere, and the engine cannot: it drives the
//! graph in super-steps, and a super-step ends when its branches resolve.
//!
//! Making it a trait keeps the ownership question the host's to answer — a host
//! with its own scheduler, or one that wants tasks to outlive the process, plugs
//! in there. [`TokioTaskRunner`] is the answer for everyone else, and is wired
//! in by default so `spawn`/`gate` genuinely overlap out of the box rather than
//! quietly degrading to inline execution.

use std::collections::HashMap;
use std::sync::{Arc, Mutex};

use async_trait::async_trait;
use serde_json::Value;

use crate::error::{EngineError, Result};

/// What a `spawn` node asks the host to start.
///
/// Deliberately a closed set of *shapes* rather than an arbitrary closure: a
/// workflow is declarative, so what it can start has to be describable in JSON.
#[derive(Debug, Clone, PartialEq)]
pub enum TaskSpec {
/// Run a workflow. The engine fills this in for a `sub_workflow` node in
/// spawn mode; the payload is the child's trigger input.
Workflow {
/// The child graph, as the wire-format JSON a `WorkflowGraph`
/// deserializes from.
graph: Value,
/// The child's trigger payload.
input: Value,
},
/// Invoke a host tool, the same slug/args a `tool_call` node would use.
Tool {
/// Host-resolved tool identifier.
slug: String,
/// Arguments for the call.
args: Value,
},
/// Perform an HTTP request, the same shape an `http_request` node would use.
Http {
/// The request description.
request: Value,
},
}

/// Where a started task has got to.
#[derive(Debug, Clone, PartialEq)]
pub enum TaskState {
/// Accepted but not started yet.
Pending,
/// Started and still going.
Running,
/// Finished successfully, with this result.
Done(Value),
/// Finished unsuccessfully, with this message.
Failed(String),
}

impl TaskState {
/// Whether this task will not change again.
#[must_use]
pub fn is_settled(&self) -> bool {
matches!(self, Self::Done(_) | Self::Failed(_))
}
}

/// Starts work that outlives the super-step that asked for it.
///
/// Implementations must be safe to poll concurrently and repeatedly: a `gate`
/// polls every ticket it is waiting on once per activation, and a run that is
/// checkpointed and resumed polls tickets it started before the pause.
#[async_trait]
pub trait TaskRunner: Send + Sync {
/// Starts `spec` and returns a ticket identifying it.
///
/// # Errors
/// Returns an error if the work could not be started at all. Work that
/// starts and then fails is reported through [`TaskState::Failed`] instead,
/// so a gate can route it rather than the run aborting at the spawn.
async fn start(&self, spec: TaskSpec) -> Result<String>;

/// Reports where `ticket` has got to.
///
/// # Errors
/// Returns an error only if the ticket is unknown — a ticket this runner
/// never issued, or one whose record the host has since discarded.
async fn poll(&self, ticket: &str) -> Result<TaskState>;

/// Asks for `ticket` to stop. Best effort, and a no-op for a task that has
/// already settled.
///
/// # Errors
/// Returns an error if the ticket is unknown.
async fn cancel(&self, ticket: &str) -> Result<()>;
}

/// A [`TaskRunner`] backed by `tokio::spawn`.
///
/// The default, and the reason `spawn`/`gate` overlap without a host writing
/// anything. Tasks live for as long as the process does: this is in-process
/// concurrency, not durable job execution. A host that needs work to survive a
/// restart implements the trait against its own queue.
///
/// # Requires a tokio runtime
///
/// `tokio::spawn` panics when no runtime is running, so [`Self::start`] checks
/// for one and returns a capability error instead of taking the process down.
/// That is why this is only *default*, not mandatory — an embedder driving the
/// engine on another executor gets a clear error rather than a panic.
#[derive(Default)]
pub struct TokioTaskRunner {
/// Issued tickets and their state. Also the ticket counter's home, so ids
/// are unique without a clock or a random source.
tasks: Mutex<HashMap<String, Arc<Mutex<TaskState>>>>,
handles: Mutex<HashMap<String, tokio::task::AbortHandle>>,
next_id: Mutex<u64>,
}

impl std::fmt::Debug for TokioTaskRunner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TokioTaskRunner").finish_non_exhaustive()
}
}

impl TokioTaskRunner {
/// Creates a runner with no tasks.
#[must_use]
pub fn new() -> Self {
Self::default()
}

fn issue_ticket(&self) -> String {
let mut next = self.next_id.lock().expect("ticket counter poisoned");
*next += 1;
format!("task-{next}")
}

fn state_of(&self, ticket: &str) -> Result<Arc<Mutex<TaskState>>> {
self.tasks
.lock()
.expect("task table poisoned")
.get(ticket)
.cloned()
.ok_or_else(|| EngineError::Capability(format!("unknown task ticket {ticket:?}")))
}
}

#[async_trait]
impl TaskRunner for TokioTaskRunner {
async fn start(&self, spec: TaskSpec) -> Result<String> {
if tokio::runtime::Handle::try_current().is_err() {
return Err(EngineError::Capability(
"TokioTaskRunner needs a running tokio runtime; inject a TaskRunner suited to \
this host's executor, or run the engine on tokio"
.to_string(),
));
}
let ticket = self.issue_ticket();
let state = Arc::new(Mutex::new(TaskState::Pending));
self.tasks
.lock()
.expect("task table poisoned")
.insert(ticket.clone(), state.clone());

// This runner owns scheduling, not meaning: it has no capabilities of
// its own to run a workflow or call a tool with. It records the spec as
// the task's result so a `gate` collecting it still sees what was asked
// for, and a host that wants real execution implements `TaskRunner`
// against its own stack. Keeping that honest — rather than silently
// producing nothing — is why the payload is echoed rather than dropped.
let handle = tokio::spawn(async move {
*state.lock().expect("task state poisoned") = TaskState::Running;
let result = match spec {
TaskSpec::Workflow { graph, input } => {
serde_json::json!({ "spec": "workflow", "graph": graph, "input": input })
}
TaskSpec::Tool { slug, args } => {
serde_json::json!({ "spec": "tool", "slug": slug, "args": args })
}
TaskSpec::Http { request } => {
serde_json::json!({ "spec": "http", "request": request })
}
};
*state.lock().expect("task state poisoned") = TaskState::Done(result);
});
self.handles
.lock()
.expect("handle table poisoned")
.insert(ticket.clone(), handle.abort_handle());
Ok(ticket)
}

async fn poll(&self, ticket: &str) -> Result<TaskState> {
let state = self.state_of(ticket)?;
let state = state.lock().expect("task state poisoned").clone();
Ok(state)
}

async fn cancel(&self, ticket: &str) -> Result<()> {
let state = self.state_of(ticket)?;
if let Some(handle) = self
.handles
.lock()
.expect("handle table poisoned")
.remove(ticket)
{
handle.abort();
}
let mut state = state.lock().expect("task state poisoned");
if !state.is_settled() {
*state = TaskState::Failed("cancelled".to_string());
}
Ok(())
}
}

#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;

fn spec() -> TaskSpec {
TaskSpec::Tool {
slug: "demo.run".to_string(),
args: json!({ "x": 1 }),
}
}

#[tokio::test]
async fn a_started_task_settles_and_can_be_polled_repeatedly() {
let runner = TokioTaskRunner::new();
let ticket = runner.start(spec()).await.expect("start");

// Poll until settled; the gate does exactly this, once per activation.
let mut state = runner.poll(&ticket).await.expect("poll");
for _ in 0..64 {
if state.is_settled() {
break;
}
tokio::task::yield_now().await;
state = runner.poll(&ticket).await.expect("poll");
}
assert!(
matches!(state, TaskState::Done(_)),
"task should settle, got {state:?}"
);
// Polling again must not consume the result — a gate may see the same
// ticket on several activations before it releases.
assert_eq!(runner.poll(&ticket).await.expect("re-poll"), state);
}

#[tokio::test]
async fn tickets_are_unique() {
let runner = TokioTaskRunner::new();
let a = runner.start(spec()).await.expect("start");
let b = runner.start(spec()).await.expect("start");
assert_ne!(a, b);
}

#[tokio::test]
async fn an_unknown_ticket_is_an_error_rather_than_a_silent_pending() {
let runner = TokioTaskRunner::new();
assert!(runner.poll("task-999").await.is_err());
assert!(runner.cancel("task-999").await.is_err());
}

#[tokio::test]
async fn cancelling_settles_the_task_as_failed() {
let runner = TokioTaskRunner::new();
let ticket = runner.start(spec()).await.expect("start");
runner.cancel(&ticket).await.expect("cancel");
let state = runner.poll(&ticket).await.expect("poll");
assert!(state.is_settled(), "a cancelled task must not stay pending");
}

/// Cancelling something that already finished must not rewrite its result —
/// a gate that released on it has already used that value.
#[tokio::test]
async fn cancelling_a_settled_task_leaves_its_result_alone() {
let runner = TokioTaskRunner::new();
let ticket = runner.start(spec()).await.expect("start");
for _ in 0..64 {
if runner.poll(&ticket).await.expect("poll").is_settled() {
break;
}
tokio::task::yield_now().await;
}
let before = runner.poll(&ticket).await.expect("poll");
runner.cancel(&ticket).await.expect("cancel");
assert_eq!(runner.poll(&ticket).await.expect("poll"), before);
}
}
Loading