From dae9b5b0e36d1f3a9ba5c48a5d7337870e162bb2 Mon Sep 17 00:00:00 2001 From: antoine-berger <64917674+antoine-berger@users.noreply.github.com> Date: Sun, 6 Sep 2026 17:48:09 -0500 Subject: [PATCH 01/12] docs(agent): document the engine's default pause deadline A pause that omits `timeoutMs` had no documented deadline, so authors assumed a pause waits indefinitely. The engine is gaining a 7-day default (the capability resume-token TTL) after which the run is finalized as failed with `PauseTimeoutError` instead of hanging silently. Document it on `PauseUntilSignalDirective.timeoutMs`, `Pause.timeoutMs`, the `pauseUntilSignal` doc block, and the README's pausing section, with an example of overriding it for a longer human gate. Docs only: no SDK behavior changes. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/pause-deadline-docs.md | 5 +++++ packages/agent/README.md | 13 +++++++++++++ packages/agent/src/directives.ts | 16 ++++++++++++++++ 3 files changed, 34 insertions(+) create mode 100644 .changeset/pause-deadline-docs.md diff --git a/.changeset/pause-deadline-docs.md b/.changeset/pause-deadline-docs.md new file mode 100644 index 000000000..fd81dd2d7 --- /dev/null +++ b/.changeset/pause-deadline-docs.md @@ -0,0 +1,5 @@ +--- +"@sapiom/agent": patch +--- + +Document the engine's default pause deadline on `pauseUntilSignal`. A pause that omits `timeoutMs` now carries a 7-day deadline (the capability resume-token TTL) and is finalized as failed with `PauseTimeoutError` if no signal arrives, instead of waiting forever. Docs only: no SDK behavior changes. diff --git a/packages/agent/README.md b/packages/agent/README.md index 7855689f9..9c677c064 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -178,6 +178,19 @@ Things to know: ``` - **Outside an agent run nothing changes** — `await launch().wait()` the capability as usual; the pause wiring only engages when a step pauses on the handle. +- **Every pause has a deadline.** `timeoutMs` sets it; omitted, the engine applies + its default of 7 days (the capability resume-token TTL). A pause that receives no + signal by then is finalized as failed with `PauseTimeoutError`, so a lost result + surfaces as an error instead of a run that waits forever. Pass an explicit + `timeoutMs` when a human gate needs longer, or when the wait should give up sooner: + + ```ts + return pauseUntilSignal({ + signal: "demo.approval", + resumeStep: "finalize", + timeoutMs: 30 * 24 * 60 * 60 * 1000, // 30 days + }); + ``` ### Compatible capabilities diff --git a/packages/agent/src/directives.ts b/packages/agent/src/directives.ts index 2ef3258c6..1b6a9eb28 100644 --- a/packages/agent/src/directives.ts +++ b/packages/agent/src/directives.ts @@ -73,6 +73,14 @@ export interface PauseUntilSignalDirective { readonly name: string; readonly correlationId?: string; }; + /** + * Deadline for the signal, in ms from the moment the pause is recorded. + * Omitted, the engine applies its default pause deadline of 7 days (the + * capability resume-token TTL, so no dispatched result can land after it). + * A pause that receives no signal by its deadline is finalized as failed + * with `PauseTimeoutError` rather than waiting forever. Pass an explicit + * value for a wait that must run longer or give up sooner. + */ readonly timeoutMs?: number; /** Step to run when the signal arrives. Defaults to the paused step. */ readonly resumeStep?: string; @@ -189,6 +197,7 @@ export interface Pause { readonly kind: typeof DIRECTIVE_KIND.PAUSE_UNTIL_SIGNAL; readonly signal: { readonly name: string; readonly correlationId?: string }; readonly resumeStep?: Resume; + /** Deadline for the signal, in ms. Omitted, the engine applies its 7-day default (see `pauseUntilSignal`). */ readonly timeoutMs?: number; /** Optional audit output recorded for the pausing step. */ readonly output?: unknown; @@ -237,6 +246,13 @@ export function fail(reason?: string, opts?: { output?: unknown }): Fail { * Both are consumed as `return pauseUntilSignal(...)` from an async `run()`, so * async-return flattening makes the sync/async distinction invisible at the call * site. + * + * **Every pause has a deadline.** `timeoutMs` sets it; omitted, the engine + * applies its default of 7 days (the capability resume-token TTL). If no signal + * arrives by then the run is finalized as failed with `PauseTimeoutError`, so a + * lost webhook or a dropped capability result surfaces as an error instead of a + * run that waits forever. Pass an explicit `timeoutMs` for a human gate that + * legitimately needs longer, or for a wait that should give up sooner. */ export function pauseUntilSignal(args: { signal: string; From f4f22be4eb84cfc030e645d29b7fb4eafec68db6 Mon Sep 17 00:00:00 2001 From: antoine-berger <64917674+antoine-berger@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:05:47 -0500 Subject: [PATCH 02/12] docs(agent): correct the pause-deadline docs and unbreak the examples The first pass overstated the new default and left the repo contradicting itself. Three corrections to the SDK copy: - `run_local` never sweeps for a pause deadline, so the expiry only fires against the hosted engine. Say so instead of "every pause has a deadline". - Raising `timeoutMs` past the default buys nothing on a capability pause, since the resume token expires on the same horizon. The README example now shows a human gate, which is the case where a longer wait is meaningful. - The changeset stated the engine behavior in the present tense and closed on "no behavior changes". It now leads with the behavior notice: a run that previously hung forever will surface as a failure. Two shipped examples documented the opposite of the new default: - `examples/approval-chain` stated in three places that its gates deliberately carry no `timeoutMs`, because a lapsed deadline terminates the run rather than resuming it and would skip the graceful `escalate` step. Omission is no longer an escape hatch, it inherits the 7-day default and does the same damage on a one-week horizon. The gates now pass an explicit one-year `GATE_PAUSE_TIMEOUT_MS`: long enough that a slow approver never loses the run, finite enough that an abandoned chain still reaches a terminal state. - `examples/wait-for-webhook` advertised an indefinite wait by default across its README, header comment, `parseTimeoutMs` doc, AGENTS.md and template notes. All now describe the 7-day fallback. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/pause-deadline-docs.md | 6 ++- examples/approval-chain/AGENTS.md | 16 ++++--- examples/approval-chain/README.md | 25 ++++++---- examples/approval-chain/index.ts | 63 ++++++++++++++++--------- examples/wait-for-webhook/AGENTS.md | 2 +- examples/wait-for-webhook/README.md | 16 ++++--- examples/wait-for-webhook/index.ts | 37 ++++++++------- examples/wait-for-webhook/template.json | 2 +- packages/agent/README.md | 24 ++++++---- packages/agent/src/directives.ts | 31 +++++++----- 10 files changed, 135 insertions(+), 87 deletions(-) diff --git a/.changeset/pause-deadline-docs.md b/.changeset/pause-deadline-docs.md index fd81dd2d7..27e1d8c6d 100644 --- a/.changeset/pause-deadline-docs.md +++ b/.changeset/pause-deadline-docs.md @@ -2,4 +2,8 @@ "@sapiom/agent": patch --- -Document the engine's default pause deadline on `pauseUntilSignal`. A pause that omits `timeoutMs` now carries a 7-day deadline (the capability resume-token TTL) and is finalized as failed with `PauseTimeoutError` if no signal arrives, instead of waiting forever. Docs only: no SDK behavior changes. +Document the pause deadline on `pauseUntilSignal`, `PauseUntilSignalDirective.timeoutMs` and `Pause.timeoutMs`. + +Behavior change (hosted engine, not this package): a pause that omits `timeoutMs` used to wait indefinitely, and the hosted engine now gives it a 7-day deadline. Past it the run is finalized as failed with `PauseTimeoutError` instead of parking silently, so a run that previously hung forever will surface as a failure. Pass an explicit `timeoutMs` on a signal pause that must outlive a week, such as a human approval gate. `run_local` is unaffected: the in-memory host records the deadline but never sweeps for it. + +This package ships documentation only, with no type, signature or runtime change. diff --git a/examples/approval-chain/AGENTS.md b/examples/approval-chain/AGENTS.md index 40af33282..ed57564fd 100644 --- a/examples/approval-chain/AGENTS.md +++ b/examples/approval-chain/AGENTS.md @@ -11,13 +11,15 @@ went silent). Inside a step's `run`, Sapiom capabilities are pre-auth'd on ## The sign-off spine - **`present`** records the current gate as `pending`, emails the approver, then - returns `pauseUntilSignal({ signal: "approval.decision", resumeStep: "decide", correlationId: ctx.executionId })`. - It carries a static `pause: { signal, resumeStep: "decide" }` annotation — the - build-time graph edge that must match the directive. **No `timeoutMs`:** the - engine's paused-run reaper *terminates* a lapsed pause (`PauseTimeoutError`) - instead of resuming it, so a gate deadline would hard-fail a slow approval and - skip `escalate`. The gates wait indefinitely; the reminder/escalation cadence - comes from the signal (see below), never the engine deadline. + returns `pauseUntilSignal({ signal: "approval.decision", resumeStep: "decide", correlationId: ctx.executionId, timeoutMs: GATE_PAUSE_TIMEOUT_MS })`. + It carries a static `pause: { signal, resumeStep: "decide" }` annotation, the + build-time graph edge that must match the directive. **A one-year `timeoutMs` + (`GATE_PAUSE_TIMEOUT_MS`):** the engine's paused-run reaper *terminates* a lapsed + pause (`PauseTimeoutError`) instead of resuming it, so a short gate deadline would + hard-fail a slow approval and skip `escalate`. Omitting `timeoutMs` does not avoid + that: a pause with no deadline inherits the engine's 7-day default. The year is an + explicit backstop; the reminder/escalation cadence comes from the signal (see + below), never from the deadline. - **`decide`** reads the approval payload **directly as its `run` input**. Safe default: only an explicit `{ decision: "approve" }` advances; `reject` compensates; `timeout` escalates; anything else (including a `run_local` resume diff --git a/examples/approval-chain/README.md b/examples/approval-chain/README.md index 0eb130d38..de5cf19e3 100644 --- a/examples/approval-chain/README.md +++ b/examples/approval-chain/README.md @@ -52,13 +52,19 @@ The canonical chain state lives in `ctx.shared` (it survives every pause). When Postgres table (`approval_chain_ledger`) via `ctx.sapiom.database` — a best-effort external audit copy that never blocks the chain. -## Reminders, escalation, and why the gates wait forever +## Reminders, escalation, and why the gates wait a year -Each gate pauses **indefinitely** at $0 — it carries no pause `timeoutMs`. That is -deliberate: the engine has a paused-run reaper that *terminates* a lapsed pause -with a `PauseTimeoutError` (it does not resume the step), so a deadline here would -silently fail any approval slower than the deadline and never run the graceful -`escalate` step. A legitimately slow approver must not lose the run. +Each gate pauses at $0 under a deliberately long deadline: `GATE_PAUSE_TIMEOUT_MS`, +one year. The engine has a paused-run reaper that *terminates* a lapsed pause with a +`PauseTimeoutError` (it does not resume the step), so a short deadline here would +silently fail any approval slower than it and never run the graceful `escalate` step. +A legitimately slow approver must not lose the run. + +Omitting `timeoutMs` is not the way to get that. A pause with no deadline inherits the +engine's 7-day default, which hard-fails a two-week approval exactly the same way. One +year is the explicit opt-out: long enough that no realistic approver loses the run, +finite enough that an abandoned chain still reaches a terminal state instead of parking +in the paused table forever. Reminders and escalation are therefore driven entirely by the `approval.decision` signal, not by an engine deadline: @@ -70,9 +76,10 @@ signal, not by an engine deadline: walks `remind` → … → `escalate` for free. `maxReminders` (default 2) bounds how many reminder ticks a gate takes before it -escalates. (The mirror template `wait-for-webhook` *wants* the terminal timeout and -so opts into `timeoutMs`; this chain wants a reminder, so it must not — don't add a -gate `timeoutMs` back without switching to that terminal model.) +escalates. (The mirror template `wait-for-webhook` *wants* a short terminal timeout and +sizes `timeoutMs` to the callback window; this chain wants a reminder loop, so its +deadline is a backstop, not a cadence. Don't shorten the gate `timeoutMs` toward the +reminder interval without switching to that terminal model.) Input: diff --git a/examples/approval-chain/index.ts b/examples/approval-chain/index.ts index 23a0be0fc..291b50801 100644 --- a/examples/approval-chain/index.ts +++ b/examples/approval-chain/index.ts @@ -50,18 +50,24 @@ import { z } from "zod/v4"; * approver and re-pause, up to `maxReminders`, after which the gate escalates. An * explicit `{ decision: "timeout" }` escalates immediately. * - * The gates pause **indefinitely** (no `timeoutMs`) on purpose. The engine has a - * paused-run reaper: a `timeoutMs` sets `pausedUntil`, and a background sweep - * *terminates* the run with a `PauseTimeoutError` once it lapses — it does NOT - * resume the step, so it would never reach `decide`/`remind`/`escalate`. Handing a - * signal pause a `reminderMs`-sized `timeoutMs` (as an earlier version did) would - * therefore hard-fail any approval slower than the reminder interval, bypassing - * the graceful `escalate` step entirely. So reminders/escalation are driven purely - * by the signal convention: the run-detail one-click Approve/Reject, a cron that - * fires `remind`/`timeout` on a schedule, or a `run_local` auto-resume — never the - * engine's pause deadline. (`wait-for-webhook` is the mirror image: it *wants* that - * terminal timeout, so it opts into `timeoutMs`; this chain wants a reminder, so it - * must not.) Do not add `timeoutMs` here without moving to that terminal model. + * The gates carry a deliberately long `GATE_PAUSE_TIMEOUT_MS` (one year), and the + * value is load-bearing. The engine has a paused-run reaper: `timeoutMs` sets + * `pausedUntil`, and a background sweep *terminates* the run with a + * `PauseTimeoutError` once it lapses. It does NOT resume the step, so a lapsed + * gate never reaches `decide`/`remind`/`escalate`. Handing a signal pause a + * `reminderMs`-sized `timeoutMs` (as an earlier version did) would hard-fail any + * approval slower than the reminder interval, bypassing the graceful `escalate` + * step entirely, and omitting `timeoutMs` is no longer an escape: a pause with no + * deadline now inherits the engine's 7-day default, which does the same damage on + * a one-week horizon. One year is the explicit opt-out: long enough that a slow + * approver never loses the run, finite enough that an abandoned chain still lands + * in a terminal state. Reminders and escalation stay driven purely by the signal + * convention: the run-detail one-click Approve/Reject, a cron that fires + * `remind`/`timeout` on a schedule, or a `run_local` auto-resume, never the pause + * deadline. (`wait-for-webhook` is the mirror image: it *wants* a short terminal + * timeout and opts into one; this chain wants a reminder loop, so its deadline is + * a backstop rather than a cadence.) Do not shorten it toward the reminder + * interval without moving to that terminal model. * * ── Chain state ──────────────────────────────────────────────────────────────── * The canonical chain state — which gate we're on, who has approved, the full @@ -86,6 +92,15 @@ const APPROVAL_SIGNAL = "approval.decision"; /** Reminders sent before a silent gate escalates. */ const DEFAULT_MAX_REMINDERS = 2; +// An explicit, deliberately long gate deadline. A pause with no `timeoutMs` gets +// the hosted engine's 7-day default, and a lapsed deadline *terminates* the run +// with a PauseTimeoutError instead of resuming the step, which would hard-fail +// any approval slower than a week and skip `escalate` entirely. One year is the +// opt-out: long enough that a slow approver never loses the run, finite enough +// that an abandoned chain still reaches a terminal state instead of parking +// forever. Reminders and escalation come from the signal, never from this value. +const GATE_PAUSE_TIMEOUT_MS = 365 * 24 * 60 * 60 * 1000; + /** Postgres table the durable ledger appends to. */ const LEDGER_TABLE = "approval_chain_ledger"; @@ -498,17 +513,18 @@ const present = defineStep({ // pause is kept on purpose: an approver IS assigned, so somebody can resume // it, and the run detail ships one-click Approve/Reject. // - // NO `timeoutMs`: the engine's paused-run reaper *terminates* a lapsed pause - // (PauseTimeoutError) rather than resuming it, so a deadline here would - // silently fail any approval slower than the deadline and skip `escalate` - // entirely. The reminder/escalation loop is driven by the `approval.decision` - // signal (one-click UI, a cron firing `remind`/`timeout`, or a `run_local` - // auto-resume) — never the engine deadline. See the header note before adding - // one back. + // `GATE_PAUSE_TIMEOUT_MS` is load-bearing, not decoration: a lapsed deadline + // *terminates* the run (PauseTimeoutError) rather than resuming it, so the + // engine's 7-day default would silently fail any approval slower than a week + // and skip `escalate`. The reminder/escalation loop is driven by the + // `approval.decision` signal (one-click UI, a cron firing `remind`/`timeout`, + // or a `run_local` auto-resume), never by the deadline. Read the header note + // before shortening it or dropping it. return pauseUntilSignal({ signal: APPROVAL_SIGNAL, resumeStep: "decide", correlationId: ctx.executionId, + timeoutMs: GATE_PAUSE_TIMEOUT_MS, }); }, }); @@ -517,8 +533,8 @@ const decide = defineStep({ name: "decide", next: ["present", "remind", "finalize", "compensate", "escalate"], // `payload` IS the approval signal body (or empty on a `remind` / `run_local` - // auto-resume — the gates never carry a pause `timeoutMs`, so the engine reaper - // never lands here; see `present`). + // auto-resume). The gates carry a one-year `timeoutMs`, so in practice the + // deadline sweep never lands here; see `present`. async run(payload: ApprovalDecision, ctx: Ctx) { const approvers = must(ctx.shared.get("approvers"), "approvers"); const gateIndex = must(ctx.shared.get("gateIndex"), "gateIndex"); @@ -614,12 +630,13 @@ const remind = defineStep({ }); // Re-suspend on the same gate until a decision (or the next reminder tick). - // As in `present`, no `timeoutMs` — the reminder cadence comes from the signal - // convention, not the engine's (terminal) pause deadline. + // As in `present`, the same one-year backstop: the reminder cadence comes from + // the signal convention, not from the engine's (terminal) pause deadline. return pauseUntilSignal({ signal: APPROVAL_SIGNAL, resumeStep: "decide", correlationId: ctx.executionId, + timeoutMs: GATE_PAUSE_TIMEOUT_MS, }); }, }); diff --git a/examples/wait-for-webhook/AGENTS.md b/examples/wait-for-webhook/AGENTS.md index efbc48d5d..a054e800a 100644 --- a/examples/wait-for-webhook/AGENTS.md +++ b/examples/wait-for-webhook/AGENTS.md @@ -7,7 +7,7 @@ This project defines exactly one Sapiom agent in `index.ts` — **Wait-for-Webho - **`kickoff`** registers `{ executionId, signal, correlationId: ctx.executionId }` with the external job, then returns `pauseUntilSignal({ signal, resumeStep: "decide", correlationId: ctx.executionId })`. It also carries a static `pause: { signal, resumeStep: "decide" }` annotation — the build-time graph edge that must match the directive. - **`decide`** reads the callback payload **directly as its `run` input** — that's the signal payload the external world delivered. Everything else (config, job params, ids) is read back from `ctx.shared`, which survives the pause. - `pauseUntilSignal` is a **runtime primitive, not a metered capability**. The only billed call is the model summary in `decide` (`ctx.sapiom.llm.run`, the live x402 path). -- **Optional deadline.** The pause waits indefinitely by default. Passing `timeoutMs` caps it: with no callback in that window, the engine's deadline sweep fails the run (a pause-timeout terminal state) instead of parking it forever. `kickoff` reads it from `config.CALLBACK_TIMEOUT_MS` via `parseTimeoutMs` — absent ⇒ indefinite wait; invalid ⇒ a loud throw at `kickoff` (a silently-dropped cap would defeat the point). +- **Optional deadline.** Passing `timeoutMs` sizes the wait to the callback window: with no callback in it, the engine's deadline sweep fails the run (a pause-timeout terminal state). Omitting it does not mean waiting forever, it means inheriting the engine's 7-day default, which is the same terminal state on a slower horizon. `kickoff` reads the value from `config.CALLBACK_TIMEOUT_MS` via `parseTimeoutMs` — absent ⇒ the engine default; invalid ⇒ a loud throw at `kickoff` (a silently-dropped cap would defeat the point). ## Authoring diff --git a/examples/wait-for-webhook/README.md b/examples/wait-for-webhook/README.md index 3a7ecf6b1..7168fb0f7 100644 --- a/examples/wait-for-webhook/README.md +++ b/examples/wait-for-webhook/README.md @@ -1,7 +1,7 @@ # Wait-for-Webhook Durable pause/resume around any slow external callback. The run starts a slow -external async job, then **suspends indefinitely at $0** until a webhook/callback +external async job, then **suspends at $0** until a webhook/callback fires — no polling loop, no held worker, no billed idle time — and resumes exactly where it left off when the external world is ready. @@ -31,8 +31,9 @@ With no `CALLBACK_REGISTER_URL` (or `DRY_RUN` set), `kickoff` runs offline via i ## Capping the wait (optional deadline) -By default the pause waits **indefinitely** at $0 — that's the whole point. But a -callback that never arrives would park the run forever. To bound it, set +The pause costs $0 for as long as it lasts, which is the whole point, but it is not +unbounded: a pause with no `timeoutMs` inherits the engine's **7-day default** +deadline. To size the wait to your callback window instead, set `config.CALLBACK_TIMEOUT_MS` to a positive number of milliseconds: ```json @@ -41,10 +42,11 @@ callback that never arrives would park the run forever. To bound it, set If no callback fires within that window, the engine's deadline sweep ends the run with a pause-timeout failure — an honest terminal state ("no callback within N") -instead of a run parked forever. Leave `CALLBACK_TIMEOUT_MS` unset to keep the -default indefinite wait. A non-numeric or non-positive value is rejected at -`kickoff` (a silently-ignored cap would just reintroduce the forever-park it's -meant to prevent). +instead of a run parked forever. Leave `CALLBACK_TIMEOUT_MS` unset to fall back on +the engine's 7-day default, which produces the same terminal state on a slower +horizon. A non-numeric or non-positive value is rejected at `kickoff` (a +silently-ignored cap would just reintroduce the forever-park it's meant to +prevent). ## Run it with Claude + the Sapiom MCP diff --git a/examples/wait-for-webhook/index.ts b/examples/wait-for-webhook/index.ts index 10ce144d3..48b82a35b 100644 --- a/examples/wait-for-webhook/index.ts +++ b/examples/wait-for-webhook/index.ts @@ -14,7 +14,7 @@ import { z } from "zod/v4"; * The sharpest showcase of the platform's durability differentiator, and the * direct counter to "agents are too expensive to run": `kickoff` starts a slow * external async job and registers a resume contract, then the run **suspends - * indefinitely at $0** via `pauseUntilSignal` — no polling loop, no held worker, + * at $0** via `pauseUntilSignal` — no polling loop, no held worker, * no billed idle time. It resumes only when the external world fires the signal * (a webhook/callback), delivering a result payload that becomes the resumed * step's input. `decide` summarizes that payload with a model and branches to @@ -34,13 +34,13 @@ import { z } from "zod/v4"; * run does NOT pause. It goes straight to `decide` on an empty payload and says * so in its output. The durable pause is what a configured run does. * - * Deadline: the pause waits **indefinitely** by default — the $0-forever - * guarantee. A callback that never arrives would otherwise park the run for - * good, so set `config.CALLBACK_TIMEOUT_MS` to a positive number of - * milliseconds to cap the wait. If no callback fires within that window, the - * engine's deadline sweep ends the run with a pause-timeout failure — an honest - * terminal state ("no callback within N") instead of a permanently parked run. - * Unset/blank keeps the indefinite wait. + * Deadline: the pause is $0 for as long as it lasts, but it is not unbounded. + * With no `timeoutMs` it inherits the engine's 7-day default deadline, so set + * `config.CALLBACK_TIMEOUT_MS` to a positive number of milliseconds to size the + * wait to your own callback window. Either way, if no callback fires inside the + * deadline the engine's sweep ends the run with a pause-timeout failure: an + * honest terminal state ("no callback within N") instead of a permanently + * parked run. Unset/blank falls back on the 7-day default. */ /** String-only config bag (matches how templates receive their `config`). */ @@ -53,8 +53,8 @@ interface WaitForWebhookInput { /** * Config bag. `CALLBACK_REGISTER_URL` (+ optional `CALLBACK_REGISTER_KEY`) * points at the external job — absent (or `DRY_RUN`) ⇒ offline. Optional - * `CALLBACK_TIMEOUT_MS` caps the wait (see `parseTimeoutMs`); absent ⇒ waits - * indefinitely at $0. + * `CALLBACK_TIMEOUT_MS` sizes the wait (see `parseTimeoutMs`); absent ⇒ the + * engine's 7-day default deadline. */ config?: Config; } @@ -108,12 +108,13 @@ function isDryRun(config: Config): boolean { /** * Optional deadline for the pause, in milliseconds, from - * `config.CALLBACK_TIMEOUT_MS`. Absent/blank ⇒ `undefined` (wait indefinitely at - * $0, the default). A positive integer caps the wait: with no callback inside it, - * the engine's deadline sweep terminates the run with a pause-timeout failure - * rather than parking it forever. A non-positive or unparseable value is - * rejected — silently ignoring a cap would reintroduce the forever-park bug it's - * meant to prevent, so a misconfigured deadline fails loudly at `kickoff`. + * `config.CALLBACK_TIMEOUT_MS`. Absent/blank ⇒ `undefined`, which leaves the + * directive without a `timeoutMs` and so inherits the engine's 7-day default. + * A positive integer sizes the wait instead: with no callback inside it, the + * engine's deadline sweep terminates the run with a pause-timeout failure + * rather than parking it. A non-positive or unparseable value is rejected — + * silently ignoring a cap would reintroduce the forever-park bug it's meant to + * prevent, so a misconfigured deadline fails loudly at `kickoff`. */ function parseTimeoutMs(config: Config): number | undefined { const raw = (config.CALLBACK_TIMEOUT_MS ?? "").trim(); @@ -226,8 +227,8 @@ const kickoff = defineStep({ // Suspend at $0 until the external world fires SIGNAL for this correlationId. // With `timeoutMs` set, the engine's deadline sweep instead fails the run if - // no callback arrives in time — an honest terminal state, not a forever park. - // Omitted ⇒ the default indefinite wait. + // no callback arrives in time: an honest terminal state, not a forever park. + // Omitted ⇒ the engine's 7-day default deadline, same outcome, slower. return pauseUntilSignal({ signal: SIGNAL, resumeStep: "decide", diff --git a/examples/wait-for-webhook/template.json b/examples/wait-for-webhook/template.json index 8c26440dd..1f91488b6 100644 --- a/examples/wait-for-webhook/template.json +++ b/examples/wait-for-webhook/template.json @@ -12,7 +12,7 @@ "$0 while waiting", "Branch on the outcome" ], - "notes": "Click **Use this template** — Sapiom builds and deploys it for you, then run it from the workflow page. Your $5 signup credit covers first runs.\n\nA deployed run pauses at `kickoff` and waits for the callback. Instead of a real webhook, send the signal yourself with the MCP `sapiom_dev_agents_signal` tool: `{ \"executionId\": \"\", \"name\": \"webhook.callback\", \"correlationId\": \"\", \"payload\": { \"status\": \"succeeded\", \"result\": { ... } } }`. The `payload` becomes `decide`'s input, and a `status` of `failed` or `error` makes it reject. With no `CALLBACK_REGISTER_URL` set (or `DRY_RUN`), it runs offline and skips the live job.\n\nThe pause waits indefinitely at $0 by default. To stop a never-arriving callback from parking the run forever, set `config.CALLBACK_TIMEOUT_MS` to a positive number of milliseconds — if no callback lands in that window, the run ends with an honest pause-timeout failure instead.\n\nPrefer to work from the code? Run it locally with `run_local` to trace the whole flow — kickoff, pause, decide, branch — for free, or edit and deploy it with the Sapiom MCP.", + "notes": "Click **Use this template** — Sapiom builds and deploys it for you, then run it from the workflow page. Your $5 signup credit covers first runs.\n\nA deployed run pauses at `kickoff` and waits for the callback. Instead of a real webhook, send the signal yourself with the MCP `sapiom_dev_agents_signal` tool: `{ \"executionId\": \"\", \"name\": \"webhook.callback\", \"correlationId\": \"\", \"payload\": { \"status\": \"succeeded\", \"result\": { ... } } }`. The `payload` becomes `decide`'s input, and a `status` of `failed` or `error` makes it reject. With no `CALLBACK_REGISTER_URL` set (or `DRY_RUN`), it runs offline and skips the live job.\n\nThe pause costs $0 for as long as it lasts, and with no `timeoutMs` it inherits the engine's 7-day default deadline. To size the wait to your own callback window, set `config.CALLBACK_TIMEOUT_MS` to a positive number of milliseconds — if no callback lands in that window, the run ends with an honest pause-timeout failure instead.\n\nPrefer to work from the code? Run it locally with `run_local` to trace the whole flow — kickoff, pause, decide, branch — for free, or edit and deploy it with the Sapiom MCP.", "examples": [ { "title": "Callback reports success", diff --git a/packages/agent/README.md b/packages/agent/README.md index 9c677c064..c7c5340d9 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -178,20 +178,28 @@ Things to know: ``` - **Outside an agent run nothing changes** — `await launch().wait()` the capability as usual; the pause wiring only engages when a step pauses on the handle. -- **Every pause has a deadline.** `timeoutMs` sets it; omitted, the engine applies - its default of 7 days (the capability resume-token TTL). A pause that receives no - signal by then is finalized as failed with `PauseTimeoutError`, so a lost result - surfaces as an error instead of a run that waits forever. Pass an explicit - `timeoutMs` when a human gate needs longer, or when the wait should give up sooner: +- **A hosted pause has a deadline.** `timeoutMs` sets it; omitted, the hosted engine + applies its default of 7 days. A pause that receives no signal by then is finalized + as failed with `PauseTimeoutError`, so a dropped result surfaces as an error rather + than a run that parks forever. For a *capability* pause the default is the right + size and raising it buys nothing: the resume token expires on the same horizon, so + a result arriving later could not be accepted anyway. Pass an explicit `timeoutMs` + on a plain signal pause that no capability backs, such as a human gate you expect + to outlive a week: ```ts + // A human gate, not a capability pause: nothing but this deadline bounds the wait. return pauseUntilSignal({ - signal: "demo.approval", - resumeStep: "finalize", - timeoutMs: 30 * 24 * 60 * 60 * 1000, // 30 days + signal: "approval.decision", + resumeStep: "decide", + timeoutMs: 365 * 24 * 60 * 60 * 1000, // one year }); ``` + `run_local` does not enforce any of this: the in-memory host records the deadline + but never sweeps for it, so a local run parks on a pause whatever `timeoutMs` says. + The expiry path only fires against the hosted engine. + ### Compatible capabilities Any capability whose `launch` returns a `DispatchHandle` (a `dispatch` member) is diff --git a/packages/agent/src/directives.ts b/packages/agent/src/directives.ts index 1b6a9eb28..03acc4362 100644 --- a/packages/agent/src/directives.ts +++ b/packages/agent/src/directives.ts @@ -75,11 +75,14 @@ export interface PauseUntilSignalDirective { }; /** * Deadline for the signal, in ms from the moment the pause is recorded. - * Omitted, the engine applies its default pause deadline of 7 days (the - * capability resume-token TTL, so no dispatched result can land after it). - * A pause that receives no signal by its deadline is finalized as failed - * with `PauseTimeoutError` rather than waiting forever. Pass an explicit - * value for a wait that must run longer or give up sooner. + * Omitted, the hosted engine applies its default pause deadline of 7 days + * (the capability resume-token TTL, so no dispatched result can land after + * it). A pause that receives no signal by its deadline is finalized as + * failed with `PauseTimeoutError` rather than parking forever. Pass an + * explicit value for a wait that must run longer or give up sooner. + * + * `run_local` records the deadline but never sweeps for it, so the expiry + * only fires against the hosted engine. */ readonly timeoutMs?: number; /** Step to run when the signal arrives. Defaults to the paused step. */ @@ -197,7 +200,7 @@ export interface Pause { readonly kind: typeof DIRECTIVE_KIND.PAUSE_UNTIL_SIGNAL; readonly signal: { readonly name: string; readonly correlationId?: string }; readonly resumeStep?: Resume; - /** Deadline for the signal, in ms. Omitted, the engine applies its 7-day default (see `pauseUntilSignal`). */ + /** Deadline for the signal, in ms. Omitted, the hosted engine applies its 7-day default (see `pauseUntilSignal`). */ readonly timeoutMs?: number; /** Optional audit output recorded for the pausing step. */ readonly output?: unknown; @@ -247,12 +250,16 @@ export function fail(reason?: string, opts?: { output?: unknown }): Fail { * async-return flattening makes the sync/async distinction invisible at the call * site. * - * **Every pause has a deadline.** `timeoutMs` sets it; omitted, the engine - * applies its default of 7 days (the capability resume-token TTL). If no signal - * arrives by then the run is finalized as failed with `PauseTimeoutError`, so a - * lost webhook or a dropped capability result surfaces as an error instead of a - * run that waits forever. Pass an explicit `timeoutMs` for a human gate that - * legitimately needs longer, or for a wait that should give up sooner. + * **A hosted pause has a deadline.** `timeoutMs` sets it; omitted, the hosted + * engine applies its default of 7 days (the capability resume-token TTL). If no + * signal arrives by then the run is finalized as failed with `PauseTimeoutError`, + * so a lost webhook or a dropped capability result surfaces as an error rather + * than a run that parks forever. Raising it past the default buys nothing on a + * capability pause (the resume token expires on the same horizon); pass an + * explicit `timeoutMs` on a plain signal pause that no capability backs, such as + * a human gate expected to outlive a week, or on any wait that should give up + * sooner. `run_local` records the deadline but never sweeps for it, so the + * expiry only fires against the hosted engine. */ export function pauseUntilSignal(args: { signal: string; From 66bf6ba461e235d200e65c8d8e3de68e674dc148 Mon Sep 17 00:00:00 2001 From: antoine-berger <64917674+antoine-berger@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:15:12 -0500 Subject: [PATCH 03/12] docs(agent): correct the run_local claim and the changeset tense Two surfaces were wrong about the local runner. `runLocal` does not park on a pause: its loop resumes any paused row immediately with the registered signal result or an empty payload (`run-local.ts:152-155`), and with `timeoutMs` omitted the in-memory store writes `pausedUntil = null`, so nothing is recorded either. An author reading "a local run parks on a pause" would file a bug the first time a gate walked straight through. Say what actually happens: no deadline is applied or enforced locally. The changeset also stated the engine default in the present tense, on the one surface that cannot be edited after publish. If `@sapiom/agent` ships before the engine deploy, the tarball CHANGELOG tells consumers their pauses fail at 7 days while they still hang forever. Reworded as a forthcoming change tied to the SAP-3207 engine deploy. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/pause-deadline-docs.md | 2 +- packages/agent/README.md | 7 ++++--- packages/agent/src/directives.ts | 9 +++++---- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.changeset/pause-deadline-docs.md b/.changeset/pause-deadline-docs.md index 27e1d8c6d..b89cf8434 100644 --- a/.changeset/pause-deadline-docs.md +++ b/.changeset/pause-deadline-docs.md @@ -4,6 +4,6 @@ Document the pause deadline on `pauseUntilSignal`, `PauseUntilSignalDirective.timeoutMs` and `Pause.timeoutMs`. -Behavior change (hosted engine, not this package): a pause that omits `timeoutMs` used to wait indefinitely, and the hosted engine now gives it a 7-day deadline. Past it the run is finalized as failed with `PauseTimeoutError` instead of parking silently, so a run that previously hung forever will surface as a failure. Pass an explicit `timeoutMs` on a signal pause that must outlive a week, such as a human approval gate. `run_local` is unaffected: the in-memory host records the deadline but never sweeps for it. +Forthcoming behavior change on the hosted engine, not in this package: a pause that omits `timeoutMs` waits indefinitely today, and will instead carry a 7-day deadline once the engine change for SAP-3207 is deployed. Past that deadline the run is finalized as failed with `PauseTimeoutError` rather than parking silently, so a run that used to hang forever will surface as a failure. Pass an explicit `timeoutMs` on a signal pause that must outlive a week, such as a human approval gate. `run_local` is unaffected either way: it never applies or enforces a pause deadline, it auto-resumes every pause immediately. This package ships documentation only, with no type, signature or runtime change. diff --git a/packages/agent/README.md b/packages/agent/README.md index c7c5340d9..2661728e7 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -196,9 +196,10 @@ Things to know: }); ``` - `run_local` does not enforce any of this: the in-memory host records the deadline - but never sweeps for it, so a local run parks on a pause whatever `timeoutMs` says. - The expiry path only fires against the hosted engine. + `run_local` neither applies nor enforces any of this: it auto-resumes every pause + immediately, with the registered capability result or an empty payload. A local run + never sits at a gate and never times out, so the deadline is only observable against + the hosted engine. ### Compatible capabilities diff --git a/packages/agent/src/directives.ts b/packages/agent/src/directives.ts index 03acc4362..e7b529d6c 100644 --- a/packages/agent/src/directives.ts +++ b/packages/agent/src/directives.ts @@ -81,8 +81,8 @@ export interface PauseUntilSignalDirective { * failed with `PauseTimeoutError` rather than parking forever. Pass an * explicit value for a wait that must run longer or give up sooner. * - * `run_local` records the deadline but never sweeps for it, so the expiry - * only fires against the hosted engine. + * `run_local` neither applies nor enforces this: it auto-resumes every pause + * immediately, so the deadline is only observable against the hosted engine. */ readonly timeoutMs?: number; /** Step to run when the signal arrives. Defaults to the paused step. */ @@ -258,8 +258,9 @@ export function fail(reason?: string, opts?: { output?: unknown }): Fail { * capability pause (the resume token expires on the same horizon); pass an * explicit `timeoutMs` on a plain signal pause that no capability backs, such as * a human gate expected to outlive a week, or on any wait that should give up - * sooner. `run_local` records the deadline but never sweeps for it, so the - * expiry only fires against the hosted engine. + * sooner. `run_local` neither applies nor enforces this: it auto-resumes every + * pause immediately, with the registered capability result or an empty payload, + * so a local run never sits at a gate and never times out. */ export function pauseUntilSignal(args: { signal: string; From 579cbc052b2f0ab08a5569b9b4edd9916410e82a Mon Sep 17 00:00:00 2001 From: antoine-berger <64917674+antoine-berger@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:42:40 -0500 Subject: [PATCH 04/12] docs(examples): make every pause deadline an explicit decision `approval-chain` was fixed because omitting `timeoutMs` no longer means an unbounded wait, it means inheriting the engine's 7-day default. The same construct was left untouched in six other pause sites, so a two-week approval would still lose its run in templates whose entire point is a human gate. Human gates now carry the same explicit one-year ceiling as `approval-chain`: `human-in-the-loop` (approval and candidate confirm), `proposal-generator`, `scheduled-compliance-audit`. The two machine waits keep the default and say why, so the choice is on the page rather than inherited by accident: `pr-review-bot` (a PR event that has not arrived in a week is not coming) and `durable-backfill` (a schedule that has not ticked in a week has stopped, and failing loudly beats a backfill parked mid-cursor). Co-Authored-By: Claude Opus 5 (1M context) --- examples/durable-backfill/index.ts | 3 +++ examples/human-in-the-loop/index.ts | 11 +++++++++++ examples/pr-review-bot/index.ts | 3 +++ examples/proposal-generator/index.ts | 10 ++++++++++ examples/scheduled-compliance-audit/index.ts | 10 ++++++++++ 5 files changed, 37 insertions(+) diff --git a/examples/durable-backfill/index.ts b/examples/durable-backfill/index.ts index c72667d42..aa2348e94 100644 --- a/examples/durable-backfill/index.ts +++ b/examples/durable-backfill/index.ts @@ -514,6 +514,9 @@ const processStep = defineStep({ }); return goto("process", {}); } + // No `timeoutMs` on purpose: the engine's 7-day default is the right ceiling + // for a heartbeat. A schedule that has not ticked in a week has stopped, and + // failing the backfill loudly beats leaving it parked mid-cursor forever. return pauseUntilSignal({ signal: HEARTBEAT, resumeStep: "process", diff --git a/examples/human-in-the-loop/index.ts b/examples/human-in-the-loop/index.ts index 7daa7097e..dcd613b51 100644 --- a/examples/human-in-the-loop/index.ts +++ b/examples/human-in-the-loop/index.ts @@ -54,6 +54,15 @@ const APPROVAL_SIGNAL = "approval.decision"; /** The signal a candidate fires to accept or decline a provisional offer. */ const CONFIRM_SIGNAL = "candidate.confirm"; +/** + * Explicit deadline for a human gate, one year. A pause with no `timeoutMs` + * inherits the engine's 7-day default, and a lapsed deadline *terminates* the + * run rather than resuming it, so the default would hard-fail any approval + * slower than a week. Long enough that a slow approver never loses the run, + * finite enough that an abandoned one still reaches a terminal state. + */ +const GATE_PAUSE_TIMEOUT_MS = 365 * 24 * 60 * 60 * 1000; + // ─────────────────────────────────────────────────────────────── shapes ── /** String-only config bag (matches how templates receive their `config`). */ type Config = Record; @@ -417,6 +426,7 @@ const notifyApprover = defineStep({ signal: APPROVAL_SIGNAL, resumeStep: "onDecision", correlationId: ctx.executionId, + timeoutMs: GATE_PAUSE_TIMEOUT_MS, }); }, }); @@ -500,6 +510,7 @@ const offer = defineStep({ signal: CONFIRM_SIGNAL, resumeStep: "resolve", correlationId: ctx.executionId, + timeoutMs: GATE_PAUSE_TIMEOUT_MS, }); }, }); diff --git a/examples/pr-review-bot/index.ts b/examples/pr-review-bot/index.ts index 852ba8c56..b5fe3cd2c 100644 --- a/examples/pr-review-bot/index.ts +++ b/examples/pr-review-bot/index.ts @@ -383,6 +383,9 @@ const watch = defineStep({ } // Suspend at $0 until the webhook fires SIGNAL for this correlationId. + // No `timeoutMs` on purpose: the engine's 7-day default is the right ceiling + // here. A PR event that has not arrived in a week is not coming, and a + // terminal PauseTimeoutError beats a run parked on a dead webhook. return pauseUntilSignal({ signal: SIGNAL, resumeStep: "review", diff --git a/examples/proposal-generator/index.ts b/examples/proposal-generator/index.ts index f49f2f40f..4e3bf68b9 100644 --- a/examples/proposal-generator/index.ts +++ b/examples/proposal-generator/index.ts @@ -66,6 +66,15 @@ import { z } from "zod/v4"; /** The signal a human fires to approve or reject the drafted proposal. */ const DECISION_SIGNAL = "proposal.decision"; +/** + * Explicit deadline for a human gate, one year. A pause with no `timeoutMs` + * inherits the engine's 7-day default, and a lapsed deadline *terminates* the + * run rather than resuming it, so the default would hard-fail any approval + * slower than a week. Long enough that a slow approver never loses the run, + * finite enough that an abandoned one still reaches a terminal state. + */ +const GATE_PAUSE_TIMEOUT_MS = 365 * 24 * 60 * 60 * 1000; + /** Package the sandbox installs to lay out the PDF (pure JS, no native deps). */ const PDF_PACKAGE = "pdf-lib@1.17.1"; @@ -612,6 +621,7 @@ const review = defineStep({ signal: DECISION_SIGNAL, resumeStep: "onDecision", correlationId: ctx.executionId, + timeoutMs: GATE_PAUSE_TIMEOUT_MS, }); }, }); diff --git a/examples/scheduled-compliance-audit/index.ts b/examples/scheduled-compliance-audit/index.ts index acaaaa50e..a4c404700 100644 --- a/examples/scheduled-compliance-audit/index.ts +++ b/examples/scheduled-compliance-audit/index.ts @@ -58,6 +58,15 @@ const MAX_BODY_CHARS = 2000; /** The signal a human fires to approve or reject the attestation. */ const SIGNOFF_SIGNAL = "attestation.signoff"; +/** + * Explicit deadline for a human gate, one year. A pause with no `timeoutMs` + * inherits the engine's 7-day default, and a lapsed deadline *terminates* the + * run rather than resuming it, so the default would hard-fail any approval + * slower than a week. Long enough that a slow approver never loses the run, + * finite enough that an abandoned one still reaches a terminal state. + */ +const GATE_PAUSE_TIMEOUT_MS = 365 * 24 * 60 * 60 * 1000; + // ─────────────────────────────────────────────────────────────── shapes ── /** A resource whose current state should be audited against the policy. */ interface ResourceRef { @@ -397,6 +406,7 @@ const review = defineStep({ signal: SIGNOFF_SIGNAL, resumeStep: "onSignoff", correlationId: ctx.executionId, + timeoutMs: GATE_PAUSE_TIMEOUT_MS, }); }, }); From c043827eee9e374e881375d17eae4042378f0ad7 Mon Sep 17 00:00:00 2001 From: antoine-berger <64917674+antoine-berger@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:46:29 -0500 Subject: [PATCH 05/12] docs(agent): the 7-day default does not bound a dispatched child agent Three surfaces justified the default with the capability resume-token TTL and concluded that raising `timeoutMs` on a capability pause buys nothing, because a later result could not be accepted. That holds for the sandboxed coding path and not for a child agent. The TTL is the `exp` of a JWT minted at dispatch and injected into the sandbox as `SAPIOM_CAPABILITY_RESUME_TOKEN`; it is verified only on the gateway's HTTP callbacks. A dispatched child returns through the orchestration-resume path, which verifies no token at all: it resolves the durable dispatch row and fires `agents.result` directly. A child can therefore report back long after seven days, and the advice not to raise `timeoutMs` would fail the parent while the child is still working. Nothing cascades that failure to the child, so it keeps running and its result is dropped against a parent that is no longer paused. Reported on SAP-3207 for the engine side. Here, say what actually bounds what, and tell authors to size a child pause on the child's worst case. Co-Authored-By: Claude Opus 5 (1M context) --- packages/agent/README.md | 14 ++++++++------ packages/agent/src/directives.ts | 23 +++++++++++++---------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/packages/agent/README.md b/packages/agent/README.md index 2661728e7..5b28e7e40 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -181,14 +181,16 @@ Things to know: - **A hosted pause has a deadline.** `timeoutMs` sets it; omitted, the hosted engine applies its default of 7 days. A pause that receives no signal by then is finalized as failed with `PauseTimeoutError`, so a dropped result surfaces as an error rather - than a run that parks forever. For a *capability* pause the default is the right - size and raising it buys nothing: the resume token expires on the same horizon, so - a result arriving later could not be accepted anyway. Pass an explicit `timeoutMs` - on a plain signal pause that no capability backs, such as a human gate you expect - to outlive a week: + than a run that parks forever. The default matches the sandboxed capability's + resume-token TTL, so for a coding pause a later result could not be accepted + anyway. It does **not** bound a dispatched child agent: a child's result comes + back through stored parent linkage with no token check, so it can land long after + seven days. Size `timeoutMs` to whatever you are waiting on, on any pause that can + outlive a week: a child run's worst case (otherwise the parent fails while the + child is still working), or a human gate: ```ts - // A human gate, not a capability pause: nothing but this deadline bounds the wait. + // A human gate: nothing but this deadline bounds the wait. return pauseUntilSignal({ signal: "approval.decision", resumeStep: "decide", diff --git a/packages/agent/src/directives.ts b/packages/agent/src/directives.ts index e7b529d6c..d9eff5120 100644 --- a/packages/agent/src/directives.ts +++ b/packages/agent/src/directives.ts @@ -75,11 +75,11 @@ export interface PauseUntilSignalDirective { }; /** * Deadline for the signal, in ms from the moment the pause is recorded. - * Omitted, the hosted engine applies its default pause deadline of 7 days - * (the capability resume-token TTL, so no dispatched result can land after - * it). A pause that receives no signal by its deadline is finalized as - * failed with `PauseTimeoutError` rather than parking forever. Pass an - * explicit value for a wait that must run longer or give up sooner. + * Omitted, the hosted engine applies its default pause deadline of 7 days. + * A pause that receives no signal by its deadline is finalized as failed + * with `PauseTimeoutError` rather than parking forever. Pass an explicit + * value for a wait that must run longer or give up sooner: a dispatched + * child agent is not bounded by the default (see `pauseUntilSignal`). * * `run_local` neither applies nor enforces this: it auto-resumes every pause * immediately, so the deadline is only observable against the hosted engine. @@ -254,11 +254,14 @@ export function fail(reason?: string, opts?: { output?: unknown }): Fail { * engine applies its default of 7 days (the capability resume-token TTL). If no * signal arrives by then the run is finalized as failed with `PauseTimeoutError`, * so a lost webhook or a dropped capability result surfaces as an error rather - * than a run that parks forever. Raising it past the default buys nothing on a - * capability pause (the resume token expires on the same horizon); pass an - * explicit `timeoutMs` on a plain signal pause that no capability backs, such as - * a human gate expected to outlive a week, or on any wait that should give up - * sooner. `run_local` neither applies nor enforces this: it auto-resumes every + * than a run that parks forever. The default matches the sandboxed capability's + * resume-token TTL, so for a coding pause a later result could not be accepted + * anyway. It does NOT bound a dispatched child agent: a child's result returns + * through stored parent linkage with no token check, so it can land long after + * seven days, and the default would fail the parent while the child is still + * working. Size `timeoutMs` to what you are waiting on whenever it can outlive a + * week, a child run's worst case or a human gate, or shorten it on any wait that + * should give up sooner. `run_local` neither applies nor enforces this: it auto-resumes every * pause immediately, with the registered capability result or an empty payload, * so a local run never sits at a gate and never times out. */ From 1576f92e87a844b96019323d85469bfb788b9601 Mon Sep 17 00:00:00 2001 From: antoine-berger <64917674+antoine-berger@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:34:21 -0500 Subject: [PATCH 06/12] docs(agent): let the engine size a human gate's deadline The engine recognizes a run parked on a human approval gate and gives it a one-year deadline, applying the 7-day default only to machine waits. The hardcoded one-year `GATE_PAUSE_TIMEOUT_MS` added to five templates was working around a default that does not apply to them, so it goes: `approval-chain` (both the initial gate and the reminder re-pause), `human-in-the-loop` (approval and candidate confirm), `proposal-generator`, `scheduled-compliance-audit`. Their gates carry no `timeoutMs` again, and the prose says why omitting it is safe rather than why an explicit value was required. The SDK surfaces now state the rule as the engine applies it: a default chosen from what the pause is waiting on, 7 days for a machine wait and one year for a recognized human gate. The child-agent carve-out stands and is now the one case that still needs an explicit `timeoutMs`, since a child's result returns through parent linkage rather than a resume token; the README example moves to that case. The two machine waits are untouched: `pr-review-bot` and `durable-backfill` keep the 7-day default and the reason already written beside them. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/pause-deadline-docs.md | 2 +- examples/approval-chain/AGENTS.md | 16 ++--- examples/approval-chain/README.md | 29 ++++----- examples/approval-chain/index.ts | 67 ++++++++------------ examples/human-in-the-loop/index.ts | 11 ---- examples/proposal-generator/index.ts | 10 --- examples/scheduled-compliance-audit/index.ts | 10 --- packages/agent/README.md | 32 +++++----- packages/agent/src/directives.ts | 38 ++++++----- 9 files changed, 88 insertions(+), 127 deletions(-) diff --git a/.changeset/pause-deadline-docs.md b/.changeset/pause-deadline-docs.md index b89cf8434..d3e1498c4 100644 --- a/.changeset/pause-deadline-docs.md +++ b/.changeset/pause-deadline-docs.md @@ -4,6 +4,6 @@ Document the pause deadline on `pauseUntilSignal`, `PauseUntilSignalDirective.timeoutMs` and `Pause.timeoutMs`. -Forthcoming behavior change on the hosted engine, not in this package: a pause that omits `timeoutMs` waits indefinitely today, and will instead carry a 7-day deadline once the engine change for SAP-3207 is deployed. Past that deadline the run is finalized as failed with `PauseTimeoutError` rather than parking silently, so a run that used to hang forever will surface as a failure. Pass an explicit `timeoutMs` on a signal pause that must outlive a week, such as a human approval gate. `run_local` is unaffected either way: it never applies or enforces a pause deadline, it auto-resumes every pause immediately. +Forthcoming behavior change on the hosted engine, not in this package: a pause that omits `timeoutMs` waits indefinitely today, and will instead carry a deadline once the engine change for SAP-3207 is deployed. The engine picks it from what the pause is waiting on: 7 days for a machine wait, one year for a run it recognizes as parked on a human approval gate. Past the deadline the run is finalized as failed with `PauseTimeoutError` rather than parking silently, so a run that used to hang forever will surface as a failure. Set `timeoutMs` explicitly on a dispatched child agent that can outlive a week: its result returns through parent linkage rather than a resume token, so the machine default does not bound it. `run_local` is unaffected either way: it never applies or enforces a pause deadline, it auto-resumes every pause immediately. This package ships documentation only, with no type, signature or runtime change. diff --git a/examples/approval-chain/AGENTS.md b/examples/approval-chain/AGENTS.md index ed57564fd..deeff4c36 100644 --- a/examples/approval-chain/AGENTS.md +++ b/examples/approval-chain/AGENTS.md @@ -11,15 +11,15 @@ went silent). Inside a step's `run`, Sapiom capabilities are pre-auth'd on ## The sign-off spine - **`present`** records the current gate as `pending`, emails the approver, then - returns `pauseUntilSignal({ signal: "approval.decision", resumeStep: "decide", correlationId: ctx.executionId, timeoutMs: GATE_PAUSE_TIMEOUT_MS })`. + returns `pauseUntilSignal({ signal: "approval.decision", resumeStep: "decide", correlationId: ctx.executionId })`. It carries a static `pause: { signal, resumeStep: "decide" }` annotation, the - build-time graph edge that must match the directive. **A one-year `timeoutMs` - (`GATE_PAUSE_TIMEOUT_MS`):** the engine's paused-run reaper *terminates* a lapsed - pause (`PauseTimeoutError`) instead of resuming it, so a short gate deadline would - hard-fail a slow approval and skip `escalate`. Omitting `timeoutMs` does not avoid - that: a pause with no deadline inherits the engine's 7-day default. The year is an - explicit backstop; the reminder/escalation cadence comes from the signal (see - below), never from the deadline. + build-time graph edge that must match the directive. **No `timeoutMs`:** the engine's + paused-run reaper *terminates* a lapsed pause (`PauseTimeoutError`) instead of + resuming it, so any short gate deadline would hard-fail a slow approval and skip + `escalate`. Omitting it is safe here because the engine recognizes a human approval + gate and gives it a one-year deadline rather than the 7 days it applies to machine + waits. The reminder/escalation cadence comes from the signal (see below), never from + the deadline. - **`decide`** reads the approval payload **directly as its `run` input**. Safe default: only an explicit `{ decision: "approve" }` advances; `reject` compensates; `timeout` escalates; anything else (including a `run_local` resume diff --git a/examples/approval-chain/README.md b/examples/approval-chain/README.md index de5cf19e3..c992dc225 100644 --- a/examples/approval-chain/README.md +++ b/examples/approval-chain/README.md @@ -52,19 +52,18 @@ The canonical chain state lives in `ctx.shared` (it survives every pause). When Postgres table (`approval_chain_ledger`) via `ctx.sapiom.database` — a best-effort external audit copy that never blocks the chain. -## Reminders, escalation, and why the gates wait a year +## Reminders, escalation, and why the gates carry no deadline -Each gate pauses at $0 under a deliberately long deadline: `GATE_PAUSE_TIMEOUT_MS`, -one year. The engine has a paused-run reaper that *terminates* a lapsed pause with a -`PauseTimeoutError` (it does not resume the step), so a short deadline here would -silently fail any approval slower than it and never run the graceful `escalate` step. -A legitimately slow approver must not lose the run. +Each gate pauses at $0 with no `timeoutMs`. The engine has a paused-run reaper that +*terminates* a lapsed pause with a `PauseTimeoutError` (it does not resume the step), +so any deadline short enough to be useful would silently fail a slow approval and never +run the graceful `escalate` step. A legitimately slow approver must not lose the run. -Omitting `timeoutMs` is not the way to get that. A pause with no deadline inherits the -engine's 7-day default, which hard-fails a two-week approval exactly the same way. One -year is the explicit opt-out: long enough that no realistic approver loses the run, -finite enough that an abandoned chain still reaches a terminal state instead of parking -in the paused table forever. +Omitting it is not the same as waiting forever. The engine recognizes a run waiting on +a human approval gate and gives it a one-year deadline, rather than the 7 days it +applies to a machine wait. That is exactly the shape this chain wants: no realistic +approver loses the run, and an abandoned chain still reaches a terminal state instead of +parking in the paused table forever. Reminders and escalation are therefore driven entirely by the `approval.decision` signal, not by an engine deadline: @@ -76,10 +75,10 @@ signal, not by an engine deadline: walks `remind` → … → `escalate` for free. `maxReminders` (default 2) bounds how many reminder ticks a gate takes before it -escalates. (The mirror template `wait-for-webhook` *wants* a short terminal timeout and -sizes `timeoutMs` to the callback window; this chain wants a reminder loop, so its -deadline is a backstop, not a cadence. Don't shorten the gate `timeoutMs` toward the -reminder interval without switching to that terminal model.) +escalates. (The mirror template `wait-for-webhook` is a machine wait: it *wants* a short +terminal timeout and sizes `timeoutMs` to the callback window. This chain wants a +reminder loop, so it stays on the human-gate deadline. Don't add a gate `timeoutMs` +without switching to that terminal model.) Input: diff --git a/examples/approval-chain/index.ts b/examples/approval-chain/index.ts index 291b50801..ade29bb99 100644 --- a/examples/approval-chain/index.ts +++ b/examples/approval-chain/index.ts @@ -50,24 +50,22 @@ import { z } from "zod/v4"; * approver and re-pause, up to `maxReminders`, after which the gate escalates. An * explicit `{ decision: "timeout" }` escalates immediately. * - * The gates carry a deliberately long `GATE_PAUSE_TIMEOUT_MS` (one year), and the - * value is load-bearing. The engine has a paused-run reaper: `timeoutMs` sets - * `pausedUntil`, and a background sweep *terminates* the run with a - * `PauseTimeoutError` once it lapses. It does NOT resume the step, so a lapsed - * gate never reaches `decide`/`remind`/`escalate`. Handing a signal pause a - * `reminderMs`-sized `timeoutMs` (as an earlier version did) would hard-fail any - * approval slower than the reminder interval, bypassing the graceful `escalate` - * step entirely, and omitting `timeoutMs` is no longer an escape: a pause with no - * deadline now inherits the engine's 7-day default, which does the same damage on - * a one-week horizon. One year is the explicit opt-out: long enough that a slow - * approver never loses the run, finite enough that an abandoned chain still lands - * in a terminal state. Reminders and escalation stay driven purely by the signal - * convention: the run-detail one-click Approve/Reject, a cron that fires - * `remind`/`timeout` on a schedule, or a `run_local` auto-resume, never the pause - * deadline. (`wait-for-webhook` is the mirror image: it *wants* a short terminal - * timeout and opts into one; this chain wants a reminder loop, so its deadline is - * a backstop rather than a cadence.) Do not shorten it toward the reminder - * interval without moving to that terminal model. + * The gates carry NO `timeoutMs`, on purpose. The engine has a paused-run reaper: + * a lapsed deadline *terminates* the run with a `PauseTimeoutError` rather than + * resuming the step, so a lapsed gate never reaches `decide`/`remind`/`escalate`. + * Handing a signal pause a `reminderMs`-sized `timeoutMs` (as an earlier version + * did) would therefore hard-fail any approval slower than the reminder interval + * and bypass the graceful `escalate` step entirely. Omitting it is safe because + * the engine recognizes a run waiting on a human approval gate and gives it a + * one-year deadline instead of the 7 days it applies to machine waits, which is + * exactly the shape this chain wants: a slow approver never loses the run, and an + * abandoned chain still reaches a terminal state instead of parking forever. + * Reminders and escalation stay driven purely by the signal convention: the + * run-detail one-click Approve/Reject, a cron that fires `remind`/`timeout` on a + * schedule, or a `run_local` auto-resume, never the pause deadline. + * (`wait-for-webhook` is the mirror image: a machine wait that *wants* a short + * terminal timeout and opts into one.) Do not add a `timeoutMs` here without + * moving to that terminal model. * * ── Chain state ──────────────────────────────────────────────────────────────── * The canonical chain state — which gate we're on, who has approved, the full @@ -92,15 +90,6 @@ const APPROVAL_SIGNAL = "approval.decision"; /** Reminders sent before a silent gate escalates. */ const DEFAULT_MAX_REMINDERS = 2; -// An explicit, deliberately long gate deadline. A pause with no `timeoutMs` gets -// the hosted engine's 7-day default, and a lapsed deadline *terminates* the run -// with a PauseTimeoutError instead of resuming the step, which would hard-fail -// any approval slower than a week and skip `escalate` entirely. One year is the -// opt-out: long enough that a slow approver never loses the run, finite enough -// that an abandoned chain still reaches a terminal state instead of parking -// forever. Reminders and escalation come from the signal, never from this value. -const GATE_PAUSE_TIMEOUT_MS = 365 * 24 * 60 * 60 * 1000; - /** Postgres table the durable ledger appends to. */ const LEDGER_TABLE = "approval_chain_ledger"; @@ -513,18 +502,17 @@ const present = defineStep({ // pause is kept on purpose: an approver IS assigned, so somebody can resume // it, and the run detail ships one-click Approve/Reject. // - // `GATE_PAUSE_TIMEOUT_MS` is load-bearing, not decoration: a lapsed deadline - // *terminates* the run (PauseTimeoutError) rather than resuming it, so the - // engine's 7-day default would silently fail any approval slower than a week - // and skip `escalate`. The reminder/escalation loop is driven by the - // `approval.decision` signal (one-click UI, a cron firing `remind`/`timeout`, - // or a `run_local` auto-resume), never by the deadline. Read the header note - // before shortening it or dropping it. + // NO `timeoutMs`: a lapsed deadline *terminates* the run (PauseTimeoutError) + // rather than resuming it, so any ceiling short enough to be useful would + // skip `escalate`. The engine reads this as a human approval gate and gives + // it a one-year deadline rather than the 7 days it applies to machine waits. + // The reminder/escalation loop is driven by the `approval.decision` signal + // (one-click UI, a cron firing `remind`/`timeout`, or a `run_local` + // auto-resume), never by the deadline. See the header note before adding one. return pauseUntilSignal({ signal: APPROVAL_SIGNAL, resumeStep: "decide", correlationId: ctx.executionId, - timeoutMs: GATE_PAUSE_TIMEOUT_MS, }); }, }); @@ -533,8 +521,8 @@ const decide = defineStep({ name: "decide", next: ["present", "remind", "finalize", "compensate", "escalate"], // `payload` IS the approval signal body (or empty on a `remind` / `run_local` - // auto-resume). The gates carry a one-year `timeoutMs`, so in practice the - // deadline sweep never lands here; see `present`. + // auto-resume). The gates carry no `timeoutMs` and the engine treats them as + // human gates, so in practice the deadline sweep never lands here; see `present`. async run(payload: ApprovalDecision, ctx: Ctx) { const approvers = must(ctx.shared.get("approvers"), "approvers"); const gateIndex = must(ctx.shared.get("gateIndex"), "gateIndex"); @@ -630,13 +618,12 @@ const remind = defineStep({ }); // Re-suspend on the same gate until a decision (or the next reminder tick). - // As in `present`, the same one-year backstop: the reminder cadence comes from - // the signal convention, not from the engine's (terminal) pause deadline. + // As in `present`, no `timeoutMs`: the reminder cadence comes from the signal + // convention, not from the engine's (terminal) pause deadline. return pauseUntilSignal({ signal: APPROVAL_SIGNAL, resumeStep: "decide", correlationId: ctx.executionId, - timeoutMs: GATE_PAUSE_TIMEOUT_MS, }); }, }); diff --git a/examples/human-in-the-loop/index.ts b/examples/human-in-the-loop/index.ts index dcd613b51..7daa7097e 100644 --- a/examples/human-in-the-loop/index.ts +++ b/examples/human-in-the-loop/index.ts @@ -54,15 +54,6 @@ const APPROVAL_SIGNAL = "approval.decision"; /** The signal a candidate fires to accept or decline a provisional offer. */ const CONFIRM_SIGNAL = "candidate.confirm"; -/** - * Explicit deadline for a human gate, one year. A pause with no `timeoutMs` - * inherits the engine's 7-day default, and a lapsed deadline *terminates* the - * run rather than resuming it, so the default would hard-fail any approval - * slower than a week. Long enough that a slow approver never loses the run, - * finite enough that an abandoned one still reaches a terminal state. - */ -const GATE_PAUSE_TIMEOUT_MS = 365 * 24 * 60 * 60 * 1000; - // ─────────────────────────────────────────────────────────────── shapes ── /** String-only config bag (matches how templates receive their `config`). */ type Config = Record; @@ -426,7 +417,6 @@ const notifyApprover = defineStep({ signal: APPROVAL_SIGNAL, resumeStep: "onDecision", correlationId: ctx.executionId, - timeoutMs: GATE_PAUSE_TIMEOUT_MS, }); }, }); @@ -510,7 +500,6 @@ const offer = defineStep({ signal: CONFIRM_SIGNAL, resumeStep: "resolve", correlationId: ctx.executionId, - timeoutMs: GATE_PAUSE_TIMEOUT_MS, }); }, }); diff --git a/examples/proposal-generator/index.ts b/examples/proposal-generator/index.ts index 4e3bf68b9..f49f2f40f 100644 --- a/examples/proposal-generator/index.ts +++ b/examples/proposal-generator/index.ts @@ -66,15 +66,6 @@ import { z } from "zod/v4"; /** The signal a human fires to approve or reject the drafted proposal. */ const DECISION_SIGNAL = "proposal.decision"; -/** - * Explicit deadline for a human gate, one year. A pause with no `timeoutMs` - * inherits the engine's 7-day default, and a lapsed deadline *terminates* the - * run rather than resuming it, so the default would hard-fail any approval - * slower than a week. Long enough that a slow approver never loses the run, - * finite enough that an abandoned one still reaches a terminal state. - */ -const GATE_PAUSE_TIMEOUT_MS = 365 * 24 * 60 * 60 * 1000; - /** Package the sandbox installs to lay out the PDF (pure JS, no native deps). */ const PDF_PACKAGE = "pdf-lib@1.17.1"; @@ -621,7 +612,6 @@ const review = defineStep({ signal: DECISION_SIGNAL, resumeStep: "onDecision", correlationId: ctx.executionId, - timeoutMs: GATE_PAUSE_TIMEOUT_MS, }); }, }); diff --git a/examples/scheduled-compliance-audit/index.ts b/examples/scheduled-compliance-audit/index.ts index a4c404700..acaaaa50e 100644 --- a/examples/scheduled-compliance-audit/index.ts +++ b/examples/scheduled-compliance-audit/index.ts @@ -58,15 +58,6 @@ const MAX_BODY_CHARS = 2000; /** The signal a human fires to approve or reject the attestation. */ const SIGNOFF_SIGNAL = "attestation.signoff"; -/** - * Explicit deadline for a human gate, one year. A pause with no `timeoutMs` - * inherits the engine's 7-day default, and a lapsed deadline *terminates* the - * run rather than resuming it, so the default would hard-fail any approval - * slower than a week. Long enough that a slow approver never loses the run, - * finite enough that an abandoned one still reaches a terminal state. - */ -const GATE_PAUSE_TIMEOUT_MS = 365 * 24 * 60 * 60 * 1000; - // ─────────────────────────────────────────────────────────────── shapes ── /** A resource whose current state should be audited against the policy. */ interface ResourceRef { @@ -406,7 +397,6 @@ const review = defineStep({ signal: SIGNOFF_SIGNAL, resumeStep: "onSignoff", correlationId: ctx.executionId, - timeoutMs: GATE_PAUSE_TIMEOUT_MS, }); }, }); diff --git a/packages/agent/README.md b/packages/agent/README.md index 5b28e7e40..3f5224f85 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -178,23 +178,25 @@ Things to know: ``` - **Outside an agent run nothing changes** — `await launch().wait()` the capability as usual; the pause wiring only engages when a step pauses on the handle. -- **A hosted pause has a deadline.** `timeoutMs` sets it; omitted, the hosted engine - applies its default of 7 days. A pause that receives no signal by then is finalized - as failed with `PauseTimeoutError`, so a dropped result surfaces as an error rather - than a run that parks forever. The default matches the sandboxed capability's - resume-token TTL, so for a coding pause a later result could not be accepted - anyway. It does **not** bound a dispatched child agent: a child's result comes - back through stored parent linkage with no token check, so it can land long after - seven days. Size `timeoutMs` to whatever you are waiting on, on any pause that can - outlive a week: a child run's worst case (otherwise the parent fails while the - child is still working), or a human gate: +- **A hosted pause has a deadline.** `timeoutMs` sets it. Omitted, the hosted engine + picks one from what the pause is waiting on: **7 days for a machine wait**, and + **one year when it recognizes a run parked on a human approval gate**, since no + token expires while a person thinks. A pause that receives no signal by its + deadline is finalized as failed with `PauseTimeoutError`, so a dropped result + surfaces as an error rather than a run that parks forever. + + The 7 days match the sandboxed capability's resume-token TTL, so for a coding pause + a later result could not be accepted anyway. That reasoning does **not** cover a + dispatched child agent: a child's result comes back through stored parent linkage + with no token check, so it can land long after seven days. Set `timeoutMs` + explicitly on a child pause that can outlive a week, or the parent fails while the + child is still working: ```ts - // A human gate: nothing but this deadline bounds the wait. - return pauseUntilSignal({ - signal: "approval.decision", - resumeStep: "decide", - timeoutMs: 365 * 24 * 60 * 60 * 1000, // one year + // A dispatched child, not a human gate: the machine default would cut it off. + return pauseUntilSignal(ctx.sapiom.agents.run({ definition, input }), { + resumeStep: "review", + timeoutMs: 30 * 24 * 60 * 60 * 1000, // 30 days }); ``` diff --git a/packages/agent/src/directives.ts b/packages/agent/src/directives.ts index d9eff5120..93fd5b6c2 100644 --- a/packages/agent/src/directives.ts +++ b/packages/agent/src/directives.ts @@ -75,11 +75,13 @@ export interface PauseUntilSignalDirective { }; /** * Deadline for the signal, in ms from the moment the pause is recorded. - * Omitted, the hosted engine applies its default pause deadline of 7 days. - * A pause that receives no signal by its deadline is finalized as failed - * with `PauseTimeoutError` rather than parking forever. Pass an explicit - * value for a wait that must run longer or give up sooner: a dispatched - * child agent is not bounded by the default (see `pauseUntilSignal`). + * Omitted, the hosted engine picks a default from what the pause is waiting + * on: 7 days for a machine wait, one year for a run it recognizes as parked + * on a human approval gate. A pause that receives no signal by its deadline + * is finalized as failed with `PauseTimeoutError` rather than parking + * forever. Pass an explicit value for a wait that must run longer or give up + * sooner: a dispatched child agent is not bounded by the machine default + * (see `pauseUntilSignal`). * * `run_local` neither applies nor enforces this: it auto-resumes every pause * immediately, so the deadline is only observable against the hosted engine. @@ -250,18 +252,20 @@ export function fail(reason?: string, opts?: { output?: unknown }): Fail { * async-return flattening makes the sync/async distinction invisible at the call * site. * - * **A hosted pause has a deadline.** `timeoutMs` sets it; omitted, the hosted - * engine applies its default of 7 days (the capability resume-token TTL). If no - * signal arrives by then the run is finalized as failed with `PauseTimeoutError`, - * so a lost webhook or a dropped capability result surfaces as an error rather - * than a run that parks forever. The default matches the sandboxed capability's - * resume-token TTL, so for a coding pause a later result could not be accepted - * anyway. It does NOT bound a dispatched child agent: a child's result returns - * through stored parent linkage with no token check, so it can land long after - * seven days, and the default would fail the parent while the child is still - * working. Size `timeoutMs` to what you are waiting on whenever it can outlive a - * week, a child run's worst case or a human gate, or shorten it on any wait that - * should give up sooner. `run_local` neither applies nor enforces this: it auto-resumes every + * **A hosted pause has a deadline.** `timeoutMs` sets it. Omitted, the hosted + * engine picks one from what the pause is waiting on: 7 days for a machine wait + * (the capability resume-token TTL), one year for a run it recognizes as parked + * on a human approval gate, since no token expires while a person thinks. If no + * signal arrives by the deadline the run is finalized as failed with + * `PauseTimeoutError`, so a lost webhook or a dropped capability result surfaces + * as an error rather than a run that parks forever. + * + * The 7 days rest on the resume token, so for a coding pause a later result could + * not be accepted anyway. That does NOT cover a dispatched child agent: a child's + * result returns through stored parent linkage with no token check, so it can land + * long after seven days and the machine default would fail the parent while the + * child is still working. Set `timeoutMs` explicitly on a child pause that can + * outlive a week, or on any wait that should give up sooner. `run_local` neither applies nor enforces this: it auto-resumes every * pause immediately, with the registered capability result or an empty payload, * so a local run never sits at a gate and never times out. */ From db428512652ca01fe05bf9004d2013523333da1c Mon Sep 17 00:00:00 2001 From: antoine-berger <64917674+antoine-berger@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:11:44 -0500 Subject: [PATCH 07/12] Revert "docs(agent): let the engine size a human gate's deadline" This reverts commit 213b693a843bd4f7cccb2e7ec73962abf3d15d84. The rule it documented, a 365-day default for a pause the engine recognizes as a human approval gate, was decided and then cancelled. The engine applies 7 days to every pause that omits `timeoutMs`, with no distinction by kind. The cancellation matches the code. On the engine branch, `pause-deadline.ts` exports only `DEFAULT_PAUSE_TIMEOUT_MS`; there is no human-gate constant, and `isApprovalGateSchema` is referenced only by the RUN_WAITING alerting path and the outbox listener, never by `pauseExecution`. The detection would also have rested on an optional author declaration on the resume step that none of the production human gates makes, so the rule would have protected the rigorous authors and failed exactly the ones it was meant to cover. Restored by this revert: the single 7-day default with no exception by kind, and the explicit `GATE_PAUSE_TIMEOUT_MS` on all five templates, justified as an author choosing to wait longer than the default rather than as engine recognition. The child-agent carve-out is untouched: it is factually true and implemented engine-side. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/pause-deadline-docs.md | 2 +- examples/approval-chain/AGENTS.md | 16 ++--- examples/approval-chain/README.md | 29 +++++---- examples/approval-chain/index.ts | 67 ++++++++++++-------- examples/human-in-the-loop/index.ts | 11 ++++ examples/proposal-generator/index.ts | 10 +++ examples/scheduled-compliance-audit/index.ts | 10 +++ packages/agent/README.md | 32 +++++----- packages/agent/src/directives.ts | 38 +++++------ 9 files changed, 127 insertions(+), 88 deletions(-) diff --git a/.changeset/pause-deadline-docs.md b/.changeset/pause-deadline-docs.md index d3e1498c4..b89cf8434 100644 --- a/.changeset/pause-deadline-docs.md +++ b/.changeset/pause-deadline-docs.md @@ -4,6 +4,6 @@ Document the pause deadline on `pauseUntilSignal`, `PauseUntilSignalDirective.timeoutMs` and `Pause.timeoutMs`. -Forthcoming behavior change on the hosted engine, not in this package: a pause that omits `timeoutMs` waits indefinitely today, and will instead carry a deadline once the engine change for SAP-3207 is deployed. The engine picks it from what the pause is waiting on: 7 days for a machine wait, one year for a run it recognizes as parked on a human approval gate. Past the deadline the run is finalized as failed with `PauseTimeoutError` rather than parking silently, so a run that used to hang forever will surface as a failure. Set `timeoutMs` explicitly on a dispatched child agent that can outlive a week: its result returns through parent linkage rather than a resume token, so the machine default does not bound it. `run_local` is unaffected either way: it never applies or enforces a pause deadline, it auto-resumes every pause immediately. +Forthcoming behavior change on the hosted engine, not in this package: a pause that omits `timeoutMs` waits indefinitely today, and will instead carry a 7-day deadline once the engine change for SAP-3207 is deployed. Past that deadline the run is finalized as failed with `PauseTimeoutError` rather than parking silently, so a run that used to hang forever will surface as a failure. Pass an explicit `timeoutMs` on a signal pause that must outlive a week, such as a human approval gate. `run_local` is unaffected either way: it never applies or enforces a pause deadline, it auto-resumes every pause immediately. This package ships documentation only, with no type, signature or runtime change. diff --git a/examples/approval-chain/AGENTS.md b/examples/approval-chain/AGENTS.md index deeff4c36..ed57564fd 100644 --- a/examples/approval-chain/AGENTS.md +++ b/examples/approval-chain/AGENTS.md @@ -11,15 +11,15 @@ went silent). Inside a step's `run`, Sapiom capabilities are pre-auth'd on ## The sign-off spine - **`present`** records the current gate as `pending`, emails the approver, then - returns `pauseUntilSignal({ signal: "approval.decision", resumeStep: "decide", correlationId: ctx.executionId })`. + returns `pauseUntilSignal({ signal: "approval.decision", resumeStep: "decide", correlationId: ctx.executionId, timeoutMs: GATE_PAUSE_TIMEOUT_MS })`. It carries a static `pause: { signal, resumeStep: "decide" }` annotation, the - build-time graph edge that must match the directive. **No `timeoutMs`:** the engine's - paused-run reaper *terminates* a lapsed pause (`PauseTimeoutError`) instead of - resuming it, so any short gate deadline would hard-fail a slow approval and skip - `escalate`. Omitting it is safe here because the engine recognizes a human approval - gate and gives it a one-year deadline rather than the 7 days it applies to machine - waits. The reminder/escalation cadence comes from the signal (see below), never from - the deadline. + build-time graph edge that must match the directive. **A one-year `timeoutMs` + (`GATE_PAUSE_TIMEOUT_MS`):** the engine's paused-run reaper *terminates* a lapsed + pause (`PauseTimeoutError`) instead of resuming it, so a short gate deadline would + hard-fail a slow approval and skip `escalate`. Omitting `timeoutMs` does not avoid + that: a pause with no deadline inherits the engine's 7-day default. The year is an + explicit backstop; the reminder/escalation cadence comes from the signal (see + below), never from the deadline. - **`decide`** reads the approval payload **directly as its `run` input**. Safe default: only an explicit `{ decision: "approve" }` advances; `reject` compensates; `timeout` escalates; anything else (including a `run_local` resume diff --git a/examples/approval-chain/README.md b/examples/approval-chain/README.md index c992dc225..de5cf19e3 100644 --- a/examples/approval-chain/README.md +++ b/examples/approval-chain/README.md @@ -52,18 +52,19 @@ The canonical chain state lives in `ctx.shared` (it survives every pause). When Postgres table (`approval_chain_ledger`) via `ctx.sapiom.database` — a best-effort external audit copy that never blocks the chain. -## Reminders, escalation, and why the gates carry no deadline +## Reminders, escalation, and why the gates wait a year -Each gate pauses at $0 with no `timeoutMs`. The engine has a paused-run reaper that -*terminates* a lapsed pause with a `PauseTimeoutError` (it does not resume the step), -so any deadline short enough to be useful would silently fail a slow approval and never -run the graceful `escalate` step. A legitimately slow approver must not lose the run. +Each gate pauses at $0 under a deliberately long deadline: `GATE_PAUSE_TIMEOUT_MS`, +one year. The engine has a paused-run reaper that *terminates* a lapsed pause with a +`PauseTimeoutError` (it does not resume the step), so a short deadline here would +silently fail any approval slower than it and never run the graceful `escalate` step. +A legitimately slow approver must not lose the run. -Omitting it is not the same as waiting forever. The engine recognizes a run waiting on -a human approval gate and gives it a one-year deadline, rather than the 7 days it -applies to a machine wait. That is exactly the shape this chain wants: no realistic -approver loses the run, and an abandoned chain still reaches a terminal state instead of -parking in the paused table forever. +Omitting `timeoutMs` is not the way to get that. A pause with no deadline inherits the +engine's 7-day default, which hard-fails a two-week approval exactly the same way. One +year is the explicit opt-out: long enough that no realistic approver loses the run, +finite enough that an abandoned chain still reaches a terminal state instead of parking +in the paused table forever. Reminders and escalation are therefore driven entirely by the `approval.decision` signal, not by an engine deadline: @@ -75,10 +76,10 @@ signal, not by an engine deadline: walks `remind` → … → `escalate` for free. `maxReminders` (default 2) bounds how many reminder ticks a gate takes before it -escalates. (The mirror template `wait-for-webhook` is a machine wait: it *wants* a short -terminal timeout and sizes `timeoutMs` to the callback window. This chain wants a -reminder loop, so it stays on the human-gate deadline. Don't add a gate `timeoutMs` -without switching to that terminal model.) +escalates. (The mirror template `wait-for-webhook` *wants* a short terminal timeout and +sizes `timeoutMs` to the callback window; this chain wants a reminder loop, so its +deadline is a backstop, not a cadence. Don't shorten the gate `timeoutMs` toward the +reminder interval without switching to that terminal model.) Input: diff --git a/examples/approval-chain/index.ts b/examples/approval-chain/index.ts index ade29bb99..291b50801 100644 --- a/examples/approval-chain/index.ts +++ b/examples/approval-chain/index.ts @@ -50,22 +50,24 @@ import { z } from "zod/v4"; * approver and re-pause, up to `maxReminders`, after which the gate escalates. An * explicit `{ decision: "timeout" }` escalates immediately. * - * The gates carry NO `timeoutMs`, on purpose. The engine has a paused-run reaper: - * a lapsed deadline *terminates* the run with a `PauseTimeoutError` rather than - * resuming the step, so a lapsed gate never reaches `decide`/`remind`/`escalate`. - * Handing a signal pause a `reminderMs`-sized `timeoutMs` (as an earlier version - * did) would therefore hard-fail any approval slower than the reminder interval - * and bypass the graceful `escalate` step entirely. Omitting it is safe because - * the engine recognizes a run waiting on a human approval gate and gives it a - * one-year deadline instead of the 7 days it applies to machine waits, which is - * exactly the shape this chain wants: a slow approver never loses the run, and an - * abandoned chain still reaches a terminal state instead of parking forever. - * Reminders and escalation stay driven purely by the signal convention: the - * run-detail one-click Approve/Reject, a cron that fires `remind`/`timeout` on a - * schedule, or a `run_local` auto-resume, never the pause deadline. - * (`wait-for-webhook` is the mirror image: a machine wait that *wants* a short - * terminal timeout and opts into one.) Do not add a `timeoutMs` here without - * moving to that terminal model. + * The gates carry a deliberately long `GATE_PAUSE_TIMEOUT_MS` (one year), and the + * value is load-bearing. The engine has a paused-run reaper: `timeoutMs` sets + * `pausedUntil`, and a background sweep *terminates* the run with a + * `PauseTimeoutError` once it lapses. It does NOT resume the step, so a lapsed + * gate never reaches `decide`/`remind`/`escalate`. Handing a signal pause a + * `reminderMs`-sized `timeoutMs` (as an earlier version did) would hard-fail any + * approval slower than the reminder interval, bypassing the graceful `escalate` + * step entirely, and omitting `timeoutMs` is no longer an escape: a pause with no + * deadline now inherits the engine's 7-day default, which does the same damage on + * a one-week horizon. One year is the explicit opt-out: long enough that a slow + * approver never loses the run, finite enough that an abandoned chain still lands + * in a terminal state. Reminders and escalation stay driven purely by the signal + * convention: the run-detail one-click Approve/Reject, a cron that fires + * `remind`/`timeout` on a schedule, or a `run_local` auto-resume, never the pause + * deadline. (`wait-for-webhook` is the mirror image: it *wants* a short terminal + * timeout and opts into one; this chain wants a reminder loop, so its deadline is + * a backstop rather than a cadence.) Do not shorten it toward the reminder + * interval without moving to that terminal model. * * ── Chain state ──────────────────────────────────────────────────────────────── * The canonical chain state — which gate we're on, who has approved, the full @@ -90,6 +92,15 @@ const APPROVAL_SIGNAL = "approval.decision"; /** Reminders sent before a silent gate escalates. */ const DEFAULT_MAX_REMINDERS = 2; +// An explicit, deliberately long gate deadline. A pause with no `timeoutMs` gets +// the hosted engine's 7-day default, and a lapsed deadline *terminates* the run +// with a PauseTimeoutError instead of resuming the step, which would hard-fail +// any approval slower than a week and skip `escalate` entirely. One year is the +// opt-out: long enough that a slow approver never loses the run, finite enough +// that an abandoned chain still reaches a terminal state instead of parking +// forever. Reminders and escalation come from the signal, never from this value. +const GATE_PAUSE_TIMEOUT_MS = 365 * 24 * 60 * 60 * 1000; + /** Postgres table the durable ledger appends to. */ const LEDGER_TABLE = "approval_chain_ledger"; @@ -502,17 +513,18 @@ const present = defineStep({ // pause is kept on purpose: an approver IS assigned, so somebody can resume // it, and the run detail ships one-click Approve/Reject. // - // NO `timeoutMs`: a lapsed deadline *terminates* the run (PauseTimeoutError) - // rather than resuming it, so any ceiling short enough to be useful would - // skip `escalate`. The engine reads this as a human approval gate and gives - // it a one-year deadline rather than the 7 days it applies to machine waits. - // The reminder/escalation loop is driven by the `approval.decision` signal - // (one-click UI, a cron firing `remind`/`timeout`, or a `run_local` - // auto-resume), never by the deadline. See the header note before adding one. + // `GATE_PAUSE_TIMEOUT_MS` is load-bearing, not decoration: a lapsed deadline + // *terminates* the run (PauseTimeoutError) rather than resuming it, so the + // engine's 7-day default would silently fail any approval slower than a week + // and skip `escalate`. The reminder/escalation loop is driven by the + // `approval.decision` signal (one-click UI, a cron firing `remind`/`timeout`, + // or a `run_local` auto-resume), never by the deadline. Read the header note + // before shortening it or dropping it. return pauseUntilSignal({ signal: APPROVAL_SIGNAL, resumeStep: "decide", correlationId: ctx.executionId, + timeoutMs: GATE_PAUSE_TIMEOUT_MS, }); }, }); @@ -521,8 +533,8 @@ const decide = defineStep({ name: "decide", next: ["present", "remind", "finalize", "compensate", "escalate"], // `payload` IS the approval signal body (or empty on a `remind` / `run_local` - // auto-resume). The gates carry no `timeoutMs` and the engine treats them as - // human gates, so in practice the deadline sweep never lands here; see `present`. + // auto-resume). The gates carry a one-year `timeoutMs`, so in practice the + // deadline sweep never lands here; see `present`. async run(payload: ApprovalDecision, ctx: Ctx) { const approvers = must(ctx.shared.get("approvers"), "approvers"); const gateIndex = must(ctx.shared.get("gateIndex"), "gateIndex"); @@ -618,12 +630,13 @@ const remind = defineStep({ }); // Re-suspend on the same gate until a decision (or the next reminder tick). - // As in `present`, no `timeoutMs`: the reminder cadence comes from the signal - // convention, not from the engine's (terminal) pause deadline. + // As in `present`, the same one-year backstop: the reminder cadence comes from + // the signal convention, not from the engine's (terminal) pause deadline. return pauseUntilSignal({ signal: APPROVAL_SIGNAL, resumeStep: "decide", correlationId: ctx.executionId, + timeoutMs: GATE_PAUSE_TIMEOUT_MS, }); }, }); diff --git a/examples/human-in-the-loop/index.ts b/examples/human-in-the-loop/index.ts index 7daa7097e..dcd613b51 100644 --- a/examples/human-in-the-loop/index.ts +++ b/examples/human-in-the-loop/index.ts @@ -54,6 +54,15 @@ const APPROVAL_SIGNAL = "approval.decision"; /** The signal a candidate fires to accept or decline a provisional offer. */ const CONFIRM_SIGNAL = "candidate.confirm"; +/** + * Explicit deadline for a human gate, one year. A pause with no `timeoutMs` + * inherits the engine's 7-day default, and a lapsed deadline *terminates* the + * run rather than resuming it, so the default would hard-fail any approval + * slower than a week. Long enough that a slow approver never loses the run, + * finite enough that an abandoned one still reaches a terminal state. + */ +const GATE_PAUSE_TIMEOUT_MS = 365 * 24 * 60 * 60 * 1000; + // ─────────────────────────────────────────────────────────────── shapes ── /** String-only config bag (matches how templates receive their `config`). */ type Config = Record; @@ -417,6 +426,7 @@ const notifyApprover = defineStep({ signal: APPROVAL_SIGNAL, resumeStep: "onDecision", correlationId: ctx.executionId, + timeoutMs: GATE_PAUSE_TIMEOUT_MS, }); }, }); @@ -500,6 +510,7 @@ const offer = defineStep({ signal: CONFIRM_SIGNAL, resumeStep: "resolve", correlationId: ctx.executionId, + timeoutMs: GATE_PAUSE_TIMEOUT_MS, }); }, }); diff --git a/examples/proposal-generator/index.ts b/examples/proposal-generator/index.ts index f49f2f40f..4e3bf68b9 100644 --- a/examples/proposal-generator/index.ts +++ b/examples/proposal-generator/index.ts @@ -66,6 +66,15 @@ import { z } from "zod/v4"; /** The signal a human fires to approve or reject the drafted proposal. */ const DECISION_SIGNAL = "proposal.decision"; +/** + * Explicit deadline for a human gate, one year. A pause with no `timeoutMs` + * inherits the engine's 7-day default, and a lapsed deadline *terminates* the + * run rather than resuming it, so the default would hard-fail any approval + * slower than a week. Long enough that a slow approver never loses the run, + * finite enough that an abandoned one still reaches a terminal state. + */ +const GATE_PAUSE_TIMEOUT_MS = 365 * 24 * 60 * 60 * 1000; + /** Package the sandbox installs to lay out the PDF (pure JS, no native deps). */ const PDF_PACKAGE = "pdf-lib@1.17.1"; @@ -612,6 +621,7 @@ const review = defineStep({ signal: DECISION_SIGNAL, resumeStep: "onDecision", correlationId: ctx.executionId, + timeoutMs: GATE_PAUSE_TIMEOUT_MS, }); }, }); diff --git a/examples/scheduled-compliance-audit/index.ts b/examples/scheduled-compliance-audit/index.ts index acaaaa50e..a4c404700 100644 --- a/examples/scheduled-compliance-audit/index.ts +++ b/examples/scheduled-compliance-audit/index.ts @@ -58,6 +58,15 @@ const MAX_BODY_CHARS = 2000; /** The signal a human fires to approve or reject the attestation. */ const SIGNOFF_SIGNAL = "attestation.signoff"; +/** + * Explicit deadline for a human gate, one year. A pause with no `timeoutMs` + * inherits the engine's 7-day default, and a lapsed deadline *terminates* the + * run rather than resuming it, so the default would hard-fail any approval + * slower than a week. Long enough that a slow approver never loses the run, + * finite enough that an abandoned one still reaches a terminal state. + */ +const GATE_PAUSE_TIMEOUT_MS = 365 * 24 * 60 * 60 * 1000; + // ─────────────────────────────────────────────────────────────── shapes ── /** A resource whose current state should be audited against the policy. */ interface ResourceRef { @@ -397,6 +406,7 @@ const review = defineStep({ signal: SIGNOFF_SIGNAL, resumeStep: "onSignoff", correlationId: ctx.executionId, + timeoutMs: GATE_PAUSE_TIMEOUT_MS, }); }, }); diff --git a/packages/agent/README.md b/packages/agent/README.md index 3f5224f85..5b28e7e40 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -178,25 +178,23 @@ Things to know: ``` - **Outside an agent run nothing changes** — `await launch().wait()` the capability as usual; the pause wiring only engages when a step pauses on the handle. -- **A hosted pause has a deadline.** `timeoutMs` sets it. Omitted, the hosted engine - picks one from what the pause is waiting on: **7 days for a machine wait**, and - **one year when it recognizes a run parked on a human approval gate**, since no - token expires while a person thinks. A pause that receives no signal by its - deadline is finalized as failed with `PauseTimeoutError`, so a dropped result - surfaces as an error rather than a run that parks forever. - - The 7 days match the sandboxed capability's resume-token TTL, so for a coding pause - a later result could not be accepted anyway. That reasoning does **not** cover a - dispatched child agent: a child's result comes back through stored parent linkage - with no token check, so it can land long after seven days. Set `timeoutMs` - explicitly on a child pause that can outlive a week, or the parent fails while the - child is still working: +- **A hosted pause has a deadline.** `timeoutMs` sets it; omitted, the hosted engine + applies its default of 7 days. A pause that receives no signal by then is finalized + as failed with `PauseTimeoutError`, so a dropped result surfaces as an error rather + than a run that parks forever. The default matches the sandboxed capability's + resume-token TTL, so for a coding pause a later result could not be accepted + anyway. It does **not** bound a dispatched child agent: a child's result comes + back through stored parent linkage with no token check, so it can land long after + seven days. Size `timeoutMs` to whatever you are waiting on, on any pause that can + outlive a week: a child run's worst case (otherwise the parent fails while the + child is still working), or a human gate: ```ts - // A dispatched child, not a human gate: the machine default would cut it off. - return pauseUntilSignal(ctx.sapiom.agents.run({ definition, input }), { - resumeStep: "review", - timeoutMs: 30 * 24 * 60 * 60 * 1000, // 30 days + // A human gate: nothing but this deadline bounds the wait. + return pauseUntilSignal({ + signal: "approval.decision", + resumeStep: "decide", + timeoutMs: 365 * 24 * 60 * 60 * 1000, // one year }); ``` diff --git a/packages/agent/src/directives.ts b/packages/agent/src/directives.ts index 93fd5b6c2..d9eff5120 100644 --- a/packages/agent/src/directives.ts +++ b/packages/agent/src/directives.ts @@ -75,13 +75,11 @@ export interface PauseUntilSignalDirective { }; /** * Deadline for the signal, in ms from the moment the pause is recorded. - * Omitted, the hosted engine picks a default from what the pause is waiting - * on: 7 days for a machine wait, one year for a run it recognizes as parked - * on a human approval gate. A pause that receives no signal by its deadline - * is finalized as failed with `PauseTimeoutError` rather than parking - * forever. Pass an explicit value for a wait that must run longer or give up - * sooner: a dispatched child agent is not bounded by the machine default - * (see `pauseUntilSignal`). + * Omitted, the hosted engine applies its default pause deadline of 7 days. + * A pause that receives no signal by its deadline is finalized as failed + * with `PauseTimeoutError` rather than parking forever. Pass an explicit + * value for a wait that must run longer or give up sooner: a dispatched + * child agent is not bounded by the default (see `pauseUntilSignal`). * * `run_local` neither applies nor enforces this: it auto-resumes every pause * immediately, so the deadline is only observable against the hosted engine. @@ -252,20 +250,18 @@ export function fail(reason?: string, opts?: { output?: unknown }): Fail { * async-return flattening makes the sync/async distinction invisible at the call * site. * - * **A hosted pause has a deadline.** `timeoutMs` sets it. Omitted, the hosted - * engine picks one from what the pause is waiting on: 7 days for a machine wait - * (the capability resume-token TTL), one year for a run it recognizes as parked - * on a human approval gate, since no token expires while a person thinks. If no - * signal arrives by the deadline the run is finalized as failed with - * `PauseTimeoutError`, so a lost webhook or a dropped capability result surfaces - * as an error rather than a run that parks forever. - * - * The 7 days rest on the resume token, so for a coding pause a later result could - * not be accepted anyway. That does NOT cover a dispatched child agent: a child's - * result returns through stored parent linkage with no token check, so it can land - * long after seven days and the machine default would fail the parent while the - * child is still working. Set `timeoutMs` explicitly on a child pause that can - * outlive a week, or on any wait that should give up sooner. `run_local` neither applies nor enforces this: it auto-resumes every + * **A hosted pause has a deadline.** `timeoutMs` sets it; omitted, the hosted + * engine applies its default of 7 days (the capability resume-token TTL). If no + * signal arrives by then the run is finalized as failed with `PauseTimeoutError`, + * so a lost webhook or a dropped capability result surfaces as an error rather + * than a run that parks forever. The default matches the sandboxed capability's + * resume-token TTL, so for a coding pause a later result could not be accepted + * anyway. It does NOT bound a dispatched child agent: a child's result returns + * through stored parent linkage with no token check, so it can land long after + * seven days, and the default would fail the parent while the child is still + * working. Size `timeoutMs` to what you are waiting on whenever it can outlive a + * week, a child run's worst case or a human gate, or shorten it on any wait that + * should give up sooner. `run_local` neither applies nor enforces this: it auto-resumes every * pause immediately, with the registered capability result or an empty payload, * so a local run never sits at a gate and never times out. */ From 6f22f92a52f7f64fcdb32345c46f7cc3a65279ec Mon Sep 17 00:00:00 2001 From: antoine-berger <64917674+antoine-berger@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:54:05 -0500 Subject: [PATCH 08/12] docs(agent): the engine holds a parent open while its child runs Three review findings, one of which had the docs telling authors to work around behavior the engine now provides. `expirePausedExecution` waives the pause deadline while a dispatched child agent is alive (`runner.service.ts`, `awaitsLiveChildAgent`). The docs still told authors to size `timeoutMs` to a child run's worst case, "otherwise the parent fails while the child is still working", which is no longer true and makes them write a timeout they do not need. Both JSDoc sites and the README now say a child pause needs none, and state the waiver's edges: it covers a dispatch still pending or waiting on the parent, and lapses when the child goes terminal or its run no longer exists. `PauseTimeoutError` was named four times as though it were importable. It is an engine-side class, not an export of this package, so the docs describe the failure instead of naming a symbol an author cannot reach. `approval-chain` claimed a slow approver "never loses the run". The one-year value is still a terminal deadline: an approval that outlives it is failed by the sweep like any other. The header, the constant comment and the README now say the year is picked so nobody realistic reaches it, not that it is unbounded. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/pause-deadline-docs.md | 2 +- examples/approval-chain/README.md | 7 +++--- examples/approval-chain/index.ts | 17 +++++++------- packages/agent/README.md | 21 ++++++++++------- packages/agent/src/directives.ts | 39 ++++++++++++++++++------------- 5 files changed, 50 insertions(+), 36 deletions(-) diff --git a/.changeset/pause-deadline-docs.md b/.changeset/pause-deadline-docs.md index b89cf8434..6ea55ca3c 100644 --- a/.changeset/pause-deadline-docs.md +++ b/.changeset/pause-deadline-docs.md @@ -4,6 +4,6 @@ Document the pause deadline on `pauseUntilSignal`, `PauseUntilSignalDirective.timeoutMs` and `Pause.timeoutMs`. -Forthcoming behavior change on the hosted engine, not in this package: a pause that omits `timeoutMs` waits indefinitely today, and will instead carry a 7-day deadline once the engine change for SAP-3207 is deployed. Past that deadline the run is finalized as failed with `PauseTimeoutError` rather than parking silently, so a run that used to hang forever will surface as a failure. Pass an explicit `timeoutMs` on a signal pause that must outlive a week, such as a human approval gate. `run_local` is unaffected either way: it never applies or enforces a pause deadline, it auto-resumes every pause immediately. +Forthcoming behavior change on the hosted engine, not in this package: a pause that omits `timeoutMs` waits indefinitely today, and will instead carry a 7-day deadline once the engine change for SAP-3207 is deployed. Past that deadline the run is finalized as failed rather than parking silently, carrying the engine's pause-timeout error, so a run that used to hang forever will surface as a failure. Pass an explicit `timeoutMs` on a signal pause that must outlive a week, such as a human approval gate; a pause on a dispatched child agent needs none, since the engine waives the deadline while the child is alive. `run_local` is unaffected either way: it never applies or enforces a pause deadline, it auto-resumes every pause immediately. This package ships documentation only, with no type, signature or runtime change. diff --git a/examples/approval-chain/README.md b/examples/approval-chain/README.md index de5cf19e3..f06cf0d17 100644 --- a/examples/approval-chain/README.md +++ b/examples/approval-chain/README.md @@ -62,9 +62,10 @@ A legitimately slow approver must not lose the run. Omitting `timeoutMs` is not the way to get that. A pause with no deadline inherits the engine's 7-day default, which hard-fails a two-week approval exactly the same way. One -year is the explicit opt-out: long enough that no realistic approver loses the run, -finite enough that an abandoned chain still reaches a terminal state instead of parking -in the paused table forever. +year is the explicit opt-out, and it stays a terminal deadline: an approval that +outlives it is failed by the sweep like any other, not resumed. The year is picked so +no realistic approver reaches it, while an abandoned chain still lands in a terminal +state instead of parking in the paused table forever. Reminders and escalation are therefore driven entirely by the `approval.decision` signal, not by an engine deadline: diff --git a/examples/approval-chain/index.ts b/examples/approval-chain/index.ts index 291b50801..1f2ff2d7b 100644 --- a/examples/approval-chain/index.ts +++ b/examples/approval-chain/index.ts @@ -59,9 +59,10 @@ import { z } from "zod/v4"; * approval slower than the reminder interval, bypassing the graceful `escalate` * step entirely, and omitting `timeoutMs` is no longer an escape: a pause with no * deadline now inherits the engine's 7-day default, which does the same damage on - * a one-week horizon. One year is the explicit opt-out: long enough that a slow - * approver never loses the run, finite enough that an abandoned chain still lands - * in a terminal state. Reminders and escalation stay driven purely by the signal + * a one-week horizon. One year is the explicit opt-out, and it is still a terminal + * deadline: an approval that outlives it is failed by the sweep like any other, + * not resumed. The year is picked so no realistic approver reaches it, while an + * abandoned chain still lands in a terminal state instead of parking forever. Reminders and escalation stay driven purely by the signal * convention: the run-detail one-click Approve/Reject, a cron that fires * `remind`/`timeout` on a schedule, or a `run_local` auto-resume, never the pause * deadline. (`wait-for-webhook` is the mirror image: it *wants* a short terminal @@ -94,11 +95,11 @@ const DEFAULT_MAX_REMINDERS = 2; // An explicit, deliberately long gate deadline. A pause with no `timeoutMs` gets // the hosted engine's 7-day default, and a lapsed deadline *terminates* the run -// with a PauseTimeoutError instead of resuming the step, which would hard-fail -// any approval slower than a week and skip `escalate` entirely. One year is the -// opt-out: long enough that a slow approver never loses the run, finite enough -// that an abandoned chain still reaches a terminal state instead of parking -// forever. Reminders and escalation come from the signal, never from this value. +// instead of resuming the step, which would hard-fail any approval slower than a +// week and skip `escalate` entirely. One year is the opt-out, and it is still +// terminal: an approval that outlives it is failed too. The year is picked so no +// realistic approver reaches it, while an abandoned chain still lands in a +// terminal state. Reminders and escalation come from the signal, not this value. const GATE_PAUSE_TIMEOUT_MS = 365 * 24 * 60 * 60 * 1000; /** Postgres table the durable ledger appends to. */ diff --git a/packages/agent/README.md b/packages/agent/README.md index 5b28e7e40..8155739e5 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -180,14 +180,19 @@ Things to know: usual; the pause wiring only engages when a step pauses on the handle. - **A hosted pause has a deadline.** `timeoutMs` sets it; omitted, the hosted engine applies its default of 7 days. A pause that receives no signal by then is finalized - as failed with `PauseTimeoutError`, so a dropped result surfaces as an error rather - than a run that parks forever. The default matches the sandboxed capability's - resume-token TTL, so for a coding pause a later result could not be accepted - anyway. It does **not** bound a dispatched child agent: a child's result comes - back through stored parent linkage with no token check, so it can land long after - seven days. Size `timeoutMs` to whatever you are waiting on, on any pause that can - outlive a week: a child run's worst case (otherwise the parent fails while the - child is still working), or a human gate: + as failed rather than parked forever, so a dropped result surfaces as an error. The + failure carries the engine's pause-timeout error, an engine state on the run rather + than a symbol this package exports. The default matches the sandboxed capability's + resume-token TTL, so for a coding pause a later result could not be accepted anyway. + + **A pause on a dispatched child agent needs no `timeoutMs`.** Its result comes back + through stored parent linkage rather than a resume token, and the engine waives the + deadline for as long as the child is alive. The waiver is narrow: it covers a + dispatch still pending or waiting on the parent, and lapses as soon as the child + reaches a terminal state or its run no longer exists. + + Set `timeoutMs` for what the default does not fit: a human gate you expect to + outlive a week, or a wait that should give up sooner. ```ts // A human gate: nothing but this deadline bounds the wait. diff --git a/packages/agent/src/directives.ts b/packages/agent/src/directives.ts index d9eff5120..30cdeaf0f 100644 --- a/packages/agent/src/directives.ts +++ b/packages/agent/src/directives.ts @@ -77,9 +77,10 @@ export interface PauseUntilSignalDirective { * Deadline for the signal, in ms from the moment the pause is recorded. * Omitted, the hosted engine applies its default pause deadline of 7 days. * A pause that receives no signal by its deadline is finalized as failed - * with `PauseTimeoutError` rather than parking forever. Pass an explicit - * value for a wait that must run longer or give up sooner: a dispatched - * child agent is not bounded by the default (see `pauseUntilSignal`). + * rather than parking forever, with the engine's pause-timeout error on the + * run (not an export of this package). Pass an explicit value for a wait + * that must run longer or give up sooner; a pause on a dispatched child + * agent needs none (see `pauseUntilSignal`). * * `run_local` neither applies nor enforces this: it auto-resumes every pause * immediately, so the deadline is only observable against the hosted engine. @@ -251,19 +252,25 @@ export function fail(reason?: string, opts?: { output?: unknown }): Fail { * site. * * **A hosted pause has a deadline.** `timeoutMs` sets it; omitted, the hosted - * engine applies its default of 7 days (the capability resume-token TTL). If no - * signal arrives by then the run is finalized as failed with `PauseTimeoutError`, - * so a lost webhook or a dropped capability result surfaces as an error rather - * than a run that parks forever. The default matches the sandboxed capability's - * resume-token TTL, so for a coding pause a later result could not be accepted - * anyway. It does NOT bound a dispatched child agent: a child's result returns - * through stored parent linkage with no token check, so it can land long after - * seven days, and the default would fail the parent while the child is still - * working. Size `timeoutMs` to what you are waiting on whenever it can outlive a - * week, a child run's worst case or a human gate, or shorten it on any wait that - * should give up sooner. `run_local` neither applies nor enforces this: it auto-resumes every - * pause immediately, with the registered capability result or an empty payload, - * so a local run never sits at a gate and never times out. + * engine applies its default of 7 days, the capability resume-token TTL. If no + * signal arrives by then the run is finalized as failed rather than parking + * forever, so a lost webhook or a dropped capability result surfaces as an + * error. The failure carries the engine's pause-timeout error; it is an engine + * state on the run, not a symbol this package exports. + * + * A pause on a **dispatched child agent** needs no `timeoutMs`. Its result comes + * back through stored parent linkage rather than a resume token, so it is not + * bounded by the TTL the default rests on, and the engine waives the deadline for + * as long as the child is alive. That waiver is narrow: it covers a dispatch that + * is still pending or waiting on the parent, and stops the moment the child + * reaches a terminal state or its run no longer exists, at which point the + * ordinary deadline applies again. + * + * So set `timeoutMs` for what the default does not fit: a human gate expected to + * outlive a week, or any wait that should give up sooner. `run_local` neither + * applies nor enforces any of this: it auto-resumes every pause immediately, with + * the registered capability result or an empty payload, so a local run never sits + * at a gate and never times out. */ export function pauseUntilSignal(args: { signal: string; From 9772ea76382742b09ef4411c5197a53db418071b Mon Sep 17 00:00:00 2001 From: antoine-berger <64917674+antoine-berger@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:37:57 -0500 Subject: [PATCH 09/12] docs(examples): finish the deadline cleanup the SDK docs started Second review pass caught the three templates the first one missed. `human-in-the-loop`, `proposal-generator` and `scheduled-compliance-audit` still promised that a slow approver "never loses the run". The one-year value is a terminal deadline like any other; the year is picked so nobody realistic reaches it, not so that nobody can. `PauseTimeoutError` was still named in `approval-chain` and `pr-review-bot` after being removed from the SDK docs for the same reason: it is an engine class, not something an author can import or catch from `@sapiom/agent`. The candidate gate in `human-in-the-loop` read as though its deadline were the fallback that moves on to the next candidate. It is not: expiry fails the run rather than delivering `{ decision: "timeout" }` to `resolve`. The comment now says the signal drives that branch and the deadline is only the backstop. The flow itself is unchanged: wiring a real timeout producer is the template's own design question, not this PR's. Co-Authored-By: Claude Opus 5 (1M context) --- examples/approval-chain/AGENTS.md | 2 +- examples/approval-chain/README.md | 2 +- examples/approval-chain/index.ts | 4 ++-- examples/human-in-the-loop/index.ts | 11 ++++++++--- examples/pr-review-bot/index.ts | 2 +- examples/proposal-generator/index.ts | 5 +++-- examples/scheduled-compliance-audit/index.ts | 5 +++-- 7 files changed, 19 insertions(+), 12 deletions(-) diff --git a/examples/approval-chain/AGENTS.md b/examples/approval-chain/AGENTS.md index ed57564fd..1a74cfc8a 100644 --- a/examples/approval-chain/AGENTS.md +++ b/examples/approval-chain/AGENTS.md @@ -15,7 +15,7 @@ went silent). Inside a step's `run`, Sapiom capabilities are pre-auth'd on It carries a static `pause: { signal, resumeStep: "decide" }` annotation, the build-time graph edge that must match the directive. **A one-year `timeoutMs` (`GATE_PAUSE_TIMEOUT_MS`):** the engine's paused-run reaper *terminates* a lapsed - pause (`PauseTimeoutError`) instead of resuming it, so a short gate deadline would + pause with a pause-timeout failure instead of resuming it, so a short gate deadline would hard-fail a slow approval and skip `escalate`. Omitting `timeoutMs` does not avoid that: a pause with no deadline inherits the engine's 7-day default. The year is an explicit backstop; the reminder/escalation cadence comes from the signal (see diff --git a/examples/approval-chain/README.md b/examples/approval-chain/README.md index f06cf0d17..fd1eb0910 100644 --- a/examples/approval-chain/README.md +++ b/examples/approval-chain/README.md @@ -56,7 +56,7 @@ external audit copy that never blocks the chain. Each gate pauses at $0 under a deliberately long deadline: `GATE_PAUSE_TIMEOUT_MS`, one year. The engine has a paused-run reaper that *terminates* a lapsed pause with a -`PauseTimeoutError` (it does not resume the step), so a short deadline here would +with the engine's pause-timeout error (it does not resume the step), so a short deadline here would silently fail any approval slower than it and never run the graceful `escalate` step. A legitimately slow approver must not lose the run. diff --git a/examples/approval-chain/index.ts b/examples/approval-chain/index.ts index 1f2ff2d7b..c00e8c1ea 100644 --- a/examples/approval-chain/index.ts +++ b/examples/approval-chain/index.ts @@ -53,7 +53,7 @@ import { z } from "zod/v4"; * The gates carry a deliberately long `GATE_PAUSE_TIMEOUT_MS` (one year), and the * value is load-bearing. The engine has a paused-run reaper: `timeoutMs` sets * `pausedUntil`, and a background sweep *terminates* the run with a - * `PauseTimeoutError` once it lapses. It does NOT resume the step, so a lapsed + * a pause-timeout failure once it lapses. It does NOT resume the step, so a lapsed * gate never reaches `decide`/`remind`/`escalate`. Handing a signal pause a * `reminderMs`-sized `timeoutMs` (as an earlier version did) would hard-fail any * approval slower than the reminder interval, bypassing the graceful `escalate` @@ -515,7 +515,7 @@ const present = defineStep({ // it, and the run detail ships one-click Approve/Reject. // // `GATE_PAUSE_TIMEOUT_MS` is load-bearing, not decoration: a lapsed deadline - // *terminates* the run (PauseTimeoutError) rather than resuming it, so the + // *terminates* the run (a pause-timeout failure) rather than resuming it, so the // engine's 7-day default would silently fail any approval slower than a week // and skip `escalate`. The reminder/escalation loop is driven by the // `approval.decision` signal (one-click UI, a cron firing `remind`/`timeout`, diff --git a/examples/human-in-the-loop/index.ts b/examples/human-in-the-loop/index.ts index dcd613b51..188b31ed4 100644 --- a/examples/human-in-the-loop/index.ts +++ b/examples/human-in-the-loop/index.ts @@ -58,8 +58,9 @@ const CONFIRM_SIGNAL = "candidate.confirm"; * Explicit deadline for a human gate, one year. A pause with no `timeoutMs` * inherits the engine's 7-day default, and a lapsed deadline *terminates* the * run rather than resuming it, so the default would hard-fail any approval - * slower than a week. Long enough that a slow approver never loses the run, - * finite enough that an abandoned one still reaches a terminal state. + * slower than a week. It stays a terminal deadline: an approval that outlives + * the year is failed too. The year is picked so no realistic approver reaches + * it, while an abandoned gate still lands in a terminal state. */ const GATE_PAUSE_TIMEOUT_MS = 365 * 24 * 60 * 60 * 1000; @@ -505,7 +506,11 @@ const offer = defineStep({ index, }); - // Suspend at $0 until this candidate confirms (or a timeout fires the signal). + // Suspend at $0 until this candidate confirms. Moving on when a candidate + // goes quiet is driven by the signal, not by `timeoutMs`: something external + // fires `candidate.confirm` with `{ decision: "timeout" }` and `resolve` + // advances to the next candidate. The deadline below is only the terminal + // backstop, it fails the run rather than delivering that payload. return pauseUntilSignal({ signal: CONFIRM_SIGNAL, resumeStep: "resolve", diff --git a/examples/pr-review-bot/index.ts b/examples/pr-review-bot/index.ts index b5fe3cd2c..eb3567e92 100644 --- a/examples/pr-review-bot/index.ts +++ b/examples/pr-review-bot/index.ts @@ -385,7 +385,7 @@ const watch = defineStep({ // Suspend at $0 until the webhook fires SIGNAL for this correlationId. // No `timeoutMs` on purpose: the engine's 7-day default is the right ceiling // here. A PR event that has not arrived in a week is not coming, and a - // terminal PauseTimeoutError beats a run parked on a dead webhook. + // terminal pause-timeout failure beats a run parked on a dead webhook. return pauseUntilSignal({ signal: SIGNAL, resumeStep: "review", diff --git a/examples/proposal-generator/index.ts b/examples/proposal-generator/index.ts index 4e3bf68b9..50f68f670 100644 --- a/examples/proposal-generator/index.ts +++ b/examples/proposal-generator/index.ts @@ -70,8 +70,9 @@ const DECISION_SIGNAL = "proposal.decision"; * Explicit deadline for a human gate, one year. A pause with no `timeoutMs` * inherits the engine's 7-day default, and a lapsed deadline *terminates* the * run rather than resuming it, so the default would hard-fail any approval - * slower than a week. Long enough that a slow approver never loses the run, - * finite enough that an abandoned one still reaches a terminal state. + * slower than a week. It stays a terminal deadline: an approval that outlives + * the year is failed too. The year is picked so no realistic approver reaches + * it, while an abandoned gate still lands in a terminal state. */ const GATE_PAUSE_TIMEOUT_MS = 365 * 24 * 60 * 60 * 1000; diff --git a/examples/scheduled-compliance-audit/index.ts b/examples/scheduled-compliance-audit/index.ts index a4c404700..b786df447 100644 --- a/examples/scheduled-compliance-audit/index.ts +++ b/examples/scheduled-compliance-audit/index.ts @@ -62,8 +62,9 @@ const SIGNOFF_SIGNAL = "attestation.signoff"; * Explicit deadline for a human gate, one year. A pause with no `timeoutMs` * inherits the engine's 7-day default, and a lapsed deadline *terminates* the * run rather than resuming it, so the default would hard-fail any approval - * slower than a week. Long enough that a slow approver never loses the run, - * finite enough that an abandoned one still reaches a terminal state. + * slower than a week. It stays a terminal deadline: an approval that outlives + * the year is failed too. The year is picked so no realistic approver reaches + * it, while an abandoned gate still lands in a terminal state. */ const GATE_PAUSE_TIMEOUT_MS = 365 * 24 * 60 * 60 * 1000; From 34dbb087aa6660af6504ed4967c2ea4c0d75be42 Mon Sep 17 00:00:00 2001 From: antoine-berger <64917674+antoine-berger@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:52:11 -0500 Subject: [PATCH 10/12] docs(agent): a scheduled child does not get the live-child waiver The docs told authors that a pause on a dispatched child needs no `timeoutMs`. That holds for a child launched now and not for one launched with `at`. The waiver is found through the dispatch ledger, whose row is keyed on the child's execution id. A scheduled launch pauses the parent on the schedule's correlation instead, minted before any child exists, so the two never match and the waiver never fires for it. The parent's deadline is anchored to the scheduled time, which covers the wait until the child starts but not the child's own run: a scheduled child that opens a gate of its own can outlive it. Both JSDoc sites and the README now split the two cases and tell authors to pass an explicit `timeoutMs` on a scheduled child that can take more than a week once it begins. Co-Authored-By: Claude Opus 5 (1M context) --- packages/agent/README.md | 22 ++++++++++++++-------- packages/agent/src/directives.ts | 11 ++++++++--- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/packages/agent/README.md b/packages/agent/README.md index 8155739e5..9c0b1af26 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -185,14 +185,20 @@ Things to know: than a symbol this package exports. The default matches the sandboxed capability's resume-token TTL, so for a coding pause a later result could not be accepted anyway. - **A pause on a dispatched child agent needs no `timeoutMs`.** Its result comes back - through stored parent linkage rather than a resume token, and the engine waives the - deadline for as long as the child is alive. The waiver is narrow: it covers a - dispatch still pending or waiting on the parent, and lapses as soon as the child - reaches a terminal state or its run no longer exists. - - Set `timeoutMs` for what the default does not fit: a human gate you expect to - outlive a week, or a wait that should give up sooner. + **A pause on a child agent launched now needs no `timeoutMs`.** Its result comes + back through stored parent linkage rather than a resume token, and the engine + waives the deadline for as long as the child is alive. The waiver is narrow: it + covers a dispatch still pending or waiting on the parent, and lapses as soon as the + child reaches a terminal state or its run no longer exists. + + **A child scheduled with `at` is the exception.** The parent's deadline runs from + the scheduled time rather than from the pause, which covers the wait until the + child starts, but the live-child waiver does not apply to it. Pass an explicit + `timeoutMs` when a scheduled child can take more than a week once it begins, for + instance because it opens a gate of its own. + + Otherwise set `timeoutMs` for what the default does not fit: a human gate you + expect to outlive a week, or a wait that should give up sooner. ```ts // A human gate: nothing but this deadline bounds the wait. diff --git a/packages/agent/src/directives.ts b/packages/agent/src/directives.ts index 30cdeaf0f..682bd620c 100644 --- a/packages/agent/src/directives.ts +++ b/packages/agent/src/directives.ts @@ -258,7 +258,7 @@ export function fail(reason?: string, opts?: { output?: unknown }): Fail { * error. The failure carries the engine's pause-timeout error; it is an engine * state on the run, not a symbol this package exports. * - * A pause on a **dispatched child agent** needs no `timeoutMs`. Its result comes + * A pause on a **child agent launched now** needs no `timeoutMs`. Its result comes * back through stored parent linkage rather than a resume token, so it is not * bounded by the TTL the default rests on, and the engine waives the deadline for * as long as the child is alive. That waiver is narrow: it covers a dispatch that @@ -266,8 +266,13 @@ export function fail(reason?: string, opts?: { output?: unknown }): Fail { * reaches a terminal state or its run no longer exists, at which point the * ordinary deadline applies again. * - * So set `timeoutMs` for what the default does not fit: a human gate expected to - * outlive a week, or any wait that should give up sooner. `run_local` neither + * A **child scheduled with `at`** does not get that waiver. Its parent's deadline + * runs from the scheduled time instead of the pause, which covers the wait until + * the child starts but not the child's own run, so pass an explicit `timeoutMs` + * when a scheduled child can take more than a week once it begins. + * + * Otherwise set `timeoutMs` for what the default does not fit: a human gate + * expected to outlive a week, or any wait that should give up sooner. `run_local` neither * applies nor enforces any of this: it auto-resumes every pause immediately, with * the registered capability result or an empty payload, so a local run never sits * at a gate and never times out. From 36213f78594538aa5f10944057610ddaad954542 Mon Sep 17 00:00:00 2001 From: antoine-berger <64917674+antoine-berger@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:24:54 -0500 Subject: [PATCH 11/12] docs(agent): the engine follows a scheduled child too, and timeoutMs opts out An independent review pass caught that the child-agent guidance had gone stale and inverted, in the one direction that hurts. The engine branch moved after these docs were written. It now reaches a child launched with `at` through the child's own parent linkage, precisely because no dispatch-ledger row exists for a delayed dispatch, and pushes the parent's deadline while that child is alive exactly as it does for an immediate launch. The docs said the opposite, that a scheduled child gets no waiver. Worse, the remedy they prescribed is the one action that disables it: a deadline the author set is taken literally and never extended, so passing `timeoutMs` on a child pause trades a deadline that follows the child for one that can expire while the child is still working. All three surfaces now state the rule once, for both kinds of launch, and name the opt-out. Four smaller defects from the earlier revert rounds, same review: - `approval-chain` shipped two sentences with a duplicated fragment, in the load-bearing explanation of why its gates carry a year. - `wait-for-webhook`'s `parseTimeoutMs` justified rejecting a bad value by a forever-park three lines after saying an absent value inherits the 7-day default. The justification is now the real one: a dropped cap leaves the run on the default while the operator believes their own window applies. Same in its README. - Six templates justified their offline bypass with "pausing here would suspend the run forever". Nothing suspends forever after this change; the reason to skip the pause is that nothing will ever fire the signal. Co-Authored-By: Claude Opus 5 (1M context) --- examples/approval-chain/README.md | 4 +-- examples/approval-chain/index.ts | 2 +- examples/durable-backfill/index.ts | 2 +- examples/human-in-the-loop/index.ts | 2 +- examples/pr-review-bot/index.ts | 2 +- examples/proposal-generator/index.ts | 2 +- examples/scheduled-compliance-audit/index.ts | 2 +- examples/wait-for-webhook/README.md | 6 ++--- examples/wait-for-webhook/index.ts | 9 ++++--- packages/agent/README.md | 28 ++++++++++---------- packages/agent/src/directives.ts | 26 +++++++++--------- 11 files changed, 44 insertions(+), 41 deletions(-) diff --git a/examples/approval-chain/README.md b/examples/approval-chain/README.md index fd1eb0910..cda84d92f 100644 --- a/examples/approval-chain/README.md +++ b/examples/approval-chain/README.md @@ -55,8 +55,8 @@ external audit copy that never blocks the chain. ## Reminders, escalation, and why the gates wait a year Each gate pauses at $0 under a deliberately long deadline: `GATE_PAUSE_TIMEOUT_MS`, -one year. The engine has a paused-run reaper that *terminates* a lapsed pause with a -with the engine's pause-timeout error (it does not resume the step), so a short deadline here would +one year. The engine has a paused-run reaper that *terminates* a lapsed pause with its +pause-timeout error (it does not resume the step), so a short deadline here would silently fail any approval slower than it and never run the graceful `escalate` step. A legitimately slow approver must not lose the run. diff --git a/examples/approval-chain/index.ts b/examples/approval-chain/index.ts index c00e8c1ea..420fea0ad 100644 --- a/examples/approval-chain/index.ts +++ b/examples/approval-chain/index.ts @@ -53,7 +53,7 @@ import { z } from "zod/v4"; * The gates carry a deliberately long `GATE_PAUSE_TIMEOUT_MS` (one year), and the * value is load-bearing. The engine has a paused-run reaper: `timeoutMs` sets * `pausedUntil`, and a background sweep *terminates* the run with a - * a pause-timeout failure once it lapses. It does NOT resume the step, so a lapsed + * pause-timeout failure once it lapses. It does NOT resume the step, so a lapsed * gate never reaches `decide`/`remind`/`escalate`. Handing a signal pause a * `reminderMs`-sized `timeoutMs` (as an earlier version did) would hard-fail any * approval slower than the reminder interval, bypassing the graceful `escalate` diff --git a/examples/durable-backfill/index.ts b/examples/durable-backfill/index.ts index aa2348e94..2dbd3d9d6 100644 --- a/examples/durable-backfill/index.ts +++ b/examples/durable-backfill/index.ts @@ -503,7 +503,7 @@ const processStep = defineStep({ if (nextCursor >= total) return goto("finalize", {}); // Offline, nothing is wired to fire the heartbeat — no cron trigger, no - // external scheduler — so pausing here would suspend the run forever after + // external scheduler, so pausing here only burns the deadline after // one chunk of real work. Loop directly instead and finish the backfill. The // durable-pause path is what a live run takes; this one still exercises the // same chunk-and-checkpoint loop, just without waiting for a clock. diff --git a/examples/human-in-the-loop/index.ts b/examples/human-in-the-loop/index.ts index 188b31ed4..5f29ae7aa 100644 --- a/examples/human-in-the-loop/index.ts +++ b/examples/human-in-the-loop/index.ts @@ -398,7 +398,7 @@ const notifyApprover = defineStep({ const approver = ctx.shared.get("approver") ?? null; // Nobody is assigned to approve, so nothing will ever fire the decision - // signal and pausing here would suspend the run forever. Stop holding the + // signal, so pausing here only burns the deadline. Stop holding the // ranked shortlist instead. Never as approved — fabricating a human yes is // the worst output this template could produce — and never auto-rejected // either, which reads as breakage when nobody actually said no. diff --git a/examples/pr-review-bot/index.ts b/examples/pr-review-bot/index.ts index eb3567e92..5b7f9d8fa 100644 --- a/examples/pr-review-bot/index.ts +++ b/examples/pr-review-bot/index.ts @@ -363,7 +363,7 @@ const watch = defineStep({ if (dryRun) { // No webhook source registered, so no PR event will ever arrive and the - // pause below would suspend the run forever. Review the sample PR instead — + // pause below would only burn the deadline. Review the sample PR instead — // a real model review of a real diff — and say whose PR it was. ctx.shared.set( "note", diff --git a/examples/proposal-generator/index.ts b/examples/proposal-generator/index.ts index 50f68f670..2bed0afcd 100644 --- a/examples/proposal-generator/index.ts +++ b/examples/proposal-generator/index.ts @@ -583,7 +583,7 @@ const review = defineStep({ const downloadUrl = ctx.shared.get("downloadUrl") ?? null; // Nobody is assigned to approve, so nothing will ever fire the decision - // signal: pausing here would suspend the run forever. Stop holding the draft + // signal: pausing here would only burn the deadline. Stop holding the draft // instead — never as approved, and never auto-rejected either. if (!approver) { ctx.logger.info("no approver configured — terminating at the gate", { diff --git a/examples/scheduled-compliance-audit/index.ts b/examples/scheduled-compliance-audit/index.ts index b786df447..8491dc33d 100644 --- a/examples/scheduled-compliance-audit/index.ts +++ b/examples/scheduled-compliance-audit/index.ts @@ -376,7 +376,7 @@ const review = defineStep({ const signOffBy = ctx.shared.get("signOffBy") ?? null; // Nobody is assigned to sign off, so nothing will ever fire the sign-off - // signal and pausing here would suspend the run forever. Stop holding the + // signal, so pausing here only burns the deadline. Stop holding the // DRAFT attestation instead. // // What this must never do: resuming with an empty payload fabricates no diff --git a/examples/wait-for-webhook/README.md b/examples/wait-for-webhook/README.md index 7168fb0f7..2466392a7 100644 --- a/examples/wait-for-webhook/README.md +++ b/examples/wait-for-webhook/README.md @@ -44,9 +44,9 @@ If no callback fires within that window, the engine's deadline sweep ends the ru with a pause-timeout failure — an honest terminal state ("no callback within N") instead of a run parked forever. Leave `CALLBACK_TIMEOUT_MS` unset to fall back on the engine's 7-day default, which produces the same terminal state on a slower -horizon. A non-numeric or non-positive value is rejected at `kickoff` (a -silently-ignored cap would just reintroduce the forever-park it's meant to -prevent). +horizon. A non-numeric or non-positive value is rejected at `kickoff`: a silently +dropped cap would leave the run on that default while you believe your own window +applies. ## Run it with Claude + the Sapiom MCP diff --git a/examples/wait-for-webhook/index.ts b/examples/wait-for-webhook/index.ts index 48b82a35b..73daa4333 100644 --- a/examples/wait-for-webhook/index.ts +++ b/examples/wait-for-webhook/index.ts @@ -112,9 +112,10 @@ function isDryRun(config: Config): boolean { * directive without a `timeoutMs` and so inherits the engine's 7-day default. * A positive integer sizes the wait instead: with no callback inside it, the * engine's deadline sweep terminates the run with a pause-timeout failure - * rather than parking it. A non-positive or unparseable value is rejected — - * silently ignoring a cap would reintroduce the forever-park bug it's meant to - * prevent, so a misconfigured deadline fails loudly at `kickoff`. + * rather than parking it. A non-positive or unparseable value is rejected: a + * silently dropped cap would leave the run on the 7-day default while the operator + * believes their own window applies, so a misconfigured deadline fails loudly at + * `kickoff` instead. */ function parseTimeoutMs(config: Config): number | undefined { const raw = (config.CALLBACK_TIMEOUT_MS ?? "").trim(); @@ -202,7 +203,7 @@ const kickoff = defineStep({ if (isDryRun(config)) { // No live endpoint, so no external job was registered and nothing will ever - // fire the callback. Pausing here would suspend the run forever. Take the + // fire the callback, so pausing here only burns the deadline. Take the // decide branch on an empty payload instead — a real model call over a // callback that says, honestly, that no callback arrived. ctx.shared.set("jobId", "not-registered"); diff --git a/packages/agent/README.md b/packages/agent/README.md index 9c0b1af26..11caeaf31 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -185,20 +185,20 @@ Things to know: than a symbol this package exports. The default matches the sandboxed capability's resume-token TTL, so for a coding pause a later result could not be accepted anyway. - **A pause on a child agent launched now needs no `timeoutMs`.** Its result comes - back through stored parent linkage rather than a resume token, and the engine - waives the deadline for as long as the child is alive. The waiver is narrow: it - covers a dispatch still pending or waiting on the parent, and lapses as soon as the - child reaches a terminal state or its run no longer exists. - - **A child scheduled with `at` is the exception.** The parent's deadline runs from - the scheduled time rather than from the pause, which covers the wait until the - child starts, but the live-child waiver does not apply to it. Pass an explicit - `timeoutMs` when a scheduled child can take more than a week once it begins, for - instance because it opens a gate of its own. - - Otherwise set `timeoutMs` for what the default does not fit: a human gate you - expect to outlive a week, or a wait that should give up sooner. + **A pause on a dispatched child agent needs no `timeoutMs`.** Its result comes back + through stored parent linkage rather than a resume token, and the engine keeps + pushing the parent's deadline for as long as the child is alive. That holds whether + the child was launched now or scheduled with `at`, and it lapses once the child + reaches a terminal state or its run no longer exists. + + **Passing `timeoutMs` on a child pause opts out of that.** A deadline the author + set is taken literally and never extended, so an explicit value on a child pause + replaces a deadline that would have followed the child with one that can expire + while the child is still working. Leave it off unless you want the parent to give + up on a schedule of your own. + + Set `timeoutMs` for what the default does not fit: a human gate you expect to + outlive a week, or a wait that should give up sooner. ```ts // A human gate: nothing but this deadline bounds the wait. diff --git a/packages/agent/src/directives.ts b/packages/agent/src/directives.ts index 682bd620c..1e660da07 100644 --- a/packages/agent/src/directives.ts +++ b/packages/agent/src/directives.ts @@ -79,8 +79,9 @@ export interface PauseUntilSignalDirective { * A pause that receives no signal by its deadline is finalized as failed * rather than parking forever, with the engine's pause-timeout error on the * run (not an export of this package). Pass an explicit value for a wait - * that must run longer or give up sooner; a pause on a dispatched child - * agent needs none (see `pauseUntilSignal`). + * that must run longer or give up sooner. A pause on a dispatched child agent + * needs none, and setting one there opts out of the engine following the child + * (see `pauseUntilSignal`). * * `run_local` neither applies nor enforces this: it auto-resumes every pause * immediately, so the deadline is only observable against the hosted engine. @@ -258,21 +259,22 @@ export function fail(reason?: string, opts?: { output?: unknown }): Fail { * error. The failure carries the engine's pause-timeout error; it is an engine * state on the run, not a symbol this package exports. * - * A pause on a **child agent launched now** needs no `timeoutMs`. Its result comes + * A pause on a **dispatched child agent** needs no `timeoutMs`. Its result comes * back through stored parent linkage rather than a resume token, so it is not - * bounded by the TTL the default rests on, and the engine waives the deadline for - * as long as the child is alive. That waiver is narrow: it covers a dispatch that - * is still pending or waiting on the parent, and stops the moment the child + * bounded by the TTL the default rests on, and the engine keeps pushing the + * parent's deadline for as long as the child is alive. That holds for a child + * launched now and for one scheduled with `at`, and it lapses once the child * reaches a terminal state or its run no longer exists, at which point the * ordinary deadline applies again. * - * A **child scheduled with `at`** does not get that waiver. Its parent's deadline - * runs from the scheduled time instead of the pause, which covers the wait until - * the child starts but not the child's own run, so pass an explicit `timeoutMs` - * when a scheduled child can take more than a week once it begins. + * Passing `timeoutMs` on a child pause **opts out** of that. A deadline the author + * set is taken literally and never extended, so an explicit value replaces a + * deadline that would have followed the child with one that can expire while the + * child is still working. Leave it off unless the parent should give up on a + * schedule of your own. * - * Otherwise set `timeoutMs` for what the default does not fit: a human gate - * expected to outlive a week, or any wait that should give up sooner. `run_local` neither + * Set `timeoutMs` for what the default does not fit: a human gate expected to + * outlive a week, or any wait that should give up sooner. `run_local` neither * applies nor enforces any of this: it auto-resumes every pause immediately, with * the registered capability result or an empty payload, so a local run never sits * at a gate and never times out. From f399c15bd39ddee50b49cc1591acd7917b600269 Mon Sep 17 00:00:00 2001 From: antoine-berger <64917674+antoine-berger@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:13:10 -0500 Subject: [PATCH 12/12] docs(agent): close the gaps left against the engine's final deadline rules A second pass against the current engine head found the rules themselves stated correctly and five surfaces lagging behind them. - The changeset now says what happens to runs already paused when the engine change deploys: they are held to the same 7 days from their last activity, and a new `timeoutMs` only reaches pauses recorded after a redeploy. Its child-agent clause now matches the README and JSDoc: it covers a child launched with `at`, and an explicit `timeoutMs` on a child pause turns the extension off. - The README still told authors to give a human gate a `timeoutMs` "so the failure is recorded rather than sat on", which only made sense while an omitted deadline meant waiting forever. It now says to size the value to how long a human may take, and the example uses a value that actually differs from the default. - `pr-review-bot` promised a wait "for as long as it takes". Its pause carries no `timeoutMs`, so it waits up to the 7-day default and then fails. - `human-in-the-loop`, `proposal-generator` and `scheduled-compliance-audit` showed their gate directive without the `timeoutMs` the code passes, in the AGENTS.md files a coding agent reads before editing the template. They now show it and say why, as `approval-chain` already did. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/pause-deadline-docs.md | 2 +- examples/human-in-the-loop/AGENTS.md | 8 ++++++-- examples/pr-review-bot/AGENTS.md | 2 +- examples/pr-review-bot/README.md | 8 +++++--- examples/proposal-generator/AGENTS.md | 4 +++- examples/scheduled-compliance-audit/AGENTS.md | 6 ++++-- packages/agent/README.md | 9 +++++---- 7 files changed, 25 insertions(+), 14 deletions(-) diff --git a/.changeset/pause-deadline-docs.md b/.changeset/pause-deadline-docs.md index 6ea55ca3c..72ba5f6b2 100644 --- a/.changeset/pause-deadline-docs.md +++ b/.changeset/pause-deadline-docs.md @@ -4,6 +4,6 @@ Document the pause deadline on `pauseUntilSignal`, `PauseUntilSignalDirective.timeoutMs` and `Pause.timeoutMs`. -Forthcoming behavior change on the hosted engine, not in this package: a pause that omits `timeoutMs` waits indefinitely today, and will instead carry a 7-day deadline once the engine change for SAP-3207 is deployed. Past that deadline the run is finalized as failed rather than parking silently, carrying the engine's pause-timeout error, so a run that used to hang forever will surface as a failure. Pass an explicit `timeoutMs` on a signal pause that must outlive a week, such as a human approval gate; a pause on a dispatched child agent needs none, since the engine waives the deadline while the child is alive. `run_local` is unaffected either way: it never applies or enforces a pause deadline, it auto-resumes every pause immediately. +Forthcoming behavior change on the hosted engine, not in this package: a pause that omits `timeoutMs` waits indefinitely today, and will instead carry a 7-day deadline once the engine change for SAP-3207 is deployed. Past that deadline the run is finalized as failed rather than parking silently, carrying the engine's pause-timeout error, so a run that used to hang forever will surface as a failure. Runs already paused without `timeoutMs` when it deploys are held to the same 7 days, counted from their last activity, so one that has already waited longer fails soon after the deploy; a new `timeoutMs` only reaches pauses recorded after you redeploy. Pass an explicit `timeoutMs` on a signal pause that must outlive a week, such as a human approval gate. A pause on a dispatched child agent, launched now or with `at`, needs none: the engine keeps extending it while the child is still running, and an explicit `timeoutMs` there is taken literally and turns that off. `run_local` is unaffected either way: it never applies or enforces a pause deadline, it auto-resumes every pause immediately. This package ships documentation only, with no type, signature or runtime change. diff --git a/examples/human-in-the-loop/AGENTS.md b/examples/human-in-the-loop/AGENTS.md index 818427a22..ca6053cc1 100644 --- a/examples/human-in-the-loop/AGENTS.md +++ b/examples/human-in-the-loop/AGENTS.md @@ -12,15 +12,19 @@ capabilities are pre-auth'd on `ctx.sapiom` (here, `ctx.sapiom.llm.run` and ## The approval + fallback spine - **`notifyApprover`** emails the ranked recommendation, then returns - `pauseUntilSignal({ signal: "approval.decision", resumeStep: "onDecision", correlationId: ctx.executionId })`. + `pauseUntilSignal({ signal: "approval.decision", resumeStep: "onDecision", correlationId: ctx.executionId, timeoutMs: GATE_PAUSE_TIMEOUT_MS })`. It carries a static `pause: { signal, resumeStep: "onDecision" }` annotation — the build-time graph edge that must match the directive. + **A one-year `timeoutMs` (`GATE_PAUSE_TIMEOUT_MS`):** omitting it inherits the + engine's 7-day default, and a lapsed deadline fails the run instead of resuming it. - **`onDecision`** reads the approval payload **directly as its `run` input**. Safe default: only `{ decision: "approve" }` proceeds; anything else (including a `run_local` resume with no payload) routes to `revert` — nothing commits without a deliberate human yes. - **`offer`** makes a _provisional, non-committing_ offer to `ranked[index]` and - pauses on `candidate.confirm`, resuming at `resolve`. + pauses on `candidate.confirm`, resuming at `resolve`, under the same one-year + `timeoutMs`. That deadline is only a backstop: moving on from a silent candidate + needs something external to fire `candidate.confirm` with `{ decision: "timeout" }`. - **`resolve`** reads the confirm payload as its input. `accept` → `commit`; `decline`/`timeout`/absent → `index + 1` and loop back to `offer` while candidates remain, else `escalate`. The loop edge is `resolve → offer`; the diff --git a/examples/pr-review-bot/AGENTS.md b/examples/pr-review-bot/AGENTS.md index b9254fc75..54c538e39 100644 --- a/examples/pr-review-bot/AGENTS.md +++ b/examples/pr-review-bot/AGENTS.md @@ -3,7 +3,7 @@ This project defines exactly one Sapiom agent in `index.ts` — **PR Review Bot** — authored against `@sapiom/agent`. It builds a concrete agent on the durable pause/resume spine: `watch` registers a PR webhook and returns -`pauseUntilSignal(...)`; the run suspends at $0 until a pull request is opened; +`pauseUntilSignal(...)`; the run suspends at $0 until a pull request is opened, for up to the engine's 7-day default; `review` (the resume target) receives the PR payload as its input, hands the diff to a coding agent (`ctx.sapiom.models.coding.run`) that checks the code out and analyzes it; `assess` turns the findings into a structured review with diff --git a/examples/pr-review-bot/README.md b/examples/pr-review-bot/README.md index 5749e3327..a5a3503e8 100644 --- a/examples/pr-review-bot/README.md +++ b/examples/pr-review-bot/README.md @@ -4,8 +4,8 @@ On a pull-request webhook, a coding agent checks out and analyzes the code, flags any change that shipped without matching tests, and posts the review to your email or a Slack channel — using your own bot token. -The run **suspends at $0** while it waits for a PR, so it can sit idle for days -between reviews and cost nothing until a webhook wakes it. +The run **suspends at $0** while it waits for a PR, so it can sit idle for up to a +week and cost nothing until a webhook wakes it. ## What it does @@ -19,7 +19,9 @@ watch ──(pause: wait for "pr.opened", $0 while idle)──▶ review ─ `{ executionId, signal, correlationId }`, then returns `pauseUntilSignal({ signal: "pr.opened", resumeStep: "review", correlationId })`. The run suspends here at zero cost until a PR is opened. -2. **(paused)** — nothing runs, nothing is billed, for as long as it takes. +2. **(paused)** — nothing runs, nothing is billed, for up to the engine's 7-day + default pause deadline; if no PR opens in that window the run ends with a + pause-timeout failure. 3. **review** (resume target) — its **input IS the PR payload**. A coding agent (`ctx.sapiom.models.coding.run`) checks the code out in a sandbox and analyzes the diff, told to surface changes that lack tests. It's read-only — diff --git a/examples/proposal-generator/AGENTS.md b/examples/proposal-generator/AGENTS.md index 59f00e481..0c7d7133e 100644 --- a/examples/proposal-generator/AGENTS.md +++ b/examples/proposal-generator/AGENTS.md @@ -24,9 +24,11 @@ Sapiom capabilities are pre-auth'd on `ctx.sapiom` (`ctx.sapiom.llm.run`, - **`review`** checks for an assigned approver first. With none — nothing will ever fire the decision signal on an unconfigured run — it takes the `pending` branch instead of pausing. With one, it emails them and returns - `pauseUntilSignal({ signal: "proposal.decision", resumeStep: "onDecision", correlationId: ctx.executionId })`. + `pauseUntilSignal({ signal: "proposal.decision", resumeStep: "onDecision", correlationId: ctx.executionId, timeoutMs: GATE_PAUSE_TIMEOUT_MS })`. It carries a static `pause: { signal, resumeStep: "onDecision" }` annotation — the build-time graph edge that must match the directive. + **A one-year `timeoutMs` (`GATE_PAUSE_TIMEOUT_MS`):** omitting it inherits the + engine's 7-day default, and a lapsed deadline fails the run instead of resuming it. - **`pending`** is the gate's honest off-ramp: terminal, carrying the rendered PDF's `fileId`/`downloadUrl` and `outcome: "pending-approval"`. This — not a hung pause — is what a `{}` run reaches. diff --git a/examples/scheduled-compliance-audit/AGENTS.md b/examples/scheduled-compliance-audit/AGENTS.md index 11a358f31..f109b7525 100644 --- a/examples/scheduled-compliance-audit/AGENTS.md +++ b/examples/scheduled-compliance-audit/AGENTS.md @@ -22,8 +22,10 @@ the **attestation archive**: nothing is filed until a person explicitly signs of guessing. A wrong capability or method name fails typecheck. - **The durable pause is a primitive, not a capability.** `review` declares a static `pause: { signal, resumeStep }` edge AND returns - `pauseUntilSignal({ signal, resumeStep, correlationId: ctx.executionId })`. The - resume target (`onSignoff`) receives the signal payload as its `run` input. + `pauseUntilSignal({ signal, resumeStep, correlationId: ctx.executionId, timeoutMs: GATE_PAUSE_TIMEOUT_MS })`. + The resume target (`onSignoff`) receives the signal payload as its `run` input. + **A one-year `timeoutMs` (`GATE_PAUSE_TIMEOUT_MS`):** omitting it inherits the + engine's 7-day default, and a lapsed deadline fails the run instead of resuming it. - **Gate real side effects behind `dryRun`.** `archive` uploads the attestation only on a live run with `dryRun` off; otherwise it returns the computed attestation as a preview. The upload's presigned PUT is a raw `fetch` (not a diff --git a/packages/agent/README.md b/packages/agent/README.md index 11caeaf31..7aa2f48c7 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -241,7 +241,7 @@ const present = defineStep({ signal: "approval.decision", resumeStep: "finalize", correlationId: ctx.executionId, - timeoutMs: 7 * 24 * 60 * 60 * 1000, + timeoutMs: 14 * 24 * 60 * 60 * 1000, // two weeks, past the 7-day default }); }, }); @@ -257,9 +257,10 @@ whole design decision: signal matching X": the fanout is 0..N runs, deliberately, and the count you get back is how many actually resumed. -Give any gate a human might never answer an explicit `timeoutMs`: when the -deadline lapses the run ends as a pause timeout instead of resuming, so the -failure is recorded rather than sat on. +Size `timeoutMs` to how long a human may take. Omitted, the hosted 7-day default +applies; whichever deadline lapses, the run ends as a pause timeout instead of +resuming, so pass a longer value for a gate that can outlive a week, or a shorter +one for a decision that goes stale. Under `run_local` a manual gate auto-resumes with `{}` — there is no way to inject a payload — so type the resumed step's input with optional fields.