Skip to content
Français

Schedule nightly maintenance

Schedule a maintenance workflow and resume the same run after a quota pause.

Both schedules give the same night the same runId, such as deps-2026-09-29. The 07:00 job resumes that run if a usage limit paused it.

export const timeZone = "Europe/Paris";
export const runId = (slot: Date) =>
  `deps-${slot.toLocaleDateString("en-CA", { timeZone })}`;
import { createCronSchedule } from "@elie-laloum/outpost";
import { timeZone, runId } from "./nightly-time.ts";

export const schedules = [
  {
    name: "nightly-deps",
    cron: createCronSchedule("0 2 * * 1-5", { timeZone }),
    handler: "nightly-deps",
    runId,
  },
  {
    name: "nightly-deps-resume",
    cron: createCronSchedule("0 7 * * 1-5", { timeZone }),
    handler: "nightly-deps",
    runId,
  },
];
import { createSqliteTaskQueue, runSchedules } from "@elie-laloum/outpost";
import { schedules } from "./nightly-schedules.ts";

export const queue = await createSqliteTaskQueue(".outpost/jobs.sqlite");
export const stop = new AbortController();
process.once("SIGINT", () => stop.abort());
try {
  await runSchedules({ queue, signal: stop.signal, schedules });
} finally {
  queue.close();
}

Define the report, agent and update task.

import { defineJsonResponse } from "@elie-laloum/outpost";

export function names(value: unknown): string[] {
  if (!Array.isArray(value) || !value.every((item) => typeof item === "string"))
    throw new Error("Expected a list of strings");
  return value;
}
export const report = defineJsonResponse({
  tag: "report",
  schema(input) {
    if (typeof input !== "object" || input === null)
      throw new Error("Expected an object");
    if (!("updated" in input) || !("skipped" in input))
      throw new Error("Expected updated and skipped");
    return { updated: names(input.updated), skipped: names(input.skipped) };
  },
});
import {
  createFallbackAgent,
  createAgent,
  createClaudeHarness,
} from "@elie-laloum/outpost";
import { coder } from "./outpost.config.ts";

export const agent = createFallbackAgent(
  [
    coder,
    createAgent({
      harness: createClaudeHarness({ authentication: "account" }),
    }),
  ],
  { on: ["quota"] },
);
export const brief = {
  text: [
    "Update outdated dependencies one at a time.",
    "Run the tests after each update; commit it if they pass, revert it otherwise.",
    "The branch may already hold updates from an earlier attempt: keep them.",
    'End with <report>{"updated": ["name@version"], "skipped": ["name: reason"]}</report>.',
  ].join("\n"),
};
import { defineIsolatedTask } from "@elie-laloum/outpost";
import { repository, sandboxProvider } from "./outpost.config.ts";
import { agent } from "./nightly-agent.ts";
import { report } from "./nightly-report.ts";
import { brief } from "./nightly-brief.ts";

export function updateAgent(runId: string) {
  return defineIsolatedTask({
    key: "update-agent",
    request: () => ({
      repository,
      sandboxProvider,
      agent,
      branch: { mode: "named", name: `outpost/${runId}` },
      response: report,
      brief,
    }),
  });
}
import { updateAgent } from "./update-agent.ts";
import { defineTask } from "@elie-laloum/outpost";

export function updateTask(runId: string) {
  const agentTask = updateAgent(runId);
  return defineTask({
    key: "update",
    perform: async (context) => {
      const { branch, commits, value } = await agentTask.perform(context);
      return {
        branch,
        commits: commits.map((commit) => commit.subject),
        report: value,
      };
    },
  });
}

Publish the report and process checkpointed jobs in worker.ts.

import { updateTask } from "./update-task.ts";
import { defineTask } from "@elie-laloum/outpost";
import { mkdir, writeFile } from "node:fs/promises";

export function publishTask(
  runId: string,
  update: ReturnType<typeof updateTask>,
) {
  return defineTask({
    key: "publish",
    after: [update],
    perform: async (context) => {
      await mkdir("reports", { recursive: true });
      const file = `reports/${runId}.json`;
      await writeFile(file, JSON.stringify(context.value(update), null, 2));
      return file;
    },
  });
}
import { updateTask } from "./update-task.ts";
import { publishTask } from "./publish-report.ts";
import { defineWorkflow } from "@elie-laloum/outpost";

export function nightly(runId: string) {
  const update = updateTask(runId);
  const publish = publishTask(runId, update);
  return defineWorkflow("nightly-deps", [update, publish]);
}
import {
  createWorkflowCheckpointStore,
  createLocalTransport,
  defineWorkflowJob,
} from "@elie-laloum/outpost";
import { nightly } from "./nightly-workflow.ts";

export const store = createWorkflowCheckpointStore({
  transporter: createLocalTransport({ directory: ".outpost/storage" }),
});
export const job = defineWorkflowJob({
  checkpoint: { store, version: "1", resume: "retry-incomplete" },
  start: { onQuota: { action: "pause", maxWaitMs: 4 * 60 * 60_000 } },
  workflow: (_input, { runId }) => nightly(runId),
});
import { createSqliteTaskQueue, runQueueWorker } from "@elie-laloum/outpost";
import { job } from "./nightly-job.ts";

export const queue = await createSqliteTaskQueue(".outpost/jobs.sqlite");
export const stop = new AbortController();
process.once("SIGINT", () => stop.abort());
try {
  await runQueueWorker({
    queue,
    worker: "nightly-1",
    signal: stop.signal,
    handlers: { "nightly-deps": job },
  });
} finally {
  queue.close();
}

Run each command in its own terminal or service. Ctrl+C stops either one cleanly.

node scheduler.ts
node worker.ts
Drag to move · Ctrl + scroll to zoom
100 %
  • SchedulerrunSchedules() publishes a job at 02:00 and another at 07:00, Monday to Friday, Paris time. (host)
    • → Queue : same runId twice
  • Queue.outpost/jobs.sqlite; a job whose worker stops returns after its 30-second lease. (host)
    • → Worker : claim
  • WorkerdefineWorkflowJob() runs the night’s workflow under the checkpoint deps-<date>. (host)
    • → update : start or resume
    • → Checkpoint : finished tasks, pauses
  • WorkflowTwo tasks; resume: "retry-incomplete" reruns the unfinished one.
    1. updatedefineIsolatedTask() on outpost/deps-<date>
      • → Codex : brief
    2. publishsaves the branch, the commits and the report
      • → Report : reports/deps-<date>.json
    (workflow)
  • CodexUpdates one dependency at a time, runs the tests, commits or reverts. (sandbox)
    • → Claude Code : usage limit
    • → update : { updated, skipped }
  • Claude CodecreateFallbackAgent() hands it the original brief on the same branch. (sandbox)
    • → update : report, or a pause at its own limit
  • CheckpointThe 07:00 job resumes a paused task unless its reset is still beyond maxWaitMs. (host)
  • ReportYou read it with the branch in the morning. (host)

Claude Code can report when its limit resets; Codex never does. A run stopped by Codex alone therefore waits for the 07:00 job. With the fallback, the task pauses only when both agents hit their limit, and the reset is known only if both report one.

Finished tasks always come from the checkpoint. resume: "retry-incomplete" authorizes the rest to run again: a task interrupted by a worker stop, or one that failed during the night.

If you stop the worker during a run, its job returns to the queue after the 30-second lease. The restarted worker claims it and continues from the interrupted task.

Change the brief and the report schema: fix lint warnings, remove dead code, update a changelog. Keep one dated branch per run so each morning has its own review.

Add a defineApprovalTask() after update, then a task that merges the branch. The job completes paused and lists the gate in pauses. Submit the decision with workflow.start() and the runId and version from the job value.

Replace createSqliteTaskQueue() with createBullMQTaskQueue() from Redis and BullMQ to run workers on several machines. Give each worker a unique worker name.

Without a long-running scheduler, a scheduled CI job can call nightly(runId).start() with the same checkpoint and onQuota options. Store checkpoints in S3 or R2 so the next CI run resumes a paused one; see Run in CI.

  • A worker killed without a clean stop keeps ownership of the checkpoint. The next job for that run fails until you clear ownership with recoverWorkflowCheckpoint() (Durable runs).
  • A resumed task continues the captured conversation of a single agent. With a fallback agent, it restarts from the first candidate and the original brief, on the same branch.
  • One worker runs jobs one at a time, so the 07:00 job waits behind a run still in progress. With several workers, that job fails while the run is still executing.

API: runSchedules · createCronSchedule · runQueueWorker · defineWorkflowJob · WorkflowQuotaPolicy · createFallbackAgent · defineJsonResponse.