Skip to content

Timers ​

A run with events can wait days for a time to come: a reminder, a deadline, a quiet day after a release. A timer is that time, recorded once under a name. Once it is due, a wait takes it as a timer:due event, like any other event, and the run parks in between with no process left.

A first timer ​

A race of an event and a duration waits for whichever comes first:

ts
const firstDay = await wf.race(
  "first-day",
  { incident: wf.signal("incident.opened"), quiet: "1d" },
  { label: `watching ${version}` },
);
if (firstDay.incident)
  return { version, ended: `incident: ${firstDay.incident.data.summary}` };

quiet: "1d" is a timer of the race's own. The race is recorded under its name, first-day, and the timer under first-day@quiet, so every wake finds the same timer, not a new day counted from the wake.

Sleep ​

wf.sleep(name, when) waits until a time and goes on, so straight-line code can wait for weeks. After the quiet day above, the release holds for two weeks, and then the flag goes:

ts
await wf.sleep("hold", "14d");
const removed = await wf.stage(
  "remove-flag",
  { result: REMOVED },
  async () => {
    const { outcome } = await engineer.run({
      prompt:
        `${version} has held for two weeks. ` +
        `Remove the ${flag} flag and its dead branch, and commit.`,
      schema: REMOVED,
    });
    if (!outcome.answered)
      return wf.stop(`flag not removed: ${outcome.reason}`);
    return outcome.value;
  },
);
return { version, ended: `removed ${flag} in ${removed.commit}` };

A sleep is a race of one timer, hold@sleep, and the run parks while it waits. A run woken early waits only for what is left, and one woken late goes straight on. Sleeps share their names with races, so a sleep in a loop is named per round. Without events, a workflow sleeps, races and selects as well, but its run never parks: it waits in its process.

Both samples are one workflow, checked and tested: examples/site/guide/events-waits.ts. Its test runs the whole fortnight in milliseconds, since a test's clock is virtual.

Named timers ​

A race's own timer lives as long as its race. A time that must outlast one race, shared by every round of a loop or cancelled by your code, is a named timer: wf.timer(name, when), passed to a wait as a branch. Here a doc waits for approval, with a reminder every two days, the race's own, and an expiry at fourteen that every round shares:

ts
// A reminder every two days, an approval, or an expiry at fourteen.
const expiry = wf.timer("doc-expires", "14d");
for (let round = 1; ; round++) {
  const next = await wf.race(
    `approval-${round}`,
    {
      reply: wf.signal("approver:reply"), // a participant's message to the code
      remind: "2d", // a timer of the race's own
      expiry, // a timer the code made, shared by every round
    },
    { label: "awaiting approval" },
  );
  if (next.expiry) return "expired";
  if (next.reply && isApproval(next.reply.data.text)) break;
  if (next.remind) await wf.stage(`remind-${round}`, () => approvals.post(REMINDER));
}
  • when is a duration or a Date. A duration, "90s", "10m", "1.5h" or "14d", counts from the first call under the name; a unit typo, "1hr", fails tsc, and a string read from data is checked with duration(text). A Date is a fixed time, from the run's event, arguments or files.
  • wf.timer returns at once. It records the time; a wait takes its timer:due when it comes. The timer has name, due (an ISO time), cancel() and is(e).

The first call names the time ​

A run's code runs again from the top on every wake, so wf.timer("doc-expires", "14d") runs again too. The first call records the time, and every later call under that name, in this attempt or any other, gets the recorded timer back. That is what keeps the fourteen days from restarting on each wake.

So a timer can't be moved by calling it again: a later call asking another time is logged and ignored. For the same reason, a time counted from now is a duration, not a Date built from wf.now(): that Date is different on every wake, and each wake logs the mismatch.

Moving a timer ​

A deadline put off, a reminder snoozed, a retry backed off: there are two ways, by how often the time moves.

Cancel it and make the next ​

For a time that moves now and then: cancel the timer and make a new one under the next name. Here a pull request is asked after once it has had no push for three days, and each push starts the three days again:

ts
let stale = wf.timer("stale-0", "3d");
for (let round = 1; ; round++) {
  const next = await wf.race(
    `wait-${round}`,
    {
      pushed: wf.signal("pr.pushed"),
      closed: wf.signal("pr.closed"),
      stale,
    },
    { label: `#${pr}: waiting for a push, round ${round}` },
  );
  if (next.closed) return { pr, ended: "closed" };
  if (next.stale) {
    await wf.stage("ask-after", async () => {
      await reviewer.run({
        prompt: `#${pr} has had no push for three days. Ask its author, in a comment, whether it is still wanted.`,
      });
    });
    return { pr, ended: "stale" };
  }
  if (next.pushed) {
    await stale.cancel();
    stale = wf.timer(`stale-${round}`, "3d");
    const { head } = next.pushed.data;
    await wf.stage(`review-${head}`, async () => {
      await reviewer.run({ prompt: `Review #${pr} again, at ${head}.` });
    });
  }
}
  • await stale.cancel() stops stale-0, and withdraws its timer:due if it came but no wait took it. A cancelled name stays cancelled.
  • stale-1 is new, so its three days count from the push. On a later wake, the races give back their recorded answers, the cancel is a no-op, and wf.timer("stale-1", "3d") gets the recorded time: the loop walks back to where it was.

The whole workflow and its test are in examples/site/guide/timers.ts.

Keep the time in state, with wf.at ​

For a time that moves all the time, keep it where the run keeps its state, and let a select read it. wf.at(read) is a branch due at whatever read returns: a Date, or null for none. It is asked at every wait, so when the code changes the time, the branch follows.

Here a question waits for an engineer's answer, nudging every four hours. A reply of "later" moves the next nudge a day out, and an answer clears it:

ts
for await (const next of wf.select(
  { reply: wf.signal("engineer:reply"), nudge: wf.at(() => ledger.nextNudge()) },
  { label: () => `awaiting an answer, ${ledger.nudges} nudged` },
)) {
  if (next.reply) {
    // A stand-in for reading a reply: one that starts "later" asks for time.
    if (/^later\b/i.test(next.reply.data.text.trim())) {
      await ledger.putOff(wf.after(putOff as Duration));
      continue;
    }
    await ledger.answered();
    return { answer: next.reply.data.text, nudges: ledger.nudges };
  }
  if (next.nudge) {
    const n = ledger.nudges + 1;
    await wf.stage(`nudge-${n}`, async () => {
      const drafted = await writer.run({
        prompt: `Write one short, friendly sentence nudging an engineer to answer this question: "${question}". This is nudge ${n} of ${maxNudges}.`,
        schema: Nudge,
      });
      const text = drafted.outcome.answered ? drafted.outcome.value.text : `Nudge: ${question}`;
      await engineer.post(text);
    });
    if (n >= maxNudges) {
      await ledger.nudged(null);
      return { answer: null, nudges: n };
    }
    await ledger.nudged(wf.after(nudgeAfter as Duration));
  }
}

The ledger is a small JSON file under the run's working directory, holding the next nudge's time and the count. Nothing is named per move, and there is no stale timer to cancel or ignore.

  • read reads state that outlives the process: a file, or what is rebuilt from one. A parked run wakes in a new process and asks again. A variable starts over on every wake.
  • read is cheap and synchronous. wf.now() and wf.after() throw inside it, as does returning anything but a Date or null.
  • wf.after(duration) is now plus a duration: the time to store, where the code sets it.
  • A time returned once isn't returned again, so a handler that leaves the state alone doesn't fire on every pass.
  • It isn't a timer: nothing is recorded under a name and there is nothing to cancel. It is a select's only; a race refuses it.

The example and its test are in examples/nudges.

Which to use ​

Cancel and make the nextwf.at
The time movesnow and thenon every event, or often
Where the time livesthe run's record, under a name per moveyour state: a file
The waitrace, select or receiveselect only

Many timers ​

A run can hold any number, each under its own name, and a wait names each timer it takes as a branch. In a receive loop, timer.is(e) picks out one timer's event, and any timer's has e.name === "timer:due" and its name in e.from.

When a timer fires ​

  • A running run takes a due timer at its next wait.
  • A parked run has no process, so nothing fires until awf tick runs and continues it: with a tick every five minutes, a timer fires up to five minutes late. Hosting events sets the tick up.
  • A wait takes only what it names. A due timer the current wait doesn't name stays for a later one, and doesn't wake a run parked at that wait.
  • Of two ready, the earlier wins: a timer and an event both waiting are taken in order of their time.
  • In a test the clock is virtual. Where the run would park with a timer to come, the clock moves to it, so three days take no time. After maxTimers, 20 by default, the run parks instead; maxTimers: 0 parks at the first, to test the parking: A workflow with events.

Catches ​

  • Calling wf.timer again doesn't move it. Cancel it and make the next, or keep the time in state.
  • select refuses a duration. A timer in a loop is a wf.timer named per round, or a wf.at.
  • race refuses a Date and wf.at. A fixed time in a race is wf.timer(name, date).
  • A race cancels only its own timers. A wf.timer passed in stays until you cancel it.

See also ​