Phase 06 · Cancellation
The Archivist sometimes talks to slow external APIs. When the visitor closes the page, the dispatcher aborts cleanly — every node that is mid-network call sees the signal flip, skips its work, and the lifecycle records cancelled with the abort reason. A deadlineMs cap adds a hard ceiling regardless of the signal.
Flow
Code
Dispatcher + signal + deadline
The #cancellation-run region shows the AbortController, the signal + deadlineMs execute options, and the lifecycle switch that reads the terminal state:
// Caller-driven cancellation — the visitor closes the page.
const controller = new AbortController();
// Simulate visitor abandoning 800 ms in.
setTimeout(() => controller.abort('visitor closed page'), 800);
const cancelVisitor = new ArchivistState();
cancelVisitor.query = "What's a book about a labyrinth?";
const cancelResult = await dispatcher.execute('the-archivist', cancelVisitor, {
'signal': controller.signal,
'deadlineMs': 5000, // hard 5s ceiling regardless of signal
});
const lc = cancelResult.state.lifecycle;
switch (lc.kind) {
case 'completed':
logger.result(`responded: ${cancelResult.state.draft}`);
break;
case 'cancelled':
logger.result(`visitor abandoned at: ${lc.reason}`);
break;
case 'timed_out':
logger.result(`hit deadline at: ${lc.finishedAt}`);
break;
}
// result.cursor is the next node that would have run — pass it to
// Checkpoint.from to persist and resume in a later process.
if (cancelResult.cursor !== null) {
logger.result(`stopped at ${cancelResult.cursor} — resumable`);
}Scout signal pass-through
The #signal-scout region shows how openLibraryScout propagates context.signal through the scoutRetry policy and into the tool call — when the signal fires, the retry policy aborts mid-backoff instead of waiting:
// ── OpenLibrary scout ────────────────────────────────────────────────────────
// Gates on `state.toolPlan` for a `web_search_books` call. Writes to
// `state.candidates`. Non-deterministic (live network).
export const openLibraryScout: NodeInterface<ArchivistState, 'success' | 'empty', ArchivistServices> = {
"name": 'open-library-scout',
"outputs": ['success', 'empty'],
"timeoutMs": 60_000,
async execute(state, context) {
const planned = state.toolPlan.find((call) => call.name === 'web_search_books');
if (planned === undefined) return { "output": 'empty' };
const args = planned.arguments as { query?: string; limit?: number };
const rawQuery = typeof args.query === 'string' && args.query.length > 0
? args.query
: state.terms.join(' ');
const query = unquote(rawQuery);
if (query.length === 0) return { "output": 'empty' };
try {
const tool = context.services.webSearch;
context.services.logger.info(`openlibrary: "${query}" (limit ${String(args.limit ?? 8)})`);
const candidates = await scoutRetry.run(
() => tool.execute({ query, "limit": args.limit ?? 8 }, context.signal),
context.signal,
);
state.candidates = [...state.candidates, ...candidates];
context.services.logger.info(`openlibrary: ${String(candidates.length)} hits`);
if (candidates.length === 0) {
state.failureCause += `OpenLibrary: 0 hits for "${query}". `;
}
return { "output": candidates.length > 0 ? 'success' : 'empty' };
} catch (error) {
const msg = error instanceof Error ? error.message.slice(0, 100) : String(error).slice(0, 100);
state.collectError({
"code": 'OPEN_LIBRARY_FAILED',
"message": error instanceof Error ? error.message : String(error),
"operation": 'open-library-scout',
"recoverable": true,
"timestamp": new Date().toISOString(),
});
state.failureCause += `OpenLibrary: error — ${msg}. `;
context.services.logger.warn(`openlibrary failed: ${String(error)}`);
return { "output": 'empty' };
}
},
};What it demonstrates
signal+deadlineMscomposition —SignalComposercombines the caller-suppliedAbortSignalwith the deadline into one internal signal passed to every node viacontext.signal. Neither option is required; both can be used together.- Nodes propagate the signal — every scout passes
context.signalas the second argument toscoutRetry.run(task, signal). The retry policy aborts mid-wait when the signal fires, so scouts do not wait through the full backoff window. - Lifecycle records the exact terminal state —
cancelledcarries the abortreasonstring;timed_outcarries the deadline-finished timestamp.completedmeans all nodes ran to their terminal outputs. result.cursor— records the next node that would have run. When non-null, the flow was interrupted. Pair withCheckpoint.from(see Phase 08) to resume in a later process.
See this in action in the Archivist live demo — the cancel button fires the same AbortController.abort() path.