Skip to content
Foundry
Esc
↑↓navigate↵open⌘Jpreview
On this page

Schedule updates and reports

Fetch remote changes, report tracked work, and keep on-call attention visible.

Once one issue has completed the cycle, add a patrol. Every 15 minutes it fetches remote refs, lists labeled issues, investigates one new or changed incident, checks tracked PRs, and publishes a report.

The patrol imports the shared record.ts, the on-call modules, and status.ts from delivery. Create those helpers before adding it.

Keep delivery and maintenance as separately launchable workflows. This patrol gathers status. A PR review or inspection is additional work with its own inputs and limits. Schedule those only after a manual run produces evidence you can use.

Create .foundry/marbles/_factory/patrol.ts:

Complete patrol.ts
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { artifact, schedule, step } from "@foundry.sh/marbles";
import { z } from "zod";
import { coverageSchema, onCall } from "./on-call";
import { baseBranch, readRecord, repository } from "./record";
import { status } from "./status";

const reports = artifact({ name: "Factory patrol", type: "text/markdown" });
const issuesSchema = z.array(
  z.object({
    labels: z.array(z.object({ name: z.string() })),
    number: z.number(),
    state: z.string(),
    title: z.string(),
    updatedAt: z.string(),
    url: z.string(),
  })
);
const patrol = step("factory-patrol").do(
  async ({ exec, workspaces, report, run }) => {
    await exec(["git", "fetch", "origin"]);
    const revision = (
      await exec(["git", "rev-parse", `origin/${baseBranch}`])
    ).stdout.trim();
    const previous = await (await reports.latest())?.text("revision.txt");
    const range = previous ? `${previous.trim()}..${revision}` : revision;
    const changes = await exec(["git", "log", "-10", "--oneline", range]);
    const response = await exec([
      "gh",
      "issue",
      "list",
      "--repo",
      repository,
      "--state",
      "all",
      "--search",
      "label:factory-ready,incident",
      "--limit",
      "100",
      "--json",
      "number,title,url,state,updatedAt,labels",
    ]);
    const issues = issuesSchema.parse(JSON.parse(response.stdout));
    const coverage = coverageSchema.parse(
      JSON.parse(
        await readFile(
          join(workspaces.current.root, ".foundry/on-call.json"),
          "utf8"
        )
      )
    );
    const rows: string[] = [];
    let investigated = false;
    for (const issue of issues) {
      let work = await readRecord(issue.number);
      const marker = JSON.stringify({ coverage, updatedAt: issue.updatedAt });
      const observed = work?.events.some(
        (event) => event.stage === "on-call observed" && event.detail === marker
      );
      if (
        !investigated &&
        issue.state === "OPEN" &&
        !observed &&
        issue.labels.some((label) => label.name === "incident")
      ) {
        work = await onCall.run({ coverage, issue: issue.number });
        investigated = true;
      }
      if (work?.pullRequest) {
        work = await status.run({ issue: issue.number, quiet: true });
      }
      const assessment = work?.events.findLast(
        (event) => event.stage === "on-call assessment"
      );
      const handoff = work?.events.findLast(
        (event) => event.stage === "on-call handoff"
      );
      let attention = "";
      if (
        issue.state === "OPEN" &&
        assessment &&
        (!handoff || handoff.at < assessment.at)
      ) {
        const due = z
          .object({ acknowledgeBy: z.string(), backup: z.string() })
          .parse(JSON.parse(assessment.detail));
        if (Date.now() > Date.parse(due.acknowledgeBy)) {
          attention = `; acknowledgement overdue, ${due.backup} needs to take over`;
        }
      }
      rows.push(
        `- [#${issue.number}](${issue.url}) ${issue.title}: ${work?.status ?? issue.state}${attention}`
      );
    }
    const body = [
      `Run: ${run.id}`,
      `Fetched origin/${baseBranch}: ${revision}`,
      "## Latest commits",
      changes.stdout || "No new commits since the last report.",
      "## Issues and incidents",
      ...rows,
      "This report covers up to 100 labeled issues and 10 commits. Check the tracker and Git history for the rest.",
    ].join("\n\n");
    const version = await report.artifact(
      reports,
      {
        "patrol.md": `# Factory patrol\n\n${body}`,
        "revision.txt": revision,
      },
      { body }
    );
    return { issues: issues.length, report: version.id, revision };
  }
);

schedule(patrol).every("15m");

Create .foundry/marbles/patrol.ts:

import "./_factory/patrol";

The query includes issues labeled factory-ready or incident, including closed issues so delivery can be observed. It is bounded to 100 issues and 10 recent commits. Increase coverage or paginate before using it for a larger queue. Keep a tracked issue’s label until its delivery has been recorded.

Run once before scheduling

marbles roll factory-patrol
marbles list
marbles

Inspect the report in the Feed, then start triggers in the dashboard. For unattended operation, use marbles run </dev/null. Keep the process running; schedules stop when it exits.

What the report tells you

The versioned Factory patrol artifact records the fetched revision, recent commits, issue progress, unstarted work, open incidents, and overdue acknowledgements. It keeps the last reported revision for the next comparison. Use factory-status to read one issue’s full chronicle.

report.artifact(reports, files, { body }) also puts the readable patrol report in the Feed. People read that entry; the next patrol reads revision.txt from the artifact. The artifact and Feed roles stay separate even when one call creates both.

Fetching brings in new remote changes without merging them into the active checkout. New issues pin that fetched revision. Existing candidates retain their recorded base until you deliberately update and reverify them.

The patrol’s calls to factory-on-call and factory-status use .run(). They belong to the patrol’s execution rather than appearing as separate dashboard runs. Their issue logs still record the results. Run those steps by name when investigating one issue directly.

Advance ready work

Use the report to select the next issue, then run marbles roll factory --input '{"issue":42}'. Keep publication attended. The patrol does not interpret every observed issue as permission to edit code.

This sample assumes one writer per issue. A schedule’s local overlap lock does not protect against a second manual launch or another machine. Before adding automatic claims, define ownership, duplicate handling, and recovery of partially completed work. Remote execution and the roadmap describe the current deployment limits and future webhook support.

Was this page helpful?