diff --git a/.changeset/pause-deadline-docs.md b/.changeset/pause-deadline-docs.md new file mode 100644 index 000000000..72ba5f6b2 --- /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. Runs already paused without `timeoutMs` when it deploys are held to the same 7 days, counted from their last activity, so one that has already waited longer fails soon after the deploy; a new `timeoutMs` only reaches pauses recorded after you redeploy. Pass an explicit `timeoutMs` on a signal pause that must outlive a week, such as a human approval gate. A pause on a dispatched child agent, launched now or with `at`, needs none: the engine keeps extending it while the child is still running, and an explicit `timeoutMs` there is taken literally and turns that off. `run_local` is unaffected either way: it never applies or enforces a pause deadline, it auto-resumes every pause immediately. + +This package ships documentation only, with no type, signature or runtime change. diff --git a/examples/approval-chain/AGENTS.md b/examples/approval-chain/AGENTS.md index 40af33282..1a74cfc8a 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 with a pause-timeout failure instead of resuming it, so a short gate deadline would + hard-fail a slow approval and skip `escalate`. Omitting `timeoutMs` does not avoid + that: a pause with no deadline inherits the engine's 7-day default. The year is an + explicit backstop; the reminder/escalation cadence comes from the signal (see + 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..cda84d92f 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 its +pause-timeout error (it does not resume the step), so a short deadline here would +silently fail any approval slower than it and never run the graceful `escalate` step. +A legitimately slow approver must not lose the run. + +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 23a0be0fc..420fea0ad 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 + * pause-timeout failure once it lapses. It does NOT resume the step, so a lapsed + * gate never reaches `decide`/`remind`/`escalate`. Handing a signal pause a + * `reminderMs`-sized `timeoutMs` (as an earlier version did) would hard-fail any + * approval slower than the reminder interval, bypassing the graceful `escalate` + * 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 (a pause-timeout failure) rather than resuming it, so the + // engine's 7-day default would silently fail any approval slower than a week + // and skip `escalate`. The reminder/escalation loop is driven by the + // `approval.decision` signal (one-click UI, a cron firing `remind`/`timeout`, + // 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..2dbd3d9d6 100644 --- a/examples/durable-backfill/index.ts +++ b/examples/durable-backfill/index.ts @@ -503,7 +503,7 @@ const processStep = defineStep({ if (nextCursor >= total) return goto("finalize", {}); // Offline, nothing is wired to fire the heartbeat — no cron trigger, no - // external scheduler — so pausing here would suspend the run forever after + // external scheduler, so pausing here only burns the deadline after // one chunk of real work. Loop directly instead and finish the backfill. The // durable-pause path is what a live run takes; this one still exercises the // same chunk-and-checkpoint loop, just without waiting for a clock. @@ -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/AGENTS.md b/examples/human-in-the-loop/AGENTS.md index 818427a22..ca6053cc1 100644 --- a/examples/human-in-the-loop/AGENTS.md +++ b/examples/human-in-the-loop/AGENTS.md @@ -12,15 +12,19 @@ capabilities are pre-auth'd on `ctx.sapiom` (here, `ctx.sapiom.llm.run` and ## The approval + fallback spine - **`notifyApprover`** emails the ranked recommendation, then returns - `pauseUntilSignal({ signal: "approval.decision", resumeStep: "onDecision", correlationId: ctx.executionId })`. + `pauseUntilSignal({ signal: "approval.decision", resumeStep: "onDecision", correlationId: ctx.executionId, timeoutMs: GATE_PAUSE_TIMEOUT_MS })`. It carries a static `pause: { signal, resumeStep: "onDecision" }` annotation — the build-time graph edge that must match the directive. + **A one-year `timeoutMs` (`GATE_PAUSE_TIMEOUT_MS`):** omitting it inherits the + engine's 7-day default, and a lapsed deadline fails the run instead of resuming it. - **`onDecision`** reads the approval payload **directly as its `run` input**. Safe default: only `{ decision: "approve" }` proceeds; anything else (including a `run_local` resume with no payload) routes to `revert` — nothing commits without a deliberate human yes. - **`offer`** makes a _provisional, non-committing_ offer to `ranked[index]` and - pauses on `candidate.confirm`, resuming at `resolve`. + pauses on `candidate.confirm`, resuming at `resolve`, under the same one-year + `timeoutMs`. That deadline is only a backstop: moving on from a silent candidate + needs something external to fire `candidate.confirm` with `{ decision: "timeout" }`. - **`resolve`** reads the confirm payload as its input. `accept` → `commit`; `decline`/`timeout`/absent → `index + 1` and loop back to `offer` while candidates remain, else `escalate`. The loop edge is `resolve → offer`; the diff --git a/examples/human-in-the-loop/index.ts b/examples/human-in-the-loop/index.ts index 7daa7097e..5f29ae7aa 100644 --- a/examples/human-in-the-loop/index.ts +++ b/examples/human-in-the-loop/index.ts @@ -54,6 +54,16 @@ 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. It stays a terminal deadline: an approval that outlives + * the year is failed too. The year is picked so no realistic approver reaches + * it, while an abandoned gate still lands in a terminal state. + */ +const GATE_PAUSE_TIMEOUT_MS = 365 * 24 * 60 * 60 * 1000; + // ─────────────────────────────────────────────────────────────── shapes ── /** String-only config bag (matches how templates receive their `config`). */ type Config = Record; @@ -388,7 +398,7 @@ const notifyApprover = defineStep({ const approver = ctx.shared.get("approver") ?? null; // Nobody is assigned to approve, so nothing will ever fire the decision - // signal and pausing here would suspend the run forever. Stop holding the + // signal, so pausing here only burns the deadline. Stop holding the // ranked shortlist instead. Never as approved — fabricating a human yes is // the worst output this template could produce — and never auto-rejected // either, which reads as breakage when nobody actually said no. @@ -417,6 +427,7 @@ const notifyApprover = defineStep({ signal: APPROVAL_SIGNAL, resumeStep: "onDecision", correlationId: ctx.executionId, + timeoutMs: GATE_PAUSE_TIMEOUT_MS, }); }, }); @@ -495,11 +506,16 @@ const offer = defineStep({ index, }); - // Suspend at $0 until this candidate confirms (or a timeout fires the signal). + // Suspend at $0 until this candidate confirms. Moving on when a candidate + // goes quiet is driven by the signal, not by `timeoutMs`: something external + // fires `candidate.confirm` with `{ decision: "timeout" }` and `resolve` + // advances to the next candidate. The deadline below is only the terminal + // backstop, it fails the run rather than delivering that payload. return pauseUntilSignal({ signal: CONFIRM_SIGNAL, resumeStep: "resolve", correlationId: ctx.executionId, + timeoutMs: GATE_PAUSE_TIMEOUT_MS, }); }, }); diff --git a/examples/pr-review-bot/AGENTS.md b/examples/pr-review-bot/AGENTS.md index b9254fc75..54c538e39 100644 --- a/examples/pr-review-bot/AGENTS.md +++ b/examples/pr-review-bot/AGENTS.md @@ -3,7 +3,7 @@ This project defines exactly one Sapiom agent in `index.ts` — **PR Review Bot** — authored against `@sapiom/agent`. It builds a concrete agent on the durable pause/resume spine: `watch` registers a PR webhook and returns -`pauseUntilSignal(...)`; the run suspends at $0 until a pull request is opened; +`pauseUntilSignal(...)`; the run suspends at $0 until a pull request is opened, for up to the engine's 7-day default; `review` (the resume target) receives the PR payload as its input, hands the diff to a coding agent (`ctx.sapiom.models.coding.run`) that checks the code out and analyzes it; `assess` turns the findings into a structured review with diff --git a/examples/pr-review-bot/README.md b/examples/pr-review-bot/README.md index 5749e3327..a5a3503e8 100644 --- a/examples/pr-review-bot/README.md +++ b/examples/pr-review-bot/README.md @@ -4,8 +4,8 @@ On a pull-request webhook, a coding agent checks out and analyzes the code, flags any change that shipped without matching tests, and posts the review to your email or a Slack channel — using your own bot token. -The run **suspends at $0** while it waits for a PR, so it can sit idle for days -between reviews and cost nothing until a webhook wakes it. +The run **suspends at $0** while it waits for a PR, so it can sit idle for up to a +week and cost nothing until a webhook wakes it. ## What it does @@ -19,7 +19,9 @@ watch ──(pause: wait for "pr.opened", $0 while idle)──▶ review ─ `{ executionId, signal, correlationId }`, then returns `pauseUntilSignal({ signal: "pr.opened", resumeStep: "review", correlationId })`. The run suspends here at zero cost until a PR is opened. -2. **(paused)** — nothing runs, nothing is billed, for as long as it takes. +2. **(paused)** — nothing runs, nothing is billed, for up to the engine's 7-day + default pause deadline; if no PR opens in that window the run ends with a + pause-timeout failure. 3. **review** (resume target) — its **input IS the PR payload**. A coding agent (`ctx.sapiom.models.coding.run`) checks the code out in a sandbox and analyzes the diff, told to surface changes that lack tests. It's read-only — diff --git a/examples/pr-review-bot/index.ts b/examples/pr-review-bot/index.ts index 852ba8c56..5b7f9d8fa 100644 --- a/examples/pr-review-bot/index.ts +++ b/examples/pr-review-bot/index.ts @@ -363,7 +363,7 @@ const watch = defineStep({ if (dryRun) { // No webhook source registered, so no PR event will ever arrive and the - // pause below would suspend the run forever. Review the sample PR instead — + // pause below would only burn the deadline. Review the sample PR instead — // a real model review of a real diff — and say whose PR it was. ctx.shared.set( "note", @@ -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 pause-timeout failure beats a run parked on a dead webhook. return pauseUntilSignal({ signal: SIGNAL, resumeStep: "review", diff --git a/examples/proposal-generator/AGENTS.md b/examples/proposal-generator/AGENTS.md index 59f00e481..0c7d7133e 100644 --- a/examples/proposal-generator/AGENTS.md +++ b/examples/proposal-generator/AGENTS.md @@ -24,9 +24,11 @@ Sapiom capabilities are pre-auth'd on `ctx.sapiom` (`ctx.sapiom.llm.run`, - **`review`** checks for an assigned approver first. With none — nothing will ever fire the decision signal on an unconfigured run — it takes the `pending` branch instead of pausing. With one, it emails them and returns - `pauseUntilSignal({ signal: "proposal.decision", resumeStep: "onDecision", correlationId: ctx.executionId })`. + `pauseUntilSignal({ signal: "proposal.decision", resumeStep: "onDecision", correlationId: ctx.executionId, timeoutMs: GATE_PAUSE_TIMEOUT_MS })`. It carries a static `pause: { signal, resumeStep: "onDecision" }` annotation — the build-time graph edge that must match the directive. + **A one-year `timeoutMs` (`GATE_PAUSE_TIMEOUT_MS`):** omitting it inherits the + engine's 7-day default, and a lapsed deadline fails the run instead of resuming it. - **`pending`** is the gate's honest off-ramp: terminal, carrying the rendered PDF's `fileId`/`downloadUrl` and `outcome: "pending-approval"`. This — not a hung pause — is what a `{}` run reaches. diff --git a/examples/proposal-generator/index.ts b/examples/proposal-generator/index.ts index f49f2f40f..2bed0afcd 100644 --- a/examples/proposal-generator/index.ts +++ b/examples/proposal-generator/index.ts @@ -66,6 +66,16 @@ 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. It stays a terminal deadline: an approval that outlives + * the year is failed too. The year is picked so no realistic approver reaches + * it, while an abandoned gate still lands in a terminal state. + */ +const GATE_PAUSE_TIMEOUT_MS = 365 * 24 * 60 * 60 * 1000; + /** Package the sandbox installs to lay out the PDF (pure JS, no native deps). */ const PDF_PACKAGE = "pdf-lib@1.17.1"; @@ -573,7 +583,7 @@ const review = defineStep({ const downloadUrl = ctx.shared.get("downloadUrl") ?? null; // Nobody is assigned to approve, so nothing will ever fire the decision - // signal: pausing here would suspend the run forever. Stop holding the draft + // signal: pausing here would only burn the deadline. Stop holding the draft // instead — never as approved, and never auto-rejected either. if (!approver) { ctx.logger.info("no approver configured — terminating at the gate", { @@ -612,6 +622,7 @@ const review = defineStep({ signal: DECISION_SIGNAL, resumeStep: "onDecision", correlationId: ctx.executionId, + timeoutMs: GATE_PAUSE_TIMEOUT_MS, }); }, }); diff --git a/examples/scheduled-compliance-audit/AGENTS.md b/examples/scheduled-compliance-audit/AGENTS.md index 11a358f31..f109b7525 100644 --- a/examples/scheduled-compliance-audit/AGENTS.md +++ b/examples/scheduled-compliance-audit/AGENTS.md @@ -22,8 +22,10 @@ the **attestation archive**: nothing is filed until a person explicitly signs of guessing. A wrong capability or method name fails typecheck. - **The durable pause is a primitive, not a capability.** `review` declares a static `pause: { signal, resumeStep }` edge AND returns - `pauseUntilSignal({ signal, resumeStep, correlationId: ctx.executionId })`. The - resume target (`onSignoff`) receives the signal payload as its `run` input. + `pauseUntilSignal({ signal, resumeStep, correlationId: ctx.executionId, timeoutMs: GATE_PAUSE_TIMEOUT_MS })`. + The resume target (`onSignoff`) receives the signal payload as its `run` input. + **A one-year `timeoutMs` (`GATE_PAUSE_TIMEOUT_MS`):** omitting it inherits the + engine's 7-day default, and a lapsed deadline fails the run instead of resuming it. - **Gate real side effects behind `dryRun`.** `archive` uploads the attestation only on a live run with `dryRun` off; otherwise it returns the computed attestation as a preview. The upload's presigned PUT is a raw `fetch` (not a diff --git a/examples/scheduled-compliance-audit/index.ts b/examples/scheduled-compliance-audit/index.ts index acaaaa50e..8491dc33d 100644 --- a/examples/scheduled-compliance-audit/index.ts +++ b/examples/scheduled-compliance-audit/index.ts @@ -58,6 +58,16 @@ 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. It stays a terminal deadline: an approval that outlives + * the year is failed too. The year is picked so no realistic approver reaches + * it, while an abandoned gate still lands in a terminal state. + */ +const GATE_PAUSE_TIMEOUT_MS = 365 * 24 * 60 * 60 * 1000; + // ─────────────────────────────────────────────────────────────── shapes ── /** A resource whose current state should be audited against the policy. */ interface ResourceRef { @@ -366,7 +376,7 @@ const review = defineStep({ const signOffBy = ctx.shared.get("signOffBy") ?? null; // Nobody is assigned to sign off, so nothing will ever fire the sign-off - // signal and pausing here would suspend the run forever. Stop holding the + // signal, so pausing here only burns the deadline. Stop holding the // DRAFT attestation instead. // // What this must never do: resuming with an empty payload fabricates no @@ -397,6 +407,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 efbc48d5d..a054e800a 100644 --- a/examples/wait-for-webhook/AGENTS.md +++ b/examples/wait-for-webhook/AGENTS.md @@ -7,7 +7,7 @@ This project defines exactly one Sapiom agent in `index.ts` — **Wait-for-Webho - **`kickoff`** registers `{ executionId, signal, correlationId: ctx.executionId }` with the external job, then returns `pauseUntilSignal({ signal, resumeStep: "decide", correlationId: ctx.executionId })`. It also carries a static `pause: { signal, resumeStep: "decide" }` annotation — the build-time graph edge that must match the directive. - **`decide`** reads the callback payload **directly as its `run` input** — that's the signal payload the external world delivered. Everything else (config, job params, ids) is read back from `ctx.shared`, which survives the pause. - `pauseUntilSignal` is a **runtime primitive, not a metered capability**. The only billed call is the model summary in `decide` (`ctx.sapiom.llm.run`, the live x402 path). -- **Optional deadline.** The pause waits indefinitely by default. Passing `timeoutMs` caps it: with no callback in that window, the engine's deadline sweep fails the run (a pause-timeout terminal state) instead of parking it forever. `kickoff` reads it from `config.CALLBACK_TIMEOUT_MS` via `parseTimeoutMs` — absent ⇒ indefinite wait; invalid ⇒ a loud throw at `kickoff` (a silently-dropped cap would defeat the point). +- **Optional deadline.** Passing `timeoutMs` sizes the wait to the callback window: with no callback in it, the engine's deadline sweep fails the run (a pause-timeout terminal state). Omitting it does not mean waiting forever, it means inheriting the engine's 7-day default, which is the same terminal state on a slower horizon. `kickoff` reads the value from `config.CALLBACK_TIMEOUT_MS` via `parseTimeoutMs` — absent ⇒ the engine default; invalid ⇒ a loud throw at `kickoff` (a silently-dropped cap would defeat the point). ## Authoring diff --git a/examples/wait-for-webhook/README.md b/examples/wait-for-webhook/README.md index 3a7ecf6b1..2466392a7 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 +dropped cap would leave the run on that default while you believe your own window +applies. ## Run it with Claude + the Sapiom MCP diff --git a/examples/wait-for-webhook/index.ts b/examples/wait-for-webhook/index.ts index 10ce144d3..73daa4333 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,14 @@ 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: a + * silently dropped cap would leave the run on the 7-day default while the operator + * believes their own window applies, so a misconfigured deadline fails loudly at + * `kickoff` instead. */ function parseTimeoutMs(config: Config): number | undefined { const raw = (config.CALLBACK_TIMEOUT_MS ?? "").trim(); @@ -201,7 +203,7 @@ const kickoff = defineStep({ if (isDryRun(config)) { // No live endpoint, so no external job was registered and nothing will ever - // fire the callback. Pausing here would suspend the run forever. Take the + // fire the callback, so pausing here only burns the deadline. Take the // decide branch on an empty payload instead — a real model call over a // callback that says, honestly, that no callback arrived. ctx.shared.set("jobId", "not-registered"); @@ -226,8 +228,8 @@ const kickoff = defineStep({ // Suspend at $0 until the external world fires SIGNAL for this correlationId. // With `timeoutMs` set, the engine's deadline sweep instead fails the run if - // no callback arrives in time — an honest terminal state, not a forever park. - // Omitted ⇒ the default indefinite wait. + // no callback arrives in time: an honest terminal state, not a forever park. + // Omitted ⇒ the engine's 7-day default deadline, same outcome, slower. return pauseUntilSignal({ signal: SIGNAL, resumeStep: "decide", diff --git a/examples/wait-for-webhook/template.json b/examples/wait-for-webhook/template.json index 8c26440dd..1f91488b6 100644 --- a/examples/wait-for-webhook/template.json +++ b/examples/wait-for-webhook/template.json @@ -12,7 +12,7 @@ "$0 while waiting", "Branch on the outcome" ], - "notes": "Click **Use this template** — Sapiom builds and deploys it for you, then run it from the workflow page. Your $5 signup credit covers first runs.\n\nA deployed run pauses at `kickoff` and waits for the callback. Instead of a real webhook, send the signal yourself with the MCP `sapiom_dev_agents_signal` tool: `{ \"executionId\": \"\", \"name\": \"webhook.callback\", \"correlationId\": \"\", \"payload\": { \"status\": \"succeeded\", \"result\": { ... } } }`. The `payload` becomes `decide`'s input, and a `status` of `failed` or `error` makes it reject. With no `CALLBACK_REGISTER_URL` set (or `DRY_RUN`), it runs offline and skips the live job.\n\nThe pause waits indefinitely at $0 by default. To stop a never-arriving callback from parking the run forever, set `config.CALLBACK_TIMEOUT_MS` to a positive number of milliseconds — if no callback lands in that window, the run ends with an honest pause-timeout failure instead.\n\nPrefer to work from the code? Run it locally with `run_local` to trace the whole flow — kickoff, pause, decide, branch — for free, or edit and deploy it with the Sapiom MCP.", + "notes": "Click **Use this template** — Sapiom builds and deploys it for you, then run it from the workflow page. Your $5 signup credit covers first runs.\n\nA deployed run pauses at `kickoff` and waits for the callback. Instead of a real webhook, send the signal yourself with the MCP `sapiom_dev_agents_signal` tool: `{ \"executionId\": \"\", \"name\": \"webhook.callback\", \"correlationId\": \"\", \"payload\": { \"status\": \"succeeded\", \"result\": { ... } } }`. The `payload` becomes `decide`'s input, and a `status` of `failed` or `error` makes it reject. With no `CALLBACK_REGISTER_URL` set (or `DRY_RUN`), it runs offline and skips the live job.\n\nThe pause costs $0 for as long as it lasts, and with no `timeoutMs` it inherits the engine's 7-day default deadline. To size the wait to your own callback window, set `config.CALLBACK_TIMEOUT_MS` to a positive number of milliseconds — if no callback lands in that window, the run ends with an honest pause-timeout failure instead.\n\nPrefer to work from the code? Run it locally with `run_local` to trace the whole flow — kickoff, pause, decide, branch — for free, or edit and deploy it with the Sapiom MCP.", "examples": [ { "title": "Callback reports success", diff --git a/packages/agent/README.md b/packages/agent/README.md index 7855689f9..7aa2f48c7 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -178,6 +178,41 @@ 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 keeps + pushing the parent's deadline for as long as the child is alive. That holds whether + the child was launched now or scheduled with `at`, and it lapses once the child + reaches a terminal state or its run no longer exists. + + **Passing `timeoutMs` on a child pause opts out of that.** A deadline the author + set is taken literally and never extended, so an explicit value on a child pause + replaces a deadline that would have followed the child with one that can expire + while the child is still working. Leave it off unless you want the parent to give + up on a schedule of your own. + + Set `timeoutMs` for what the default does not fit: a human gate you expect to + outlive a week, or a wait that should give up sooner. + + ```ts + // A human gate: nothing but this deadline bounds the wait. + 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 @@ -206,7 +241,7 @@ const present = defineStep({ signal: "approval.decision", resumeStep: "finalize", correlationId: ctx.executionId, - timeoutMs: 7 * 24 * 60 * 60 * 1000, + timeoutMs: 14 * 24 * 60 * 60 * 1000, // two weeks, past the 7-day default }); }, }); @@ -222,9 +257,10 @@ whole design decision: signal matching X": the fanout is 0..N runs, deliberately, and the count you get back is how many actually resumed. -Give any gate a human might never answer an explicit `timeoutMs`: when the -deadline lapses the run ends as a pause timeout instead of resuming, so the -failure is recorded rather than sat on. +Size `timeoutMs` to how long a human may take. Omitted, the hosted 7-day default +applies; whichever deadline lapses, the run ends as a pause timeout instead of +resuming, so pass a longer value for a gate that can outlive a week, or a shorter +one for a decision that goes stale. Under `run_local` a manual gate auto-resumes with `{}` — there is no way to inject a payload — so type the resumed step's input with optional fields. diff --git a/packages/agent/src/directives.ts b/packages/agent/src/directives.ts index 2ef3258c6..1e660da07 100644 --- a/packages/agent/src/directives.ts +++ b/packages/agent/src/directives.ts @@ -73,6 +73,19 @@ 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, and setting one there opts out of the engine following the child + * (see `pauseUntilSignal`). + * + * `run_local` neither applies nor enforces this: it auto-resumes every pause + * immediately, so the deadline is only observable against the hosted engine. + */ readonly timeoutMs?: number; /** Step to run when the signal arrives. Defaults to the paused step. */ readonly resumeStep?: string; @@ -189,6 +202,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 +251,33 @@ 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 keeps pushing the + * parent's deadline for as long as the child is alive. That holds for a child + * launched now and for one scheduled with `at`, and it lapses once the child + * reaches a terminal state or its run no longer exists, at which point the + * ordinary deadline applies again. + * + * Passing `timeoutMs` on a child pause **opts out** of that. A deadline the author + * set is taken literally and never extended, so an explicit value replaces a + * deadline that would have followed the child with one that can expire while the + * child is still working. Leave it off unless the parent should give up on a + * schedule of your own. + * + * 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;