Skip to content

Complete request cancellation for duration waits and snapshot runtimes #1686

Description

@thymikee

Plan 003: Complete request cancellation for duration waits and snapshot runtimes

Executor instructions: Follow this plan step by step. Run every
verification command and confirm the expected result before moving to the
next step. If anything in the "STOP conditions" section occurs, stop and
report — do not improvise. A reviewer maintains the plan index for this
execution; do not create or edit plans/README.md.

Drift check (run first):
git diff --stat 13bc70f24..HEAD -- src/commands/interaction/runtime/selector-wait.ts src/commands/interaction/runtime/wait-polling.ts src/commands/interaction/runtime/selector-wait-cancellation.test.ts src/daemon/snapshot-runtime.ts src/daemon/__tests__/snapshot-runtime-cancellation.test.ts
If any in-scope file changed since this plan was written, compare the
"Current state" excerpts against the live code before proceeding; on a
mismatch, treat it as a STOP condition.

Status

  • Priority: P1
  • Effort: M
  • Risk: MED
  • Depends on: none
  • Category: bug
  • Planned at: commit 13bc70f24, 2026-08-08

Why this matters

The daemon already creates one AbortSignal per tracked request, and most
selector polling and platform snapshot implementations know how to consume it.
Two runtime boundaries drop that authority: a duration-only wait sleeps
without either available signal, and the snapshot/diff snapshot runtime
does not install or forward the registered request signal. A canceled request
can therefore keep doing work and retain its session/device execution lock
until the sleep or backend operation finishes. This plan completes the existing
contract without changing CLI syntax, timeout budgets, response shapes, or
platform fallback policy.

Current state

Relevant files and their roles:

  • src/commands/interaction/runtime/selector-wait.ts — implements duration,
    selector, text, ref, and stable waits. Its context already permits command-
    and runtime-level signals, but its duration branch does not use them.
  • src/commands/interaction/runtime/wait-polling.ts — owns the existing
    cancellation-aware wait sleep used between selector/text polls.
  • src/daemon/selector-runtime-backend.ts — exemplar for installing the
    registered daemon request signal on an AgentDeviceRuntime.
  • src/daemon/snapshot-runtime.ts — shared runtime path for both snapshot
    and diff snapshot; currently creates a signal-less runtime and discards the
    backend command context.
  • src/daemon/handlers/snapshot-capture.ts — lower capture layer. It already
    accepts signal and forwards it to Linux, the macOS helper, and dispatched
    platform snapshots; it should not need modification.
  • src/request/cancel.ts — sole request-cancellation registry. This plan must
    consume its signal, not create a second controller or cancellation registry.

The duration branch has both signal authorities in its types, but calls a
signal-blind helper:

// src/commands/interaction/runtime/selector-wait.ts:59-64,119-129,176-183
type WaitCommandContext = {
  session?: string;
  requestId?: string;
  signal?: AbortSignal;
  metadata?: Record<string, unknown>;
};

type SelectorWaitRuntime = {
  // ...
  signal?: AbortSignal;
};

if (options.target.kind === 'sleep') {
  await sleep(runtime, options.target.durationMs);
  return { kind: 'sleep', waitedMs: options.target.durationMs };
}

// src/commands/interaction/runtime/selector-wait.ts:451-454
async function sleep(runtime: SelectorWaitRuntime, durationMs: number): Promise<void> {
  if (runtime.clock) await runtime.clock.sleep(durationMs);
  else await new Promise((resolve) => setTimeout(resolve, durationMs));
}

The polling path already has the behavior to reuse. It checks both authorities,
preserves injected-clock behavior, clears the real timer on abort, removes the
listener, and unrefs the timer:

// src/commands/interaction/runtime/wait-polling.ts:165-196
async function sleepWithinWait(
  runtime: WaitPollingRuntime,
  options: WaitPollingOptions,
  durationMs: number,
): Promise<boolean> {
  const parentSignals = [options.signal, runtime.signal].filter(
    (signal): signal is AbortSignal => signal !== undefined,
  );
  for (const signal of parentSignals) signal.throwIfAborted();
  // ... injected clock branch ...
  const signal = parentSignals.length > 0 ? AbortSignal.any(parentSignals) : undefined;
  // ... clearTimeout, listener cleanup, timer.unref() ...
}

The selector runtime shows the intended daemon wiring:

// src/daemon/selector-runtime-backend.ts:62-77
export function createSelectorRuntimeForDevice(params: SelectorRuntimeDeviceParams) {
  return createAgentDevice({
    backend: createSelectorBackend(params),
    // ... policy and sessions ...
    signal: params.signal ?? getRequestSignal(params.req.meta?.requestId),
  });
}

The snapshot runtime omits that field, and its backend ignores the context that
captureRuntimeSnapshot supplies:

// src/daemon/snapshot-runtime.ts:266-311
function createSnapshotRuntime(params: { /* ... */ }) {
  const { req, sessionName, logPath, sessionStore, session, device, snapshotScope } = params;
  return createAgentDevice({
    backend: createDaemonSnapshotBackend({ /* ... */ }),
    ...createDaemonRuntimePolicy('snapshot'),
    sessions: createDaemonRuntimeSessionStore({ /* ... */ }),
  });
}

// src/daemon/snapshot-runtime.ts:386-405
return {
  platform: publicPlatformString(device),
  captureSnapshot: async (_context, options): Promise<BackendSnapshotResult> => {
    const capture = await captureSnapshot({
      device,
      session,
      flags: req.flags,
      outPath: options?.outPath ?? req.flags?.out,
      logPath,
      snapshotScope,
    });

No platform-specific implementation work is needed. The lower seam is already
signal-aware:

// src/daemon/handlers/snapshot-capture.ts:39-49,89-122
type CaptureSnapshotParams = {
  // ...
  signal?: AbortSignal;
};

if (device.platform === 'linux') {
  const linuxResult = await snapshotLinux(session?.surface, params.signal);
  // ...
}
// macOS helper receives signal: params.signal
// dispatchCommand context receives signal: params.signal

Design and repository constraints to preserve:

  • docs/adr/0018-unified-event-journal.md:216-224 defines client-disconnect
    tracking as transport-owned behavior that feeds request cancellation. Do not
    move cancellation ownership into wait or snapshot modules.
  • src/request/cancel.ts:58-81,112-115 owns registrations and exposes
    getRequestSignal; consume that exact signal.
  • docs/agents/testing.md:68-75 requires every regression test to be observed
    red against the pre-fix production code and the failing assertion recorded.
  • docs/agents/testing.md:481-493 forbids real-time sleeps in unit tests and
    test-only dependency-injection seams. Use a controlled fake clock/promise and
    the existing production request-signal registry.
  • Optional snapshot optimizations and capture fallbacks remain best-effort.
    Cancellation is request authority, not a new reason to remove those fallbacks.

Commands you will need

Purpose Command Expected on success
Inspect shared impact pnpm depgraph affected src/commands/interaction/runtime/wait-polling.ts --limit 25 exit 0; bounded dependents, gates, and command owners are printed
Inspect snapshot impact pnpm depgraph affected src/daemon/snapshot-runtime.ts --limit 25 exit 0; bounded dependents, gates, and command owners are printed
Focused tests pnpm exec vitest run src/commands/interaction/runtime/selector-wait-cancellation.test.ts src/commands/interaction/runtime/wait-polling.test.ts src/daemon/__tests__/snapshot-runtime-cancellation.test.ts src/daemon/selector-runtime-backend.test.ts src/daemon/__tests__/snapshot-quality-latch.test.ts all selected files pass
Fast static gate pnpm check:quick exit 0; lint and typecheck pass
Format pnpm format exit 0; only intended in-scope source/test formatting changes remain
Affected gate pnpm check:affected --run exit 0; selected local gates pass and CI-owned gates are reported
Broad deterministic gate pnpm check exit 0; tooling, fallow, unit, and smoke aggregates pass

The repository requires pnpm 11.17.0 and Node >=22.12. Do not create or
restore package-lock.json. No Apple target build is required because this
plan changes TypeScript runtime wiring only; pnpm check:affected --run remains
the authority if the live diff selects additional gates.

Scope

In scope (the only implementation/test files to modify or create):

  • src/commands/interaction/runtime/selector-wait.ts
  • src/commands/interaction/runtime/wait-polling.ts
  • src/commands/interaction/runtime/selector-wait-cancellation.test.ts (create)
  • src/daemon/snapshot-runtime.ts
  • src/daemon/__tests__/snapshot-runtime-cancellation.test.ts (create)

Out of scope (do NOT touch, even though they are related):

  • src/request/cancel.ts and daemon HTTP/socket transports — cancellation
    registration and disconnect detection already work and are not being redesigned.
  • src/daemon/handlers/snapshot-capture.ts, platform interactors, Android
    helpers, Linux/macOS helpers, and provider adapters — these already accept a
    signal; the missing seam is above them.
  • Apple runner cancellation code — Apple already merges an explicit signal
    with the request-id registration; do not add an Apple-specific fallback.
  • Timeout-policy descriptors, daemon reset policy, wait positional parsing,
    public error normalization, CLI/MCP schemas, response shapes, and ref-frame rules.
  • Snapshot freshness, quality, fallback, deferred-outcome, or session mutation semantics.
  • README/website/CLI-help/skill behavior guidance. The public command surface is
    unchanged and the transport cancellation contract is already documented.
  • plans/README.md and every other plan file.

Git workflow

  • Suggested branch: advisor/003-complete-request-cancellation
  • Keep test-first evidence and the production fix as logical commits if the
    operator wants multiple commits. Suggested final commit message:
    fix: complete request cancellation propagation
  • Commit messages follow the repository's conventional prefixes (fix:,
    perf:, refactor:, test:), as shown by recent history.
  • Do not push or open a PR unless the operator explicitly instructs it.

Steps

Step 1: Add deterministic red regression tests

Create src/commands/interaction/runtime/selector-wait-cancellation.test.ts.
Use the public runtime command (device.selectors.wait), not the private sleep
helper. Model setup after selector-wait.test.ts: construct a local
createAgentDevice with a minimal backend, memory session store, local policy,
and an injected clock. The clock's sleep must return a manually controlled
promise rather than waiting real time.

Cover both cancellation authorities in a table/loop:

  1. runtime signal (createAgentDevice({ signal })), and
  2. command signal (device.selectors.wait({ signal, target: ... })).

For each authority, start a duration wait, wait until the fake clock reports
that sleep began, abort with a unique Error reason, release the fake sleep,
and assert the wait rejects with that exact reason instead of returning
{ kind: 'sleep' }. This exercises abort-during-sleep deterministically; it
also proves the injected-clock post-sleep check remains load-bearing. Do not
use setTimeout, fake global timers, or a test-only production parameter.

Create src/daemon/__tests__/snapshot-runtime-cancellation.test.ts. Model its
module mock and scenario setup after
src/daemon/__tests__/snapshot-quality-latch.test.ts, but use
makeAndroidSession, makeSessionStore/mkdtempForTestSync, and the request
cancellation helpers from their existing production modules. Partially mock
dispatchCommand while keeping the real module's other exports.

Use a table covering both dispatchSnapshotViaRuntime and
dispatchSnapshotDiffViaRuntime. For each command:

  1. register a request ID with registerRequestAbort and put it in req.meta;
  2. have the mocked snapshot dispatch expose the received context.signal, wait
    on a manually controlled promise, and call context.signal?.throwIfAborted()
    immediately after that promise resolves;
  3. start the runtime command and wait until the mock has been entered;
  4. call markRequestCanceled(requestId), then release the mock so the test can
    never hang even when the signal is absent;
  5. assert the observed signal is the registration's controller signal and the
    command rejects from abort rather than completing successfully; and
  6. clear the exact registration in finally so module-global state cannot leak.

Run the focused command before changing production. At commit 13bc70f24, the
duration tests should resolve instead of reject after the fake clock is
released, and snapshot/diff should observe no dispatch signal and complete when
the mock is released. Record the failing test names/assertions in the PR or
handoff; this is the required red proof.

Verify (red, before production edits):
pnpm exec vitest run src/commands/interaction/runtime/selector-wait-cancellation.test.ts src/daemon/__tests__/snapshot-runtime-cancellation.test.ts
→ exits nonzero only on the new cancellation assertions, with no timeout or
unhandled-promise failure.

Step 2: Route duration waits through the existing cancellable sleep

In src/commands/interaction/runtime/wait-polling.ts, export the existing
sleepWithinWait production helper (a durable name such as
sleepWithWaitCancellation is also acceptable if every existing call is
updated). Do not duplicate its logic. Preserve all existing semantics:

  • inspect both options.signal and runtime.signal;
  • throw immediately for an already-aborted authority;
  • for an injected clock, check before and after clock.sleep;
  • for a real timer, combine multiple authorities, clear the timer on abort,
    remove the listener on both paths, reject with the signal's reason, and
    unref the timer; and
  • preserve its current zero/negative-duration return behavior.

In src/commands/interaction/runtime/selector-wait.ts, import that helper and
pass the full options object from the duration branch so command-level and
runtime-level signals are both visible. Remove the private signal-blind
sleep function at the bottom of the file. Preserve the successful result
exactly: { kind: 'sleep', waitedMs: options.target.durationMs }.

Verify:
pnpm exec vitest run src/commands/interaction/runtime/selector-wait-cancellation.test.ts src/commands/interaction/runtime/wait-polling.test.ts src/commands/interaction/runtime/selector-wait.test.ts
→ all pass; no test waits on wall-clock duration.

Step 3: Install and forward the request signal in snapshot/diff runtime

In src/daemon/snapshot-runtime.ts:

  1. import getRequestSignal from src/request/cancel.ts using the correct
    relative path;
  2. add signal: getRequestSignal(req.meta?.requestId) to the object passed to
    createAgentDevice in createSnapshotRuntime, matching
    createSelectorRuntimeForDevice;
  3. rename the backend callback parameter from _context to context; and
  4. pass signal: context.signal into captureSnapshot.

Do not query the registry again inside the backend and do not add platform
conditionals. The runtime command already selects options.signal ?? runtime.signal when it builds the backend context, so this one seam covers
both snapshot and diff snapshot. The lower capture layer then forwards the
same signal to non-Apple backends; Apple may merge it with the same registered
signal and must retain its existing behavior.

Verify:
pnpm exec vitest run src/daemon/__tests__/snapshot-runtime-cancellation.test.ts src/daemon/__tests__/snapshot-quality-latch.test.ts src/daemon/handlers/__tests__/snapshot-handler.test.ts
→ all pass, including both new command cases and existing quality/session behavior.

Step 4: Tighten and run repository gates

Review the diff for duplicated cancellation logic, stale imports, or a second
request controller. Confirm the snapshot fix is only signal plumbing and did
not alter capture options, annotations, session writes, error conversion, or
fallback selection. Confirm duration wait success output is unchanged.

Run formatting, the focused suite, fast static checks, the affected selector,
and the broad deterministic gate in that order. If formatting modifies any
file outside Scope, inspect why and revert only the unrelated formatter drift;
do not sweep it into this change.

Verify:

pnpm format
pnpm exec vitest run src/commands/interaction/runtime/selector-wait-cancellation.test.ts src/commands/interaction/runtime/wait-polling.test.ts src/commands/interaction/runtime/selector-wait.test.ts src/daemon/__tests__/snapshot-runtime-cancellation.test.ts src/daemon/selector-runtime-backend.test.ts src/daemon/__tests__/snapshot-quality-latch.test.ts
pnpm check:quick
pnpm check:affected --run
pnpm check

→ every command exits 0. git diff --name-only lists only the in-scope
implementation/test files plus an authorized plan-status update.

Test plan

  • src/commands/interaction/runtime/selector-wait-cancellation.test.ts:
    • duration wait observes runtime cancellation;
    • duration wait observes command cancellation;
    • cancellation happens while the controlled clock sleep is pending;
    • neither case uses real time.
  • src/daemon/__tests__/snapshot-runtime-cancellation.test.ts:
    • snapshot forwards the exact registered request signal and aborts;
    • diff snapshot forwards the same authority and aborts;
    • controlled dispatch completion prevents a missing-signal regression from
      hanging the test;
    • every request registration is cleared in finally.
  • Existing regression coverage to retain:
    • wait-polling.test.ts already proves real-timer cancellation for runtime
      and command authorities;
    • selector-runtime-backend.test.ts is the runtime signal-wiring exemplar;
    • snapshot-quality-latch.test.ts and snapshot-handler.test.ts protect
      snapshot/diff response and session semantics.
  • Red-before-fix proof: run the two new test files before production edits and
    record the specific failing assertions. A timeout is not acceptable red evidence.
  • Final verification: the focused command in Step 4 passes all selected files.

Done criteria

ALL must hold:

  • The two new test files were observed failing against the pre-fix runtime
    for the expected signal/cancellation assertions, and that red output is
    recorded in the PR or handoff.
  • Duration waits reject for both runtime and command cancellation without
    real-time test sleeps.
  • snapshot and diff snapshot forward the exact registered request signal
    through the shared runtime backend.
  • rg -n "async function sleep\(" src/commands/interaction/runtime/selector-wait.ts
    returns no matches.
  • rg -n "captureSnapshot: async \(_context" src/daemon/snapshot-runtime.ts
    returns no matches.
  • The focused Vitest command in Step 4 exits 0.
  • pnpm check:quick exits 0.
  • pnpm check:affected --run exits 0.
  • pnpm check exits 0.
  • No source/test files outside the Scope list are modified.
  • No README, website, CLI-help, MCP schema, or skill behavior file is changed;
    docs/skills are intentionally unchanged because the public contract and
    command surface did not change.
  • plans/README.md and every other plan file are unchanged by the executor.

STOP conditions

Stop and report back; do not improvise if:

  • Any in-scope current-state excerpt no longer matches after the drift check.
  • Either new regression requires waiting real time, a live daemon/device, or a
    test-only production parameter to fail before the fix.
  • Reusing the polling sleep helper would change selector/text polling cadence,
    wait timeout accounting, injected-clock semantics, or successful duration output.
  • Snapshot cancellation requires modifying snapshot-capture.ts, a platform
    interactor/helper, transport registration, or public error normalization;
    those layers already expose the needed signal seam at this commit.
  • A proposed fix creates another AbortController, registry, message sniff, or
    platform-specific cancellation fallback instead of using getRequestSignal.
  • Apple behavior changes when the explicit signal and request-id-derived signal
    are the same authority.
  • A verification command fails twice after one reasonable, in-scope correction.
  • Completion appears to require any source/test file outside Scope.

Maintenance notes

  • Future daemon runtimes should follow the same ownership chain:
    transport registration → getRequestSignal(requestId) at runtime creation →
    CommandContext.signal → backend operation. Review new runtimes for all four
    links rather than teaching individual platforms to query daemon globals.
  • Keep one cancellation-aware wait sleep implementation. Any new wait mode that
    pauses should reuse it so runtime and command signals cannot drift again.
  • Reviewers should scrutinize listener/timer cleanup and registration cleanup;
    a green cancellation test that leaves a pending timer or global request entry
    is incomplete.
  • Snapshot capture fallback and quality semantics are deliberately deferred from
    this plan. Cancellation must stop the request; ordinary optional-optimization
    failure may continue to use the documented fallback path.
  • No docs or skills update is expected: this restores an existing internal
    request-lifecycle guarantee and does not change user-facing command behavior.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions