Skip to content

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:

ts
// 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:

ts
// ── 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 + deadlineMs compositionSignalComposer combines the caller-supplied AbortSignal with the deadline into one internal signal passed to every node via context.signal. Neither option is required; both can be used together.
  • Nodes propagate the signal — every scout passes context.signal as the second argument to scoutRetry.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 statecancelled carries the abort reason string; timed_out carries the deadline-finished timestamp. completed means 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 with Checkpoint.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.

Watched over by the Order of Dagon.