diff --git a/.changeset/pause-deadline-docs.md b/.changeset/pause-deadline-docs.md new file mode 100644 index 000000000..6ea55ca3c --- /dev/null +++ b/.changeset/pause-deadline-docs.md @@ -0,0 +1,9 @@ +--- +"@sapiom/agent": patch +--- + +Document the pause deadline on `pauseUntilSignal`, `PauseUntilSignalDirective.timeoutMs` and `Pause.timeoutMs`. + +Forthcoming behavior change on the hosted engine, not in this package: a pause that omits `timeoutMs` waits indefinitely today, and will instead carry a 7-day deadline once the engine change for SAP-3207 is deployed. Past that deadline the run is finalized as failed rather than parking silently, carrying the engine's pause-timeout error, so a run that used to hang forever will surface as a failure. Pass an explicit `timeoutMs` on a signal pause that must outlive a week, such as a human approval gate; a pause on a dispatched child agent needs none, since the engine waives the deadline while the child is alive. `run_local` is unaffected either way: it never applies or enforces a pause deadline, it auto-resumes every pause immediately. + +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..f06cf0d17 100644 --- a/examples/approval-chain/README.md +++ b/examples/approval-chain/README.md @@ -52,13 +52,20 @@ 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, 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: @@ -70,9 +77,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..847d9e1e4 100644 --- a/examples/approval-chain/index.ts +++ b/examples/approval-chain/index.ts @@ -50,18 +50,25 @@ 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, 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 + * 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 +93,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 +// 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. */ const LEDGER_TABLE = "approval_chain_ledger"; @@ -498,17 +514,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 +534,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 +631,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/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, }); }, }); 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 fe068eab6..9bd5ac789 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -178,6 +178,35 @@ 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 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. + return pauseUntilSignal({ + signal: "approval.decision", + resumeStep: "decide", + timeoutMs: 365 * 24 * 60 * 60 * 1000, // one year + }); + ``` + + `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 2ef3258c6..30cdeaf0f 100644 --- a/packages/agent/src/directives.ts +++ b/packages/agent/src/directives.ts @@ -73,6 +73,18 @@ export interface PauseUntilSignalDirective { readonly name: string; readonly correlationId?: string; }; + /** + * 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 + * 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. + */ readonly timeoutMs?: number; /** Step to run when the signal arrives. Defaults to the paused step. */ readonly resumeStep?: string; @@ -189,6 +201,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 hosted engine applies its 7-day default (see `pauseUntilSignal`). */ readonly timeoutMs?: number; /** Optional audit output recorded for the pausing step. */ readonly output?: unknown; @@ -237,6 +250,27 @@ 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. + * + * **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 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;