A cancelled agent is not a failed one
When a race picks a winner it stops the others. That is the whole point: first useful answer wins, everything else gets cut off.
An agent that honours the stop signal ends with an error, because that is what aborting looks like from the inside. And an errored agent was excluded from the cancellation set. So race_cancelled fired only for losers that had ignored the signal and finished anyway, which is precisely when nothing had been cancelled.
Against a real provider that honours cancellation, the event had never once fired for its own reason.
const result = await orchestrator.runRace(pattern, { input });
// race_cancelled now fires for the agents the race actually stopped
If you build dashboards or alerts on orchestrator events, this is the one to know about. Deliberate stops that used to arrive on your failure path will start arriving as cancellations. Expect your agent-error rate to drop and your cancellation count to rise by roughly the same amount. That is the same events, correctly labelled, not a change in what your agents are doing.
dag timeouts now cut work off
A node with timeout: 50 in front of a 500 ms operation reaches "error". It used to reach "completed".
const graph = {
nodes: {
enrich: { agent: slowAgent, timeout: 50 },
},
};
The timeout fired the whole time. It just had nothing that would stop, so the node ran to completion and reported success. Graph-level timeouts behaved the same way.
If you set timeouts on DAG nodes and never saw one take effect, this is why.
And now you can test it
Both of the above had been wrong for a long time without any test noticing, and the reason is worth a paragraph, because it is probably true of your suite too.
createMockAgentRunner never read the abort signal. Its configured delay was a plain timer that nothing could interrupt, so it always ran to completion and always returned a result. Every test of cancellation written against it passed whether or not cancellation worked.
Now it honours AbortSignal:
const mock = createMockAgentRunner({
responses: { slow: { output: 'never arrives', delay: 5000 } },
});
const controller = new AbortController();
const call = mock.run(agent, 'go', { signal: controller.signal });
setTimeout(() => controller.abort(), 10);
await expect(call).rejects.toThrow(); // and now it does
It follows the rule a real HTTP client follows: aborting before the call starts or while it is in flight rejects, but an answer already in hand is not thrown away because the signal fired afterwards. That last clause is the difference between a double that models cancellation and one that just fails eagerly.
<details> <summary>The tell was a comment sitting above the assertion that contradicted it</summary>
Two of our own tests asserted that a timed-out DAG node reached "completed", each with a comment explaining that the mock slept through the abort. The comment was accurate. The assertion had turned the mock's blindness into the documented contract.
A test double is a claim about how the real thing behaves. When the double can only succeed, every test around it inherits that ceiling, and those tests look exactly like real ones: green, named after the guarantee, asserting nothing that could fail.
Worth asking of your own doubles: can this thing fail the way the real thing fails?
</details>
Upgrading
npm install @directive-run/ai@latest
If you have tests asserting that a timed-out DAG node completes, or that a race loser shows up as an error, those assertions were describing the mock and will now describe the runtime. Them turning red is the upgrade working.
Related
- Three of our published rates were wrong – We checked every rate table in @directive-run/ai against the providers' own pricing pages and corrected three. Then we gave the tables an expiry date so it cannot happen quietly again.
- The PII screen sees more of your data now – The personal-data guardrail keeps up with modules registered at runtime, and no longer skips a whole value because one member is awkward.
- Long chains run once – A reconcile chain of any length now dispatches each requirement exactly once, and settle() comes back when you swap a derivation. Four fixes to things the runtime was doing without telling anyone.
Directive is free and open source. If this was useful, consider supporting the project.

