Skip to content

Tasks and waking ​

A Task is one run of an agent and the only unit of work Noeta has. A chat that lasts for weeks, a nightly job and a delegated subagent are all Tasks. When a Task has to wait — for a person, a timer, a subtask or an outside event — it suspends, holds nothing while it waits, and is woken exactly once when the thing it waits for arrives.

pendingwaiting for a workerrunninga Worker holds the leasesuspendedwaiting onwhat wake_on namesterminalcompleted /failed / cancelledTaskCompletedTaskFailedTaskCancelleda Worker picks it uphas to wait, orthe turn is donewokenthe awaited thing arrivedfinish / failcancelcancelcancelWhat it waits for:next messageapprovalanswertimersubtaskexternal event

What it guarantees ​

  • One status for all waiting. Every kind of wait is suspended plus a typed wake condition, so there is one resume path, not four.
  • Waiting is free. A suspended Task holds no thread, connection or process memory. It can wait for seconds or months.
  • Exactly-once durable wake. A matched wake survives worker crashes and is consumed exactly once.
  • Single writer. A worker holds a lease on a Task while it runs; a worker whose lease was reclaimed cannot write.

The four statuses ​

StatusMeaning
pendingcreated, or woken, and waiting for a worker
runninga worker holds the lease and the Engine is advancing it
suspendedwaiting on a wake condition recorded in wake_on
terminalended with TaskCompleted, TaskFailed or TaskCancelled

A multi-turn conversation is one Task: each turn is wake → a few steps → suspend, and between turns the Task rests at suspended waiting for the next human message; only query() (and any Client built with multi_turn=False) ends with TaskCompleted. Any status that has not ended can be cancelled. There is no separate session or workflow object; a host that wants a user-facing "session" builds it on top.

What a Task can wait for ​

The condition a Task stores and the event that satisfies it are the same dataclass. Only the identity fields decide a match; payloads ride along.

ConditionDelivered byMatches on
SubtaskCompletedChildLifecycleObserversubtask_id
SubtaskGroupCompletedChildLifecycleObservergroup_id
HumanResponseReceivedyour human-facing channelhandle
TimerFiredthe worker's timer pollevent.fire_at >= condition.fire_at
ExternalEventany external sourceevent_kind

matches_wake is the single implementation every dispatcher uses, so storage backends cannot disagree about what matches.

How a wake is delivered ​

Task suspended withwake_onhuman · subtask · external eventDispatcher.wakeTimerfire_due_timersDispatcherstores the match,re-queuesmatcheswake_ondeadline passedWorker leases;the wake rides on the leaseEngine writes TaskWoken(commit point)Release:wake marked consumedcrash before release → same wake again
  1. A wake event arrives through Dispatcher.wake (a timer through Dispatcher.fire_due_timers). If it matches, the dispatcher stores the match and puts the Task back on the ready queue.
  2. The next worker to lease the Task receives the wake on Lease.wake_event.
  3. The Engine writes a TaskWoken event. That write is the commit point.
  4. Only then does the worker release with the wake marked consumed.

If a worker dies between steps 2 and 4, the stored match is still there; the stale sweep requeues the Task and the next lease delivers the same wake. If TaskWoken had already landed, the worker sees it and does not write a second one. At-least-once delivery plus idempotent consumption gives exactly-once.

Timers need no outside producer: each worker calls Dispatcher.fire_due_timers(now=…) on an interval, next to the stale sweep. A suspended Task with no queued wake is not an error — it is simply still waiting.

Scale ​

DeploymentSupported
One host, a pool of workersevery backend (in-memory, SQLite, Postgres)
Several hosts sharing one storePostgres only; the lease check runs inside the insert transaction against the database clock

Pointing two host processes at one SQLite file is not supported.

Subtasks ​

A subtask is an ordinary Task with its own log, related to its parent only by parent_task_id; subtask_depth is capped by the budget so delegation cannot recurse forever. The parent suspends on SubtaskCompleted (or SubtaskGroupCompleted for a fan-out) and each child's result comes back as the wake. Every node recovers on its own. The root of the tree, root_task_id, owns anything that outlives a step: background shells, background subagents, a sandbox container.

What this means for you ​

  • Ask a human, wait on a timer or delegate without holding anything open; the answer can arrive days later on a different machine.
  • Run a WorkerLoop pool so someone leases, sweeps and fires timers — see Deploy and the worker loop reference.
  • Use Postgres as soon as more than one host process shares the work.

Design records: task as the only primitive · durable wake · multi-host lease fencing

Next ​

Released under the Apache License 2.0.