Cancellation
There is no abort() API. Cancelling is appending, and the running consumer finds out through a signal.
Cancelling is appending
// in the browser: push from useSession
push({ type: 'cancelled', payload: { lastSeenIndex: index } })A user presses stop on a streaming AI response. That's an event: same route, same auth, same log as every other event. Two things then happen, at two speeds.
The running consumer stops
import { consumer, createServer } from 'experimental-a2/server'
import { chat } from '@/contracts'
const generate = consumer({
contract: chat,
name: 'generate',
types: ['generate'],
abortOn: ['cancelled'],
}).run(async (ctx) => {
// your abortable work; hand it ctx.signal:
// await streamText({ signal: ctx.signal, ... })
})
export const chatServer = createServer({
contract: chat,
consumers: [generate],
})abortOn names the events that interrupt the run. When a cancelled event
lands, or had already landed before the run started, ctx.signal fires.
Consumers without abortOn hold no subscription.
When one session runs several generations over its lifetime, match the instance with a predicate:
// server/chat.ts, the generate consumer, targeted by instance:
import { consumer } from 'experimental-a2/server'
import { chat } from '@/contracts'
const generate = consumer({
contract: chat,
name: 'generate',
types: ['generate'],
abortOn: {
// only the cancel that names this run fires the signal
cancelled: (event, trigger) => event.payload.of === trigger.id,
},
}).run(async (ctx) => {
// ...
})The pushing side already knows the id it's cancelling: it's in the hook's
events feed.
When a run loses its claim
Every consumer's signal, with or without abortOn, also fires when the run
loses its claim: CLAIM_EXPIRED when the lease lapses without renewal
(decided locally, so it works while the store is unreachable), or
SUPERSEDED_ATTEMPT when a recovery run has claimed the event. Either way, a
successor is taking over: stop external work. A superseded run can no longer
write to the log or complete; passing ctx.signal to your external calls is
what stops it racing its successor outside A2.
Pass ctx.signal to your I/O and let the abort propagate. That handles every
case:
- A propagated lease abort surrenders the attempt: no completion, and normal recovery. Like a crash, it costs the event one attempt of its failure budget.
- A user cancellation (
abortOn) is the one to catch and return normally from. The stop is the outcome, so the completion counts. - Return normally after a lease abort only when the work is done. A lapsed run that finishes before any successor claims the event still completes.
- Any other throw is a real failure and spends the retry budget.
Clean up after the run
A consumer of cancelled can run while the interrupted run is still
unwinding: the signal is the preemption channel, the event is the durable
record. When cleanup must wait, take both event types in one consumer with a
string lane. The cancelled event still fires the signal on append, but runs
only after earlier work in the lane:
import { consumer, createServer } from 'experimental-a2/server'
import { chat } from '@/contracts'
const generation = consumer({
contract: chat,
name: 'generation',
types: ['generate', 'cancelled'],
lane: 'generation',
abortOn: ['cancelled'],
}).run(async (ctx) => {
if (ctx.event.type === 'generate') {
// your abortable work; hand it ctx.signal:
// await streamText({ signal: ctx.signal, ... })
return
}
// your cleanup, after generate has returned
})
export const chatServer = createServer({
contract: chat,
consumers: [generation],
})The view cuts
case 'chunk':
return state.cancelled
? state
: { ...state, chunks: [...state.chunks, { index: event.index, delta: event.payload.delta }] }
case 'cancelled':
return {
...state,
cancelled: true,
chunks: state.chunks.filter((c) => c.index <= event.payload.lastSeenIndex),
}Chunks in flight during the cancel round trip still land after cancelled.
They're true history; keeping them out of the view is the reducer's job.
lastSeenIndex is the client's stream frontier, which it already tracks. The
cut is exact and clock-free: chunks the user never saw are removed, so the
optimistic view and the authoritative refold agree. Because the client pushes
cancelled optimistically, its own fold
cuts instantly, and stragglers hit state.cancelled and vanish.