Tasks#

A method call occupies the actor until it settles — fine for milliseconds, wrong for a sync job or an AI workflow run. A task runs detached, so ordinary reads, streams and watches keep answering while it works.

TypeScript
const Sync = defineActor({
    type: 'Sync',
    allowAnonymous: true,
    state: () => ({ done: 0, total: 0, phase: 'idle' as string }),
    methods: (ctx) => ({
        begin: (total: number) => ctx.tasks.start('run', total),
        status: () => ctx.snapshot(),
        stop: () => ctx.tasks.cancel('run'),
    }),
    tasks: (ctx) => ({
        async run(total: number) {
            await ctx.turn((c) => { c.state.phase = 'running'; c.state.total = total; });
            for (let i = 0; i < total; i++) {
                ctx.abortSignal.throwIfAborted();
                await syncOne(i, { signal: ctx.abortSignal });
                await ctx.turn((c) => { c.state.done = i + 1; });
            }
            await ctx.turn(async (c) => { c.state.phase = 'done'; await c.save(); });
        },
    }),
});

The rules#

State only through ctx.turn(fn). A task body is detached, so it gets no state or save(). turn() enqueues fn as one ordinary serialized turn with the full context, so every mutation stays race-free and everything downstream — change feeds, watches, write-behind — works unmodified. Reads in the body use ctx.snapshot() or ctx.changes().

ctx.abortSignal in a task is the run's signal. It fires on ctx.tasks.cancel(name) with reason 'cancelled', and on deactivation for any reason with the DeactivationReason — before in-flight turns drain. Deactivation then gives signalled tasks a bounded grace (taskGraceMs, default 10s) with the actor still accepting turns, so a winding-down task can run one final turn() checkpoint.

The grace covers the whole run, from reservation on. host.stop() and every other deactivation await a run from the moment ctx.tasks.start() reserves it, within the same taskGraceMs:

  • a launch that lands while the stop is in progress;
  • the body's wind-down after its signal fires;
  • the bookkeeping of a run whose body had already returned as the stop landed — the ledger clear and the task-liveness entry — so a host stopping right after a job completes still writes the completion;
  • the rollback of a start whose durable half (the ledger write, the liveness entry) failed. A crash-resume whose input the state codec rejects is rolled back the same way, rather than holding the name's single-flight reservation.

The grace counts against host.stop({ timeoutMs }), so keep it smaller than that. When it runs out, a dev build warns in one of two shapes. "…ignored their abort signal" means a body did not observe ctx.abortSignal — fix the body. "…still had task bookkeeping in flight" means a storage round trip writing or clearing the ledger or task-liveness entry outran taskGraceMs, including a start that had not launched yet. That write is not cancelled and may still land: an entry a start leaves behind resumes on the next activation, and one a finish leaves behind is cleared the next time the host's task entries are adopted.

ctx.turn() carries a deadline. A task's turn() runs with the host's callTimeoutMs (default 30s), exactly like an external call, so every ctx.actor() call made from it is bounded. A turn that awaits a peer whose turn never answers rejects with ActorCallTimeoutError at the deadline. If a long await inside a task turn is intended, give that call its own budget with .with({ deadlineMs }), or raise callTimeoutMs (0 disables it).

cancel is a request, not a join. It aborts and returns; the run leaves ctx.tasks.list() when its body settles. Awaiting settlement from a method turn would deadlock against the task's own wind-down turn().

A running task keeps the actor alive — the idle sweeper skips it, like an open stream.

start is single-flight per name and resolves when the body is launched, not finished. A task that throws is terminal: no automatic retry, because that policy belongs to the layer above.

No wire surface. Start, cancel and status go through your own methods, so your policy governs them like any other call.

Crash resume is built in#

start() resolves only after the run is durably recorded — a ledger entry in the reserved $sigx:tasks storage record — and the host has noted the actor in its task-liveness roster.

  • A run interrupted by deactivation (any reason but cancel) keeps its entry. The next activation restarts it with TaskInfo.restarts bumped and the original input replayed through the state codec. Completion, a throw and cancel all remove the entry — a thrown task is terminal, because a crash is not a throw. An empty ledger takes the actor off the roster.
  • The roster is the crash driver. Each host keeps one roster of the actors with runs in flight on it, under the reserved $sigx:tasks-roster type, and is its only writer — so tracking a run is one compare-and-set with no load, and nothing is periodic per running task. When a host dies, one survivor adopts its roster on the reminder tick and touches each actor on it, and the actor — tasks and all — re-activates wherever the cluster puts it. The delay is the membership TTL plus a tick. No client call needed.

The roster is a seam, ActorTaskLiveness, set with taskLiveness on createHost or defineActorApp. rosterTaskLiveness() is the default; reminderTaskLiveness(), both from @sigx/actors/host, arms one durable reminder per actor with a run in flight instead. The built-ins are host-scoped: pass a fresh instance per host.

  • The contract is at-least-once. The runtime resumes the function; your code resumes the work from its own checkpointed state — the ctx.turn() + save() pattern above, reading how far the last checkpoint got. A run that completes in the same instant its host stops may restart once more, so make the last step idempotent or gate it on state.

On Cloudflare Durable Objects this degrades gracefully. The ledger lives in the DO's own storage and liveness uses reminderTaskLiveness(), whose reminder maps onto the object's alarm — the alarm is the platform's wake-up, and a per-host roster would be a roster of one. A fiber does not survive eviction — so a task there is checkpoint-and-resume with short gaps rather than one continuous run. Same at-least-once contract; checkpoint aggressively.

Tasks or jobs?#

Tasks are the primitive. defineJob is the packaged experience on top — a state machine, progress, checkpoints, attempts and a client surface already decided. A job writes no $sigx:tasks entry: its own state record is its ledger.

Reach for a task when the work is an internal detail of an actor that has other responsibilities. Reach for a job when the run is the thing, and something outside needs to ask how it is going.

Next steps#