The Harness's answer to long-running work is that waiting is free. A run that parks holds no process, no memory, no connection — it is a row with a wait reason, and the dispatch loop wakes it when the reason ends. Kill the server mid-sleep and restart it; the timer sweep finds the due rows and continues.
The four wait reasons
type RunWait =
| {reason: 'children'; toolCallIds: string[]}
| {reason: 'timer'; wakeAt: number}
| {reason: 'event'; waitId: string; timeoutAt?: number; label?: string; answerWith?: string}
| {reason: 'budget-pause'; note?: string}children — the run delegated and is waiting on the tool calls it spawned.
timer — ctx.sleep(ms) persisted a wake time. Parallel sleeps keep the earliest deadline; overwriting would silently delay the shorter sleep's continuation to the longer one's wake. Sleeps under 60 seconds hold the VM with a real timer instead of parking, since the round trip would cost more than the wait.
event — ctx.waitForEvent(match, {timeout}) registered a durable wait in run_event_waits. It resumes on a matching activity event, a trigger's wake, or a human SignalRun.
budget-pause — the run exceeded its wall-clock budget and is holding for a human Resume.
Only timer and event have a clock wake; children and budget-pause are ended by something else happening. That is why parkWakeAt maps a timer to its wakeAt, an event to its optional timeoutAt, and everything else to null — the single not_before column then drives one sweep for both sleeps and timed-out event waits.
A wait is not a trigger
ctx.waitForEvent gets its own table rather than reusing the trigger machinery, and the distinction is deliberate: a trigger is standing user configuration a person lists and edits, while a wait is transient state belonging to one run. Same matching code, different lifecycle — and because both ask the same shared matcher, they can never disagree about what an event means.
A timeout is not special machinery either: it is a timer wait racing the event wait, resolving to null when the clock wins.
Every park is legible
A parked run's card says why, in words: "sleeping until 08:00", "waiting for approval", "paused: daily budget". An event park advertises the signal name that would actually answer it — and there is a test asserting that the advertised signal is one the wait would genuinely accept, so the Answer button can never send something the wait rejects.
A parked parent also keeps conversing: a run waiting on children does not count as "live" for session status, so background work never stalls the chat.
Budgets
A run's budget can bound wall time, children, and depth. Only wall time triggers a pause, measured from the run's creation — so time spent parked counts against it. Resuming drops the wall-clock budget rather than extending it, so the run does not immediately re-pause; every other budget dimension stays in force.
Staying bounded over weeks
A script that loops for days accumulates journal. continueAsNew is the escape: finalize, enqueue a successor carrying declared state only, keep the same place in the tree.
Related
SignalRun — answering an event park by hand
continueAsNew · Runs · Limits
Do you like what you are reading? Subscribe to receive updates.
Unsubscribe anytime