Current Behavior
At workflow completion, WorkflowExecutor.execute() drains background tasks with a hard 30-second budget, then force-cancels whatever is still running:
if self.background_tasks:
done, pending = await asyncio.wait(
self.background_tasks,
timeout=30.0,
)
for task in pending:
task.cancel()
The cancelled tasks are never awaited or reaped (no asyncio.gather after the cancel loop), so a cancellation kills the task silently. _run_node_background only catches Exception, not CancelledError, so a cancelled background node emits neither node.completed nor node.failed — the work simply disappears.
So blocking=False does not mean "run asynchronously to completion". It means "you get at most 30 seconds after the foreground control flow finishes, then forced cancellation". LLM-backed background nodes routinely exceed this:
- Archivist nodes are
AgentNode(role=ARCHIVIST, blocking=False) and have a configured agent timeout of 300 seconds.
- Spec generation (
spec_generate) is a non-blocking FnNode that shells out to a gated spec-generate workflow.
- Since background failures don't halt the workflow (
_run_node_background only logs a warning), work is silently lost.
Desired Behavior
blocking=False nodes run to completion; the executor should await background tasks (or drain for a duration >= the node's own timeout) before returning.
- If a background node is cancelled or fails, that fact should be visible in the result (events, logs, or
halt_reason), not silently swallowed.
Evidence
The 30s drain + cancel — factory/workflow/executor.py:125-131:
125: if self.background_tasks:
126: done, pending = await asyncio.wait(
127: self.background_tasks,
128: timeout=30.0,
129: )
130: for task in pending:
131: task.cancel()
Note: no await asyncio.gather(...) follows — cancelled tasks are left un-reaped.
blocking=False → fire-and-forget task — factory/workflow/executor.py:227-233:
227: if not node.blocking:
228: task = asyncio.create_task(self._run_node_background(node))
229: self.background_tasks.append(task)
230: next_id = self._next_unconditional(node_id)
231: if next_id:
232: await self._execute_from(next_id)
233: return
Background failure is swallowed; cancellation is unhandled — factory/workflow/executor.py:316-327:
316: except Exception as exc:
317: self._emit(
318: "node.failed",
319: NodeFailed(
...
327: log.warning("background_node_failed", node=node_id, error=str(exc))
CancelledError is not an Exception in Python 3.11+, so it bypasses this handler entirely — cancelled nodes emit nothing and halt nothing.
Archivist is non-blocking with a 300s agent timeout — factory/workflow/definitions.py:330-338 (archivist_plan, blocking=False), factory/workflow/definitions.py:410-424 (archivist_build and spec_generate, both blocking=False), and factory/workflow/primitives.py:51 ("archivist": AgentConfig(role=AgentRole.ARCHIVIST, model="haiku", timeout=300)).
Background nodes invoke LLM subprocesses with long timeouts — factory/workflow/executor.py:781-846: _run_node → _run_agent → invoke_agent(..., timeout=float(timeout) if timeout is not None else 600.0). A legitimate archivist run may take up to 300s (or 600s default), while the drain gives it 30s.
Call Trace
- Entry point:
factory ceo headless run with engine="deterministic" → factory/cli/_ceo_helpers.py:841-843 constructs WorkflowExecutor and calls asyncio.run(executor.execute()). (Also reachable via factory workflow run → factory/workflow/cli.py:83-94.)
execute() at factory/workflow/executor.py:103 calls await self._execute_from(self.workflow.start_node) (line 117).
_execute_from (line 181) walks edges to _execute_action_node (line 220).
- For
blocking=False nodes, _execute_action_node creates a background task at line 228 (asyncio.create_task(self._run_node_background(node))) and immediately continues the foreground graph (lines 230-232) without awaiting it.
- The background task runs
_run_node_background (line 285) → _run_node (line 781) → _run_agent (line 814) → invoke_agent (line 835), a Claude Code subprocess with a 300-600s budget.
- When the foreground chain completes (or halts), control returns to
execute() and hits the drain block at lines 125-131: wait up to 30.0 seconds, then task.cancel() every still-pending task.
- The cancelled task gets
CancelledError at its next await point; _run_node_background's except Exception (line 316) does not catch it, execute() never awaits it, and the executor returns ExecutionResult with no trace — the archive/spec output is lost.
Current Behavior
At workflow completion,
WorkflowExecutor.execute()drains background tasks with a hard 30-second budget, then force-cancels whatever is still running:The cancelled tasks are never awaited or reaped (no
asyncio.gatherafter the cancel loop), so a cancellation kills the task silently._run_node_backgroundonly catchesException, notCancelledError, so a cancelled background node emits neithernode.completednornode.failed— the work simply disappears.So
blocking=Falsedoes not mean "run asynchronously to completion". It means "you get at most 30 seconds after the foreground control flow finishes, then forced cancellation". LLM-backed background nodes routinely exceed this:AgentNode(role=ARCHIVIST, blocking=False)and have a configured agent timeout of 300 seconds.spec_generate) is a non-blockingFnNodethat shells out to a gated spec-generate workflow._run_node_backgroundonly logs a warning), work is silently lost.Desired Behavior
blocking=Falsenodes run to completion; the executor should await background tasks (or drain for a duration >= the node's own timeout) before returning.halt_reason), not silently swallowed.Evidence
The 30s drain + cancel —
factory/workflow/executor.py:125-131:Note: no
await asyncio.gather(...)follows — cancelled tasks are left un-reaped.blocking=False→ fire-and-forget task —factory/workflow/executor.py:227-233:Background failure is swallowed; cancellation is unhandled —
factory/workflow/executor.py:316-327:CancelledErroris not anExceptionin Python 3.11+, so it bypasses this handler entirely — cancelled nodes emit nothing and halt nothing.Archivist is non-blocking with a 300s agent timeout —
factory/workflow/definitions.py:330-338(archivist_plan,blocking=False),factory/workflow/definitions.py:410-424(archivist_buildandspec_generate, bothblocking=False), andfactory/workflow/primitives.py:51("archivist": AgentConfig(role=AgentRole.ARCHIVIST, model="haiku", timeout=300)).Background nodes invoke LLM subprocesses with long timeouts —
factory/workflow/executor.py:781-846:_run_node→_run_agent→invoke_agent(..., timeout=float(timeout) if timeout is not None else 600.0). A legitimate archivist run may take up to 300s (or 600s default), while the drain gives it 30s.Call Trace
factory ceoheadless run withengine="deterministic"→factory/cli/_ceo_helpers.py:841-843constructsWorkflowExecutorand callsasyncio.run(executor.execute()). (Also reachable viafactory workflow run→factory/workflow/cli.py:83-94.)execute()atfactory/workflow/executor.py:103callsawait self._execute_from(self.workflow.start_node)(line 117)._execute_from(line 181) walks edges to_execute_action_node(line 220).blocking=Falsenodes,_execute_action_nodecreates a background task at line 228 (asyncio.create_task(self._run_node_background(node))) and immediately continues the foreground graph (lines 230-232) without awaiting it._run_node_background(line 285) →_run_node(line 781) →_run_agent(line 814) →invoke_agent(line 835), a Claude Code subprocess with a 300-600s budget.execute()and hits the drain block at lines 125-131: wait up to30.0seconds, thentask.cancel()every still-pending task.CancelledErrorat its next await point;_run_node_background'sexcept Exception(line 316) does not catch it,execute()never awaits it, and the executor returnsExecutionResultwith no trace — the archive/spec output is lost.