Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions web/lib/flow-onboarding.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { validFlowAgentSettings, type FlowAgentSettings } from './flow-agent-settings';
import { flowPreview } from './flow-preview';
import { WORKFLOWS, workflowCode, workflowAgents, type WorkflowId } from './flow-workflows';
import { FLOW_TIME, WORKFLOWS, workflowCode, workflowAgents, type WorkflowId } from './flow-workflows';
import { ISSUE_SOURCES, issueSourceCode, validSourcePreferences, type IssueSourceId, type SourcePreferences } from './flow-sources';

import { CODING_AGENTS, isCodingAgent, type AgentId, type CodingAgent } from './flow-agents';
Expand Down Expand Up @@ -107,8 +107,10 @@ export function factoryCodeSections(draft: FactoryDraft, target: 'cloud' | 'loca
// budget no longer refuses a model-less Codex step (AgentWorkforce/flows#421);
// such a step runs unmetered, so a dollar cap cannot bound it. Wall-clock is
// enforced on every step regardless of pricing, which is why it stays the
// default here; `{ dollars, wallclock }` together is also valid.
const budget = '{ wallclock: "2h" }';
// default here; `{ dollars, wallclock }` together is also valid. Its length
// is the flow's time plan (FLOW_TIME, AgentWorkforce/cloud#4108): 2h could
// not hold a run whose checks needed repair on a ~12 minute test suite.
const budget = `{ wallclock: "${FLOW_TIME.headerMinutes / 60}h" }`;
if (!draft.sources.length) return [{ id: 'empty', code: `import { flow } from "@relayflows/surface";

export default flow("software-factory",
Expand Down
86 changes: 83 additions & 3 deletions web/lib/flow-workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,49 @@ export const FLOW_CHECK_BLOCKED_COMMAND = [
'if gh pr comment --body-file .relayflow/check-report.md >/dev/null 2>&1; then echo "relayflow: posted the check report to the pull request."; else echo "relayflow: could not comment on the pull request; .relayflow/check-report.md still holds the report." >&2; fi',
].join('; ');

/**
* The generated flow's time plan (AgentWorkforce/cloud#4108). The header's
* wallclock is charged per step and checked before each one, so once it is
* spent no step can run, not even the push that keeps the work. Agent steps
* take no time limit of their own, so the flow reads the clock (a journaled
* `date +%s`) before each optional, expensive step and starts it only when
* that step's allowance and publishing still fit. The allowances cover the
* longest steps measured on 2026-10-01: a 14m check run (its lease is 15m)
* and repair agents of 37m and 44m.
*/
export const FLOW_TIME = (() => {
const headerMinutes = 180; // Cloud's maximum run budget (RELAYFLOW_V2_RUN_BUDGET_MAX_MINUTES).
const setupMinutes = 5; // Cloud's run budget also counts the setup before the body.
const checkMinutes = 15;
const repairMinutes = 45;
const reviewMinutes = 20;
const fixerMinutes = 45;
const publishMinutes = 10;
return Object.freeze({
headerMinutes, setupMinutes, checkMinutes, repairMinutes, reviewMinutes, fixerMinutes, publishMinutes,
bodyMinutes: headerMinutes - setupMinutes,
// A repair, its re-check, the base-commit check a still-failing check
// triggers, and publishing.
repairStartMinutes: repairMinutes + checkMinutes + checkMinutes + publishMinutes,
// A review, then publishing a time stop.
reviewStartMinutes: reviewMinutes + publishMinutes,
// The fixer, its check, the next review, and publishing.
fixRoundStartMinutes: fixerMinutes + checkMinutes + reviewMinutes + publishMinutes,
});
})();

/**
* Marks the pull request as not ready when the run stops for time before its
* reviews are done, and says so on it. Every branch exits 0, like
* FLOW_CHECK_BLOCKED_COMMAND.
*/
export const FLOW_TIME_STOP_COMMAND = [
'mkdir -p .relayflow',
`printf '%s\\n' "**This run ran out of time before its reviews were done** (the flow's ${FLOW_TIME.headerMinutes / 60}h budget). The work is pushed and this pull request is a draft so it is not lost; review it by hand or run the flow again." > .relayflow/time-stop.md`,
'if gh pr ready --undo >/dev/null 2>&1; then echo "relayflow: converted the pull request to a draft."; else echo "relayflow: could not convert the pull request to a draft." >&2; fi',
Comment thread
AgentRelayBot marked this conversation as resolved.
'if gh pr comment --body-file .relayflow/time-stop.md >/dev/null 2>&1; then echo "relayflow: posted the time stop to the pull request."; else echo "relayflow: could not comment on the pull request." >&2; fi',
].join('; ');

/**
* Files the flow and its agents write for each other, which are never part of
* the change. Paths are relative to the repository root.
Expand Down Expand Up @@ -627,7 +670,16 @@ export function workflowCode(workflow: WorkflowId, agents: ReturnType<typeof wor
${JSON.stringify(WORKFLOW_FILES_HINT)};
// Files the agents write for each other (summary.md, plans, reviews, and
// .relayflow/) are never part of the change; keep them out of every commit.
await f.run(${JSON.stringify(FLOW_EXCLUDE_WORKING_FILES_COMMAND)});` }];
await f.run(${JSON.stringify(FLOW_EXCLUDE_WORKING_FILES_COMMAND)});
// The budget in the header is spent step by step, and once it is gone no
// step runs, not even the push that keeps the work. So the optional, long
// steps below (repairs, fixes, reviews) start only when they and publishing
// still fit. The clock is read through a step so a resumed run replays it.
const clock = async () => Number((await f.run("date +%s")).trim());
const startedAt = await clock();
// Parallel steps are charged once each, so they count more than the clock.
let parallelMinutes = 0;
const minutesLeft = async () => ${FLOW_TIME.bodyMinutes} - (await clock() - startedAt) / 60 - parallelMinutes;` }];
if (workflow === 'traditional') sections.push({ id: 'plan', code: ` // Read the ticket and agree on a plan before changing code.
await f.agent("planner", {
${options('planner', 'builder')}
Expand All @@ -647,12 +699,16 @@ export function workflowCode(workflow: WorkflowId, agents: ReturnType<typeof wor
for (const path of paths) {
await f.run("git worktree add --detach " + quote(path) + " " + quote(base));
}
// The budget charges each step's own time, so three prototypes in parallel
// cost up to three times the wall clock they take: count the extra two.
const prototypesStartedAt = await clock();
await Promise.all(paths.map((cwd, index) => f.agent("prototype-" + (index + 1), {
cli: prototypeAgents[index],
cwd,
model: prototypeSettings[index].model,
task: task + "\\n" + prototypeSettings[index].prompt + " Assigned approach: " + approaches[index],
})));
parallelMinutes += 2 * (await clock() - prototypesStartedAt) / 60;
// All three implementations are finished before comparison begins.
await f.agent("comparator", {
${options('comparator', 'reviewer', ' + " Prototype worktrees: " + paths.join(", ")')}
Expand Down Expand Up @@ -703,6 +759,11 @@ export function workflowCode(workflow: WorkflowId, agents: ReturnType<typeof wor
const checkAndRepair = async () => {
let result = verdict(await f.run(runChecks, { timeout: "15m" }));
for (let attempt = 0; attempt < 2 && broken(result); attempt++) {
// A repair, its re-check and publishing must fit in what is left.
if (await minutesLeft() < ${FLOW_TIME.repairStartMinutes}) {
Comment thread
AgentRelayBot marked this conversation as resolved.
console.error("Skipped the repair: too little of the flow's time budget is left to repair and still publish. The checks stand as they are.");
break;
}
await f.agent("check-repair-" + (++repairs), {
${options('check-repair', 'builder')}
});
Expand All @@ -719,7 +780,11 @@ export function workflowCode(workflow: WorkflowId, agents: ReturnType<typeof wor
? ""
: checkPlan === "none"
? "new"
: (await f.run("base=" + baseCommit + "; " + ${JSON.stringify(FLOW_BASE_CHECK_COMMAND)}, { timeout: "15m" })).trim();` });
: await minutesLeft() < ${FLOW_TIME.checkMinutes + FLOW_TIME.publishMinutes}
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
// Too little time to check the base commit and still publish: the
// report says the base could not be checked, and the draft opens.
? "skipped"
: (await f.run("base=" + baseCommit + "; " + ${JSON.stringify(FLOW_BASE_CHECK_COMMAND)}, { timeout: "15m" })).trim();` });
sections.push({ id: 'pull-request', code: ` // Publish the branch and open the pull request without an agent.
// Doing no work is a legitimate outcome: a repository with nothing to act on
// leaves no commits and no summary.md. Pushing a branch at the base commit
Expand Down Expand Up @@ -774,7 +839,9 @@ export function workflowCode(workflow: WorkflowId, agents: ReturnType<typeof wor
if (broken(check)) {
// The base commit fails too, or could not be checked: nothing here says the
// change is at fault, so the reviews still run and a person decides.
console.error("The checks fail, but not because of this change as far as the base commit shows. The pull request is a draft with the output of both.");
console.error(baseline === "skipped"
? "The checks fail, and there was no time left to check the base commit, so it is not known whether this change caused them. The pull request is a draft with the output."
: "The checks fail, but not because of this change as far as the base commit shows. The pull request is a draft with the output of both.");
}` });
if (workflow !== 'simple') sections.push({ id: 'review', code: ` // ${workflow === 'traditional' ? 'Always run two independent adversarial reviews, even if the first passes.' : 'Review the final implementation against the ticket and comparison findings.'}
// A review that found problems is this flow's verdict on its own work, so it
Expand All @@ -787,12 +854,25 @@ export function workflowCode(workflow: WorkflowId, agents: ReturnType<typeof wor
const reportReviewFindingsCommand = ${JSON.stringify(FLOW_REPORT_REVIEW_FINDINGS_COMMAND)};
let clean = false;
for (let round = 0; round < ${workflow === 'traditional' ? 2 : 1}; round++) {
// The pull request is open by now. A review that cannot finish in time
// would leave it looking ready; stop with it as a draft instead.
if (await minutesLeft() < ${FLOW_TIME.reviewStartMinutes}) {
await f.run(${JSON.stringify(FLOW_TIME_STOP_COMMAND)});
console.error("Stopped: out of time before the reviews were done. The work is pushed and the pull request is a draft.");
return f.done("needs_human");
}
await f.run("rm -f review.clean");
await f.agent("adversary-" + (round + 1), {
${options('adversary', 'reviewer')}
});
clean = (await f.run("test -f review.clean && echo yes || echo no")).trim() === "yes";
${workflow === 'traditional' ? `if (!clean && round === 0) {
if (await minutesLeft() < ${FLOW_TIME.fixRoundStartMinutes}) {
Comment thread
AgentRelayBot marked this conversation as resolved.
// No time to fix and re-review: stop as an unresolved review, so the
// findings go on the pull request and it goes back to draft.
console.error("Out of time for a fix round after the first review; reporting its findings instead.");
break;
}
await f.agent("fixer", {
${options('fixer', 'builder')}
});
Expand Down
Loading
Loading