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.