Skip to content
9 changes: 7 additions & 2 deletions docs/BABYSITTER-CATALOG-HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,13 @@ not wired to hosted dispatch and must not be treated as enablement. The package'
`compat` requires the published 2.0.26 Surface/SDK release that routes
`labeled`, `unlabeled`, and `ready_for_review`. Export it only from the reviewed
release commit pinned below. The Software Factory flow's own independently
versioned header remains `2.0.22`; a regression requires the hosted identity to
equal the identity obtained from that exact reviewed source.
versioned header is now `2.0.23`; the hosted loader pins that revision's source
bytes and its assigned identity, and a regression requires the hosted identity to
equal the identity obtained from that exact reviewed source. Both pins are bytes
of `examples/software-factory/software-factory.flow.ts`: any change to that file
is also a change to `SOFTWARE_FACTORY_SHA256` and the assigned version in
`packages/sdk/src/hosted-extension-runtime.ts`, and the base is rejected until
they are updated together.

The sandbox contract is deliberately narrower than #442. It re-verifies the
complete lock-backed installation and every manifest, binds it to the exact
Expand Down
23 changes: 22 additions & 1 deletion examples/software-factory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,28 @@ project and labels are filled in there.

Each matching ticket launches one Cloud run in a fresh
`relayflow/software-factory-<id>` branch of `--repo`; a passing review opens a
PR, a blocked one opens a draft PR carrying the findings and ends `step_failed`.
PR. Review artifacts under `.relayflow/` must contain exactly one verdict:

- `review.passed` opens a ready PR after the hooks allow it.
- `review.blocked` opens a draft with findings and ends `step_failed` (exit 1).
- Non-empty `review.unverified` names the missing verification prerequisite.
It opens a draft headed **NOT VERIFIED**, explicitly claims no defect, and
ends `needs_human` (exit 3). Unlike pre-publication parking, this outcome
leaves a pushed branch and an opened draft PR; completion detail says so.

Silence, contradictory verdicts, and an empty `review.unverified` stay BLOCKED.
Every draft verdict (including hook refusals) names the reviewed commit and says
it covers that commit only. A new head supersedes the verdict, but this terminating
flow does not edit old bodies/comments, re-review pushes, or mark drafts ready.

Draft bodies introduce a new machine-readable contract:
`<!-- relayflow-review verdict=blocked reviewed-head=<40-hex-sha> -->`.
The other verdict values are `unverified`, `post-review-blocked`, and
`merge-gate-blocked`. Exactly one marker is allowed before publication.
The intended first consumer is [the resident babysitter](../babysitter/README.md),
which is not yet ready for unattended deployment; a future shepherd can compare
the marker with the current PR head before amending a superseded verdict.

The pull-request title is the ticket title (whitespace-normalized and capped at
240 Unicode code points). GitHub inputs must carry `identifier: "#<number>"`;
the flow appends exactly one `Fixes #<number>` line and validates the final
Expand Down
73 changes: 50 additions & 23 deletions examples/software-factory/software-factory.flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,14 @@ const WORK = ".relayflow";
const PREPARE_CHANGE_METADATA = [
`if [ ! -s ${WORK}/pr-body.md ]; then echo missing-body; exit 0; fi`,
`if [ -n "$reference" ] && ! grep -qxF "$reference" ${WORK}/pr-body.md; then printf "\\n%s\\n" "$reference" >> ${WORK}/pr-body.md; fi`,
`if [ -n "$scope" ] && ! grep -qxF "$scope" ${WORK}/pr-body.md; then printf "\\n%s\\n" "$scope" >> ${WORK}/pr-body.md; fi`,
"echo prepared",
].join("; ");

// Redundant with the TypeScript checks on purpose: this runs immediately
// before the first external effect and validates the final title/body bytes.
const VALIDATE_CHANGE_METADATA = [
`if [ -n "$scope" ]; then scope_count=$(grep -cE '^<!-- relayflow-review ' ${WORK}/pr-body.md || true); if [ "$scope_count" -ne 1 ]; then echo malformed-review-scope; exit 0; fi; fi`,
`if [ ! -s ${WORK}/pr-body.md ]; then echo missing-body`,
"elif [ -z \"$title\" ]; then echo empty-title",
"elif ! printf \"%s\\n\" \"$title_length\" | grep -Eq \"^[0-9]+$\"; then echo malformed-title-length",
Expand All @@ -57,7 +59,7 @@ const VALIDATE_CHANGE_METADATA = [
const TEST = 'if [ -f package.json ] && node -e \'p=require("./package.json");process.exit(p.scripts&&p.scripts.test?0:1)\'; then npm ci --no-audit --no-fund && npm test; else echo "no test script; skipping"; fi';

export default flow<Input>("software-factory", {
version: "2.0.22",
version: "2.0.23",
hooks: ["pre-implement", "post-review", "merge-gate"],
budget: { dollars: 10, wallclock: "1h" },
}, async (f, input) => {
Expand All @@ -66,7 +68,7 @@ export default flow<Input>("software-factory", {
// Parked, not canceled: a body cannot declare a kernel outcome, and the
// printed reason is what a human reads on the parked run.
await f.run("echo 'Stopped: no ticket arrived with this run.' >&2");
return f.done("needs_human");
return f.done("needs_human", { detail: "no ticket arrived; nothing pushed" });
}
const normalizedTitle = issue.title.trim().replace(/\s+/g, " ");
const title = Array.from(normalizedTitle).slice(0, 240).join("").trim();
Expand All @@ -78,11 +80,11 @@ export default flow<Input>("software-factory", {
const issueUrl = typeof issue.url === "string" ? issue.url.trim() : "";
if (!title || placeholderTitle) {
await f.run("echo 'Stopped: the pull-request title is empty or still a placeholder.' >&2");
return f.done("needs_human");
return f.done("needs_human", { detail: "invalid pull-request title; nothing pushed" });
}
if (issueSource === "github" && !/^#[1-9]\d*$/.test(issueIdentifier)) {
await f.run("echo 'Stopped: a GitHub ticket must carry its normalized identifier in #<number> form.' >&2");
return f.done("needs_human");
return f.done("needs_human", { detail: "malformed GitHub identifier; nothing pushed" });
}
// A Linear identifier is the write-back hook: Linear's GitHub integration
// links the pull request to the issue and moves it on merge when the body
Expand All @@ -104,13 +106,23 @@ export default flow<Input>("software-factory", {
: "";
const ticket = `${issue.title}\n\n${issue.body ?? ""}${issue.url ? `\n\n${issue.url}` : ""}`;

const openPullRequest = async (bodyCommand: string, draft: boolean): Promise<boolean> => {
await f.run(bodyCommand);
await f.run(`reference=${shellWord(changeReference)}; ${PREPARE_CHANGE_METADATA}`);
let reviewedHead = "";
let publicationFailure = "";
const openPullRequest = async (body: (sha: string) => string, draft: boolean, verdict = ""): Promise<boolean> => {
reviewedHead = (await f.run("git rev-parse HEAD")).trim();
if (!/^[0-9a-f]{40}$/.test(reviewedHead)) {
publicationFailure = "could not read the reviewed head commit; nothing pushed";
await f.run("echo 'Stopped: could not read the reviewed head commit. Nothing was pushed.' >&2");
return false;
}
const scope = verdict ? `<!-- relayflow-review verdict=${verdict} reviewed-head=${reviewedHead} -->` : "";
await f.run(body(reviewedHead));
await f.run(`reference=${shellWord(changeReference)}; scope=${shellWord(scope)}; ${PREPARE_CHANGE_METADATA}`);
const metadata = (await f.run(
`title=${shellWord(title)}; title_length=${titleLength}; source=${shellWord(issueSource)}; identifier=${shellWord(issueIdentifier)}; ${VALIDATE_CHANGE_METADATA}`,
`title=${shellWord(title)}; scope=${shellWord(scope)}; title_length=${titleLength}; source=${shellWord(issueSource)}; identifier=${shellWord(issueIdentifier)}; ${VALIDATE_CHANGE_METADATA}`,
)).trim();
if (metadata !== "valid") {
publicationFailure = `invalid pull-request metadata (${metadata}); nothing pushed`;
await f.run(`echo ${shellWord(`Stopped: invalid pull-request metadata (${metadata}). No branch was pushed and no pull request was opened.`)} >&2`);
return false;
}
Expand All @@ -119,12 +131,18 @@ export default flow<Input>("software-factory", {
return true;
};

const draftBody = (heading: string, review = false, unverified = false) => (sha: string): string =>
`sha=${shellWord(sha)}; { cat ${WORK}/summary.md; printf '\\n\\n## %s at %s\\n\\n' ${shellWord(heading)} "$sha"; ` +
(unverified ? `printf '%s\\n' 'No defect claimed. Verification could not run:'; cat ${WORK}/review.unverified; printf '\\n'; ` : "") +
`printf '%s\\n\\n' 'This verdict covers this commit only; a new head supersedes it and requires a new review.'; ` +
(review ? `cat ${WORK}/review.md; ` : "") + `} > ${WORK}/pr-body.md`;

// Fresh work dir, excluded from git, no leftover verdicts.
await f.run(`rm -rf ${WORK} && mkdir -p ${WORK} && { grep -qxF '${WORK}/' .git/info/exclude 2>/dev/null || echo '${WORK}/' >> .git/info/exclude; }`);

if (!await f.hook("pre-implement", { title, issue })) {
await f.run("echo 'Stopped: pre-implement hook refused this ticket.' >&2");
return f.done("declined");
return f.done("declined", { detail: "pre-implement hook refused this ticket; nothing pushed" });
}

await f.agent("implementer", {
Expand All @@ -141,21 +159,22 @@ export default flow<Input>("software-factory", {
cli: "claude",
task: `Review the diff against the base branch as an adversary: find bugs, missing tests, unsafe defaults, and scope creep. ` +
`Fix what is mechanical and re-run the tests. Write ${WORK}/review.md with your findings, then write ${WORK}/review.passed ` +
`ONLY if the change is ready for a human to merge; otherwise write ${WORK}/review.blocked with the blocking findings.`,
`ONLY if the change is ready for a human to merge; write ${WORK}/review.blocked with any blocking defects. ` +
`If no defect was found but verification cannot run, write a non-empty ${WORK}/review.unverified instead: name the missing prerequisite in both ${WORK}/review.md and ${WORK}/review.unverified. Write exactly one verdict file.`,
}).gate({ type: "subprocess_gate", command: `test -s ${WORK}/review.md` });

await f.run(TEST, { timeout: "15m" });

if (!await f.hook("post-review", { title })) {
await f.run("git add -A && (git diff --cached --quiet || git commit -qm 'Software factory: implementation and review fixes')");
if (!await openPullRequest(`{ cat ${WORK}/summary.md; printf '\\n\\n## post-review: blocked\\n\\n'; } > ${WORK}/pr-body.md`, true)) {
return f.done("needs_human");
if (!await openPullRequest(draftBody("post-review: blocked"), true, "post-review-blocked")) {
return f.done("needs_human", { detail: publicationFailure });
}
return f.done("step_failed");
return f.done("step_failed", { detail: `post-review hook blocked at ${reviewedHead}; draft PR opened` });
}

// Passed means exactly one verdict, and it is the pass marker.
const verdict = await f.run(`if [ -f ${WORK}/review.passed ] && [ ! -f ${WORK}/review.blocked ]; then echo PASSED; else echo BLOCKED; fi`);
// Exactly one verdict is required; silence, contradictions and empty unverified fail closed.
const verdict = await f.run(`count=0; for v in blocked unverified passed; do [ -f ${WORK}/review.$v ] && count=$((count+1)); done; if [ "$count" -ne 1 ]; then echo BLOCKED; elif [ -f ${WORK}/review.blocked ]; then echo BLOCKED; elif [ -s ${WORK}/review.unverified ]; then echo UNVERIFIED; elif [ -f ${WORK}/review.passed ]; then echo PASSED; else echo BLOCKED; fi`);
await f.run("git add -A && (git diff --cached --quiet || git commit -qm 'Software factory: implementation and review fixes')");

// Deterministic step, not an agent decision: the PR is opened either way,
Expand All @@ -170,18 +189,26 @@ export default flow<Input>("software-factory", {
headSha,
});
if (!allowed) {
if (!await openPullRequest(`{ cat ${WORK}/summary.md; printf '\\n\\n## merge-gate: blocked\\n\\n'; } > ${WORK}/pr-body.md`, true)) {
return f.done("needs_human");
if (!await openPullRequest(draftBody("merge-gate: blocked"), true, "merge-gate-blocked")) {
return f.done("needs_human", { detail: publicationFailure });
}
return f.done("step_failed");
return f.done("step_failed", { detail: `merge-gate blocked at ${reviewedHead}; draft PR opened` });
}
if (!await openPullRequest(`cp ${WORK}/summary.md ${WORK}/pr-body.md`, false)) {
return f.done("needs_human");
if (!await openPullRequest(() => `cp ${WORK}/summary.md ${WORK}/pr-body.md`, false)) {
return f.done("needs_human", { detail: publicationFailure });
}
return f.done("success");
}
if (!await openPullRequest(`{ cat ${WORK}/summary.md; printf '\\n\\n## Adversarial review: BLOCKED\\n\\n'; cat ${WORK}/review.md; } > ${WORK}/pr-body.md`, true)) {
return f.done("needs_human");
const unverified = verdict.trim() === "UNVERIFIED";
if (!await openPullRequest(
draftBody(`Adversarial review: ${unverified ? "NOT VERIFIED" : "BLOCKED"}`, true, unverified),
true, unverified ? "unverified" : "blocked",
)) {
return f.done("needs_human", { detail: publicationFailure });
}
// Unlike pre-publication parking, this needs_human leaves a pushed branch and draft PR.
if (unverified) {
return f.done("needs_human", { detail: `review could not verify at ${reviewedHead}; draft PR opened, no defect claimed` });
}
f.done("step_failed");
return f.done("step_failed", { detail: `adversarial review blocked at ${reviewedHead}; draft PR opened` });
});
55 changes: 38 additions & 17 deletions kernel/relayflowd/tests/parallel_driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -440,27 +440,48 @@ fn pause_before_second_independent_step_holds_the_driver_boundary() {
.unwrap();

let deadline = Instant::now() + Duration::from_secs(15);
while fs::read_to_string(&marker).unwrap_or_default() != "first\n" {
let run_id = loop {
if let Some(entry) = fs::read_dir(data_dir.join("runs"))
.ok()
.and_then(|mut runs| runs.next())
{
break entry
.unwrap()
.path()
.file_stem()
.unwrap()
.to_str()
.unwrap()
.to_owned();
}
assert!(Instant::now() < deadline, "driver never spawned the run");
std::thread::sleep(Duration::from_millis(20));
};
// Wait until first's success is DURABLE in the journal before killing.
// The marker file only proves the effect ran; its completion may not
// have reached the journal yet, and a kill in that window makes resume
// correctly re-run the step — which says nothing about the pause
// boundary while failing the exact-effects assertion below.
let deadline = Instant::now() + Duration::from_secs(15);
let entries = loop {
if let Ok(entries) = Engine::new(&data_dir).journal_entries(&run_id, 1, usize::MAX)
&& entries.iter().any(|entry| {
entry.entry_type == EntryType::StepCompleted
&& entry.step_id.as_deref() == Some("first")
&& serde_json::from_value::<StepCompletedPayload>(entry.payload.clone())
.unwrap()
.completion_reason
== CompletionReason::Success
})
{
break entries;
}
assert!(
Instant::now() < deadline,
"driver never reached second lane"
"driver never journaled first's completion"
);
std::thread::sleep(Duration::from_millis(20));
}
let run_id = fs::read_dir(data_dir.join("runs"))
.unwrap()
.next()
.unwrap()
.unwrap()
.path()
.file_stem()
.unwrap()
.to_str()
.unwrap()
.to_owned();
let entries = Engine::new(&data_dir)
.journal_entries(&run_id, 1, usize::MAX)
.unwrap();
};
assert!(!entries.iter().any(|entry| {
entry.entry_type == EntryType::StepAttemptStarted
&& entry.step_id.as_deref() == Some("second")
Expand Down
4 changes: 2 additions & 2 deletions packages/sdk/src/hosted-extension-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const REALPATH = realpath;
const PATH_DIRNAME = dirname;
const PATH_JOIN = join;
const PATH_RESOLVE = resolve;
const SOFTWARE_FACTORY_SHA256 = 'b97a3466c2affabb61afa655d4b2f726942d753c0466740b64ba26a78da359c6';
const SOFTWARE_FACTORY_SHA256 = '8bbcf0e42f47d6bc6491a129935f96b791be03c8a5ed5b73d651c4e77913e2d7';
const ARRAY_IS_ARRAY = Array.isArray;
const OBJECT_FREEZE = Object.freeze;
const WEAK_MAP_GET = Function.prototype.call.bind(WeakMap.prototype.get) as <K extends object, V>(
Expand Down Expand Up @@ -243,7 +243,7 @@ async function baseAt(origin: string, generation: RuntimeGeneration): Promise<Ho
}
const value = frozenHostedPromiseValue({
name: 'software-factory',
version: '2.0.22',
version: '2.0.23',
});
WEAK_SET_ADD(BASE_AUTHORITY, value);
WEAK_MAP_SET(BASE_GENERATION, value, generation);
Expand Down
4 changes: 2 additions & 2 deletions packages/sdk/tests/babysitter-native-extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ async function handle(eventType: string, input: unknown, f: Ctx) {
describe('native Babysitter extension', () => {
it('composes onto Software Factory with exactly the declared, deliverable subscriptions', async () => {
const { loaded, extension, base, hostedRuntime } = installed;
expect(base).toEqual({ name: 'software-factory', version: '2.0.22' });
expect(base).toEqual({ name: 'software-factory', version: '2.0.23' });
expect(hostedRuntime.base).toEqual(base);
expect(extension.manifest.permissions).toEqual({
integrations: ['github'], harnesses: ['codex'], mcp: [], writes: ['cloud:babysitter-turn'],
Expand Down Expand Up @@ -277,7 +277,7 @@ describe('native Babysitter extension', () => {
prototype.update = function poisonedUpdate() { poisonCalls += 1; return this; } as typeof prototype.update;
prototype.digest = (() => {
poisonCalls += 1;
return '49c993220b9c34fab2d4b0e51911656f62b8b657f534d988691960d45bb9d9b6';
return '8bbcf0e42f47d6bc6491a129935f96b791be03c8a5ed5b73d651c4e77913e2d7';
}) as typeof prototype.digest;
await expect(loadHostedExtensionRuntime(racing.flowPath))
.rejects.toMatchObject({ code: 'plugin_source_invalid' });
Expand Down
Loading
Loading