Skip to content

bug: background nodes are force-cancelled 30 seconds after foreground completion regardless of progress #1251

Description

@RohanAwhad

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 + cancelfactory/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 taskfactory/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 unhandledfactory/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 timeoutfactory/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 timeoutsfactory/workflow/executor.py:781-846: _run_node_run_agentinvoke_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

  1. 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 runfactory/workflow/cli.py:83-94.)
  2. execute() at factory/workflow/executor.py:103 calls await self._execute_from(self.workflow.start_node) (line 117).
  3. _execute_from (line 181) walks edges to _execute_action_node (line 220).
  4. 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.
  5. 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.
  6. 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.
  7. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions