Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/pause-deadline-docs.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 9 additions & 7 deletions examples/approval-chain/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 17 additions & 9 deletions examples/approval-chain/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:

Expand Down
64 changes: 41 additions & 23 deletions examples/approval-chain/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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";

Expand Down Expand Up @@ -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,
});
},
});
Expand All @@ -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");
Expand Down Expand Up @@ -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,
});
},
});
Expand Down
5 changes: 4 additions & 1 deletion examples/durable-backfill/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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",
Expand Down
8 changes: 6 additions & 2 deletions examples/human-in-the-loop/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 18 additions & 2 deletions examples/human-in-the-loop/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -417,6 +427,7 @@ const notifyApprover = defineStep({
signal: APPROVAL_SIGNAL,
resumeStep: "onDecision",
correlationId: ctx.executionId,
timeoutMs: GATE_PAUSE_TIMEOUT_MS,
});
},
});
Expand Down Expand Up @@ -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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
},
});
Expand Down
2 changes: 1 addition & 1 deletion examples/pr-review-bot/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions examples/pr-review-bot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 —
Expand Down
Loading
Loading