From 49a70d5f0584a44f1853fb8984f059eb6094a877 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 1/8] 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 fe068eab6..6a722fb87 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 d7677d5f5bf684877be34293ffd09194360514dc 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 2/8] 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 70f43aac3..0020dcbf5 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 f8cc0ed0e..ec58f09e3 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 dc97a96f5..083e459bd 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 987d82170..38460026a 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 ab8780624..0390fb341 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 a2d6d5f6f..d6bc8c963 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 `workflow_signal` tool: `{ \"signal\": \"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 `workflow_signal` tool: `{ \"signal\": \"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 6a722fb87..e8a542253 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 5a7644f0bb1234388648fdec1b2471452df143a6 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 3/8] 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 e8a542253..096019d2c 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 1f6bdfec16450378e9ecba35e7d9e474d2d9af07 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 4/8] 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 5fe97d790b46101650298c1a52360699175aa8f0 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 5/8] 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 096019d2c..e7372c4e0 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 213b693a843bd4f7cccb2e7ec73962abf3d15d84 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 6/8] 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 0020dcbf5..5a8581a96 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 ec58f09e3..faaad71a7 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 e7372c4e0..2f168b6d0 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 4d0845ac458fa2f206e480e6335bb6e4c041fb2b 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 7/8] 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 5a8581a96..0020dcbf5 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 faaad71a7..ec58f09e3 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 2f168b6d0..e7372c4e0 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 1f12443312a040a76eeb60c9adf6c22feb39b93c 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 8/8] 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 ec58f09e3..847d9e1e4 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 e7372c4e0..9bd5ac789 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;