From f6dbc0dbda482a04d9346a66e74c323995585938 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Fri, 9 Oct 2026 13:24:09 -0700 Subject: [PATCH 1/2] feat(flows): Software Garden v3 stops when the implementer changed nothing, and relays its question Run 91c1a5cd (wepost-no/wepost-saga, 2026-10-09): the implementer could not tell which file its Linear ticket meant and committed nothing, yet the flow checked the unchanged branch, repaired it for 7 minutes ($1.63) and checked it again before publish saw no commits: ~17 minutes, no pull request, and the question never reached the ticket. - After the implementer (unless it timed out), FLOW_CHANGE_CHECK_COMMAND asks whether anything changed against the base commit. Only a definite "unchanged" stops the run with done("needs_human"); checks, repair and the base comparison are skipped. - The implementer is told to write .relayflow/needs-input.md with its question and the candidates it found instead of guessing or committing. FLOW_NEEDS_INPUT_COMMAND relays it (or the implementer's own last message) bounded and sanitized, as `relayflow needs-input-source:`, `relayflow needs-input:` and `relayflow needs-input-truncated:` records. - Input gains optional checkCommand, which replaces check discovery when non-empty; the author's constant and discovery remain the fallback. - Checks that passed on a discovered, default or repaired check script add a note to the PR body: commit it as .relayflow/check.sh, or set the flow's test command in Cloud, and future runs skip that setup. - `relayflow phase: implementing|checking|repairing|reviewing|publishing` progress lines. Published versions are immutable, so this is v3 (the catalog still pins v1). Co-Authored-By: Claude Opus 5.5 Session-Id: 248a5697-9493-45b7-a6fb-c23df13da25d --- web/lib/flow-onboarding.ts | 4 +- web/lib/flow-workflows.ts | 143 +++++- web/lib/test/flow-budget.test.ts | 4 +- web/lib/test/flow-needs-input.test.ts | 361 +++++++++++++++ .../flows/software-garden/manifest.json | 14 +- web/public/flows/software-garden/v3.flow.ts | 410 ++++++++++++++++++ 6 files changed, 921 insertions(+), 15 deletions(-) create mode 100644 web/lib/test/flow-needs-input.test.ts create mode 100644 web/public/flows/software-garden/v3.flow.ts diff --git a/web/lib/flow-onboarding.ts b/web/lib/flow-onboarding.ts index a46de105..7da3a788 100644 --- a/web/lib/flow-onboarding.ts +++ b/web/lib/flow-onboarding.ts @@ -166,7 +166,9 @@ const builder = "${agent}";` }); console.error("Stopped: no ticket arrived with this run, so there was nothing to work on."); return f.done("needs_human"); }`; - sections.push({ id: 'input', code: `type Input = { issue${hasMarkdown ? '?' : ''}: Issue; approver: string }; + // checkCommand: the flow's test command, when Cloud or a local input sets + // one; it replaces check discovery (see the 'discover' section). + sections.push({ id: 'input', code: `type Input = { issue${hasMarkdown ? '?' : ''}: Issue; approver: string; checkCommand?: string }; // Run in a connected repository, on a new branch. export default flow("software-factory", diff --git a/web/lib/flow-workflows.ts b/web/lib/flow-workflows.ts index ba71e27f..1f9ef78d 100644 --- a/web/lib/flow-workflows.ts +++ b/web/lib/flow-workflows.ts @@ -425,7 +425,12 @@ const CAP_FUNCTION = BLOCK_FUNCTION + '; relayflow_cap() { ' + [ * with its exit status, a timeout with how long it ran against its budget), `baseline` (the base commit's token, empty when it * was not needed, or `skipped` when there was no time to check it), and * `implementer_timeout=yes` when the implementer was stopped at its time - * limit, and `reference` to the closing reference line the next step adds. The base commit's output is shown whenever it ran, including when + * limit, and `reference` to the closing reference line the next step adds. + * It may set `check_setup` (where FLOW_CHECK_SCRIPT came from: `input`, + * `repository`, `discovered` or `default`) and `check_digest` (its `cksum` + * before the first repair, empty when none ran): checks that passed on a + * script this run discovered, defaulted or repaired get a note saying how + * future runs can skip that setup. The base commit's output is shown whenever it ran, including when * its verdict is `unknown` (it ran out of time against a branch that failed * outright). A reviewer reads the verdict, the * script that ran and the tail of each log on the pull request itself, without @@ -453,10 +458,16 @@ export const FLOW_CHECK_REPORT_COMMAND = [ 'relayflow_number() { v=$(cat "$1" 2>/dev/null | head -n 1); case "$v" in (""|*[!0-9]*) ;; (*) printf %s "$v" ;; esac; }', 'relayflow_duration() { if [ "$1" -ge 60 ]; then printf "%dm%02ds" $(($1 / 60)) $(($1 % 60)); else printf "%ds" "$1"; fi; }', 'ck_exit=$(relayflow_number .relayflow/check.log.exit); ck_elapsed=$(relayflow_number .relayflow/check.log.elapsed); ck_limit=$(relayflow_number .relayflow/check.log.limit)', + // A script this run wrote or changed that passed is worth keeping: the next + // run otherwise pays for check discovery again (run 91c1a5cd: 1.5 minutes, + // $0.66), because .relayflow/ is never committed. + 'relayflow_setup_note() { how=; case "${check_setup:-}" in (discovered) how="worked out from this repository\'s CI configuration" ;; (default) how="the default for this kind of project" ;; esac' + + `; if [ -n "\${check_digest:-}" ] && [ "$(cksum < ${FLOW_CHECK_SCRIPT} 2>/dev/null)" != "$check_digest" ]; then if [ -n "$how" ]; then how="$how, then repaired during this run"; else how="repaired during this run"; fi; fi` + + `; if [ -n "$how" ] && [ -s ${FLOW_CHECK_SCRIPT} ]; then printf '\\n%s\\n' "This run set up its own check script ($how); it is under \\"What ran\\" below. Commit it to the repository as \\\`${FLOW_CHECK_SCRIPT}\\\`, or set this flow's test command in Cloud, and future runs skip this setup."; fi; }`, '{ printf \'## Checks\\n\\n\'' + `; if [ "\${implementer_timeout:-}" = yes ]; then printf '%s\\n\\n' "**The implementer was stopped at its time limit**, so this change may be incomplete. Its committed work is what was checked and pushed; the pull request is a draft."; fi` + '; case "$check" in' - + ` pass) printf '%s\\n' "Relayflow ran this repository's checks (${FLOW_CHECK_SCRIPT}) and they passed." ;;` + + ` pass) printf '%s\\n' "Relayflow ran this repository's checks (${FLOW_CHECK_SCRIPT}) and they passed."; relayflow_setup_note ;;` + ` skipped) printf '%s\\n' "**Relayflow ran out of time before it could run this repository's checks** (${FLOW_CHECK_SCRIPT}), so no checks ran on this change. The pull request is a draft; run the checks before merging." ;;` + ` unrunnable) printf '%s\\n' "**Relayflow could not run this repository's checks** (${FLOW_CHECK_SCRIPT}): the machine it ran on has no \\\`timeout\\\`, \\\`gtimeout\\\` or \\\`perl\\\`, so there was no way to stop the suite at a time limit, and starting it could have ended the run with no verdict at all. This is not a test failure, and nothing here says the change is broken. Install coreutils (for \\\`timeout\\\`) or perl and run the flow again, or run ${FLOW_CHECK_SCRIPT} by hand. The pull request is a draft; run the checks before merging." ;;` + ` none) printf '%s\\n' "Relayflow found no way to run this repository's tests — no check command, no CI test job it could follow, no test target and no recognised project file — so no checks ran. Review the change with that in mind." ;;` @@ -934,6 +945,88 @@ export const FLOW_PUBLISH_CHECK_COMMAND = [ 'echo "relayflow: no commits were made on this branch, so there is nothing to push or publish." >&2 && echo no-commits && exit 0', ].join('; '); +/** + * Where the implementer explains a ticket it could not act on. It sits under + * `.relayflow/`, so it is a working file and is never committed. + */ +export const FLOW_NEEDS_INPUT_FILE = '.relayflow/needs-input.md'; + +/** + * Told to the implementer. Run 91c1a5cd (wepost-no/wepost-saga) could not tell + * which file its Linear ticket meant, asked about it in its own output, and + * committed nothing; the flow then spent 17 minutes checking and repairing an + * unchanged branch, and the question never reached the person who could answer + * it. A question written here ends the run at once and is relayed + * (FLOW_NEEDS_INPUT_COMMAND). + */ +export const FLOW_NEEDS_INPUT_HINT = `If the ticket is ambiguous, or you cannot identify what it refers to (a file, document, feature or component you cannot find), do not guess and do not commit. Instead write ${FLOW_NEEDS_INPUT_FILE}: the specific question or questions the ticket's author must answer, and the candidates you found, each with its path (for example "Did you mean docs/native-gamification-plan.md?"). Keep it short and plain; it is posted back to the ticket.`; + +/** + * Says whether the implementer changed anything, right after it finishes, so a + * run with nothing to check stops before the checks, the repairs and the + * base-commit comparison (run 91c1a5cd spent 17 minutes and $1.63 on them for + * a branch with no commits). The caller prefixes `base=`. + * + * unchanged no commits since the base, and nothing uncommitted + * changed commits, or uncommitted edits (the checks run on the tree) + * unknown the base commit or the tree could not be read + * + * Only `unchanged` stops the run, so anything this cannot vouch for carries on + * as before. Working files an agent committed that the base commit does not + * have (FLOW_DROP_WORKING_FILES_COMMAND removes them before publishing) do not + * count as a change; untracked working files are hidden by the clone's + * exclude file (FLOW_EXCLUDE_WORKING_FILES_COMMAND). One token on stdout, exit + * 0 on every path. + */ +export const FLOW_CHANGE_CHECK_COMMAND = [ + `paths=${shq(FLOW_WORKING_FILES.join(' '))}`, + 'if [ -z "$base" ] || ! git rev-parse --verify --quiet "$base^{commit}" >/dev/null 2>&1 || ! names=$(git diff --name-only "$base" HEAD -- 2>/dev/null) || ! pending=$(git status --porcelain 2>/dev/null); then echo "relayflow: could not tell whether the implementer changed anything; carrying on." >&2; echo unknown; exit 0; fi', + 'committed=$(printf \'%s\\n\' "$names" | while IFS= read -r p; do [ -n "$p" ] || continue; keep=yes; for w in $paths; do case "$p" in ("$w"|"$w"/*) git cat-file -e "$base:$p" 2>/dev/null || keep=no ;; esac; done; if [ "$keep" = yes ]; then printf \'%s\\n\' "$p"; fi; done)', + 'if [ -n "$committed" ] || [ -n "$pending" ]; then echo changed; else echo "relayflow: the implementer made no commits and left no changes." >&2; echo unchanged; fi', +].join('; '); + +/** The most, in bytes, of the implementer's question FLOW_NEEDS_INPUT_COMMAND prints. */ +export const FLOW_NEEDS_INPUT_LIMIT = 4000; +/** The most lines of it FLOW_NEEDS_INPUT_COMMAND prints. */ +const NEEDS_INPUT_LINES = 60; + +/** + * Relays why the implementer made no change, when it made none + * (FLOW_CHANGE_CHECK_COMMAND said `unchanged`), in a form Cloud can parse and + * post back to the ticket. The text is FLOW_NEEDS_INPUT_FILE when the + * implementer wrote one, otherwise `agent_summary` (the caller sets it to the + * end of the implementer's own last message, bounded), otherwise nothing. + * + * The output contract, on stdout, one record per line: + * + * relayflow needs-input-source: needs-input.md | implementer-summary | none + * relayflow needs-input: (zero or more) + * relayflow needs-input-truncated: of bytes (only when cut) + * + * The source line always comes first and exactly once. Content lines keep the + * text's order. The text is agent-written, so it is sanitized before it is + * printed: terminal colour sequences and control characters (carriage + * returns included) are removed, tabs become spaces, each line is trimmed and blank + * lines are dropped. It is then cut to FLOW_NEEDS_INPUT_LIMIT bytes, at a line + * boundary (or a character boundary when one line is all there is), and to + * NEEDS_INPUT_LINES lines. Every printed line carries a prefix, so nothing in + * the text can forge a record. Exit 0 on every path. + */ +export const FLOW_NEEDS_INPUT_COMMAND = [ + 'export LC_ALL=C', + 'mkdir -p .relayflow; out=.relayflow/needs-input.out', + `relayflow_clean() { sed "s/$(printf '\\033')\\[[0-9;?]*[A-Za-z]//g" | tr -d '\\000-\\010\\013-\\037\\177' | tr '\\t' ' ' | awk 'NF { sub(/^ +/, ""); sub(/ +$/, ""); print }'; }`, + `src=none; if [ -s ${FLOW_NEEDS_INPUT_FILE} ] && [ -n "$(relayflow_clean < ${FLOW_NEEDS_INPUT_FILE})" ]; then src=needs-input.md; relayflow_clean < ${FLOW_NEEDS_INPUT_FILE} > "$out"; elif [ -n "$(printf '%s\\n' "\${agent_summary:-}" | relayflow_clean)" ]; then src=implementer-summary; printf '%s\\n' "$agent_summary" | relayflow_clean > "$out"; else : > "$out"; fi`, + 'echo "relayflow needs-input-source: $src"', + 'total=$(wc -c < "$out" | tr -d " ")', + `if [ "$total" -gt ${FLOW_NEEDS_INPUT_LIMIT} ]; then head -c ${FLOW_NEEDS_INPUT_LIMIT} "$out" > "$out.cut"; if [ "$(tail -c 1 "$out.cut" | wc -l | tr -d " ")" -eq 1 ]; then cp "$out.cut" "$out.kept"; elif [ "$(tr -cd "\\n" < "$out.cut" | wc -c | tr -d " ")" -gt 0 ]; then sed '$d' "$out.cut" > "$out.kept"; else sed "s/$(printf '[\\300-\\377]')$(printf '[\\200-\\277]')*\\$//" "$out.cut" | awk 'NF { print }' > "$out.kept"; fi; mv -f "$out.kept" "$out"; rm -f "$out.cut"; fi`, + `head -n ${NEEDS_INPUT_LINES} "$out" > "$out.kept"; mv -f "$out.kept" "$out"; kept=$(wc -c < "$out" | tr -d " ")`, + 'sed "s/^/relayflow needs-input: /" "$out"', + 'if [ "$kept" -lt "$total" ]; then echo "relayflow needs-input-truncated: $kept of $total bytes"; fi', + 'rm -f "$out"', + 'exit 0', +].join('; '); + const REVIEW_BLOCKED_HEADING ='**Relayflow: the adversarial review did not pass.** This branch is not approved: the flow stopped here and did not mark it ready to merge.'; const REVIEW_TIMEOUT_NOTE = 'The review was stopped at its %s before it finished, so it is not a clean review. What it wrote before then is below.'; @@ -1102,13 +1195,22 @@ export function workflowCode(workflow: WorkflowId, agents: ReturnType "'" + value.replace(/'/g, "'\\\\''") + "'"; const checkScript = ${JSON.stringify(FLOW_CHECK_SCRIPT)}; - if (checkCommand.trim()) { - await f.run("mkdir -p .relayflow && printf '%s\\\\n' 'set -e' " + shellQuote(checkCommand) + " > " + checkScript); + // Where the check script came from, so the pull request can say when one + // this run set up is worth keeping: "input", "repository" (already in the + // tree), "discovered", or "default" (written below from the project files). + let checkSetup = "repository"; + if (chosenCheckCommand) { + checkSetup = "input"; + await f.run("mkdir -p .relayflow && printf '%s\\\\n' 'set -e' " + shellQuote(chosenCheckCommand) + " > " + checkScript); } else if ((await f.run("test -s " + checkScript + " && echo yes || echo no")).trim() !== "yes") { + checkSetup = "discovered"; // No command of yours and none committed to the repository: read how its // CI tests it. Anything this cannot settle falls back to a default below. const discovery = await f.agent("check-discovery", { @@ -1123,6 +1225,7 @@ export function workflowCode(workflow: WorkflowId, agents: ReturnType checkPlan === "none" || Math.ceil(checkTook) > ${t.checkMinutes - 2} ? 0 : Math.max(${t.checkFloorMinutes}, Math.ceil(checkTook)) + 5; for (let round = 1; round <= ${cloud ? t.repairRounds : t.localRepairRounds} && broken(check); round++) { // Each round: the repair, freeing disk, the re-check, publishing and the @@ -1259,12 +1377,19 @@ export function workflowCode(workflow: WorkflowId, agents: ReturnType/dev/null || true")).trim(); + console.error("relayflow phase: repairing"); const repair = await f.agent("check-repair" + (round === 1 ? "" : "-" + round), { ${options('check-repair', 'builder', '', 'repairLimit')} }); await f.run(freeDisk); const recheckLimit = await allowance(checkTotal, checkSlack + publishing + reviewing + baseHold); - if (recheckLimit >= 1) check = verdict(await spannedCheck(recheckLimit)); + if (recheckLimit >= 1) { + console.error("relayflow phase: checking"); + check = verdict(await spannedCheck(recheckLimit)); + } else console.error("No time was left to check the repair again; the checks stand as they were before it."); if (timedOut(repair)) { // A failed attempt: what it committed was checked again above, and @@ -1308,6 +1433,7 @@ export function workflowCode(workflow: WorkflowId, agents: ReturnType { diff --git a/web/lib/test/flow-needs-input.test.ts b/web/lib/test/flow-needs-input.test.ts new file mode 100644 index 00000000..ced36fd9 --- /dev/null +++ b/web/lib/test/flow-needs-input.test.ts @@ -0,0 +1,361 @@ +import { afterAll, describe, expect, it } from 'vitest'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import ts from 'typescript'; +import { + FLOW_BASE_CHECK_COMMAND, FLOW_CHANGE_CHECK_COMMAND, FLOW_CHECK_REPORT_COMMAND, FLOW_CHECK_RUN_COMMAND, FLOW_CHECK_SCRIPT, + FLOW_EXCLUDE_WORKING_FILES_COMMAND, FLOW_NEEDS_INPUT_COMMAND, FLOW_NEEDS_INPUT_FILE, FLOW_NEEDS_INPUT_HINT, FLOW_NEEDS_INPUT_LIMIT, + FLOW_OPEN_CHANGE_COMMAND, FLOW_PUBLISH_CHECK_COMMAND, FLOW_PUSH_COMMAND, +} from '../flow-workflows'; +import { factorySource, type FactoryDraft } from '../flow-onboarding'; + +// Run 91c1a5cd (wepost-no/wepost-saga, 2026-10-09): the implementer could not +// tell which file its Linear ticket meant, asked about it in its own output, +// and committed nothing. The flow still checked the unchanged branch, repaired +// it for 7 minutes and checked it again before publish found no commits: 17 +// minutes, no pull request, and the question never reached the ticket. + +const roots: string[] = []; +afterAll(() => { for (const root of roots) rmSync(root, { recursive: true, force: true }); }); + +const GIT_CONFIG = ['-c', 'user.email=flow@example.com', '-c', 'user.name=Flow', '-c', 'init.defaultBranch=main', '-c', 'commit.gpgsign=false']; +const git = (cwd: string, ...args: string[]) => execFileSync('git', [...GIT_CONFIG, ...args], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); +const sh = (command: string, cwd: string, env: Record = {}) => { + const result = spawnSync('/bin/sh', ['-c', command], { cwd, encoding: 'utf8', env: { ...process.env, ...env } }); + return { code: result.status, stdout: result.stdout, token: result.stdout.trim(), stderr: result.stderr }; +}; + +/** A repository with one commit, whose working files are excluded as the flow excludes them. */ +function repository() { + const root = mkdtempSync(path.join(tmpdir(), 'flow-needs-input-')); + roots.push(root); + writeFileSync(path.join(root, 'app.txt'), 'base\n'); + git(root, 'init', '-q', '.'); + git(root, 'add', '-A'); + git(root, 'commit', '-qm', 'base'); + expect(sh(FLOW_EXCLUDE_WORKING_FILES_COMMAND, root).code).toBe(0); + return { root, base: git(root, 'rev-parse', 'HEAD').trim() }; +} +const write = (root: string, name: string, content: string) => { + mkdirSync(path.dirname(path.join(root, name)), { recursive: true }); + writeFileSync(path.join(root, name), content); +}; + +describe('FLOW_CHANGE_CHECK_COMMAND', () => { + const changeCheck = (root: string, base: string) => sh(`base=${base}; ${FLOW_CHANGE_CHECK_COMMAND}`, root); + + it('says unchanged when the implementer committed nothing and left nothing, working files aside', () => { + const { root, base } = repository(); + expect(changeCheck(root, base)).toMatchObject({ code: 0, token: 'unchanged' }); + // What an implementer that could not act leaves: its question, a summary + // and the check script, all working files. + write(root, FLOW_NEEDS_INPUT_FILE, 'Which file?\n'); + write(root, 'summary.md', 'Nothing changed.\n'); + write(root, FLOW_CHECK_SCRIPT, 'set -e\nnpm test\n'); + expect(changeCheck(root, base)).toMatchObject({ code: 0, token: 'unchanged' }); + // A committed working file is removed before publishing, so it is no change. + git(root, 'add', '-f', 'summary.md'); + git(root, 'commit', '-qm', 'summary'); + expect(changeCheck(root, base)).toMatchObject({ code: 0, token: 'unchanged' }); + }); + + it('says changed for a commit, and for uncommitted or untracked work, which the checks still see', () => { + const committed = repository(); + write(committed.root, 'app.txt', 'work\n'); + git(committed.root, 'commit', '-qam', 'work'); + expect(changeCheck(committed.root, committed.base).token).toBe('changed'); + const edited = repository(); + write(edited.root, 'app.txt', 'work\n'); + expect(changeCheck(edited.root, edited.base).token).toBe('changed'); + const added = repository(); + write(added.root, 'src/new.ts', 'export {};\n'); + expect(changeCheck(added.root, added.base).token).toBe('changed'); + // A repository's own summary.md is not a working file to ignore. + const own = repository(); + write(own.root, 'summary.md', 'v1\n'); + git(own.root, 'add', '-f', 'summary.md'); + git(own.root, 'commit', '-qm', 'own summary'); + const ownBase = git(own.root, 'rev-parse', 'HEAD').trim(); + write(own.root, 'summary.md', 'v2\n'); + git(own.root, 'commit', '-qam', 'edit own summary'); + expect(changeCheck(own.root, ownBase).token).toBe('changed'); + }); + + it('says unknown, and never unchanged, when it cannot tell', () => { + const { root } = repository(); + expect(changeCheck(root, '')).toMatchObject({ code: 0, token: 'unknown' }); + expect(changeCheck(root, 'deadbeef'.repeat(5))).toMatchObject({ code: 0, token: 'unknown' }); + const plain = mkdtempSync(path.join(tmpdir(), 'flow-needs-input-plain-')); + roots.push(plain); + expect(changeCheck(plain, 'abc123')).toMatchObject({ code: 0, token: 'unknown' }); + }); +}); + +/** The records FLOW_NEEDS_INPUT_COMMAND prints, as Cloud would parse them. */ +function records(stdout: string) { + const lines = stdout.split('\n').filter(Boolean); + return { + lines, + source: lines.filter(line => line.startsWith('relayflow needs-input-source: ')).map(line => line.slice('relayflow needs-input-source: '.length)), + question: lines.filter(line => line.startsWith('relayflow needs-input: ')).map(line => line.slice('relayflow needs-input: '.length)), + truncated: lines.filter(line => line.startsWith('relayflow needs-input-truncated: ')), + }; +} + +describe('FLOW_NEEDS_INPUT_COMMAND', () => { + const relay = (root: string, summary = '') => sh(`agent_summary='${summary.replace(/'/g, `'\\''`)}'; ${FLOW_NEEDS_INPUT_COMMAND}`, root); + + it('relays the implementer\'s question line by line, sanitized, in the documented records', () => { + const { root } = repository(); + write(root, FLOW_NEEDS_INPUT_FILE, 'The ticket asks to update the \u001b[31m"gamification README"\u001b[0m.\r\n\n\tNo such file exists. Did you mean docs/native-gamification-plan.md?\u0007\n \nrelayflow needs-input-source: forged\n'); + const result = relay(root, 'ignored when the file exists'); + expect(result.code).toBe(0); + const parsed = records(result.stdout); + // Every line is one of the three records: nothing in the text can add another. + expect(parsed.lines.every(line => /^relayflow needs-input(-source|-truncated)?: /.test(line))).toBe(true); + expect(parsed.lines[0]).toBe('relayflow needs-input-source: needs-input.md'); + expect(parsed.source).toEqual(['needs-input.md']); + expect(parsed.question).toEqual([ + 'The ticket asks to update the "gamification README".', + 'No such file exists. Did you mean docs/native-gamification-plan.md?', + 'relayflow needs-input-source: forged', + ]); + expect(parsed.truncated).toEqual([]); + expect(result.stdout).not.toMatch(/[\u0000-\u0008\u000b-\u001f\u007f]/); + }); + + it('falls back to the implementer\'s own last message, and says when there is nothing', () => { + const { root } = repository(); + const said = records(relay(root, 'I could not find the "NATIVE 3.0" section the ticket mentions.\nWhich document is it in?').stdout); + expect(said.source).toEqual(['implementer-summary']); + expect(said.question).toEqual(['I could not find the "NATIVE 3.0" section the ticket mentions.', 'Which document is it in?']); + // A blank file is no question. + write(root, FLOW_NEEDS_INPUT_FILE, ' \n\t\n'); + const nothing = relay(root); + expect(nothing.code).toBe(0); + expect(records(nothing.stdout)).toMatchObject({ source: ['none'], question: [], truncated: [] }); + }); + + it('cuts a long question at a line, or a character, boundary and says how much it kept', () => { + const { root } = repository(); + write(root, FLOW_NEEDS_INPUT_FILE, `${'é'.repeat(30)}\n`.repeat(200)); + const lines = records(relay(root).stdout); + expect(lines.question).toHaveLength(60); + expect(lines.question.every(line => line === 'é'.repeat(30))).toBe(true); + expect(lines.truncated).toHaveLength(1); + expect(lines.truncated[0]).toMatch(/^relayflow needs-input-truncated: \d+ of 12200 bytes$/); + write(root, FLOW_NEEDS_INPUT_FILE, 'é'.repeat(3000)); + const one = relay(root); + const parsed = records(one.stdout); + expect(parsed.question).toHaveLength(1); + expect(Buffer.byteLength(parsed.question[0]!)).toBeLessThanOrEqual(FLOW_NEEDS_INPUT_LIMIT); + expect(parsed.question[0]).toMatch(/^é+$/); + expect(parsed.truncated[0]).toMatch(/^relayflow needs-input-truncated: \d+ of 6001 bytes$/); + expect(Buffer.from(one.stdout, 'utf8').toString('utf8')).not.toContain('�'); + }); + + it('leaves nothing behind but the question itself', () => { + const { root } = repository(); + write(root, FLOW_NEEDS_INPUT_FILE, 'Which file?\n'); + relay(root); + expect(sh('ls .relayflow', root).stdout.trim().split('\n')).toEqual(['needs-input.md']); + }); +}); + +describe('FLOW_CHECK_REPORT_COMMAND setup note', () => { + const report = (root: string, vars: string) => { + expect(sh(`${vars}; baseline=; ${FLOW_CHECK_REPORT_COMMAND}`, root).code).toBe(0); + return readFileSync(path.join(root, '.relayflow/check-report.md'), 'utf8'); + }; + const NOTE = 'future runs skip this setup'; + + it('suggests keeping a discovered or default script that passed', () => { + const { root } = repository(); + write(root, FLOW_CHECK_SCRIPT, 'set -e\nmake test\n'); + for (const setup of ['discovered', 'default']) { + const text = report(root, `check=pass; check_setup=${setup}; check_digest=''`); + expect(text, setup).toContain(NOTE); + expect(text, setup).toContain(`\`${FLOW_CHECK_SCRIPT}\``); + expect(text, setup).toContain("set this flow's test command in Cloud"); + } + }); + + it('suggests keeping a script the repair changed, whatever its origin', () => { + const { root } = repository(); + write(root, FLOW_CHECK_SCRIPT, 'set -e\nmake test\n'); + const before = sh(`cksum < ${FLOW_CHECK_SCRIPT}`, root).token; + write(root, FLOW_CHECK_SCRIPT, 'set -e\nmake build\nmake test\n'); + expect(report(root, `check=pass; check_setup=repository; check_digest='${before}'`)).toContain('(repaired during this run)'); + expect(report(root, `check=pass; check_setup=discovered; check_digest='${before}'`)).toContain('CI configuration, then repaired during this run'); + const now = sh(`cksum < ${FLOW_CHECK_SCRIPT}`, root).token; + expect(report(root, `check=pass; check_setup=repository; check_digest='${now}'`)).not.toContain(NOTE); + }); + + it('says nothing for a script the run was given, for checks that did not pass, or when the caller says nothing', () => { + const { root } = repository(); + write(root, FLOW_CHECK_SCRIPT, 'set -e\nmake test\n'); + expect(report(root, "check=pass; check_setup=input; check_digest=''")).not.toContain(NOTE); + expect(report(root, "check=pass; check_setup=repository; check_digest=''")).not.toContain(NOTE); + expect(report(root, "check=fail; check_setup=discovered; check_digest=''")).not.toContain(NOTE); + expect(report(root, 'check=pass')).not.toContain(NOTE); + }); +}); + +const issue = { source: 'linear', title: 'Update the gamification README', body: 'See NATIVE 3.0.', labels: [], repository: 'wepost-no/wepost-saga', identifier: 'WEP-12', url: 'https://linear.app/wepost/issue/WEP-12' }; +const draft: FactoryDraft = { version: 4, sources: ['github'], sourceSettings: {}, agents: ['claude'], otherAgent: '', task: '', workflow: 'traditional', step: 3 }; + +type Scenario = { + /** What the change check prints after the implementer. */ + changed?: string; + implementerTimedOut?: boolean; + summary?: string; + checks?: string[]; + input?: Record; + workflow?: FactoryDraft['workflow']; + edit?: (source: string) => string; +}; + +/** Runs the generated flow with every step mocked, recording each call. */ +async function runFlow(scenario: Scenario = {}) { + const source = (scenario.edit ?? (text => text))(factorySource({ ...draft, workflow: scenario.workflow ?? 'traditional' })).replace('import { flow } from "@relayflows/surface";', ''); + const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }); + const exports: { default?: (ctx: unknown, input: unknown) => Promise } = {}; + new Function('exports', 'flow', compiled.outputText)(exports, (_name: string, _options: unknown, body: unknown) => body); + const calls: string[] = []; + const tasks: Record = {}; + const errors: string[] = []; + let finish = ''; + let checkIndex = 0; + const originalError = console.error; + console.error = (message: string) => { errors.push(String(message)); }; + try { + await exports.default!({ + agent: async (name: string, options: { task: string }) => { + calls.push(`agent:${name}`); + tasks[name] = options.task; + if (name === 'implementer') return { completionReason: scenario.implementerTimedOut ? 'timeout' : 'success', summary: scenario.summary ?? '', artifacts: [] }; + return { completionReason: 'success', summary: '', artifacts: [] }; + }, + run: async (command: string) => { + calls.push(command); + if (command === 'date +%s') return '1759000000'; + if (command === 'git rev-parse HEAD') return 'abc123'; + if (command.endsWith(FLOW_CHANGE_CHECK_COMMAND)) return scenario.changed ?? 'changed'; + if (command.endsWith(FLOW_NEEDS_INPUT_COMMAND)) { + // The real command, on a repository holding what the implementer wrote. + const { root } = repository(); + write(root, FLOW_NEEDS_INPUT_FILE, 'Which file is the "gamification README"?\nDid you mean docs/native-gamification-plan.md?\n'); + return sh(command, root).stdout; + } + if (command.endsWith(FLOW_CHECK_RUN_COMMAND)) return scenario.checks?.[checkIndex++] ?? 'pass'; + if (command.endsWith(FLOW_BASE_CHECK_COMMAND)) return 'fail'; + if (command.endsWith(FLOW_PUBLISH_CHECK_COMMAND)) return 'publish'; + if (command.includes('validate') || command.includes('missing-github-closing-reference')) return 'valid'; + if (command.startsWith('test -s .relayflow/check.sh')) return 'no'; + if (command.startsWith('test -f review.clean')) return 'yes'; + return ''; + }, + human: async () => { throw new Error('unsupported'); }, + done: (reason: string) => { finish = reason; }, + }, { issue, approver: 'owner', ...scenario.input }); + } finally { console.error = originalError; } + return { calls, tasks, errors, finish }; +} + +describe('the generated Garden flow', () => { + const ran = (calls: string[], suffix: string) => calls.some(call => call.endsWith(suffix)); + + it('tells the implementer to write its question instead of guessing', async () => { + for (const workflow of ['traditional', 'prototype', 'simple'] as const) { + const { tasks } = await runFlow({ workflow }); + expect(tasks.implementer, workflow).toContain(FLOW_NEEDS_INPUT_HINT); + expect(tasks.implementer, workflow).toContain(FLOW_NEEDS_INPUT_FILE); + expect(tasks['check-repair'] ?? '', workflow).not.toContain(FLOW_NEEDS_INPUT_FILE); + } + }); + + it('stops right after an implementer that changed nothing, relaying its question (run 91c1a5cd)', async () => { + const { calls, errors, finish } = await runFlow({ changed: 'unchanged', checks: ['fail', 'fail', 'fail'] }); + expect(finish).toBe('needs_human'); + // Nothing was checked, repaired, compared, pushed or opened. + const after = calls.slice(calls.indexOf('agent:implementer') + 1); + expect(after.filter(call => call.startsWith('agent:'))).toEqual([]); + expect(ran(calls, FLOW_CHECK_RUN_COMMAND)).toBe(false); + expect(ran(calls, FLOW_BASE_CHECK_COMMAND)).toBe(false); + expect(calls.some(call => call.includes(FLOW_PUSH_COMMAND))).toBe(false); + expect(calls.some(call => call.startsWith(FLOW_OPEN_CHANGE_COMMAND))).toBe(false); + expect(after.at(-1)).toMatch(new RegExp(`${FLOW_NEEDS_INPUT_COMMAND.slice(0, 20).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)); + // The stop message carries the question in its parseable records. + const stop = errors.find(error => error.startsWith('Stopped: the implementer made no changes'))!; + expect(stop).toContain('No checks ran, no branch was pushed and no pull request was opened.'); + expect(stop).toContain('\nrelayflow needs-input-source: needs-input.md\nrelayflow needs-input: Which file is the "gamification README"?\nrelayflow needs-input: Did you mean docs/native-gamification-plan.md?'); + }); + + it('hands the implementer\'s own last message to the relay, bounded', async () => { + const summary = 'x'.repeat(10_000) + 'I asked about the gamification README.'; + const { calls } = await runFlow({ changed: 'unchanged', summary }); + const relayCall = calls.find(call => call.endsWith(FLOW_NEEDS_INPUT_COMMAND))!; + const value = /^agent_summary='([^']*)'; /.exec(relayCall)![1]!; + expect(value.length).toBe(FLOW_NEEDS_INPUT_LIMIT); + expect(value.endsWith('I asked about the gamification README.')).toBe(true); + }); + + it('carries on as before when there are changes, when it cannot tell, or when the implementer timed out', async () => { + for (const changed of ['changed', 'unknown', '', 'garbled']) { + const { calls, finish } = await runFlow({ changed }); + expect(ran(calls, FLOW_CHECK_RUN_COMMAND), changed).toBe(true); + expect(calls.some(call => call.startsWith(FLOW_OPEN_CHANGE_COMMAND)), changed).toBe(true); + expect(ran(calls, FLOW_NEEDS_INPUT_COMMAND), changed).toBe(false); + expect(finish, changed).toBe('needs_human'); + } + // A timed-out implementer's work is checked and published as a draft, as + // before; whether it changed anything is not asked. + const timedOut = await runFlow({ changed: 'unchanged', implementerTimedOut: true }); + expect(ran(timedOut.calls, FLOW_CHANGE_CHECK_COMMAND)).toBe(false); + expect(ran(timedOut.calls, FLOW_CHECK_RUN_COMMAND)).toBe(true); + }); + + it('uses input.checkCommand when it is non-empty, instead of discovering one', async () => { + const { calls } = await runFlow({ input: { checkCommand: ' npm run build:core && npm test ' } }); + expect(calls).not.toContain('agent:check-discovery'); + expect(calls).toContain("mkdir -p .relayflow && printf '%s\\n' 'set -e' 'npm run build:core && npm test' > .relayflow/check.sh"); + expect(calls.find(call => call.startsWith('check=pass; '))).toContain('check_setup=input; '); + for (const checkCommand of ['', ' ', undefined, 42]) { + const fallback = await runFlow({ input: { checkCommand } }); + expect(fallback.calls, String(checkCommand)).toContain('agent:check-discovery'); + expect(fallback.calls.some(call => call.startsWith("mkdir -p .relayflow && printf")), String(checkCommand)).toBe(false); + expect(fallback.calls.find(call => call.startsWith('check=pass; ')), String(checkCommand)).toContain('check_setup=discovered; '); + } + }); + + it('lets input.checkCommand override the author\'s constant, which still applies without it', async () => { + const edit = (text: string) => text.replace('const checkCommand = "";', 'const checkCommand = "make ci";'); + const constant = await runFlow({ edit }); + expect(constant.calls).toContain("mkdir -p .relayflow && printf '%s\\n' 'set -e' 'make ci' > .relayflow/check.sh"); + const both = await runFlow({ edit, input: { checkCommand: 'npm test' } }); + expect(both.calls).toContain("mkdir -p .relayflow && printf '%s\\n' 'set -e' 'npm test' > .relayflow/check.sh"); + expect(both.calls.some(call => call.includes("'make ci'"))).toBe(false); + expect(factorySource(draft)).toContain('type Input = { issue: Issue; approver: string; checkCommand?: string };'); + }); + + it('reports a repaired script to the check report by its digest before the repair', async () => { + const { calls } = await runFlow({ checks: ['fail', 'pass'] }); + const digest = calls.findIndex(call => call.startsWith('cksum < .relayflow/check.sh')); + expect(digest).toBeGreaterThan(-1); + expect(digest).toBeLessThan(calls.indexOf('agent:check-repair')); + expect(calls.filter(call => call.startsWith('cksum < '))).toHaveLength(1); + const clean = await runFlow(); + expect(clean.calls.some(call => call.startsWith('cksum < '))).toBe(false); + expect(clean.calls.find(call => call.startsWith('check=pass; '))).toContain("check_digest=''; "); + }); + + it('prints one progress line per phase change', async () => { + const { errors } = await runFlow({ checks: ['fail', 'pass'] }); + expect(errors.filter(error => error.startsWith('relayflow phase: '))).toEqual([ + 'relayflow phase: implementing', 'relayflow phase: checking', 'relayflow phase: repairing', 'relayflow phase: checking', + 'relayflow phase: publishing', 'relayflow phase: reviewing', + ]); + }); +}); diff --git a/web/public/flows/software-garden/manifest.json b/web/public/flows/software-garden/manifest.json index 27be09c1..3c72fb45 100644 --- a/web/public/flows/software-garden/manifest.json +++ b/web/public/flows/software-garden/manifest.json @@ -1,11 +1,11 @@ { "name": "Software Garden", "catalogId": "software-factory", - "version": 2, - "sha256": "516ccd18d39e7f03df20442963fe339cfc64fcd09fa3659e53d85cf6a31d60fb", - "bytes": 87545, - "path": "web/public/flows/software-garden/v2.flow.ts", - "url": "https://agentrelay.com/flows/software-garden/v2.flow.ts", + "version": 3, + "sha256": "22dcab992e015d2620d8d90836ff0fadff2349bf679182bba854ad92f304c5ce", + "bytes": 93601, + "path": "web/public/flows/software-garden/v3.flow.ts", + "url": "https://agentrelay.com/flows/software-garden/v3.flow.ts", "target": "cloud", "draft": { "version": 4, @@ -29,6 +29,10 @@ { "version": 2, "sha256": "516ccd18d39e7f03df20442963fe339cfc64fcd09fa3659e53d85cf6a31d60fb" + }, + { + "version": 3, + "sha256": "22dcab992e015d2620d8d90836ff0fadff2349bf679182bba854ad92f304c5ce" } ] } diff --git a/web/public/flows/software-garden/v3.flow.ts b/web/public/flows/software-garden/v3.flow.ts new file mode 100644 index 00000000..4cfb346a --- /dev/null +++ b/web/public/flows/software-garden/v3.flow.ts @@ -0,0 +1,410 @@ +import { flow } from "@relayflows/surface"; + +// The ticket this flow works on. +export type Issue = { + source: string; + title: string; + body: string; + labels: string[]; + repository?: string; + // Normalized by Cloud for every connected ticket provider. A local input + // may leave either field blank when that provider has no such metadata. + identifier?: string; + url?: string; +}; + +// Your coding agent, ready to work. +const builder = "claude"; + +type Input = { issue: Issue; approver: string; checkCommand?: string }; + +// Run in a connected repository, on a new branch. +export default flow("software-factory", + { budget: { wallclock: "2h" } }, async (f, input) => { + const issue = input.issue; + // Cloud starts this flow only for tickets that already match the sources + // and filters you chose, so just check the ticket arrived intact. + if (!issue?.title?.trim()) { + // Parked, not canceled: the runtime refuses done("canceled") by design, as + // a kernel fact a flow body cannot declare (AgentWorkforce/flows#436). The + // reason this wants is a declination — AgentWorkforce/flows#438, unshipped. + console.error("Stopped: no ticket arrived with this run, so there was nothing to work on."); + return f.done("needs_human"); + } + + const normalizedTitle = issue.title.trim().replace(/\s+/g, " "); + // Bound and measure by Unicode code points so neither truncation nor the + // final shell validation can split or byte-count a multibyte character. + const changeTitle = Array.from(normalizedTitle).slice(0, 240).join("").trim(); + const changeTitleLength = Array.from(changeTitle).length; + const placeholderTitle = ["software factory change", "replace with your ticket title"] + .includes(changeTitle.toLowerCase()); + const issueSource = issue.source.trim().toLowerCase(); + const issueIdentifier = issue.identifier?.trim() ?? ""; + const issueUrl = issue.url?.trim() ?? ""; + if (!changeTitle || placeholderTitle) { + console.error("Stopped: the pull-request title is empty or still a placeholder. No branch was pushed and no pull request was opened."); + return f.done("needs_human"); + } + if (issueSource === "github" && !/^#[1-9]\d*$/.test(issueIdentifier)) { + console.error("Stopped: a GitHub ticket must carry its normalized identifier in # form. No branch was pushed and no pull request was opened."); + return f.done("needs_human"); + } + // GitHub receives its exact closing keyword. Other providers get a stable + // native reference when one is available; Markdown invents no identifier. + const changeReference = issueSource === "github" + ? "Fixes " + issueIdentifier + : issueSource === "gitlab" && /^#[1-9]\d*$/.test(issueIdentifier) + ? "Closes " + issueIdentifier + : issueUrl + ? "Ticket: " + issueUrl + : issueIdentifier + ? "Ticket: " + issueIdentifier + : ""; + const task = issue.title + "\n" + issue.body + "\n" + + "Follow existing patterns. Keep changes focused and add regression tests." + "\n" + + "Changes under .github/workflows/ may not be pushable by this run's token, so do not edit workflow files unless the task requires it; if you do, keep those edits in separate commits."; + // Files the agents write for each other (summary.md, reviews, and + // .relayflow/) are never part of the change; keep them out of every commit. + await f.run("x=$(git rev-parse --git-path info/exclude 2>/dev/null); if [ -n \"$x\" ]; then mkdir -p \"$(dirname \"$x\")\" 2>/dev/null; if ! grep -qxF '# relayflow working files' \"$x\" 2>/dev/null; then printf '%s\\n' '' '# relayflow working files' '/summary.md' '/plan.md' '/reviewed-plan.md' '/review.md' '/review.clean' '/review-blocked.md' '/comparison.md' '/prototype-notes.md' '/.relayflow/' '/START-HERE.txt' '/flow-input.json' '/relay-preflight.mjs' '/software-factory.flow.mts' '/.relayflowd/' >> \"$x\"; fi; echo \"relayflow: working files are excluded from commits.\" >&2; else echo \"relayflow: not a git repository, so nothing was excluded.\" >&2; fi; echo done"); + // 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 each long step below + // gets only what is left once the steps behind it, 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 () => 112 - (await clock() - startedAt) / 60 - parallelMinutes; + // The most a long step may take, in whole minutes, when `after` minutes + // must still fit behind it: at most `cap`, and below 1 once nothing is left. + const allowance = async (cap: number, after: number) => Math.min(cap, Math.floor(await minutesLeft() - after)); + // Publishing, and the steps between a long step and it. + const publishing = 26; + // What the steps up to the implementer leave: on Cloud, everything after + // minute 34, so the build takes no longer than it did in 60 minutes. + const building = 78; + // A long agent step may be stopped at its time limit. Its step then resolves + // with completionReason "timeout" instead of throwing, and what the agent + // committed stays. That is never success: each caller says what it means. + const timedOut = (result: unknown) => (result as { completionReason?: string } | undefined)?.completionReason === "timeout"; + + // Work out how this repository checks itself, before any code changes, so + // the change and the commit it started from are checked the same way. + // To use your own command instead, set checkCommand, for example + // "npm run build && npm test" or "make ci". A checkCommand in the run's + // input (the flow's test command in Cloud) takes precedence over it. + const checkCommand = ""; + const inputCheckCommand = typeof input.checkCommand === "string" ? input.checkCommand.trim() : ""; + const chosenCheckCommand = inputCheckCommand || checkCommand.trim(); + const shellQuote = (value: string) => "'" + value.replace(/'/g, "'\\''") + "'"; + const checkScript = ".relayflow/check.sh"; + // Where the check script came from, so the pull request can say when one + // this run set up is worth keeping: "input", "repository" (already in the + // tree), "discovered", or "default" (written below from the project files). + let checkSetup = "repository"; + if (chosenCheckCommand) { + checkSetup = "input"; + await f.run("mkdir -p .relayflow && printf '%s\\n' 'set -e' " + shellQuote(chosenCheckCommand) + " > " + checkScript); + } else if ((await f.run("test -s " + checkScript + " && echo yes || echo no")).trim() !== "yes") { + checkSetup = "discovered"; + // No command of yours and none committed to the repository: read how its + // CI tests it. Anything this cannot settle falls back to a default below. + const discovery = await f.agent("check-discovery", { + cli: builder, + model: "claude-sonnet-5", + task: task + "\n" + "This is a setup step: do not start on the ticket. Work out how this repository checks itself on a fresh machine, the way its CI does. Read the CI configuration (.github/workflows, .gitlab-ci.yml, .circleci and similar), any Makefile, justfile or Taskfile, and AGENTS.md, CLAUDE.md, CONTRIBUTING and README. Write .relayflow/check.sh: a POSIX sh script starting with set -e that installs dependencies, runs whatever CI runs before its tests (builds, code generation), then runs the tests. Leave out steps that need secrets, deployments or services this machine does not have, with a comment saying why. Do not run the full test suite, do not change any other file, and do not commit. If the repository has no tests, do not create the file.", + timeout: "15m", + }); + if (timedOut(discovery)) { + // A script it was stopped while writing is not a recipe: discard it, + // so the ecosystem default below is used instead. + console.error("check-discovery was stopped at its 15m limit. Any check script it left is discarded, and the ecosystem default is used."); + await f.run("rm -f " + checkScript); + } + } + const resolveChecks = "mkdir -p .relayflow; has_test_target() { for f in GNUmakefile makefile Makefile; do if [ -f \"$f\" ] && grep -Eq '^test[[:space:]]*:' \"$f\"; then return 0; fi; done; return 1; }; eco=; cmd=; if [ -s .relayflow/check.sh ]; then eco=script; elif has_test_target; then eco=make; cmd='make test'; elif [ -f justfile ] && grep -Eq '^test([[:space:]]|:)' justfile; then eco=just; cmd='just test'; elif [ -f package.json ] && node -e 'const f=require(\"fs\");let p;try{p=JSON.parse(f.readFileSync(\"package.json\",\"utf8\"))}catch{process.exit(1)}const t=(p.scripts||{}).test;process.exit(t&&String(t).trim()?0:1)' >/dev/null 2>&1; then eco=node; elif [ -f Cargo.toml ]; then eco=cargo; cmd='cargo test'; elif [ -f go.mod ]; then eco=go; cmd='go test ./...'; elif [ -f pyproject.toml ] || [ -f setup.py ] || [ -f setup.cfg ] || [ -f requirements.txt ] || [ -f pytest.ini ] || [ -f tox.ini ]; then eco=python; if [ -f uv.lock ]; then cmd='uv run pytest'; elif [ -f poetry.lock ]; then cmd='poetry install --no-interaction && poetry run pytest'; elif [ -f requirements.txt ]; then cmd='python3 -m pip install -r requirements.txt && python3 -m pytest'; else cmd='python3 -m pytest'; fi; elif [ -f Gemfile ]; then eco=ruby; if [ -d spec ]; then cmd='bundle install && bundle exec rspec'; else cmd='bundle install && bundle exec rake test'; fi; elif [ -f pom.xml ]; then eco=maven; if [ -x mvnw ]; then cmd='./mvnw -B test'; else cmd='mvn -B test'; fi; elif [ -f build.gradle ] || [ -f build.gradle.kts ]; then eco=gradle; if [ -x gradlew ]; then cmd='./gradlew test'; else cmd='gradle test'; fi; elif [ -n \"$(find . -maxdepth 1 \\( -name '*.sln' -o -name '*.csproj' -o -name '*.fsproj' \\) -print 2>/dev/null | head -n 1)\" ]; then eco=dotnet; cmd='dotnet test'; elif [ -f mix.exs ]; then eco=elixir; cmd='mix deps.get && mix test'; fi; if [ \"$eco\" = script ]; then echo \"relayflow: checking with the repository's own .relayflow/check.sh.\" >&2; echo script; elif [ -z \"$eco\" ]; then echo \"relayflow: found no way to run this repository's tests: no check command, no test target and no recognised project file.\" >&2; echo none; elif [ \"$eco\" = node ]; then printf '%s\\n' '# Written by relayflow from the files in this repository. Edit it to match how CI runs the tests.' 'set -e' 'if [ -f pnpm-lock.yaml ]; then pm=pnpm; elif [ -f yarn.lock ]; then pm=yarn; elif [ -f bun.lock ] || [ -f bun.lockb ]; then pm=bun; else pm=npm; fi' 'if [ \"$pm\" = pnpm ] || [ \"$pm\" = yarn ]; then if ! command -v \"$pm\" >/dev/null 2>&1; then mkdir -p \"$HOME/.local/bin\" && { corepack enable --install-directory \"$HOME/.local/bin\" \"$pm\" >/dev/null 2>&1 || true; } && PATH=\"$HOME/.local/bin:$PATH\" && export PATH; fi; COREPACK_ENABLE_DOWNLOAD_PROMPT=0 && export COREPACK_ENABLE_DOWNLOAD_PROMPT; fi' 'if ! command -v \"$pm\" >/dev/null 2>&1; then echo \"relayflow: this repository'\\''s lockfile requires $pm, which is not installed and could not be provisioned.\" >&2; exit 1; fi' 'echo \"relayflow: installing dependencies and running tests with $pm\"' 'if [ \"$pm\" = pnpm ]; then pnpm install --frozen-lockfile && pnpm test; elif [ \"$pm\" = yarn ]; then { if [ -f .yarnrc.yml ]; then yarn install --immutable; else yarn install --frozen-lockfile; fi; } && yarn test; elif [ \"$pm\" = bun ]; then bun install --frozen-lockfile && bun run test; else { if [ -f package-lock.json ]; then npm ci; else npm install; fi; } && npm test; fi' > .relayflow/check.sh; echo \"relayflow: wrote the Node default to .relayflow/check.sh.\" >&2; echo default; else printf '%s\\n' '# Written by relayflow from the files in this repository. Edit it to match how CI runs the tests.' 'set -e' \"$cmd\" > .relayflow/check.sh; echo \"relayflow: wrote the $eco default ($cmd) to .relayflow/check.sh.\" >&2; echo default; fi"; + const checkPlan = (await f.run(resolveChecks)).trim(); + if (checkPlan === "default") checkSetup = "default"; + // Removes build outputs the run no longer needs before a check, so a second + // build cannot fill the sandbox's disk. + const freeDisk = "relayflow_build_dirs() { ( cd \"$1\" 2>/dev/null && find . -maxdepth 3 \\( -name .git -o -name node_modules \\) -prune -o -type d -exec test -f {}/CACHEDIR.TAG \\; -print -prune 2>/dev/null | while IFS= read -r d; do if git check-ignore -q -- \"$d/\" 2>/dev/null; then printf '%s\\n' \"${d#./}\"; fi; done ); }; relayflow_drop_builds() { relayflow_build_dirs \"$1\" | while IFS= read -r d; do rm -rf -- \"$1/$d\" && echo \"relayflow: removed $1/$d\" >&2; done; }; for t in ${dirs:-}; do if [ -d \"$t\" ]; then relayflow_drop_builds \"$t\"; fi; done; git worktree prune >/dev/null 2>&1; min=\"${RELAYFLOW_MIN_FREE_MB:-4096}\"; avail=$(df -Pk . 2>/dev/null | awk 'NR == 2 { print $4 }'); if printf '%s\\n' \"$avail\" | grep -Eq '^[0-9]+$' && [ \"$avail\" -lt $((min * 1024)) ]; then echo \"relayflow: low on disk ($((avail / 1024)) MB free), so this tree's build directories are removed; the next check rebuilds.\" >&2; relayflow_drop_builds \"$PWD\"; fi; echo done"; + + // Build and test the change, then open a pull request. + // Where this branch started, so the publish step below can tell whether the + // agents actually committed anything. + const baseCommit = (await f.run("git rev-parse HEAD")).trim(); + // The implementer may use what is left once what follows the build fits. + // Stopped there, its committed work is still checked, and published as a + // draft that says so. + const implementerLimit = Math.max(1, await allowance(60, building)); + console.error("relayflow phase: implementing"); + const implementation = await f.agent("implementer", { + cli: builder, + model: "claude-sonnet-5", + task: task + "\n" + "Start by reading the ticket and the code it touches, and plan the change before you write it. Implement on the current branch. Add regression tests. Commit changes. Write a PR summary to summary.md." + "\n" + "If the ticket is ambiguous, or you cannot identify what it refers to (a file, document, feature or component you cannot find), do not guess and do not commit. Instead write .relayflow/needs-input.md: the specific question or questions the ticket's author must answer, and the candidates you found, each with its path (for example \"Did you mean docs/native-gamification-plan.md?\"). Keep it short and plain; it is posted back to the ticket.", + timeout: implementerLimit + "m", + }); + const implementerTimedOut = timedOut(implementation); + if (implementerTimedOut) console.error("implementer was stopped at its " + implementerLimit + "m limit. Its committed work is kept, and the pull request opens as a draft."); + // An implementer that finished without changing anything (it could not tell + // what the ticket meant, or found nothing to do) leaves nothing to check, + // repair or compare: stop now and relay its question (run 91c1a5cd spent 17 + // minutes on an unchanged branch). Only a definite "unchanged" stops here. + if (!implementerTimedOut && (await f.run("base=" + baseCommit + "; " + "paths='summary.md plan.md reviewed-plan.md review.md review.clean review-blocked.md comparison.md prototype-notes.md .relayflow START-HERE.txt flow-input.json relay-preflight.mjs software-factory.flow.mts'; if [ -z \"$base\" ] || ! git rev-parse --verify --quiet \"$base^{commit}\" >/dev/null 2>&1 || ! names=$(git diff --name-only \"$base\" HEAD -- 2>/dev/null) || ! pending=$(git status --porcelain 2>/dev/null); then echo \"relayflow: could not tell whether the implementer changed anything; carrying on.\" >&2; echo unknown; exit 0; fi; committed=$(printf '%s\\n' \"$names\" | while IFS= read -r p; do [ -n \"$p\" ] || continue; keep=yes; for w in $paths; do case \"$p\" in (\"$w\"|\"$w\"/*) git cat-file -e \"$base:$p\" 2>/dev/null || keep=no ;; esac; done; if [ \"$keep\" = yes ]; then printf '%s\\n' \"$p\"; fi; done); if [ -n \"$committed\" ] || [ -n \"$pending\" ]; then echo changed; else echo \"relayflow: the implementer made no commits and left no changes.\" >&2; echo unchanged; fi")).trim() === "unchanged") { + // Its own last message stands in when it wrote no .relayflow/needs-input.md. + const said = (implementation as unknown as { summary?: unknown } | undefined)?.summary; + const agentSummary = typeof said === "string" ? said.slice(-4000) : ""; + const needsInput = (await f.run("agent_summary=" + shellQuote(agentSummary) + "; " + "export LC_ALL=C; mkdir -p .relayflow; out=.relayflow/needs-input.out; relayflow_clean() { sed \"s/$(printf '\\033')\\[[0-9;?]*[A-Za-z]//g\" | tr -d '\\000-\\010\\013-\\037\\177' | tr '\\t' ' ' | awk 'NF { sub(/^ +/, \"\"); sub(/ +$/, \"\"); print }'; }; src=none; if [ -s .relayflow/needs-input.md ] && [ -n \"$(relayflow_clean < .relayflow/needs-input.md)\" ]; then src=needs-input.md; relayflow_clean < .relayflow/needs-input.md > \"$out\"; elif [ -n \"$(printf '%s\\n' \"${agent_summary:-}\" | relayflow_clean)\" ]; then src=implementer-summary; printf '%s\\n' \"$agent_summary\" | relayflow_clean > \"$out\"; else : > \"$out\"; fi; echo \"relayflow needs-input-source: $src\"; total=$(wc -c < \"$out\" | tr -d \" \"); if [ \"$total\" -gt 4000 ]; then head -c 4000 \"$out\" > \"$out.cut\"; if [ \"$(tail -c 1 \"$out.cut\" | wc -l | tr -d \" \")\" -eq 1 ]; then cp \"$out.cut\" \"$out.kept\"; elif [ \"$(tr -cd \"\\n\" < \"$out.cut\" | wc -c | tr -d \" \")\" -gt 0 ]; then sed '$d' \"$out.cut\" > \"$out.kept\"; else sed \"s/$(printf '[\\300-\\377]')$(printf '[\\200-\\277]')*\\$//\" \"$out.cut\" | awk 'NF { print }' > \"$out.kept\"; fi; mv -f \"$out.kept\" \"$out\"; rm -f \"$out.cut\"; fi; head -n 60 \"$out\" > \"$out.kept\"; mv -f \"$out.kept\" \"$out\"; kept=$(wc -c < \"$out\" | tr -d \" \"); sed \"s/^/relayflow needs-input: /\" \"$out\"; if [ \"$kept\" -lt \"$total\" ]; then echo \"relayflow needs-input-truncated: $kept of $total bytes\"; fi; rm -f \"$out\"; exit 0")).trim(); + console.error("Stopped: the implementer made no changes, so there was nothing to check or publish. No checks ran, no branch was pushed and no pull request was opened. Its question or reason follows, when it gave one: answer it on the ticket and run again.\n" + needsInput); + return f.done("needs_human"); + } + // A repository with no way to test itself may have gained one in this + // change (a first package.json with a test script). Resolving only before + // the change reported "no checks ran" on a pull request that added tests + // (cloud-e2e-sandbox#31), so a "none" is looked at again. + if (checkPlan === "none") await f.run(resolveChecks); + + // Run the checks. A failure is not the end of the run: the tests are + // how this flow learns what is wrong, so the repair agent reads the output + // and fixes what it can, at most twice, and whatever still fails is compared against + // the commit this branch started from when there is time. Every check step + // prints one word and exits 0; the full output stays in .relayflow/ for the + // report. Each branch check gets up to checkTotal minutes, what is left once + // publishing and the review's floor fit, and stops there. + const verdict = (value: string) => ["pass", "fail", "timeout", "none", "unrunnable"].includes(value.trim()) ? value.trim() : "fail"; + const verdictOf = (value: string) => /^[a-z]*$/.test(value) ? value : "unknown"; + const broken = (value: string) => value === "fail" || value === "timeout"; + const runChecks = "root=\"$PWD\"; script=\"${check_script:-$root/.relayflow/check.sh}\"; check_dir=\"${check_dir:-$root}\"; check_out=\"${check_out:-$root/.relayflow/check.log}\"; mkdir -p \"$(dirname \"$check_out\")\"; rm -f \"$check_out.exit\" \"$check_out.elapsed\" \"$check_out.limit\"; if [ ! -s \"$script\" ]; then echo \"relayflow: there is no check script, so no checks ran.\" > \"$check_out\"; echo \"relayflow: there is no check script, so no checks ran.\" >&2; echo none; else limit=\"${RELAYFLOW_CHECK_TIMEOUT:-840}\"; limiter=; if command -v timeout >/dev/null 2>&1; then limiter=timeout; elif command -v gtimeout >/dev/null 2>&1; then limiter=gtimeout; elif command -v perl >/dev/null 2>&1; then limiter=perl; fi; relayflow_limiter='my $l = shift; my $p = fork; defined $p or exit 127; if (!$p) { setpgrp(0, 0); exec @ARGV or exit 127 } $SIG{ALRM} = sub { kill '\\''TERM'\\'', -$p; sleep 2; kill '\\''KILL'\\'', -$p; exit 124 }; alarm $l; waitpid($p, 0); exit($? & 127 ? 128 + ($? & 127) : $? >> 8)'; grouper=; if command -v setsid >/dev/null 2>&1; then grouper=setsid; elif command -v perl >/dev/null 2>&1; then grouper=perl; fi; relayflow_inline=; relayflow_check_done() { printf '%s\\n' \"$1\" > \"$check_out.exit\"; printf '%s\\n' \"$2\" > \"$check_out.elapsed\"; printf '%s\\n' \"$limit\" > \"$check_out.limit\"; tail -n 40 \"$check_out\" >&2; if [ \"$1\" -eq 0 ]; then echo \"relayflow: the checks passed.\" >&2; echo pass; elif [ \"$1\" -eq 124 ] && { { [ \"$relayflow_inline\" = yes ] && [ -n \"$limiter\" ]; } || [ \"${3:-}\" = stopped ]; }; then echo \"relayflow: the checks did not finish within ${limit}s (they ran $2s).\" >&2; echo timeout; else echo \"relayflow: the checks failed with exit $1; the full output is in $check_out.\" >&2; echo fail; fi; }; if [ -z \"$limiter\" ]; then relayflow_unrunnable=\"relayflow: this machine has no timeout, gtimeout or perl, so there is no way to stop the checks at a time limit and they were not run. Install coreutils (timeout) or perl, then run again.\"; printf '%s\\n' \"$relayflow_unrunnable\" > \"$check_out\"; echo \"$relayflow_unrunnable\" >&2; echo unrunnable; elif [ -z \"${RELAYFLOW_CHECK_WAIT:-}\" ] || [ -z \"$grouper\" ]; then relayflow_inline=yes; if [ -n \"${RELAYFLOW_CHECK_WAIT:-}\" ]; then if [ \"$RELAYFLOW_CHECK_WAIT\" -lt \"$limit\" ]; then limit=\"$RELAYFLOW_CHECK_WAIT\"; fi; echo \"relayflow: neither setsid nor perl is installed, so the checks cannot be detached; they run in this step under $limiter, limited to ${limit}s.\" >&2; fi; echo \"relayflow: running $script in $check_dir\" >&2; since=$(date +%s); ( cd \"$check_dir\" || { printf \"%s\\n\" 126 > \"$check_out.status\"; exit 0; }; unset GIT_CONFIG_COUNT GIT_CONFIG_GLOBAL GIT_CONFIG_NOSYSTEM; case \"$limiter\" in (timeout|gtimeout) \"$limiter\" \"$limit\" sh \"$script\" ;; (perl) perl -e \"$relayflow_limiter\" \"$limit\" sh \"$script\" ;; (*) sh \"$script\" ;; esac > \"$check_out\" 2>&1 < /dev/null; printf \"%s\\n\" \"$?\" > \"$check_out.status.tmp\"; mv -f \"$check_out.status.tmp\" \"$check_out.status\" ); relayflow_check_done \"$(cat \"$check_out.status\" 2>/dev/null || echo 125)\" \"$(( $(date +%s) - since ))\"; rm -f \"$check_out.status\"; else id=\"${RELAYFLOW_CHECK_ID:-1}\"; relayflow_group() { cat \"$check_out.group\" 2>/dev/null || cat \"$check_out.pid\" 2>/dev/null; }; relayflow_alive() { g=$(relayflow_group); [ -n \"$g\" ] && { kill -0 \"-$g\" 2>/dev/null || kill -0 \"$g\" 2>/dev/null; }; }; relayflow_stop() { g=$(relayflow_group); [ -n \"$g\" ] || return 0; kill -TERM \"-$g\" 2>/dev/null || kill -TERM \"$g\" 2>/dev/null; i=0; while relayflow_alive && [ \"$i\" -lt 5 ]; do sleep 1; i=$((i + 1)); done; kill -KILL \"-$g\" 2>/dev/null || kill -KILL \"$g\" 2>/dev/null; return 0; }; relayflow_status_done() { if [ -f \"$check_out.stopped\" ]; then relayflow_check_done \"$(cat \"$check_out.status\")\" \"$1\" stopped; else relayflow_check_done \"$(cat \"$check_out.status\")\" \"$1\"; fi; }; if [ \"$(cat \"$check_out.id\" 2>/dev/null)\" != \"$id\" ]; then if [ ! -f \"$check_out.status\" ] && relayflow_alive; then if ! command -v ps >/dev/null 2>&1; then relayflow_stop; echo \"relayflow: stopped what an earlier attempt left running (no ps here to identify it).\" >&2; elif ps -o args= -p \"$(relayflow_group)\" 2>/dev/null | grep -qF 'check_out.group'; then relayflow_stop; echo \"relayflow: stopped a check an earlier attempt left running.\" >&2; fi; fi; rm -f \"$check_out.status\" \"$check_out.status.tmp\" \"$check_out.stopped\" \"$check_out.group\" \"$check_out.pid\"; : > \"$check_out\"; date +%s > \"$check_out.since\"; printf '%s\\n' \"$id\" > \"$check_out.id\"; export check_dir check_out script limit; if [ \"$grouper\" = setsid ]; then setsid sh -c 'printf \"%s\\n\" \"$$\" > \"$check_out.group\"; cd \"$check_dir\" || { printf \"%s\\n\" 126 > \"$check_out.status\"; exit 0; }; unset GIT_CONFIG_COUNT GIT_CONFIG_GLOBAL GIT_CONFIG_NOSYSTEM; sh \"$script\" > \"$check_out\" 2>&1 < /dev/null & c=$!; ( s=$(date +%s); while kill -0 \"$c\" 2>/dev/null; do if [ $(( $(date +%s) - s )) -gt $((limit + 60)) ]; then : > \"$check_out.stopped\"; printf \"%s\\n\" 124 > \"$check_out.status.tmp\"; mv -f \"$check_out.status.tmp\" \"$check_out.status\"; kill -KILL \"-$$\" 2>/dev/null || kill -KILL \"$c\" 2>/dev/null; fi; sleep 5; done ) > /dev/null 2>&1 & wait \"$c\"; st=$?; if [ ! -f \"$check_out.stopped\" ]; then printf \"%s\\n\" \"$st\" > \"$check_out.status.tmp\"; mv -f \"$check_out.status.tmp\" \"$check_out.status\"; fi' > /dev/null 2>&1 < /dev/null & else perl -e '$SIG{HUP} = \"IGNORE\"; setpgrp(0, 0); exec @ARGV or exit 127' sh -c 'printf \"%s\\n\" \"$$\" > \"$check_out.group\"; cd \"$check_dir\" || { printf \"%s\\n\" 126 > \"$check_out.status\"; exit 0; }; unset GIT_CONFIG_COUNT GIT_CONFIG_GLOBAL GIT_CONFIG_NOSYSTEM; sh \"$script\" > \"$check_out\" 2>&1 < /dev/null & c=$!; ( s=$(date +%s); while kill -0 \"$c\" 2>/dev/null; do if [ $(( $(date +%s) - s )) -gt $((limit + 60)) ]; then : > \"$check_out.stopped\"; printf \"%s\\n\" 124 > \"$check_out.status.tmp\"; mv -f \"$check_out.status.tmp\" \"$check_out.status\"; kill -KILL \"-$$\" 2>/dev/null || kill -KILL \"$c\" 2>/dev/null; fi; sleep 5; done ) > /dev/null 2>&1 & wait \"$c\"; st=$?; if [ ! -f \"$check_out.stopped\" ]; then printf \"%s\\n\" \"$st\" > \"$check_out.status.tmp\"; mv -f \"$check_out.status.tmp\" \"$check_out.status\"; fi' > /dev/null 2>&1 < /dev/null & fi; echo \"$!\" > \"$check_out.pid\"; echo \"relayflow: started $script in $check_dir; it may run ${limit}s in all, and each step waits up to ${RELAYFLOW_CHECK_WAIT}s for it.\" >&2; fi; since=$(cat \"$check_out.since\" 2>/dev/null || date +%s); waited=0; while [ ! -f \"$check_out.status\" ] && [ \"$waited\" -lt \"$RELAYFLOW_CHECK_WAIT\" ] && relayflow_alive && [ $(( $(date +%s) - since )) -lt \"$limit\" ]; do sleep 1; waited=$((waited + 1)); done; elapsed=$(( $(date +%s) - since )); if [ -f \"$check_out.status\" ]; then relayflow_status_done \"$elapsed\"; elif relayflow_alive && [ \"$elapsed\" -lt \"$limit\" ]; then echo \"relayflow: the checks are still running (${elapsed}s of ${limit}s).\" >&2; echo running; elif relayflow_alive; then : > \"$check_out.stopped\"; printf '%s\\n' 124 > \"$check_out.status.tmp\"; mv -f \"$check_out.status.tmp\" \"$check_out.status\"; relayflow_stop; echo \"relayflow: stopped the checks after ${elapsed}s, at their ${limit}s.\" >> \"$check_out\"; relayflow_check_done 124 \"$elapsed\" stopped; else sleep 1; if [ -f \"$check_out.status\" ]; then relayflow_status_done \"$elapsed\"; else echo \"relayflow: the checks stopped without reporting an exit status.\" >> \"$check_out\"; relayflow_check_done 125 \"$elapsed\"; fi; fi; fi; fi"; + // How long the last check took. A re-check runs in the same tree and reuses + // its build, and the base commit's check runs the same recipe, so each is + // budgeted like it. + let checkTook = 0; + // The f.run lease is longer than the check's own limit, so the check stops + // itself and reports "timeout" first. + const timedCheck = async (prefix: string, command: string, minutes: number, lease = minutes + 1) => { + const from = await clock(); + const result = (await f.run(prefix + "RELAYFLOW_CHECK_TIMEOUT=" + minutes * 60 + "; " + command, { timeout: lease + "m" })).trim(); + checkTook = (await clock() - from) / 60; + return result; + }; + // The most a branch check may run in all. To give a slow suite more, raise + // it; the run's time budget still decides what each check gets. + const checkTotal = 30; + // A branch check may need more than one f.run lease (flows#626), so it runs + // detached and each step waits at most 14 minutes for it, until it + // finishes or its total runs out (reported as "timeout"). Each check has its + // own ID, so a resumed run waits for the check it started. + let checkRuns = 0; + const spannedCheck = async (minutes: number) => { + const from = await clock(); + const id = startedAt + "-" + (++checkRuns); + let result = "running"; + for (let waited = 0; result === "running" && waited <= minutes; ) { + const wait = Math.min(14, minutes + 1 - waited); + waited += wait; + result = (await f.run("RELAYFLOW_CHECK_ID=" + id + "; RELAYFLOW_CHECK_TIMEOUT=" + minutes * 60 + "; RELAYFLOW_CHECK_WAIT=" + wait * 60 + "; " + runChecks, { timeout: wait + 1 + "m" })).trim(); + } + checkTook = (await clock() - from) / 60; + return result === "running" ? "timeout" : result; + }; + // Behind each check: the two clock reads around it and each wait's minute + // of lease beyond it, then publishing, and the review's floor where there + // is one (its whole allowance locally, where nothing stops it sooner). + const checkSlack = 4; + const reviewing = 10.5; + let check = "skipped"; + const checkLimit = await allowance(checkTotal, checkSlack + publishing + reviewing); + if (checkLimit < 5) { + console.error("Skipped the checks: too little of the flow's time budget is left to run them and still publish. The pull request opens as a draft that says no checks ran."); + } else { + console.error("relayflow phase: checking"); + check = verdict(await spannedCheck(checkLimit)); + // No way to bound the suite, so it was never started: not a verdict on + // this change, and nothing to repair or compare. The draft says so. + if (check === "unrunnable") console.error("The checks could not be run: this machine has no timeout, gtimeout or perl to stop them at a time limit, so starting them could have ended the run with no verdict. The pull request opens as a draft that says so."); + } + // A second repair round runs only for checks that still fail after the + // first, and leaves the review, where there is one, its floor: it is + // recovery, never a longer plan. + // The base-commit check that may follow, as compareWithBase budgets it: + // nothing when it would be skipped anyway. + let checkDigest = ""; + const baseReserve = () => checkPlan === "none" || Math.ceil(checkTook) > 13 ? 0 : Math.max(5, Math.ceil(checkTook)) + 5; + for (let round = 1; round <= 2 && broken(check); round++) { + // Each round: the repair, freeing disk, the re-check, publishing and the + // review must fit in what is left; a second round also keeps the base + // check's time, and its re-check is held to what was kept for it. + const recheck = Math.min(checkTotal, Math.ceil(checkTook) + 1); + const baseHold = round > 1 ? baseReserve() : 0; + const repairLimit = await allowance(20, recheck + 1 + checkSlack + publishing + reviewing + baseHold); + if (repairLimit < 5) { + console.error(round === 1 + ? "Skipped the repair: too little of the flow's time budget is left to repair and still publish. The checks stand as they are." + : "Skipped the second repair: too little of the flow's time budget is left to repair again and still publish and review. The checks stand as they are."); + break; + } + // The script as it was before any repair, so the report can tell when a + // repair changed it. + if (round === 1) checkDigest = (await f.run("cksum < " + checkScript + " 2>/dev/null || true")).trim(); + console.error("relayflow phase: repairing"); + const repair = await f.agent("check-repair" + (round === 1 ? "" : "-" + round), { + cli: builder, + model: "claude-sonnet-5", + task: task + "\n" + "The repository's checks failed on this branch. The command that ran is .relayflow/check.sh and its full output is in .relayflow/check.log. For each failure, work out whether it comes from missing setup (a build, code generation or install step the tests expect, often named in the error) or from a bug in the change on this branch. Fix missing setup by adding the step to .relayflow/check.sh the way the repository's CI does it; do not commit that file. Fix bugs in the change and commit the fix. Never skip, delete or weaken a test, and never change a test only to make it pass. If a failure is outside your control, such as a tool that is not installed, no network, or missing credentials, leave it and write what you found to .relayflow/repair-notes.md.", + timeout: repairLimit + "m", + }); + await f.run(freeDisk); + const recheckLimit = await allowance(checkTotal, checkSlack + publishing + reviewing + baseHold); + if (recheckLimit >= 1) { + console.error("relayflow phase: checking"); + check = verdict(await spannedCheck(recheckLimit)); + } + else console.error("No time was left to check the repair again; the checks stand as they were before it."); + if (timedOut(repair)) { + // A failed attempt: what it committed was checked again above, and + // another repair of the same failures would most likely run out too, + // so whatever still fails takes the draft-and-report path below. + console.error("check-repair was stopped at its " + repairLimit + "m limit, so it counts as a failed repair attempt. Its committed work is kept and was checked again (" + check + "); no further repair is tried."); + break; + } + } + // Only a failure pays for the comparison: pass, fail, timeout or unknown. + // Checks this change introduced (nothing to run before it) have no base to + // compare with: the base lacks the files they need and would always fail, + // which read as "pre-existing". Their failure is this change's own. With too + // little time to check the base commit as long as the branch took and still + // publish, the report says the base was not checked, and the draft opens. + const compareWithBase = async () => { + if (!broken(check)) return ""; + if (checkPlan === "none") return "new"; + // Checking out the base commit and returning to the branch share the + // check's lease, so it gets two minutes beyond the limit, within f.run's + // 15. It never gets less time than the branch's check took: a branch check + // that ran to its own limit cannot be compared at all. + const baseLimit = await allowance(13, 4 + publishing + reviewing); + if (baseLimit < Math.max(5, Math.ceil(checkTook))) return "skipped"; + await f.run(freeDisk); + const result = await timedCheck("base=" + baseCommit + "; ", "root=\"$PWD\"; tmp=; relayflow_build_dirs() { ( cd \"$1\" 2>/dev/null && find . -maxdepth 3 \\( -name .git -o -name node_modules \\) -prune -o -type d -exec test -f {}/CACHEDIR.TAG \\; -print -prune 2>/dev/null | while IFS= read -r d; do if git check-ignore -q -- \"$d/\" 2>/dev/null; then printf '%s\\n' \"${d#./}\"; fi; done ); }; if [ -z \"$base\" ] || ! git rev-parse --verify --quiet \"$base^{commit}\" >/dev/null 2>&1; then echo \"relayflow: could not check out the base commit to compare against.\" >&2; echo unknown; elif git diff --quiet HEAD -- >/dev/null 2>&1 && git diff --cached --quiet >/dev/null 2>&1 && head=$(git rev-parse HEAD) && orig=$(git symbolic-ref -q --short HEAD || git rev-parse HEAD) && recipe=$(mktemp \"${TMPDIR:-/tmp}/relayflow-recipe.XXXXXX\") && { [ ! -f \"$root/.relayflow/check.sh\" ] || cp \"$root/.relayflow/check.sh\" \"$recipe\"; } && git checkout -q --detach \"$base\" >/dev/null 2>&1; then check_dir=\"$root\"; check_out=\"$root/.relayflow/base-check.log\"; check_script=\"$recipe\"; token=$( root=\"$PWD\"; script=\"${check_script:-$root/.relayflow/check.sh}\"; check_dir=\"${check_dir:-$root}\"; check_out=\"${check_out:-$root/.relayflow/check.log}\"; mkdir -p \"$(dirname \"$check_out\")\"; rm -f \"$check_out.exit\" \"$check_out.elapsed\" \"$check_out.limit\"; if [ ! -s \"$script\" ]; then echo \"relayflow: there is no check script, so no checks ran.\" > \"$check_out\"; echo \"relayflow: there is no check script, so no checks ran.\" >&2; echo none; else limit=\"${RELAYFLOW_CHECK_TIMEOUT:-840}\"; limiter=; if command -v timeout >/dev/null 2>&1; then limiter=timeout; elif command -v gtimeout >/dev/null 2>&1; then limiter=gtimeout; elif command -v perl >/dev/null 2>&1; then limiter=perl; fi; relayflow_limiter='my $l = shift; my $p = fork; defined $p or exit 127; if (!$p) { setpgrp(0, 0); exec @ARGV or exit 127 } $SIG{ALRM} = sub { kill '\\''TERM'\\'', -$p; sleep 2; kill '\\''KILL'\\'', -$p; exit 124 }; alarm $l; waitpid($p, 0); exit($? & 127 ? 128 + ($? & 127) : $? >> 8)'; grouper=; if command -v setsid >/dev/null 2>&1; then grouper=setsid; elif command -v perl >/dev/null 2>&1; then grouper=perl; fi; relayflow_inline=; relayflow_check_done() { printf '%s\\n' \"$1\" > \"$check_out.exit\"; printf '%s\\n' \"$2\" > \"$check_out.elapsed\"; printf '%s\\n' \"$limit\" > \"$check_out.limit\"; tail -n 40 \"$check_out\" >&2; if [ \"$1\" -eq 0 ]; then echo \"relayflow: the checks passed.\" >&2; echo pass; elif [ \"$1\" -eq 124 ] && { { [ \"$relayflow_inline\" = yes ] && [ -n \"$limiter\" ]; } || [ \"${3:-}\" = stopped ]; }; then echo \"relayflow: the checks did not finish within ${limit}s (they ran $2s).\" >&2; echo timeout; else echo \"relayflow: the checks failed with exit $1; the full output is in $check_out.\" >&2; echo fail; fi; }; if [ -z \"$limiter\" ]; then relayflow_unrunnable=\"relayflow: this machine has no timeout, gtimeout or perl, so there is no way to stop the checks at a time limit and they were not run. Install coreutils (timeout) or perl, then run again.\"; printf '%s\\n' \"$relayflow_unrunnable\" > \"$check_out\"; echo \"$relayflow_unrunnable\" >&2; echo unrunnable; elif [ -z \"${RELAYFLOW_CHECK_WAIT:-}\" ] || [ -z \"$grouper\" ]; then relayflow_inline=yes; if [ -n \"${RELAYFLOW_CHECK_WAIT:-}\" ]; then if [ \"$RELAYFLOW_CHECK_WAIT\" -lt \"$limit\" ]; then limit=\"$RELAYFLOW_CHECK_WAIT\"; fi; echo \"relayflow: neither setsid nor perl is installed, so the checks cannot be detached; they run in this step under $limiter, limited to ${limit}s.\" >&2; fi; echo \"relayflow: running $script in $check_dir\" >&2; since=$(date +%s); ( cd \"$check_dir\" || { printf \"%s\\n\" 126 > \"$check_out.status\"; exit 0; }; unset GIT_CONFIG_COUNT GIT_CONFIG_GLOBAL GIT_CONFIG_NOSYSTEM; case \"$limiter\" in (timeout|gtimeout) \"$limiter\" \"$limit\" sh \"$script\" ;; (perl) perl -e \"$relayflow_limiter\" \"$limit\" sh \"$script\" ;; (*) sh \"$script\" ;; esac > \"$check_out\" 2>&1 < /dev/null; printf \"%s\\n\" \"$?\" > \"$check_out.status.tmp\"; mv -f \"$check_out.status.tmp\" \"$check_out.status\" ); relayflow_check_done \"$(cat \"$check_out.status\" 2>/dev/null || echo 125)\" \"$(( $(date +%s) - since ))\"; rm -f \"$check_out.status\"; else id=\"${RELAYFLOW_CHECK_ID:-1}\"; relayflow_group() { cat \"$check_out.group\" 2>/dev/null || cat \"$check_out.pid\" 2>/dev/null; }; relayflow_alive() { g=$(relayflow_group); [ -n \"$g\" ] && { kill -0 \"-$g\" 2>/dev/null || kill -0 \"$g\" 2>/dev/null; }; }; relayflow_stop() { g=$(relayflow_group); [ -n \"$g\" ] || return 0; kill -TERM \"-$g\" 2>/dev/null || kill -TERM \"$g\" 2>/dev/null; i=0; while relayflow_alive && [ \"$i\" -lt 5 ]; do sleep 1; i=$((i + 1)); done; kill -KILL \"-$g\" 2>/dev/null || kill -KILL \"$g\" 2>/dev/null; return 0; }; relayflow_status_done() { if [ -f \"$check_out.stopped\" ]; then relayflow_check_done \"$(cat \"$check_out.status\")\" \"$1\" stopped; else relayflow_check_done \"$(cat \"$check_out.status\")\" \"$1\"; fi; }; if [ \"$(cat \"$check_out.id\" 2>/dev/null)\" != \"$id\" ]; then if [ ! -f \"$check_out.status\" ] && relayflow_alive; then if ! command -v ps >/dev/null 2>&1; then relayflow_stop; echo \"relayflow: stopped what an earlier attempt left running (no ps here to identify it).\" >&2; elif ps -o args= -p \"$(relayflow_group)\" 2>/dev/null | grep -qF 'check_out.group'; then relayflow_stop; echo \"relayflow: stopped a check an earlier attempt left running.\" >&2; fi; fi; rm -f \"$check_out.status\" \"$check_out.status.tmp\" \"$check_out.stopped\" \"$check_out.group\" \"$check_out.pid\"; : > \"$check_out\"; date +%s > \"$check_out.since\"; printf '%s\\n' \"$id\" > \"$check_out.id\"; export check_dir check_out script limit; if [ \"$grouper\" = setsid ]; then setsid sh -c 'printf \"%s\\n\" \"$$\" > \"$check_out.group\"; cd \"$check_dir\" || { printf \"%s\\n\" 126 > \"$check_out.status\"; exit 0; }; unset GIT_CONFIG_COUNT GIT_CONFIG_GLOBAL GIT_CONFIG_NOSYSTEM; sh \"$script\" > \"$check_out\" 2>&1 < /dev/null & c=$!; ( s=$(date +%s); while kill -0 \"$c\" 2>/dev/null; do if [ $(( $(date +%s) - s )) -gt $((limit + 60)) ]; then : > \"$check_out.stopped\"; printf \"%s\\n\" 124 > \"$check_out.status.tmp\"; mv -f \"$check_out.status.tmp\" \"$check_out.status\"; kill -KILL \"-$$\" 2>/dev/null || kill -KILL \"$c\" 2>/dev/null; fi; sleep 5; done ) > /dev/null 2>&1 & wait \"$c\"; st=$?; if [ ! -f \"$check_out.stopped\" ]; then printf \"%s\\n\" \"$st\" > \"$check_out.status.tmp\"; mv -f \"$check_out.status.tmp\" \"$check_out.status\"; fi' > /dev/null 2>&1 < /dev/null & else perl -e '$SIG{HUP} = \"IGNORE\"; setpgrp(0, 0); exec @ARGV or exit 127' sh -c 'printf \"%s\\n\" \"$$\" > \"$check_out.group\"; cd \"$check_dir\" || { printf \"%s\\n\" 126 > \"$check_out.status\"; exit 0; }; unset GIT_CONFIG_COUNT GIT_CONFIG_GLOBAL GIT_CONFIG_NOSYSTEM; sh \"$script\" > \"$check_out\" 2>&1 < /dev/null & c=$!; ( s=$(date +%s); while kill -0 \"$c\" 2>/dev/null; do if [ $(( $(date +%s) - s )) -gt $((limit + 60)) ]; then : > \"$check_out.stopped\"; printf \"%s\\n\" 124 > \"$check_out.status.tmp\"; mv -f \"$check_out.status.tmp\" \"$check_out.status\"; kill -KILL \"-$$\" 2>/dev/null || kill -KILL \"$c\" 2>/dev/null; fi; sleep 5; done ) > /dev/null 2>&1 & wait \"$c\"; st=$?; if [ ! -f \"$check_out.stopped\" ]; then printf \"%s\\n\" \"$st\" > \"$check_out.status.tmp\"; mv -f \"$check_out.status.tmp\" \"$check_out.status\"; fi' > /dev/null 2>&1 < /dev/null & fi; echo \"$!\" > \"$check_out.pid\"; echo \"relayflow: started $script in $check_dir; it may run ${limit}s in all, and each step waits up to ${RELAYFLOW_CHECK_WAIT}s for it.\" >&2; fi; since=$(cat \"$check_out.since\" 2>/dev/null || date +%s); waited=0; while [ ! -f \"$check_out.status\" ] && [ \"$waited\" -lt \"$RELAYFLOW_CHECK_WAIT\" ] && relayflow_alive && [ $(( $(date +%s) - since )) -lt \"$limit\" ]; do sleep 1; waited=$((waited + 1)); done; elapsed=$(( $(date +%s) - since )); if [ -f \"$check_out.status\" ]; then relayflow_status_done \"$elapsed\"; elif relayflow_alive && [ \"$elapsed\" -lt \"$limit\" ]; then echo \"relayflow: the checks are still running (${elapsed}s of ${limit}s).\" >&2; echo running; elif relayflow_alive; then : > \"$check_out.stopped\"; printf '%s\\n' 124 > \"$check_out.status.tmp\"; mv -f \"$check_out.status.tmp\" \"$check_out.status\"; relayflow_stop; echo \"relayflow: stopped the checks after ${elapsed}s, at their ${limit}s.\" >> \"$check_out\"; relayflow_check_done 124 \"$elapsed\" stopped; else sleep 1; if [ -f \"$check_out.status\" ]; then relayflow_status_done \"$elapsed\"; else echo \"relayflow: the checks stopped without reporting an exit status.\" >> \"$check_out\"; relayflow_check_done 125 \"$elapsed\"; fi; fi; fi; fi ); rm -f \"$recipe\"; git checkout -q -f \"$orig\" >/dev/null 2>&1 || git checkout -q -f \"$head\" >/dev/null 2>&1; if [ \"$(git rev-parse HEAD 2>/dev/null)\" = \"$head\" ]; then echo \"$token\"; else echo \"relayflow: could not return to $orig after checking the base commit.\" >&2; echo unknown; fi; elif if [ -n \"${recipe:-}\" ]; then rm -f \"$recipe\"; fi; tmp=$(mktemp -d \"${TMPDIR:-/tmp}/relayflow-base.XXXXXX\") && git worktree add --detach -q \"$tmp/base\" \"$base\" >/dev/null 2>&1; then relayflow_build_dirs \"$root\" | while IFS= read -r d; do if [ ! -e \"$tmp/base/$d\" ] && mkdir -p \"$(dirname \"$tmp/base/$d\")\"; then ln -s \"$root/$d\" \"$tmp/base/$d\"; fi; done; check_dir=\"$tmp/base\"; check_out=\"$root/.relayflow/base-check.log\"; root=\"$PWD\"; script=\"${check_script:-$root/.relayflow/check.sh}\"; check_dir=\"${check_dir:-$root}\"; check_out=\"${check_out:-$root/.relayflow/check.log}\"; mkdir -p \"$(dirname \"$check_out\")\"; rm -f \"$check_out.exit\" \"$check_out.elapsed\" \"$check_out.limit\"; if [ ! -s \"$script\" ]; then echo \"relayflow: there is no check script, so no checks ran.\" > \"$check_out\"; echo \"relayflow: there is no check script, so no checks ran.\" >&2; echo none; else limit=\"${RELAYFLOW_CHECK_TIMEOUT:-840}\"; limiter=; if command -v timeout >/dev/null 2>&1; then limiter=timeout; elif command -v gtimeout >/dev/null 2>&1; then limiter=gtimeout; elif command -v perl >/dev/null 2>&1; then limiter=perl; fi; relayflow_limiter='my $l = shift; my $p = fork; defined $p or exit 127; if (!$p) { setpgrp(0, 0); exec @ARGV or exit 127 } $SIG{ALRM} = sub { kill '\\''TERM'\\'', -$p; sleep 2; kill '\\''KILL'\\'', -$p; exit 124 }; alarm $l; waitpid($p, 0); exit($? & 127 ? 128 + ($? & 127) : $? >> 8)'; grouper=; if command -v setsid >/dev/null 2>&1; then grouper=setsid; elif command -v perl >/dev/null 2>&1; then grouper=perl; fi; relayflow_inline=; relayflow_check_done() { printf '%s\\n' \"$1\" > \"$check_out.exit\"; printf '%s\\n' \"$2\" > \"$check_out.elapsed\"; printf '%s\\n' \"$limit\" > \"$check_out.limit\"; tail -n 40 \"$check_out\" >&2; if [ \"$1\" -eq 0 ]; then echo \"relayflow: the checks passed.\" >&2; echo pass; elif [ \"$1\" -eq 124 ] && { { [ \"$relayflow_inline\" = yes ] && [ -n \"$limiter\" ]; } || [ \"${3:-}\" = stopped ]; }; then echo \"relayflow: the checks did not finish within ${limit}s (they ran $2s).\" >&2; echo timeout; else echo \"relayflow: the checks failed with exit $1; the full output is in $check_out.\" >&2; echo fail; fi; }; if [ -z \"$limiter\" ]; then relayflow_unrunnable=\"relayflow: this machine has no timeout, gtimeout or perl, so there is no way to stop the checks at a time limit and they were not run. Install coreutils (timeout) or perl, then run again.\"; printf '%s\\n' \"$relayflow_unrunnable\" > \"$check_out\"; echo \"$relayflow_unrunnable\" >&2; echo unrunnable; elif [ -z \"${RELAYFLOW_CHECK_WAIT:-}\" ] || [ -z \"$grouper\" ]; then relayflow_inline=yes; if [ -n \"${RELAYFLOW_CHECK_WAIT:-}\" ]; then if [ \"$RELAYFLOW_CHECK_WAIT\" -lt \"$limit\" ]; then limit=\"$RELAYFLOW_CHECK_WAIT\"; fi; echo \"relayflow: neither setsid nor perl is installed, so the checks cannot be detached; they run in this step under $limiter, limited to ${limit}s.\" >&2; fi; echo \"relayflow: running $script in $check_dir\" >&2; since=$(date +%s); ( cd \"$check_dir\" || { printf \"%s\\n\" 126 > \"$check_out.status\"; exit 0; }; unset GIT_CONFIG_COUNT GIT_CONFIG_GLOBAL GIT_CONFIG_NOSYSTEM; case \"$limiter\" in (timeout|gtimeout) \"$limiter\" \"$limit\" sh \"$script\" ;; (perl) perl -e \"$relayflow_limiter\" \"$limit\" sh \"$script\" ;; (*) sh \"$script\" ;; esac > \"$check_out\" 2>&1 < /dev/null; printf \"%s\\n\" \"$?\" > \"$check_out.status.tmp\"; mv -f \"$check_out.status.tmp\" \"$check_out.status\" ); relayflow_check_done \"$(cat \"$check_out.status\" 2>/dev/null || echo 125)\" \"$(( $(date +%s) - since ))\"; rm -f \"$check_out.status\"; else id=\"${RELAYFLOW_CHECK_ID:-1}\"; relayflow_group() { cat \"$check_out.group\" 2>/dev/null || cat \"$check_out.pid\" 2>/dev/null; }; relayflow_alive() { g=$(relayflow_group); [ -n \"$g\" ] && { kill -0 \"-$g\" 2>/dev/null || kill -0 \"$g\" 2>/dev/null; }; }; relayflow_stop() { g=$(relayflow_group); [ -n \"$g\" ] || return 0; kill -TERM \"-$g\" 2>/dev/null || kill -TERM \"$g\" 2>/dev/null; i=0; while relayflow_alive && [ \"$i\" -lt 5 ]; do sleep 1; i=$((i + 1)); done; kill -KILL \"-$g\" 2>/dev/null || kill -KILL \"$g\" 2>/dev/null; return 0; }; relayflow_status_done() { if [ -f \"$check_out.stopped\" ]; then relayflow_check_done \"$(cat \"$check_out.status\")\" \"$1\" stopped; else relayflow_check_done \"$(cat \"$check_out.status\")\" \"$1\"; fi; }; if [ \"$(cat \"$check_out.id\" 2>/dev/null)\" != \"$id\" ]; then if [ ! -f \"$check_out.status\" ] && relayflow_alive; then if ! command -v ps >/dev/null 2>&1; then relayflow_stop; echo \"relayflow: stopped what an earlier attempt left running (no ps here to identify it).\" >&2; elif ps -o args= -p \"$(relayflow_group)\" 2>/dev/null | grep -qF 'check_out.group'; then relayflow_stop; echo \"relayflow: stopped a check an earlier attempt left running.\" >&2; fi; fi; rm -f \"$check_out.status\" \"$check_out.status.tmp\" \"$check_out.stopped\" \"$check_out.group\" \"$check_out.pid\"; : > \"$check_out\"; date +%s > \"$check_out.since\"; printf '%s\\n' \"$id\" > \"$check_out.id\"; export check_dir check_out script limit; if [ \"$grouper\" = setsid ]; then setsid sh -c 'printf \"%s\\n\" \"$$\" > \"$check_out.group\"; cd \"$check_dir\" || { printf \"%s\\n\" 126 > \"$check_out.status\"; exit 0; }; unset GIT_CONFIG_COUNT GIT_CONFIG_GLOBAL GIT_CONFIG_NOSYSTEM; sh \"$script\" > \"$check_out\" 2>&1 < /dev/null & c=$!; ( s=$(date +%s); while kill -0 \"$c\" 2>/dev/null; do if [ $(( $(date +%s) - s )) -gt $((limit + 60)) ]; then : > \"$check_out.stopped\"; printf \"%s\\n\" 124 > \"$check_out.status.tmp\"; mv -f \"$check_out.status.tmp\" \"$check_out.status\"; kill -KILL \"-$$\" 2>/dev/null || kill -KILL \"$c\" 2>/dev/null; fi; sleep 5; done ) > /dev/null 2>&1 & wait \"$c\"; st=$?; if [ ! -f \"$check_out.stopped\" ]; then printf \"%s\\n\" \"$st\" > \"$check_out.status.tmp\"; mv -f \"$check_out.status.tmp\" \"$check_out.status\"; fi' > /dev/null 2>&1 < /dev/null & else perl -e '$SIG{HUP} = \"IGNORE\"; setpgrp(0, 0); exec @ARGV or exit 127' sh -c 'printf \"%s\\n\" \"$$\" > \"$check_out.group\"; cd \"$check_dir\" || { printf \"%s\\n\" 126 > \"$check_out.status\"; exit 0; }; unset GIT_CONFIG_COUNT GIT_CONFIG_GLOBAL GIT_CONFIG_NOSYSTEM; sh \"$script\" > \"$check_out\" 2>&1 < /dev/null & c=$!; ( s=$(date +%s); while kill -0 \"$c\" 2>/dev/null; do if [ $(( $(date +%s) - s )) -gt $((limit + 60)) ]; then : > \"$check_out.stopped\"; printf \"%s\\n\" 124 > \"$check_out.status.tmp\"; mv -f \"$check_out.status.tmp\" \"$check_out.status\"; kill -KILL \"-$$\" 2>/dev/null || kill -KILL \"$c\" 2>/dev/null; fi; sleep 5; done ) > /dev/null 2>&1 & wait \"$c\"; st=$?; if [ ! -f \"$check_out.stopped\" ]; then printf \"%s\\n\" \"$st\" > \"$check_out.status.tmp\"; mv -f \"$check_out.status.tmp\" \"$check_out.status\"; fi' > /dev/null 2>&1 < /dev/null & fi; echo \"$!\" > \"$check_out.pid\"; echo \"relayflow: started $script in $check_dir; it may run ${limit}s in all, and each step waits up to ${RELAYFLOW_CHECK_WAIT}s for it.\" >&2; fi; since=$(cat \"$check_out.since\" 2>/dev/null || date +%s); waited=0; while [ ! -f \"$check_out.status\" ] && [ \"$waited\" -lt \"$RELAYFLOW_CHECK_WAIT\" ] && relayflow_alive && [ $(( $(date +%s) - since )) -lt \"$limit\" ]; do sleep 1; waited=$((waited + 1)); done; elapsed=$(( $(date +%s) - since )); if [ -f \"$check_out.status\" ]; then relayflow_status_done \"$elapsed\"; elif relayflow_alive && [ \"$elapsed\" -lt \"$limit\" ]; then echo \"relayflow: the checks are still running (${elapsed}s of ${limit}s).\" >&2; echo running; elif relayflow_alive; then : > \"$check_out.stopped\"; printf '%s\\n' 124 > \"$check_out.status.tmp\"; mv -f \"$check_out.status.tmp\" \"$check_out.status\"; relayflow_stop; echo \"relayflow: stopped the checks after ${elapsed}s, at their ${limit}s.\" >> \"$check_out\"; relayflow_check_done 124 \"$elapsed\" stopped; else sleep 1; if [ -f \"$check_out.status\" ]; then relayflow_status_done \"$elapsed\"; else echo \"relayflow: the checks stopped without reporting an exit status.\" >> \"$check_out\"; relayflow_check_done 125 \"$elapsed\"; fi; fi; fi; fi; git worktree remove --force \"$tmp/base\" >/dev/null 2>&1; git worktree prune >/dev/null 2>&1; rm -rf \"$tmp\"; else echo \"relayflow: could not check out the base commit to compare against.\" >&2; if [ -n \"$tmp\" ]; then rm -rf \"$tmp\"; fi; echo unknown; fi", baseLimit, baseLimit + 2); + // A base check that ran out of time says nothing about a branch that + // failed outright; only a branch that timed out too compares with it. + return result === "timeout" && check !== "timeout" ? "unknown" : result; + }; + const baseline = await compareWithBase(); + + // 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 + // and failing inside "gh pr create" is not the report such a run should + // leave, so the check below decides, and anything it cannot vouch for is + // treated as nothing to publish. Working files an agent committed are taken + // out of the branch first, so they are neither published nor counted. + const dropWorkingFiles = "paths='summary.md plan.md reviewed-plan.md review.md review.clean review-blocked.md comparison.md prototype-notes.md .relayflow START-HERE.txt flow-input.json relay-preflight.mjs software-factory.flow.mts'; if [ -z \"$base\" ] || ! git rev-parse --verify --quiet \"$base^{commit}\" >/dev/null 2>&1; then echo \"relayflow: the base commit is unknown, so working files were left as committed.\" >&2; else drop=$(git ls-files -- $paths | while IFS= read -r p; do git cat-file -e \"$base:$p\" 2>/dev/null || printf '%s\\n' \"$p\"; done); if [ -z \"$drop\" ]; then echo \"relayflow: no working files were committed.\" >&2; else tmp=$(mktemp -d); msg=\"Keep relayflow working files out of the change\"; if GIT_INDEX_FILE=\"$tmp/index\" git read-tree HEAD && printf '%s\\n' \"$drop\" | GIT_INDEX_FILE=\"$tmp/index\" git update-index --force-remove --stdin && tree=$(GIT_INDEX_FILE=\"$tmp/index\" git write-tree) && { commit=$(git commit-tree \"$tree\" -p HEAD -m \"$msg\" 2>/dev/null) || commit=$(git -c user.name=Relayflow -c user.email=noreply@agentrelay.com commit-tree \"$tree\" -p HEAD -m \"$msg\"); } && git update-ref -m \"relayflow: drop working files\" HEAD \"$commit\"; then printf '%s\\n' \"$drop\" | git update-index --force-remove --stdin 2>/dev/null; echo \"relayflow: removed working files from the branch: $(printf '%s ' $drop)\" >&2; else echo \"relayflow: could not remove the working files from the branch.\" >&2; fi; rm -rf \"$tmp\"; fi; fi; echo done"; + // Every push goes through this: if GitHub refuses the branch only because + // it edits .github/workflows/ (the run's token may lack that permission), + // the work is pushed without those edits and the patch goes on the PR. + const pushBranch = "relayflow_push() { wf=.github/workflows; err=$(mktemp \"${TMPDIR:-/tmp}/relayflow-push.XXXXXX\") || { echo \"relayflow push-guard: could not create a temporary file, so the push was not attempted (its output could not be scrubbed of credentials).\" >&2; return 1; }; git push \"$@\" 2>\"$err\"; status=$?; sed -e 's#://[^/@[:space:]]*@#://#g' \"$err\" >&2; if [ \"$status\" -eq 0 ]; then rm -f \"$err\"; return 0; fi; if ! grep -Eq 'refusing to allow a GitHub App to create or update workflow|without .workflows. permission' \"$err\"; then rm -f \"$err\"; return \"$status\"; fi; mb=; if [ -n \"${base:-}\" ] && git rev-parse --verify --quiet \"$base^{commit}\" >/dev/null 2>&1; then mb=$(git merge-base \"$base\" HEAD 2>/dev/null); fi; if [ -z \"$mb\" ]; then echo \"relayflow push-guard: GitHub refused the workflow edits, and the base commit is unknown, so nothing was withheld.\" >&2; rm -f \"$err\"; return \"$status\"; fi; orig=$(git rev-parse HEAD); if [ -z \"$(git rev-list --full-history \"$orig\" \"^$mb\" --not --remotes -- \"$wf\")\" ]; then echo \"relayflow push-guard: no unpushed commit edits $wf, so there is nothing to withhold.\" >&2; rm -f \"$err\"; return \"$status\"; fi; tmp=$(mktemp -d \"${TMPDIR:-/tmp}/relayflow-withhold.XXXXXX\") || { rm -f \"$err\"; return \"$status\"; }; : > \"$tmp/map\"; ok=yes; relayflow_new() { n=$(grep \"^$1 \" \"$tmp/map\" | cut -d\" \" -f2); if [ -n \"$n\" ]; then echo \"$n\"; else echo \"$1\"; fi; }; for c in $(git rev-list --reverse --topo-order \"$orig\" \"^$mb\" --not --remotes); do parents=$(git rev-list --parents -n 1 \"$c\" | cut -s -d\" \" -f2-); p1=; np=; for p in $parents; do q=$(relayflow_new \"$p\"); if [ -z \"$p1\" ]; then p1=$q; fi; np=\"$np -p $q\"; done; if GIT_INDEX_FILE=\"$tmp/index\" git read-tree \"$c\" && { GIT_INDEX_FILE=\"$tmp/index\" git ls-files -z -- \"$wf\" | GIT_INDEX_FILE=\"$tmp/index\" git update-index -z --force-remove --stdin; } && { [ -z \"$p1\" ] || git ls-tree -r --full-tree \"$p1\" -- \"$wf\" | GIT_INDEX_FILE=\"$tmp/index\" git update-index --index-info; } && tree=$(GIT_INDEX_FILE=\"$tmp/index\" git write-tree); then :; else ok=no; break; fi; case \"$parents\" in (*\" \"*) single=no ;; (*) single=yes ;; esac; if [ \"$single\" = yes ] && [ -n \"$p1\" ] && [ \"$tree\" = \"$(git rev-parse \"$p1^{tree}\")\" ]; then echo \"$c $p1\" >> \"$tmp/map\"; continue; fi; git cat-file commit \"$c\" | sed \"1,/^\\$/d\" > \"$tmp/msg\"; new=$(GIT_AUTHOR_NAME=\"$(git show -s --format=%an \"$c\")\" GIT_AUTHOR_EMAIL=\"$(git show -s --format=%ae \"$c\")\" GIT_AUTHOR_DATE=\"$(git show -s --date=raw --format=%ad \"$c\")\" GIT_COMMITTER_NAME=\"$(git show -s --format=%cn \"$c\")\" GIT_COMMITTER_EMAIL=\"$(git show -s --format=%ce \"$c\")\" GIT_COMMITTER_DATE=\"$(git show -s --date=raw --format=%cd \"$c\")\" git commit-tree \"$tree\" $np -F \"$tmp/msg\") || { ok=no; break; }; echo \"$c $new\" >> \"$tmp/map\"; done; new=$(relayflow_new \"$orig\"); if [ \"$ok\" != yes ] || [ \"$new\" = \"$orig\" ]; then echo \"relayflow push-guard: could not withhold the workflow edits.\" >&2; rm -rf \"$tmp\" \"$err\"; return \"$status\"; fi; mkdir -p .relayflow; patch=.relayflow/workflow-changes.patch; section=.relayflow/workflow-changes.md; git diff --full-index \"$mb\" \"$orig\" -- \"$wf\" > \"$patch\"; n=$(git diff --name-only \"$mb\" \"$orig\" -- \"$wf\" | wc -l | tr -d \" \"); relayflow_block() { block_run=$(LC_ALL=C awk '{ s = $0; while (match(s, /`+/)) { if (RLENGTH > m) m = RLENGTH; s = substr(s, RSTART + RLENGTH) } } END { print m + 0 }' \"$1\"); if [ \"${block_run:-0}\" -lt 3 ]; then block_run=2; fi; block_fence=$(printf \"%$((block_run + 1))s\" \"\" | tr \" \" '`'); printf \"%s%s\\n\" \"$block_fence\" \"$2\"; cat \"$1\"; if [ -s \"$1\" ] && [ \"$(LC_ALL=C tail -c 1 \"$1\" | wc -l | tr -d \" \")\" -eq 0 ]; then echo; fi; printf \"%s\\n\" \"$block_fence\"; }; relayflow_cap() { cap_size=$(LC_ALL=C wc -c < \"$1\" | tr -d \" \"); if [ \"$cap_size\" -le \"$2\" ]; then return 0; fi; echo \"relayflow: $1 is $cap_size bytes, more than the $2 it may hold, so it was cut; the full text is $4.\" >&2; cap_note=\"_…truncated to fit GitHub's limit: $cap_size bytes were cut to under $2. The full text is $4._\"; cap_lead=$(printf '[\\300-\\377]'); cap_cont=$(printf '[\\200-\\277]'); cap_keep=$(($2 - 512)); cap_tries=0; while :; do if [ \"$cap_keep\" -le 0 ] || [ \"$cap_tries\" -ge 3 ]; then : > \"$1.cap\"; break; fi; cap_tries=$((cap_tries + 1)); if [ \"$3\" = tail ]; then LC_ALL=C tail -c \"$cap_keep\" \"$1\" > \"$1.cut\"; else LC_ALL=C head -c \"$cap_keep\" \"$1\" > \"$1.cut\"; fi; cap_lines=$(LC_ALL=C tr -cd \"\\n\" < \"$1.cut\" | wc -c | tr -d \" \"); if [ \"$3\" = tail ]; then { printf '%s\\n\\n' \"$cap_note\"; if [ \"$cap_lines\" -gt 0 ]; then LC_ALL=C sed 1d \"$1.cut\"; else LC_ALL=C sed \"s/^$cap_cont*//\" \"$1.cut\"; fi; } > \"$1.cap\"; else { if [ \"$(LC_ALL=C tail -c 1 \"$1.cut\" | wc -l | tr -d \" \")\" -eq 1 ]; then cat \"$1.cut\"; elif [ \"$cap_lines\" -gt 0 ]; then LC_ALL=C sed '$d' \"$1.cut\"; else LC_ALL=C sed \"s/$cap_lead$cap_cont*\\$//\" \"$1.cut\"; echo; fi; } > \"$1.cap\"; { relayflow_block \"$1.cap\" text; printf \"\\n%s\\n\" \"$cap_note\"; } > \"$1.cut\"; mv -f \"$1.cut\" \"$1.cap\"; fi; cap_out=$(LC_ALL=C wc -c < \"$1.cap\" | tr -d \" \"); if [ \"$cap_out\" -le \"$2\" ]; then break; fi; cap_keep=$((cap_keep - cap_out + $2)); done; rm -f \"$1.cut\"; mv -f \"$1.cap\" \"$1\"; return 0; }; relayflow_section() { used=0; if [ \"${comment:-}\" != yes ] && [ -f .relayflow/pr-body.md ]; then used=$(wc -c < .relayflow/pr-body.md | tr -d \" \"); fi; { printf '\\n## Workflow changes not applied\\n\\n%s\\n\\n' \"The GitHub App token this run pushes with lacks the \\`workflows\\` permission, so GitHub refused the commits that change \\`.github/workflows/\\`. The rest of the work is pushed; these edits were taken out of its commits. Apply them manually:\"; git diff --name-status \"$mb\" \"$orig\" -- \"$wf\" | awk -F '\\t' '{ printf \"- %s \\140%s\\140\\n\", substr($1, 1, 1), $NF }' | head -n 50; if [ \"$n\" -gt 50 ]; then printf -- '- …and %s more\\n' \"$((n - 50))\"; fi; } > \"$tmp/head\"; overhead=$(( $(wc -c < \"$tmp/head\" | tr -d \" \") + 400 )); limit=${RELAYFLOW_WITHHELD_PATCH_LIMIT:-61440}; room=$((60000 - used - overhead)); if [ \"$room\" -lt \"$limit\" ]; then limit=$room; fi; size=$(wc -c < \"$patch\" | tr -d \" \"); { cat \"$tmp/head\"; if [ \"$limit\" -le 0 ]; then printf '\\n%s\\n' \"_The patch ($size bytes) does not fit in the pull request body. The full patch is .relayflow/workflow-changes.patch in the run workspace, and in this push step's output._\"; elif [ \"$size\" -le \"$limit\" ]; then printf '\\n````diff\\n'; cat \"$patch\"; printf '````\\n'; else printf '\\n````diff\\n'; head -c \"$limit\" \"$patch\" | sed '$d'; printf '````\\n\\n%s\\n' \"_Truncated to $limit of $size bytes. The full patch is .relayflow/workflow-changes.patch in the run workspace, and in this push step's output._\"; fi; } > \"$section\"; relayflow_cap \"$section\" $((60000 - used)) head \"$patch in the run workspace\"; }; dirty=$( { git diff --name-only \"$orig\" -- \"$wf\"; git diff --cached --name-only \"$orig\" -- \"$wf\"; git ls-files --others --exclude-standard -- \"$wf\"; } 2>/dev/null | sort -u); if [ -z \"$(git rev-list \"$new\" --not --remotes 2>/dev/null)\" ]; then relayflow_section; cat \"$patch\" >&2; if [ \"${comment:-}\" = yes ] && { if command -v relayflow-change >/dev/null 2>&1; then relayflow-change comment --body-file \"$section\"; else gh pr comment --body-file \"$section\"; fi; } >/dev/null 2>&1; then echo \"relayflow push-guard: every new change edits $wf, which this token cannot push, so nothing else was pushed; posted the withheld workflow changes to the pull request.\" >&2; rm -rf \"$tmp\" \"$err\"; return 0; fi; echo \"relayflow push-guard: every change in this run edits $wf, which this token cannot push, so there is nothing else to publish. The edits are in $patch and printed above.\" >&2; rm -rf \"$tmp\" \"$err\"; return \"$status\"; fi; git update-ref -m \"relayflow: withhold workflow edits\" HEAD \"$new\" \"$orig\"; git push \"$@\" 2>\"$tmp/push\"; again=$?; sed -e 's#://[^/@[:space:]]*@#://#g' \"$tmp/push\" >&2; if [ \"$again\" -ne 0 ]; then git update-ref -m \"relayflow: restore after a failed push\" HEAD \"$orig\" \"$new\"; echo \"relayflow push-guard: the push failed again after withholding the workflow edits. The original error was:\" >&2; sed -e 's#://[^/@[:space:]]*@#://#g' \"$err\" >&2; rm -rf \"$tmp\" \"$err\"; return \"$again\"; fi; previous=$(git rev-parse --verify --quiet refs/relayflow/withheld-workflows 2>/dev/null || :); if [ -n \"$previous\" ] && [ \"$previous\" != \"$orig\" ]; then git update-ref \"refs/relayflow/withheld-workflows-history/$previous\" \"$previous\"; fi; git update-ref refs/relayflow/withheld-workflows \"$orig\"; git diff --name-only --no-renames \"$new\" \"$orig\" -- \"$wf\" | while IFS= read -r p; do if printf \"%s\\n\" \"$dirty\" | grep -qxF -- \"$p\"; then echo \"relayflow push-guard: $p has uncommitted edits, so it was left as it is.\" >&2; continue; fi; if git cat-file -e \"$new:$p\" 2>/dev/null; then git checkout -q \"$new\" -- \"$p\"; else git rm -q -f --ignore-unmatch -- \"$p\" >/dev/null 2>&1; rm -f -- \"$p\"; fi; done; if [ -s \"$patch\" ]; then relayflow_section; if [ \"$size\" -gt \"$limit\" ]; then cat \"$patch\" >&2; fi; if [ \"${comment:-}\" = yes ]; then if { if command -v relayflow-change >/dev/null 2>&1; then relayflow-change comment --body-file \"$section\"; else gh pr comment --body-file \"$section\"; fi; } >/dev/null 2>&1; then echo \"relayflow push-guard: posted the withheld workflow changes to the pull request.\" >&2; else echo \"relayflow push-guard: could not comment on the pull request; $section holds the withheld workflow changes.\" >&2; fi; elif [ -f .relayflow/pr-body.md ]; then cat \"$section\" >> .relayflow/pr-body.md; fi; fi; echo \"relayflow push-guard: workflow edits withheld ($n files)\"; echo \"relayflow push-guard: GitHub refused the edits to $wf, so the branch was pushed without them. The patch is $patch; the original commits are refs/relayflow/withheld-workflows.\" >&2; rm -rf \"$tmp\" \"$err\"; }; relayflow_push"; + console.error("relayflow phase: publishing"); + await f.run("base=" + baseCommit + "; " + dropWorkingFiles, { timeout: "5m" }); + const publishCheck = "if [ -s summary.md ]; then summary=yes; else summary=no; fi; changed=unknown; if [ -n \"$base\" ] && git rev-parse --verify --quiet \"$base^{commit}\" >/dev/null 2>&1; then if git diff --quiet \"$base\" HEAD 2>/dev/null; then changed=no; else changed=yes; fi; fi; if [ \"$changed\" != no ] && [ \"$summary\" = yes ]; then echo \"relayflow: new commits and a summary.md are present; opening the pull request.\" >&2 && echo publish && exit 0; fi; if [ \"$changed\" = yes ]; then echo \"relayflow: there are commits but summary.md is missing or empty, so there is no pull-request body.\" >&2 && echo no-summary && exit 0; fi; echo \"relayflow: no commits were made on this branch, so there is nothing to push or publish.\" >&2 && echo no-commits && exit 0"; + const publish = (await f.run("base=" + baseCommit + "; " + publishCheck)).trim(); + if (publish !== "publish" && publish !== "no-summary") { + console.error("Stopped: the agents made no commits on this branch, so there is nothing to publish. No branch was pushed and no pull request was opened."); + return f.done("needs_human"); + } + if (publish !== "publish") { + await f.run("base=" + baseCommit + "; " + pushBranch + " --set-upstream origin HEAD", { timeout: "11m" }); + console.error("Stopped: the branch was pushed, but no summary.md was written, so there is no pull-request body. Open the pull request by hand, or run again."); + return f.done("needs_human"); + } + // Failing, unrun or unfinished work is never thrown away: the pull request + // opens as a draft, with the verdict, the script and the output in its body. + const checkReport = "mkdir -p .relayflow; relayflow_block() { block_run=$(LC_ALL=C awk '{ s = $0; while (match(s, /`+/)) { if (RLENGTH > m) m = RLENGTH; s = substr(s, RSTART + RLENGTH) } } END { print m + 0 }' \"$1\"); if [ \"${block_run:-0}\" -lt 3 ]; then block_run=2; fi; block_fence=$(printf \"%$((block_run + 1))s\" \"\" | tr \" \" '`'); printf \"%s%s\\n\" \"$block_fence\" \"$2\"; cat \"$1\"; if [ -s \"$1\" ] && [ \"$(LC_ALL=C tail -c 1 \"$1\" | wc -l | tr -d \" \")\" -eq 0 ]; then echo; fi; printf \"%s\\n\" \"$block_fence\"; }; relayflow_cap() { cap_size=$(LC_ALL=C wc -c < \"$1\" | tr -d \" \"); if [ \"$cap_size\" -le \"$2\" ]; then return 0; fi; echo \"relayflow: $1 is $cap_size bytes, more than the $2 it may hold, so it was cut; the full text is $4.\" >&2; cap_note=\"_…truncated to fit GitHub's limit: $cap_size bytes were cut to under $2. The full text is $4._\"; cap_lead=$(printf '[\\300-\\377]'); cap_cont=$(printf '[\\200-\\277]'); cap_keep=$(($2 - 512)); cap_tries=0; while :; do if [ \"$cap_keep\" -le 0 ] || [ \"$cap_tries\" -ge 3 ]; then : > \"$1.cap\"; break; fi; cap_tries=$((cap_tries + 1)); if [ \"$3\" = tail ]; then LC_ALL=C tail -c \"$cap_keep\" \"$1\" > \"$1.cut\"; else LC_ALL=C head -c \"$cap_keep\" \"$1\" > \"$1.cut\"; fi; cap_lines=$(LC_ALL=C tr -cd \"\\n\" < \"$1.cut\" | wc -c | tr -d \" \"); if [ \"$3\" = tail ]; then { printf '%s\\n\\n' \"$cap_note\"; if [ \"$cap_lines\" -gt 0 ]; then LC_ALL=C sed 1d \"$1.cut\"; else LC_ALL=C sed \"s/^$cap_cont*//\" \"$1.cut\"; fi; } > \"$1.cap\"; else { if [ \"$(LC_ALL=C tail -c 1 \"$1.cut\" | wc -l | tr -d \" \")\" -eq 1 ]; then cat \"$1.cut\"; elif [ \"$cap_lines\" -gt 0 ]; then LC_ALL=C sed '$d' \"$1.cut\"; else LC_ALL=C sed \"s/$cap_lead$cap_cont*\\$//\" \"$1.cut\"; echo; fi; } > \"$1.cap\"; { relayflow_block \"$1.cap\" text; printf \"\\n%s\\n\" \"$cap_note\"; } > \"$1.cut\"; mv -f \"$1.cut\" \"$1.cap\"; fi; cap_out=$(LC_ALL=C wc -c < \"$1.cap\" | tr -d \" \"); if [ \"$cap_out\" -le \"$2\" ]; then break; fi; cap_keep=$((cap_keep - cap_out + $2)); done; rm -f \"$1.cut\"; mv -f \"$1.cap\" \"$1\"; return 0; }; report=.relayflow/check-report.md; parts=.relayflow/report-parts; rm -rf \"$parts\"; mkdir -p \"$parts\"; if [ -s .relayflow/check.sh ]; then cp .relayflow/check.sh \"$parts/script\"; relayflow_cap \"$parts/script\" 4000 head \".relayflow/check.sh in the run workspace\"; fi; if [ -s .relayflow/check.log ]; then tail -n 80 .relayflow/check.log > \"$parts/check\"; relayflow_cap \"$parts/check\" 10000 tail \".relayflow/check.log in the run workspace\"; fi; if [ -s .relayflow/base-check.log ]; then tail -n 80 .relayflow/base-check.log > \"$parts/base\"; relayflow_cap \"$parts/base\" 10000 tail \".relayflow/base-check.log in the run workspace\"; fi; if [ -s .relayflow/repair-notes.md ]; then cp .relayflow/repair-notes.md \"$parts/repair\"; relayflow_cap \"$parts/repair\" 4000 head \".relayflow/repair-notes.md in the run workspace\"; fi; relayflow_number() { v=$(cat \"$1\" 2>/dev/null | head -n 1); case \"$v\" in (\"\"|*[!0-9]*) ;; (*) printf %s \"$v\" ;; esac; }; relayflow_duration() { if [ \"$1\" -ge 60 ]; then printf \"%dm%02ds\" $(($1 / 60)) $(($1 % 60)); else printf \"%ds\" \"$1\"; fi; }; ck_exit=$(relayflow_number .relayflow/check.log.exit); ck_elapsed=$(relayflow_number .relayflow/check.log.elapsed); ck_limit=$(relayflow_number .relayflow/check.log.limit); relayflow_setup_note() { how=; case \"${check_setup:-}\" in (discovered) how=\"worked out from this repository's CI configuration\" ;; (default) how=\"the default for this kind of project\" ;; esac; if [ -n \"${check_digest:-}\" ] && [ \"$(cksum < .relayflow/check.sh 2>/dev/null)\" != \"$check_digest\" ]; then if [ -n \"$how\" ]; then how=\"$how, then repaired during this run\"; else how=\"repaired during this run\"; fi; fi; if [ -n \"$how\" ] && [ -s .relayflow/check.sh ]; then printf '\\n%s\\n' \"This run set up its own check script ($how); it is under \\\"What ran\\\" below. Commit it to the repository as \\`.relayflow/check.sh\\`, or set this flow's test command in Cloud, and future runs skip this setup.\"; fi; }; { printf '## Checks\\n\\n'; if [ \"${implementer_timeout:-}\" = yes ]; then printf '%s\\n\\n' \"**The implementer was stopped at its time limit**, so this change may be incomplete. Its committed work is what was checked and pushed; the pull request is a draft.\"; fi; case \"$check\" in pass) printf '%s\\n' \"Relayflow ran this repository's checks (.relayflow/check.sh) and they passed.\"; relayflow_setup_note ;; skipped) printf '%s\\n' \"**Relayflow ran out of time before it could run this repository's checks** (.relayflow/check.sh), so no checks ran on this change. The pull request is a draft; run the checks before merging.\" ;; unrunnable) printf '%s\\n' \"**Relayflow could not run this repository's checks** (.relayflow/check.sh): the machine it ran on has no \\`timeout\\`, \\`gtimeout\\` or \\`perl\\`, so there was no way to stop the suite at a time limit, and starting it could have ended the run with no verdict at all. This is not a test failure, and nothing here says the change is broken. Install coreutils (for \\`timeout\\`) or perl and run the flow again, or run .relayflow/check.sh by hand. The pull request is a draft; run the checks before merging.\" ;; none) printf '%s\\n' \"Relayflow found no way to run this repository's tests — no check command, no CI test job it could follow, no test target and no recognised project file — so no checks ran. Review the change with that in mind.\" ;; *) if [ \"$check\" = timeout ]; then if [ -n \"$ck_elapsed\" ] && [ -n \"$ck_limit\" ]; then printf '%s\\n\\n' \"**The checks timed out**: they ran for $(relayflow_duration \"$ck_elapsed\") of their $(relayflow_duration \"$ck_limit\") budget and were stopped before they finished, so this is a time limit, not a test failure. Run them locally (\\`sh .relayflow/check.sh\\`) to see how long they take, or raise the check budget (\\`checkTotal\\` in the flow) and run the flow again.\"; else printf '%s\\n\\n' \"**The checks timed out**: they were stopped at their time budget before they finished, so this is a time limit, not a test failure. Run them locally (\\`sh .relayflow/check.sh\\`) to see how long they take, or raise the check budget (\\`checkTotal\\` in the flow) and run the flow again.\"; fi; else if [ -n \"$ck_exit\" ]; then printf '%s\\n\\n' \"**The checks failed** (exit $ck_exit). The end of their output is below.\"; else printf '%s\\n\\n' \"**The checks failed.** The end of their output is below.\"; fi; fi; case \"$baseline\" in fail|timeout) printf '%s\\n' \"**The checks fail on the base commit too**, so this was not introduced by this change: it comes from the repository itself or from the environment the checks ran in. This pull request is a draft until someone looks.\" ;; pass) printf '%s\\n' \"**This change breaks checks that pass on the base commit.** The flow tried to repair it and could not. The pull request is a draft so the work is not lost; it is not ready to merge.\" ;; new) printf '%s\\n' \"**The checks this change adds fail.** The repository had no checks before this change, so there is no base commit to compare with; the flow tried to repair them and could not. The pull request is a draft so the work is not lost; it is not ready to merge.\" ;; skipped) printf '%s\\n' \"There was no time left in the run to check the base commit (base not checked), so it is not known whether this change caused this. This pull request is a draft until someone looks.\" ;; *) printf '%s\\n' \"The base commit could not be checked for comparison, so it is not known whether this change caused this. This pull request is a draft until someone looks.\" ;; esac ;; esac; if [ -s \"$parts/script\" ]; then printf '\\n
What ran (.relayflow/check.sh)\\n\\n'; relayflow_block \"$parts/script\" sh; printf '
\\n'; fi; if [ \"$check\" != pass ] && [ \"$check\" != none ] && [ \"$check\" != skipped ] && [ \"$check\" != unrunnable ] && [ -s \"$parts/check\" ]; then printf '\\n
Output on this branch (last 80 lines)\\n\\n'; relayflow_block \"$parts/check\" ''; printf '
\\n'; fi; if [ \"$baseline\" = fail ] || [ \"$baseline\" = timeout ] || [ \"$baseline\" = unknown ]; then if [ -s \"$parts/base\" ]; then printf '\\n
Output on the base commit (last 80 lines)\\n\\n'; relayflow_block \"$parts/base\" ''; printf '
\\n'; fi; fi; if [ -s \"$parts/repair\" ]; then printf '\\n### What the repair agent found\\n\\n'; cat \"$parts/repair\"; fi; } > \"$report\"; if [ -s summary.md ]; then room=$((57888 - $(LC_ALL=C wc -c < \"$report\" | tr -d \" \") - $(printf %s \"${reference:-}\" | LC_ALL=C wc -c | tr -d \" \"))); if [ -n \"${reference:-}\" ] && [ \"$(LC_ALL=C wc -c < summary.md | tr -d \" \")\" -gt \"$room\" ]; then grep -vxF -- \"$reference\" summary.md > \"$parts/summary\" || :; else cp summary.md \"$parts/summary\"; fi; relayflow_cap \"$parts/summary\" \"$room\" head \"summary.md in the run workspace\"; fi; { if [ -s \"$parts/summary\" ]; then cat \"$parts/summary\"; printf '\\n\\n'; fi; cat \"$report\"; } > .relayflow/pr-body.md; rm -rf \"$parts\"; echo \"relayflow: wrote the check report to $report.\" >&2; echo written"; + await f.run("check=" + check + "; baseline=" + verdictOf(baseline) + "; implementer_timeout=" + (implementerTimedOut ? "yes" : "no") + "; check_setup=" + checkSetup + "; check_digest=" + shellQuote(checkDigest) + "; reference=" + shellQuote(changeReference) + "; " + checkReport); + const prepareChangeMetadata = "if [ ! -s .relayflow/pr-body.md ]; then echo missing-body; exit 0; fi; relayflow_block() { block_run=$(LC_ALL=C awk '{ s = $0; while (match(s, /`+/)) { if (RLENGTH > m) m = RLENGTH; s = substr(s, RSTART + RLENGTH) } } END { print m + 0 }' \"$1\"); if [ \"${block_run:-0}\" -lt 3 ]; then block_run=2; fi; block_fence=$(printf \"%$((block_run + 1))s\" \"\" | tr \" \" '`'); printf \"%s%s\\n\" \"$block_fence\" \"$2\"; cat \"$1\"; if [ -s \"$1\" ] && [ \"$(LC_ALL=C tail -c 1 \"$1\" | wc -l | tr -d \" \")\" -eq 0 ]; then echo; fi; printf \"%s\\n\" \"$block_fence\"; }; relayflow_cap() { cap_size=$(LC_ALL=C wc -c < \"$1\" | tr -d \" \"); if [ \"$cap_size\" -le \"$2\" ]; then return 0; fi; echo \"relayflow: $1 is $cap_size bytes, more than the $2 it may hold, so it was cut; the full text is $4.\" >&2; cap_note=\"_…truncated to fit GitHub's limit: $cap_size bytes were cut to under $2. The full text is $4._\"; cap_lead=$(printf '[\\300-\\377]'); cap_cont=$(printf '[\\200-\\277]'); cap_keep=$(($2 - 512)); cap_tries=0; while :; do if [ \"$cap_keep\" -le 0 ] || [ \"$cap_tries\" -ge 3 ]; then : > \"$1.cap\"; break; fi; cap_tries=$((cap_tries + 1)); if [ \"$3\" = tail ]; then LC_ALL=C tail -c \"$cap_keep\" \"$1\" > \"$1.cut\"; else LC_ALL=C head -c \"$cap_keep\" \"$1\" > \"$1.cut\"; fi; cap_lines=$(LC_ALL=C tr -cd \"\\n\" < \"$1.cut\" | wc -c | tr -d \" \"); if [ \"$3\" = tail ]; then { printf '%s\\n\\n' \"$cap_note\"; if [ \"$cap_lines\" -gt 0 ]; then LC_ALL=C sed 1d \"$1.cut\"; else LC_ALL=C sed \"s/^$cap_cont*//\" \"$1.cut\"; fi; } > \"$1.cap\"; else { if [ \"$(LC_ALL=C tail -c 1 \"$1.cut\" | wc -l | tr -d \" \")\" -eq 1 ]; then cat \"$1.cut\"; elif [ \"$cap_lines\" -gt 0 ]; then LC_ALL=C sed '$d' \"$1.cut\"; else LC_ALL=C sed \"s/$cap_lead$cap_cont*\\$//\" \"$1.cut\"; echo; fi; } > \"$1.cap\"; { relayflow_block \"$1.cap\" text; printf \"\\n%s\\n\" \"$cap_note\"; } > \"$1.cut\"; mv -f \"$1.cut\" \"$1.cap\"; fi; cap_out=$(LC_ALL=C wc -c < \"$1.cap\" | tr -d \" \"); if [ \"$cap_out\" -le \"$2\" ]; then break; fi; cap_keep=$((cap_keep - cap_out + $2)); done; rm -f \"$1.cut\"; mv -f \"$1.cap\" \"$1\"; return 0; }; relayflow_reference() { if [ -n \"$reference\" ] && [ \"$(printf %s \"$reference\" | LC_ALL=C wc -c | tr -d \" \")\" -le 1024 ] && ! grep -qxF \"$reference\" .relayflow/pr-body.md; then printf \"\\n%s\\n\" \"$reference\" >> .relayflow/pr-body.md; fi; }; relayflow_reference; if [ \"$(LC_ALL=C wc -c < .relayflow/pr-body.md | tr -d \" \")\" -gt 57952 ]; then if [ -n \"$reference\" ]; then grep -vxF -- \"$reference\" .relayflow/pr-body.md > .relayflow/pr-body.md.tmp || :; mv -f .relayflow/pr-body.md.tmp .relayflow/pr-body.md; fi; relayflow_cap .relayflow/pr-body.md $((57952 - 2 - $(printf %s \"$reference\" | LC_ALL=C wc -c | tr -d \" \"))) head \"the run journal\"; relayflow_reference; fi; echo prepared"; + await f.run("reference=" + shellQuote(changeReference) + "; " + prepareChangeMetadata); + const validateChangeMetadata = "if [ ! -s .relayflow/pr-body.md ]; then echo missing-body; elif [ \"$(printf %s \"${reference:-}\" | LC_ALL=C wc -c | tr -d \" \")\" -gt 1024 ]; then echo reference-too-long; elif [ -z \"$title\" ]; then echo empty-title; elif ! printf \"%s\\n\" \"$title_length\" | grep -Eq \"^[0-9]+$\"; then echo malformed-title-length; elif [ \"$title_length\" -gt 240 ]; then echo title-too-long; elif [ \"$(printf %s \"$title\" | tr \"[:upper:]\" \"[:lower:]\")\" = \"software factory change\" ] || [ \"$(printf %s \"$title\" | tr \"[:upper:]\" \"[:lower:]\")\" = \"replace with your ticket title\" ]; then echo placeholder-title; elif [ \"$source\" = github ] && ! printf \"%s\\n\" \"$identifier\" | grep -Eq \"^#[1-9][0-9]*$\"; then echo malformed-github-identifier; elif [ \"$source\" = github ]; then expected=\"Fixes $identifier\"; count=$(grep -xcF \"$expected\" .relayflow/pr-body.md || true); if [ \"$count\" -eq 0 ]; then echo missing-github-closing-reference; elif [ \"$count\" -ne 1 ]; then echo duplicate-github-closing-reference; else echo valid; fi; else echo valid; fi"; + const metadataVerdict = (await f.run("reference=" + shellQuote(changeReference) + "; title=" + shellQuote(changeTitle) + "; title_length=" + changeTitleLength + "; source=" + shellQuote(issueSource) + "; identifier=" + shellQuote(issueIdentifier) + "; " + validateChangeMetadata)).trim(); + if (metadataVerdict !== "valid") { + console.error("Stopped: invalid pull-request metadata (" + metadataVerdict + "). No branch was pushed and no pull request was opened."); + return f.done("needs_human"); + } + await f.run("base=" + baseCommit + "; " + pushBranch + " --set-upstream origin HEAD", { timeout: "11m" }); + // Hosted runs put relayflow-open-change on PATH: gh pr create on GitHub, a + // merge request on GitLab. A local run has only gh. + const openChange = "open_change() { if command -v relayflow-open-change >/dev/null 2>&1; then relayflow-open-change \"$@\"; else gh pr create \"$@\"; fi; }; open_change"; + const draft = broken(check) || check === "skipped" || check === "unrunnable" || implementerTimedOut; + await f.run(openChange + " --title " + shellQuote(changeTitle) + " --body-file .relayflow/pr-body.md" + (draft ? " --draft" : ""), { timeout: "5m" }); + if (broken(check) && (baseline === "pass" || baseline === "new")) { + // The base commit passes and this branch does not, or the checks are the + // change's own and fail: the change broke them and repair could not fix + // it. That is this flow's verdict on its own work, the same as a failed + // review, so it reports step_failed. + console.error(baseline === "new" + ? "Stopped: the checks this change adds fail, and repair could not fix them. The pull request is a draft with the output." + : "Stopped: this change breaks checks that pass on the base commit, and repair could not fix it. The pull request is a draft with the output."); + return f.done("step_failed"); + } + if (broken(check)) { + // The base commit fails too, or could not be checked: nothing here says the + // change is at fault, so the review still runs and a person decides. + const outcome = check === "timeout" ? "The checks timed out (stopped at their time budget, not a test failure)" : "The checks fail"; + console.error(baseline === "skipped" + ? outcome + ", and there was no time left to check the base commit (base not checked), so it is not known whether this change caused it. The pull request is a draft with the output." + : baseline === "fail" || baseline === "timeout" + ? outcome + ", but not because of this change as far as the base commit shows. The pull request is a draft with the output of both." + : outcome + ", and the base commit could not be checked for comparison, so it is not known whether this change caused it. The pull request is a draft with the output."); + } + + // One independent adversarial review. There is no fix round: what it finds goes on the pull request. + // A review that found problems is this flow's verdict on its own work, so it + // reports done("step_failed"). The pinned runtime (2.0.15 and later) lowers that reason + // (AgentWorkforce/flows#436) and the CLI gives it exit 1, distinct from the + // exit 3 a clean run parks with. An exit code cannot carry what the reviewer + // found, so the step below still drafts the pull request and posts the + // findings to it. + const reviewBlockedCommand = "set -e; { printf '%s\\n\\n' \"**Relayflow: the adversarial review did not pass.** This branch is not approved: the flow stopped here and did not mark it ready to merge.\"; if [ \"${review_timeout:-}\" = yes ]; then if [ -n \"${review_limit:-}\" ]; then stopped_at=\"${review_limit}-minute limit\"; else stopped_at=\"time limit\"; fi; printf 'The review was stopped at its %s before it finished, so it is not a clean review. What it wrote before then is below.\\n\\n' \"$stopped_at\"; fi; if [ -s review.md ]; then cat review.md; else printf '%s\\n' \"_The reviewer left no review.md; see the review step in the run journal._\"; fi; } > review-blocked.md || true; relayflow_block() { block_run=$(LC_ALL=C awk '{ s = $0; while (match(s, /`+/)) { if (RLENGTH > m) m = RLENGTH; s = substr(s, RSTART + RLENGTH) } } END { print m + 0 }' \"$1\"); if [ \"${block_run:-0}\" -lt 3 ]; then block_run=2; fi; block_fence=$(printf \"%$((block_run + 1))s\" \"\" | tr \" \" '`'); printf \"%s%s\\n\" \"$block_fence\" \"$2\"; cat \"$1\"; if [ -s \"$1\" ] && [ \"$(LC_ALL=C tail -c 1 \"$1\" | wc -l | tr -d \" \")\" -eq 0 ]; then echo; fi; printf \"%s\\n\" \"$block_fence\"; }; relayflow_cap() { cap_size=$(LC_ALL=C wc -c < \"$1\" | tr -d \" \"); if [ \"$cap_size\" -le \"$2\" ]; then return 0; fi; echo \"relayflow: $1 is $cap_size bytes, more than the $2 it may hold, so it was cut; the full text is $4.\" >&2; cap_note=\"_…truncated to fit GitHub's limit: $cap_size bytes were cut to under $2. The full text is $4._\"; cap_lead=$(printf '[\\300-\\377]'); cap_cont=$(printf '[\\200-\\277]'); cap_keep=$(($2 - 512)); cap_tries=0; while :; do if [ \"$cap_keep\" -le 0 ] || [ \"$cap_tries\" -ge 3 ]; then : > \"$1.cap\"; break; fi; cap_tries=$((cap_tries + 1)); if [ \"$3\" = tail ]; then LC_ALL=C tail -c \"$cap_keep\" \"$1\" > \"$1.cut\"; else LC_ALL=C head -c \"$cap_keep\" \"$1\" > \"$1.cut\"; fi; cap_lines=$(LC_ALL=C tr -cd \"\\n\" < \"$1.cut\" | wc -c | tr -d \" \"); if [ \"$3\" = tail ]; then { printf '%s\\n\\n' \"$cap_note\"; if [ \"$cap_lines\" -gt 0 ]; then LC_ALL=C sed 1d \"$1.cut\"; else LC_ALL=C sed \"s/^$cap_cont*//\" \"$1.cut\"; fi; } > \"$1.cap\"; else { if [ \"$(LC_ALL=C tail -c 1 \"$1.cut\" | wc -l | tr -d \" \")\" -eq 1 ]; then cat \"$1.cut\"; elif [ \"$cap_lines\" -gt 0 ]; then LC_ALL=C sed '$d' \"$1.cut\"; else LC_ALL=C sed \"s/$cap_lead$cap_cont*\\$//\" \"$1.cut\"; echo; fi; } > \"$1.cap\"; { relayflow_block \"$1.cap\" text; printf \"\\n%s\\n\" \"$cap_note\"; } > \"$1.cut\"; mv -f \"$1.cut\" \"$1.cap\"; fi; cap_out=$(LC_ALL=C wc -c < \"$1.cap\" | tr -d \" \"); if [ \"$cap_out\" -le \"$2\" ]; then break; fi; cap_keep=$((cap_keep - cap_out + $2)); done; rm -f \"$1.cut\"; mv -f \"$1.cap\" \"$1\"; return 0; }; relayflow_cap review-blocked.md 60000 head \"review.md in the run workspace\" || true; echo \"relayflow: the adversarial review did not pass; wrote review-blocked.md.\"; if { if command -v relayflow-change >/dev/null 2>&1; then relayflow-change draft; else gh pr ready --undo; fi; } >/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; review-blocked.md still holds the findings.\" >&2; fi; if { if command -v relayflow-change >/dev/null 2>&1; then relayflow-change comment --body-file review-blocked.md; else gh pr comment --body-file review-blocked.md; fi; } >/dev/null 2>&1; then echo \"relayflow: posted the unresolved review to the pull request.\"; else echo \"relayflow: could not comment on the pull request; review-blocked.md still holds the findings.\" >&2; fi"; + const reportReviewFindingsCommand = "export LC_ALL=C; findings() { tr -d '\\000-\\010\\013-\\037\\177' < review.md | sed '/^[[:space:]]*$/d'; }; size=0; if [ -s review.md ]; then size=$(findings | wc -c | tr -d \" \"); fi; if [ \"$size\" -eq 0 ]; then echo \"relayflow report-review-findings: review.clean absent and review.md missing or empty\" && exit 0; fi; echo \"relayflow report-review-findings: review.clean absent; remaining findings from review.md:\"; findings | head -c 1700; echo; if [ \"$size\" -gt 1700 ]; then echo \"relayflow report-review-findings: cut at 1700 of $size bytes; the full review is in review-blocked.md.\"; fi; exit 0"; + // 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. + const reviewLimit = await allowance(20, 2.5); + if (reviewLimit < 8) { + await f.run("mkdir -p .relayflow; printf '%s\\n' \"**This run ran out of time before its review was done** (the flow's time 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; relayflow_block() { block_run=$(LC_ALL=C awk '{ s = $0; while (match(s, /`+/)) { if (RLENGTH > m) m = RLENGTH; s = substr(s, RSTART + RLENGTH) } } END { print m + 0 }' \"$1\"); if [ \"${block_run:-0}\" -lt 3 ]; then block_run=2; fi; block_fence=$(printf \"%$((block_run + 1))s\" \"\" | tr \" \" '`'); printf \"%s%s\\n\" \"$block_fence\" \"$2\"; cat \"$1\"; if [ -s \"$1\" ] && [ \"$(LC_ALL=C tail -c 1 \"$1\" | wc -l | tr -d \" \")\" -eq 0 ]; then echo; fi; printf \"%s\\n\" \"$block_fence\"; }; relayflow_cap() { cap_size=$(LC_ALL=C wc -c < \"$1\" | tr -d \" \"); if [ \"$cap_size\" -le \"$2\" ]; then return 0; fi; echo \"relayflow: $1 is $cap_size bytes, more than the $2 it may hold, so it was cut; the full text is $4.\" >&2; cap_note=\"_…truncated to fit GitHub's limit: $cap_size bytes were cut to under $2. The full text is $4._\"; cap_lead=$(printf '[\\300-\\377]'); cap_cont=$(printf '[\\200-\\277]'); cap_keep=$(($2 - 512)); cap_tries=0; while :; do if [ \"$cap_keep\" -le 0 ] || [ \"$cap_tries\" -ge 3 ]; then : > \"$1.cap\"; break; fi; cap_tries=$((cap_tries + 1)); if [ \"$3\" = tail ]; then LC_ALL=C tail -c \"$cap_keep\" \"$1\" > \"$1.cut\"; else LC_ALL=C head -c \"$cap_keep\" \"$1\" > \"$1.cut\"; fi; cap_lines=$(LC_ALL=C tr -cd \"\\n\" < \"$1.cut\" | wc -c | tr -d \" \"); if [ \"$3\" = tail ]; then { printf '%s\\n\\n' \"$cap_note\"; if [ \"$cap_lines\" -gt 0 ]; then LC_ALL=C sed 1d \"$1.cut\"; else LC_ALL=C sed \"s/^$cap_cont*//\" \"$1.cut\"; fi; } > \"$1.cap\"; else { if [ \"$(LC_ALL=C tail -c 1 \"$1.cut\" | wc -l | tr -d \" \")\" -eq 1 ]; then cat \"$1.cut\"; elif [ \"$cap_lines\" -gt 0 ]; then LC_ALL=C sed '$d' \"$1.cut\"; else LC_ALL=C sed \"s/$cap_lead$cap_cont*\\$//\" \"$1.cut\"; echo; fi; } > \"$1.cap\"; { relayflow_block \"$1.cap\" text; printf \"\\n%s\\n\" \"$cap_note\"; } > \"$1.cut\"; mv -f \"$1.cut\" \"$1.cap\"; fi; cap_out=$(LC_ALL=C wc -c < \"$1.cap\" | tr -d \" \"); if [ \"$cap_out\" -le \"$2\" ]; then break; fi; cap_keep=$((cap_keep - cap_out + $2)); done; rm -f \"$1.cut\"; mv -f \"$1.cap\" \"$1\"; return 0; }; relayflow_cap .relayflow/time-stop.md 60000 head \"the run journal\"; if { if command -v relayflow-change >/dev/null 2>&1; then relayflow-change draft; else gh pr ready --undo; fi; } >/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; if { if command -v relayflow-change >/dev/null 2>&1; then relayflow-change comment --body-file .relayflow/time-stop.md; else gh pr comment --body-file .relayflow/time-stop.md; fi; } >/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", { timeout: "1m" }); + console.error("Stopped: out of time before the review was done. The work is pushed and the pull request is a draft."); + return f.done("needs_human"); + } + // The reviewer starts from nothing: a review.md left in the tree would read + // as its findings if it is stopped before writing. + await f.run("rm -f review.clean review.md"); + console.error("relayflow phase: reviewing"); + const review = await f.agent("adversary", { + cli: "claude", + model: "claude-sonnet-5", + task: task + "\n" + "Review the PR diff, tests, and all PR comments. Find bugs and edge cases. Write review.md. Create review.clean only if no issues remain.", + timeout: reviewLimit + "m", + }); + // A reviewer stopped at its limit did not finish, whatever it left + // behind: its review is unresolved, never clean. + const reviewTimedOut = timedOut(review); + if (reviewTimedOut) console.error("adversary was stopped at its " + reviewLimit + "m limit before it finished, so its review is not clean and is treated as unresolved."); + const clean = !reviewTimedOut && (await f.run("test -f review.clean && echo yes || echo no")).trim() === "yes"; + // Unresolved feedback stops the flow short of approval. + if (!clean) { + await f.run("review_timeout=" + (reviewTimedOut ? "yes" : "no") + "; review_limit=" + reviewLimit + "; " + reviewBlockedCommand, { timeout: "1m" }); + // report-review-findings: the run outcome says only that "its own checks + // did not pass", so this step prints why (a bounded excerpt of review.md) + // where the journal and flows status show it. Move this text into + // done("step_failed", { detail }) once AgentWorkforce/flows#542 ships. + const reviewFindings = (await f.run(reportReviewFindingsCommand)).trim(); + // Says only what is certain: the step above reports per branch whether it + // could draft the pull request or comment on it. + console.error("The adversarial review did not pass. The findings are in review-blocked.md, and on the pull request if it could be reached. This branch is not approved.\n" + reviewFindings); + return f.done("step_failed"); + } + + // Require approving reviews and passing CI checks in GitHub branch rules. + // Stop for human review. This flow never merges the pull request. + // Review and merge in GitHub; approval happens outside the runner. + return f.done("needs_human"); + +}); From 6bf465a51224355c46d2b20c3fd5db2cbe688c49 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Fri, 9 Oct 2026 14:22:54 -0700 Subject: [PATCH 2/2] fix(flows): Software Garden v3 asks what really changed, and relays the end of the summary Review round on #192, in the generator; v3 regenerated (v1 and v2 untouched). - The change check compares the base commit with the working tree, with --no-renames, so a rename onto a working-file path still counts its source's deletion. Working files the base lacks are ignored whether committed, staged or edited again (the drop-step rule), .relayflow/ is ignored even when the repository commits it (the flow writes the run's checkCommand there before the implementer), and untracked files, which nothing publishes, do not count but are named on stderr. - The summary fallback keeps the end of the implementer's message: the flow passes a line-aligned window and the message's UTF-8 size, and the relay keeps the last whole lines (or a character-aligned tail) within 4000 bytes and 60 lines. The needs-input records and their order are unchanged. - checkSetup becomes "default" when the second resolve writes the default script, so the report suggests keeping it. - Ticket source, identifier and url read as blank when missing or not strings, instead of throwing. Co-Authored-By: Claude Opus 5.5 Session-Id: 248a5697-9493-45b7-a6fb-c23df13da25d --- web/lib/flow-workflows.ts | 101 +++++++++--- web/lib/test/flow-needs-input.test.ts | 156 ++++++++++++++++-- web/lib/test/flow-onboarding.test.ts | 2 +- .../flows/software-garden/manifest.json | 6 +- web/public/flows/software-garden/v3.flow.ts | 29 +++- 5 files changed, 247 insertions(+), 47 deletions(-) diff --git a/web/lib/flow-workflows.ts b/web/lib/flow-workflows.ts index 1f9ef78d..c8abfc62 100644 --- a/web/lib/flow-workflows.ts +++ b/web/lib/flow-workflows.ts @@ -962,40 +962,66 @@ export const FLOW_NEEDS_INPUT_FILE = '.relayflow/needs-input.md'; export const FLOW_NEEDS_INPUT_HINT = `If the ticket is ambiguous, or you cannot identify what it refers to (a file, document, feature or component you cannot find), do not guess and do not commit. Instead write ${FLOW_NEEDS_INPUT_FILE}: the specific question or questions the ticket's author must answer, and the candidates you found, each with its path (for example "Did you mean docs/native-gamification-plan.md?"). Keep it short and plain; it is posted back to the ticket.`; /** - * Says whether the implementer changed anything, right after it finishes, so a - * run with nothing to check stops before the checks, the repairs and the - * base-commit comparison (run 91c1a5cd spent 17 minutes and $1.63 on them for - * a branch with no commits). The caller prefixes `base=`. + * Says whether the implementer changed the repository's content, right after + * it finishes, so a run with nothing to check stops before the checks, the + * repairs and the base-commit comparison (run 91c1a5cd spent 17 minutes and + * $1.63 on them for a branch with no commits). The caller prefixes + * `base=`. * - * unchanged no commits since the base, and nothing uncommitted - * changed commits, or uncommitted edits (the checks run on the tree) + * unchanged the tracked files hold what the base commit holds + * changed commits, or edits to tracked files (the checks run on the tree) * unknown the base commit or the tree could not be read * + * It compares the base commit with the working tree, so committed, staged and + * unstaged edits all count, an edit undone before the end does not, and a + * rename (compared with --no-renames) counts its source's deletion as well as + * its destination. These do not count, whether committed, staged or edited: + * + * - working files the base commit does not have, the same rule + * FLOW_DROP_WORKING_FILES_COMMAND uses to take them out of the branch; + * untracked ones are hidden by the clone's exclude file + * (FLOW_EXCLUDE_WORKING_FILES_COMMAND). A repository's own summary.md + * is published, so an edit to it is a change; + * - anything under `.relayflow/`, even when the repository commits it: the + * flow writes `.relayflow/check.sh` there itself before the implementer + * starts (a run's checkCommand), so a difference there is not its work; + * - untracked files: nothing in the flow adds them to a commit, so they + * could never be published, and an agent's scratch notes must not hide + * its question. Up to five are named on stderr. + * * Only `unchanged` stops the run, so anything this cannot vouch for carries on - * as before. Working files an agent committed that the base commit does not - * have (FLOW_DROP_WORKING_FILES_COMMAND removes them before publishing) do not - * count as a change; untracked working files are hidden by the clone's - * exclude file (FLOW_EXCLUDE_WORKING_FILES_COMMAND). One token on stdout, exit - * 0 on every path. + * as before. One token on stdout, exit 0 on every path. */ export const FLOW_CHANGE_CHECK_COMMAND = [ `paths=${shq(FLOW_WORKING_FILES.join(' '))}`, - 'if [ -z "$base" ] || ! git rev-parse --verify --quiet "$base^{commit}" >/dev/null 2>&1 || ! names=$(git diff --name-only "$base" HEAD -- 2>/dev/null) || ! pending=$(git status --porcelain 2>/dev/null); then echo "relayflow: could not tell whether the implementer changed anything; carrying on." >&2; echo unknown; exit 0; fi', - 'committed=$(printf \'%s\\n\' "$names" | while IFS= read -r p; do [ -n "$p" ] || continue; keep=yes; for w in $paths; do case "$p" in ("$w"|"$w"/*) git cat-file -e "$base:$p" 2>/dev/null || keep=no ;; esac; done; if [ "$keep" = yes ]; then printf \'%s\\n\' "$p"; fi; done)', - 'if [ -n "$committed" ] || [ -n "$pending" ]; then echo changed; else echo "relayflow: the implementer made no commits and left no changes." >&2; echo unchanged; fi', + 'if [ -z "$base" ] || ! git rev-parse --verify --quiet "$base^{commit}" >/dev/null 2>&1 || ! names=$(git diff --no-ext-diff --no-renames --name-only "$base" -- 2>/dev/null); then echo "relayflow: could not tell whether the implementer changed anything; carrying on." >&2; echo unknown; exit 0; fi', + 'changes=$(printf \'%s\\n\' "$names" | while IFS= read -r p; do [ -n "$p" ] || continue; keep=yes; for w in $paths; do case "$p" in ("$w"|"$w"/*) if [ "$w" = .relayflow ] || ! git cat-file -e "$base:$p" 2>/dev/null; then keep=no; fi ;; esac; done; if [ "$keep" = yes ]; then printf \'%s\\n\' "$p"; fi; done)', + 'if [ -n "$changes" ]; then echo changed; exit 0; fi', + 'loose=$(git ls-files --others --exclude-standard 2>/dev/null | head -n 5 | tr \'\\n\' \' \')', + 'if [ -n "$loose" ]; then echo "relayflow: untracked files were never added or committed, so they are not a change: $loose" >&2; fi', + 'echo "relayflow: the implementer changed nothing in the repository." >&2; echo unchanged', ].join('; '); /** The most, in bytes, of the implementer's question FLOW_NEEDS_INPUT_COMMAND prints. */ export const FLOW_NEEDS_INPUT_LIMIT = 4000; /** The most lines of it FLOW_NEEDS_INPUT_COMMAND prints. */ const NEEDS_INPUT_LINES = 60; +/** + * How much of the implementer's last message, in UTF-16 code units, the flow + * hands FLOW_NEEDS_INPUT_COMMAND: enough to fill FLOW_NEEDS_INPUT_LIMIT bytes + * after sanitizing, and small enough (at most 64 KB once quoted) for one + * shell argument. + */ +export const NEEDS_INPUT_SUMMARY_WINDOW = FLOW_NEEDS_INPUT_LIMIT * 4; /** * Relays why the implementer made no change, when it made none * (FLOW_CHANGE_CHECK_COMMAND said `unchanged`), in a form Cloud can parse and * post back to the ticket. The text is FLOW_NEEDS_INPUT_FILE when the * implementer wrote one, otherwise `agent_summary` (the caller sets it to the - * end of the implementer's own last message, bounded), otherwise nothing. + * end of the implementer's own last message, NEEDS_INPUT_SUMMARY_WINDOW + * characters from a line start, and `agent_summary_bytes` to the whole + * message's UTF-8 size when it had to cut it), otherwise nothing. * * The output contract, on stdout, one record per line: * @@ -1009,8 +1035,12 @@ const NEEDS_INPUT_LINES = 60; * returns included) are removed, tabs become spaces, each line is trimmed and blank * lines are dropped. It is then cut to FLOW_NEEDS_INPUT_LIMIT bytes, at a line * boundary (or a character boundary when one line is all there is), and to - * NEEDS_INPUT_LINES lines. Every printed line carries a prefix, so nothing in - * the text can forge a record. Exit 0 on every path. + * NEEDS_INPUT_LINES lines. A needs-input.md keeps its start, where the + * question was written to go; the implementer's summary keeps its end, where + * an agent's last message asks it. `` is the size before any cut, + * the whole message's for a summary the caller cut. Every printed line + * carries a prefix, so nothing in the text can forge a record. Exit 0 on + * every path. */ export const FLOW_NEEDS_INPUT_COMMAND = [ 'export LC_ALL=C', @@ -1018,9 +1048,13 @@ export const FLOW_NEEDS_INPUT_COMMAND = [ `relayflow_clean() { sed "s/$(printf '\\033')\\[[0-9;?]*[A-Za-z]//g" | tr -d '\\000-\\010\\013-\\037\\177' | tr '\\t' ' ' | awk 'NF { sub(/^ +/, ""); sub(/ +$/, ""); print }'; }`, `src=none; if [ -s ${FLOW_NEEDS_INPUT_FILE} ] && [ -n "$(relayflow_clean < ${FLOW_NEEDS_INPUT_FILE})" ]; then src=needs-input.md; relayflow_clean < ${FLOW_NEEDS_INPUT_FILE} > "$out"; elif [ -n "$(printf '%s\\n' "\${agent_summary:-}" | relayflow_clean)" ]; then src=implementer-summary; printf '%s\\n' "$agent_summary" | relayflow_clean > "$out"; else : > "$out"; fi`, 'echo "relayflow needs-input-source: $src"', - 'total=$(wc -c < "$out" | tr -d " ")', - `if [ "$total" -gt ${FLOW_NEEDS_INPUT_LIMIT} ]; then head -c ${FLOW_NEEDS_INPUT_LIMIT} "$out" > "$out.cut"; if [ "$(tail -c 1 "$out.cut" | wc -l | tr -d " ")" -eq 1 ]; then cp "$out.cut" "$out.kept"; elif [ "$(tr -cd "\\n" < "$out.cut" | wc -c | tr -d " ")" -gt 0 ]; then sed '$d' "$out.cut" > "$out.kept"; else sed "s/$(printf '[\\300-\\377]')$(printf '[\\200-\\277]')*\\$//" "$out.cut" | awk 'NF { print }' > "$out.kept"; fi; mv -f "$out.kept" "$out"; rm -f "$out.cut"; fi`, - `head -n ${NEEDS_INPUT_LINES} "$out" > "$out.kept"; mv -f "$out.kept" "$out"; kept=$(wc -c < "$out" | tr -d " ")`, + 'size=$(wc -c < "$out" | tr -d " "); total=$size', + 'case "${agent_summary_bytes:-}" in (""|*[!0-9]*) ;; (*) if [ "$src" = implementer-summary ] && [ "$agent_summary_bytes" -gt "$total" ]; then total=$agent_summary_bytes; fi ;; esac', + // A summary keeps its end: the whole lines that fit, or the tail of its only + // line from a character boundary. Anything else keeps its start the same way. + `if [ "$size" -gt ${FLOW_NEEDS_INPUT_LIMIT} ] && [ "$src" = implementer-summary ]; then tail -c ${FLOW_NEEDS_INPUT_LIMIT} "$out" > "$out.cut"; if [ "$(tail -c $((${FLOW_NEEDS_INPUT_LIMIT} + 1)) "$out" | head -c 1 | wc -l | tr -d " ")" -eq 1 ]; then cp "$out.cut" "$out.kept"; elif [ "$(tr -cd "\\n" < "$out.cut" | wc -c | tr -d " ")" -gt 1 ]; then sed 1d "$out.cut" > "$out.kept"; else sed "s/^$(printf '[\\200-\\277]')*//" "$out.cut" | awk 'NF { sub(/^ +/, ""); print }' > "$out.kept"; fi; mv -f "$out.kept" "$out"; rm -f "$out.cut"` + + `; elif [ "$size" -gt ${FLOW_NEEDS_INPUT_LIMIT} ]; then head -c ${FLOW_NEEDS_INPUT_LIMIT} "$out" > "$out.cut"; if [ "$(tail -c 1 "$out.cut" | wc -l | tr -d " ")" -eq 1 ]; then cp "$out.cut" "$out.kept"; elif [ "$(tr -cd "\\n" < "$out.cut" | wc -c | tr -d " ")" -gt 0 ]; then sed '$d' "$out.cut" > "$out.kept"; else sed "s/$(printf '[\\300-\\377]')$(printf '[\\200-\\277]')*\\$//" "$out.cut" | awk 'NF { print }' > "$out.kept"; fi; mv -f "$out.kept" "$out"; rm -f "$out.cut"; fi`, + `if [ "$src" = implementer-summary ]; then tail -n ${NEEDS_INPUT_LINES} "$out" > "$out.kept"; else head -n ${NEEDS_INPUT_LINES} "$out" > "$out.kept"; fi; mv -f "$out.kept" "$out"; kept=$(wc -c < "$out" | tr -d " ")`, 'sed "s/^/relayflow needs-input: /" "$out"', 'if [ "$kept" -lt "$total" ]; then echo "relayflow needs-input-truncated: $kept of $total bytes"; fi', 'rm -f "$out"', @@ -1143,9 +1177,12 @@ export function workflowCode(workflow: WorkflowId, agents: ReturnType typeof value === "string" ? value.trim() : ""; + const issueSource = field(issue.source).toLowerCase(); + const issueIdentifier = field(issue.identifier); + const issueUrl = field(issue.url); if (!changeTitle || placeholderTitle) { console.error("Stopped: the pull-request title is empty or still a placeholder. No branch was pushed and no pull request was opened."); return f.done("needs_human"); @@ -1287,9 +1324,20 @@ export function workflowCode(workflow: WorkflowId, agents: ReturnType { + const point = character.codePointAt(0) ?? 0; + return bytes + (point < 0x80 ? 1 : point < 0x800 ? 2 : point < 0x10000 ? 3 : 4); + }, 0)); + } + const needsInput = (await f.run("agent_summary=" + shellQuote(agentSummary) + "; agent_summary_bytes=" + summaryBytes + "; " + ${JSON.stringify(FLOW_NEEDS_INPUT_COMMAND)})).trim(); console.error("Stopped: the implementer made no changes, so there was nothing to check or publish. No checks ran, no branch was pushed and no pull request was opened. Its question or reason follows, when it gave one: answer it on the ticket and run again.\\n" + needsInput); return f.done("needs_human"); } @@ -1297,7 +1345,8 @@ export function workflowCode(workflow: WorkflowId, agents: ReturnType { expect(changeCheck(root, base)).toMatchObject({ code: 0, token: 'unchanged' }); }); - it('says changed for a commit, and for uncommitted or untracked work, which the checks still see', () => { + it('says changed for a commit, and for staged or unstaged edits to tracked files, which the checks still see', () => { const committed = repository(); write(committed.root, 'app.txt', 'work\n'); git(committed.root, 'commit', '-qam', 'work'); @@ -69,9 +69,13 @@ describe('FLOW_CHANGE_CHECK_COMMAND', () => { const edited = repository(); write(edited.root, 'app.txt', 'work\n'); expect(changeCheck(edited.root, edited.base).token).toBe('changed'); - const added = repository(); - write(added.root, 'src/new.ts', 'export {};\n'); - expect(changeCheck(added.root, added.base).token).toBe('changed'); + const staged = repository(); + write(staged.root, 'src/new.ts', 'export {};\n'); + git(staged.root, 'add', 'src/new.ts'); + expect(changeCheck(staged.root, staged.base).token).toBe('changed'); + const deleted = repository(); + git(deleted.root, 'rm', '-q', 'app.txt'); + expect(changeCheck(deleted.root, deleted.base).token).toBe('changed'); // A repository's own summary.md is not a working file to ignore. const own = repository(); write(own.root, 'summary.md', 'v1\n'); @@ -83,6 +87,66 @@ describe('FLOW_CHANGE_CHECK_COMMAND', () => { expect(changeCheck(own.root, ownBase).token).toBe('changed'); }); + it('counts a rename onto a working-file path as the deletion of its source', () => { + // Rename detection would report only the destination, summary.md, which + // the base lacks and is ignored: the deleted app.txt must still count. + const { root, base } = repository(); + git(root, 'mv', 'app.txt', 'summary.md'); + git(root, 'commit', '-qm', 'rename'); + expect(changeCheck(root, base).token).toBe('changed'); + const unstaged = repository(); + git(unstaged.root, 'mv', 'app.txt', 'plan.md'); + expect(changeCheck(unstaged.root, unstaged.base).token).toBe('changed'); + }); + + it('ignores a committed working file even when it is edited again afterwards', () => { + // Publishing removes the commit that adds summary.md, so neither it nor a + // later edit to it is a change, staged or not. + const { root, base } = repository(); + write(root, 'summary.md', 'Nothing to do.\n'); + git(root, 'add', '-f', 'summary.md'); + git(root, 'commit', '-qm', 'summary'); + write(root, 'summary.md', 'Nothing to do, and here is why.\n'); + expect(changeCheck(root, base).token).toBe('unchanged'); + git(root, 'add', '-f', 'summary.md'); + expect(changeCheck(root, base).token).toBe('unchanged'); + }); + + it('ignores .relayflow/, even when the repository commits its check script', () => { + // The flow writes the run's checkCommand over a committed check script + // before the implementer starts; that difference is not the implementer's. + const { root } = repository(); + write(root, FLOW_CHECK_SCRIPT, 'set -e\nmake test\n'); + git(root, 'add', '-f', FLOW_CHECK_SCRIPT); + git(root, 'commit', '-qm', 'check script'); + const base = git(root, 'rev-parse', 'HEAD').trim(); + write(root, FLOW_CHECK_SCRIPT, 'set -e\nnpm test\n'); + expect(changeCheck(root, base).token).toBe('unchanged'); + git(root, 'commit', '-qam', 'edit check script'); + expect(changeCheck(root, base).token).toBe('unchanged'); + }); + + it('does not count untracked files, which nothing publishes, but names them', () => { + const { root, base } = repository(); + write(root, 'notes.txt', 'scratch\n'); + write(root, 'src/new.ts', 'export {};\n'); + const result = changeCheck(root, base); + expect(result).toMatchObject({ code: 0, token: 'unchanged' }); + expect(result.stderr).toContain('notes.txt'); + expect(result.stderr).toContain('src/new.ts'); + }); + + it('says unchanged when an edit was undone, committed or not', () => { + const { root, base } = repository(); + write(root, 'app.txt', 'work\n'); + git(root, 'commit', '-qam', 'work'); + git(root, 'revert', '--no-edit', 'HEAD'); + expect(changeCheck(root, base).token).toBe('unchanged'); + write(root, 'app.txt', 'work\n'); + write(root, 'app.txt', 'base\n'); + expect(changeCheck(root, base).token).toBe('unchanged'); + }); + it('says unknown, and never unchanged, when it cannot tell', () => { const { root } = repository(); expect(changeCheck(root, '')).toMatchObject({ code: 0, token: 'unknown' }); @@ -156,6 +220,41 @@ describe('FLOW_NEEDS_INPUT_COMMAND', () => { expect(Buffer.from(one.stdout, 'utf8').toString('utf8')).not.toContain('�'); }); + it('keeps the end of a long implementer summary, where its question is, cut on byte boundaries', () => { + const { root } = repository(); + // One line of multibyte text: its tail, from a character boundary. + const line = 'é'.repeat(3000) + ' Which document did you mean?'; + const one = relay(root, line); + const parsed = records(one.stdout); + expect(parsed.source).toEqual(['implementer-summary']); + expect(parsed.question).toHaveLength(1); + expect(parsed.question[0]!.endsWith('Which document did you mean?')).toBe(true); + expect(Buffer.byteLength(parsed.question[0]!) + 1).toBeLessThanOrEqual(FLOW_NEEDS_INPUT_LIMIT); + expect(parsed.question[0]).toMatch(/^é+ Which/); + expect(parsed.truncated).toEqual([`relayflow needs-input-truncated: ${Buffer.byteLength(parsed.question[0]!) + 1} of ${Buffer.byteLength(line) + 1} bytes`]); + expect(one.stdout).not.toContain('\uFFFD'); + // Many lines: the last whole lines that fit, never a partial first one. + const lines = Array.from({ length: 300 }, (_, i) => `${i} ${'ü'.repeat(20)}`).concat('Which file is the README?'); + const many = records(relay(root, lines.join('\n')).stdout); + expect(many.question.at(-1)).toBe('Which file is the README?'); + expect(many.question.length).toBeLessThanOrEqual(60); + expect(many.question).toEqual(lines.slice(-many.question.length)); + expect(many.question.join('\n').length + many.question.length).toBeLessThanOrEqual(FLOW_NEEDS_INPUT_LIMIT); + // More than 60 short lines: the last 60. + const short = Array.from({ length: 100 }, (_, i) => `line ${i}`); + expect(records(relay(root, short.join('\n')).stdout).question).toEqual(short.slice(-60)); + }); + + it('reports the whole summary\'s size when the caller had to cut it', () => { + const { root } = repository(); + const result = sh(`agent_summary='Which file?'; agent_summary_bytes=90000; ${FLOW_NEEDS_INPUT_COMMAND}`, root); + expect(records(result.stdout)).toMatchObject({ question: ['Which file?'], truncated: ['relayflow needs-input-truncated: 12 of 90000 bytes'] }); + // A size that is not a number, or one for a needs-input.md, changes nothing. + expect(records(sh(`agent_summary='Which file?'; agent_summary_bytes='1; x'; ${FLOW_NEEDS_INPUT_COMMAND}`, root).stdout).truncated).toEqual([]); + write(root, FLOW_NEEDS_INPUT_FILE, 'Which file?\n'); + expect(records(sh(`agent_summary=''; agent_summary_bytes=90000; ${FLOW_NEEDS_INPUT_COMMAND}`, root).stdout).truncated).toEqual([]); + }); + it('leaves nothing behind but the question itself', () => { const { root } = repository(); write(root, FLOW_NEEDS_INPUT_FILE, 'Which file?\n'); @@ -212,6 +311,8 @@ type Scenario = { implementerTimedOut?: boolean; summary?: string; checks?: string[]; + /** What each FLOW_CHECK_RESOLVE_COMMAND prints, in order. */ + resolves?: string[]; input?: Record; workflow?: FactoryDraft['workflow']; edit?: (source: string) => string; @@ -228,6 +329,7 @@ async function runFlow(scenario: Scenario = {}) { const errors: string[] = []; let finish = ''; let checkIndex = 0; + let resolveIndex = 0; const originalError = console.error; console.error = (message: string) => { errors.push(String(message)); }; try { @@ -250,6 +352,7 @@ async function runFlow(scenario: Scenario = {}) { return sh(command, root).stdout; } if (command.endsWith(FLOW_CHECK_RUN_COMMAND)) return scenario.checks?.[checkIndex++] ?? 'pass'; + if (command === FLOW_CHECK_RESOLVE_COMMAND) return scenario.resolves?.[resolveIndex++] ?? 'script'; if (command.endsWith(FLOW_BASE_CHECK_COMMAND)) return 'fail'; if (command.endsWith(FLOW_PUBLISH_CHECK_COMMAND)) return 'publish'; if (command.includes('validate') || command.includes('missing-github-closing-reference')) return 'valid'; @@ -293,13 +396,28 @@ describe('the generated Garden flow', () => { expect(stop).toContain('\nrelayflow needs-input-source: needs-input.md\nrelayflow needs-input: Which file is the "gamification README"?\nrelayflow needs-input: Did you mean docs/native-gamification-plan.md?'); }); - it('hands the implementer\'s own last message to the relay, bounded', async () => { - const summary = 'x'.repeat(10_000) + 'I asked about the gamification README.'; + it('hands the end of the implementer\'s own last message to the relay, from a line start (Cursor, cubic)', async () => { + // Multibyte text: a character limit and a byte limit are not the same, and + // the question at the end must survive both. + const summary = Array.from({ length: 2000 }, (_, i) => `${i} ${'é'.repeat(20)}`).join('\n') + '\nI asked about the gamification README.'; const { calls } = await runFlow({ changed: 'unchanged', summary }); const relayCall = calls.find(call => call.endsWith(FLOW_NEEDS_INPUT_COMMAND))!; const value = /^agent_summary='([^']*)'; /.exec(relayCall)![1]!; - expect(value.length).toBe(FLOW_NEEDS_INPUT_LIMIT); - expect(value.endsWith('I asked about the gamification README.')).toBe(true); + expect(value.length).toBeLessThanOrEqual(NEEDS_INPUT_SUMMARY_WINDOW); + expect(summary.endsWith(value)).toBe(true); + expect(summary[summary.length - value.length - 1]).toBe('\n'); + expect(relayCall).toContain(`; agent_summary_bytes=${Buffer.byteLength(summary)}; `); + // The flow's own call, run for real where the implementer wrote no file. + const { root } = repository(); + const parsed = records(sh(relayCall, root).stdout); + expect(parsed.source).toEqual(['implementer-summary']); + expect(parsed.question.at(-1)).toBe('I asked about the gamification README.'); + expect(parsed.question[0]).toMatch(/^\d+ é{20}$/); + expect(parsed.truncated).toHaveLength(1); + expect(parsed.truncated[0]).toMatch(new RegExp(`^relayflow needs-input-truncated: \\d+ of ${Buffer.byteLength(summary)} bytes$`)); + // A short message is passed whole, with no size. + const short = await runFlow({ changed: 'unchanged', summary: 'Which file?' }); + expect(short.calls.find(call => call.endsWith(FLOW_NEEDS_INPUT_COMMAND))).toMatch(/^agent_summary='Which file\?'; agent_summary_bytes=; /); }); it('carries on as before when there are changes, when it cannot tell, or when the implementer timed out', async () => { @@ -351,6 +469,24 @@ describe('the generated Garden flow', () => { expect(clean.calls.find(call => call.startsWith('check=pass; '))).toContain("check_digest=''; "); }); + it('says a script it wrote after the change is the default, so the report suggests keeping it (CodeRabbit, cubic)', async () => { + // Nothing to run before the change; the change added a project file, so the + // second resolve wrote the ecosystem default. + const added = await runFlow({ resolves: ['none', 'default'] }); + expect(added.calls.filter(call => call === FLOW_CHECK_RESOLVE_COMMAND)).toHaveLength(2); + expect(added.calls.find(call => call.startsWith('check=pass; '))).toContain('check_setup=default; '); + const still = await runFlow({ resolves: ['none', 'none'] }); + expect(still.calls.find(call => call.startsWith('check=skipped; ') || call.startsWith('check=none; ') || call.startsWith('check=pass; '))).toContain('check_setup=discovered; '); + }); + + it('treats missing or non-string ticket metadata as blank instead of throwing (CodeRabbit, cubic)', async () => { + for (const metadata of [{ source: undefined }, { source: 42, identifier: 7, url: null }]) { + const { finish, calls } = await runFlow({ input: { issue: { ...issue, ...metadata } } }); + expect(finish, JSON.stringify(metadata)).toBe('needs_human'); + expect(calls, JSON.stringify(metadata)).toContain('agent:implementer'); + } + }); + it('prints one progress line per phase change', async () => { const { errors } = await runFlow({ checks: ['fail', 'pass'] }); expect(errors.filter(error => error.startsWith('relayflow phase: '))).toEqual([ diff --git a/web/lib/test/flow-onboarding.test.ts b/web/lib/test/flow-onboarding.test.ts index 8d2e229a..5df46747 100644 --- a/web/lib/test/flow-onboarding.test.ts +++ b/web/lib/test/flow-onboarding.test.ts @@ -102,7 +102,7 @@ describe('software factory onboarding', () => { for (const workflow of ['simple', 'traditional', 'prototype'] as const) { const source = factorySource({ ...DEFAULT_FACTORY, sources: ['github'], agents: ['claude'], workflow, step: 3 }); const implementer = source.indexOf('f.agent("implementer"'); - const again = source.indexOf('if (checkPlan === "none") await f.run(resolveChecks);'); + const again = source.indexOf('if (checkPlan === "none" && (await f.run(resolveChecks)).trim() === "default") checkSetup = "default";'); expect(implementer, workflow).toBeGreaterThan(-1); expect(again, workflow).toBeGreaterThan(implementer); expect(source.indexOf('await spannedCheck(checkLimit)'), workflow).toBeGreaterThan(again); diff --git a/web/public/flows/software-garden/manifest.json b/web/public/flows/software-garden/manifest.json index 3c72fb45..9ecdcf4d 100644 --- a/web/public/flows/software-garden/manifest.json +++ b/web/public/flows/software-garden/manifest.json @@ -2,8 +2,8 @@ "name": "Software Garden", "catalogId": "software-factory", "version": 3, - "sha256": "22dcab992e015d2620d8d90836ff0fadff2349bf679182bba854ad92f304c5ce", - "bytes": 93601, + "sha256": "a54a282e0ac607bf4481aee5c3353c7a5c63178da44080ba72ff872b24fcba73", + "bytes": 95632, "path": "web/public/flows/software-garden/v3.flow.ts", "url": "https://agentrelay.com/flows/software-garden/v3.flow.ts", "target": "cloud", @@ -32,7 +32,7 @@ }, { "version": 3, - "sha256": "22dcab992e015d2620d8d90836ff0fadff2349bf679182bba854ad92f304c5ce" + "sha256": "a54a282e0ac607bf4481aee5c3353c7a5c63178da44080ba72ff872b24fcba73" } ] } diff --git a/web/public/flows/software-garden/v3.flow.ts b/web/public/flows/software-garden/v3.flow.ts index 4cfb346a..3236a845 100644 --- a/web/public/flows/software-garden/v3.flow.ts +++ b/web/public/flows/software-garden/v3.flow.ts @@ -39,9 +39,12 @@ export default flow("software-factory", const changeTitleLength = Array.from(changeTitle).length; const placeholderTitle = ["software factory change", "replace with your ticket title"] .includes(changeTitle.toLowerCase()); - const issueSource = issue.source.trim().toLowerCase(); - const issueIdentifier = issue.identifier?.trim() ?? ""; - const issueUrl = issue.url?.trim() ?? ""; + // A local input may leave ticket metadata out or give it another type: it + // reads as blank, where calling .trim() on it would throw. + const field = (value: unknown) => typeof value === "string" ? value.trim() : ""; + const issueSource = field(issue.source).toLowerCase(); + const issueIdentifier = field(issue.identifier); + const issueUrl = field(issue.url); if (!changeTitle || placeholderTitle) { console.error("Stopped: the pull-request title is empty or still a placeholder. No branch was pushed and no pull request was opened."); return f.done("needs_human"); @@ -151,11 +154,22 @@ export default flow("software-factory", // what the ticket meant, or found nothing to do) leaves nothing to check, // repair or compare: stop now and relay its question (run 91c1a5cd spent 17 // minutes on an unchanged branch). Only a definite "unchanged" stops here. - if (!implementerTimedOut && (await f.run("base=" + baseCommit + "; " + "paths='summary.md plan.md reviewed-plan.md review.md review.clean review-blocked.md comparison.md prototype-notes.md .relayflow START-HERE.txt flow-input.json relay-preflight.mjs software-factory.flow.mts'; if [ -z \"$base\" ] || ! git rev-parse --verify --quiet \"$base^{commit}\" >/dev/null 2>&1 || ! names=$(git diff --name-only \"$base\" HEAD -- 2>/dev/null) || ! pending=$(git status --porcelain 2>/dev/null); then echo \"relayflow: could not tell whether the implementer changed anything; carrying on.\" >&2; echo unknown; exit 0; fi; committed=$(printf '%s\\n' \"$names\" | while IFS= read -r p; do [ -n \"$p\" ] || continue; keep=yes; for w in $paths; do case \"$p\" in (\"$w\"|\"$w\"/*) git cat-file -e \"$base:$p\" 2>/dev/null || keep=no ;; esac; done; if [ \"$keep\" = yes ]; then printf '%s\\n' \"$p\"; fi; done); if [ -n \"$committed\" ] || [ -n \"$pending\" ]; then echo changed; else echo \"relayflow: the implementer made no commits and left no changes.\" >&2; echo unchanged; fi")).trim() === "unchanged") { + if (!implementerTimedOut && (await f.run("base=" + baseCommit + "; " + "paths='summary.md plan.md reviewed-plan.md review.md review.clean review-blocked.md comparison.md prototype-notes.md .relayflow START-HERE.txt flow-input.json relay-preflight.mjs software-factory.flow.mts'; if [ -z \"$base\" ] || ! git rev-parse --verify --quiet \"$base^{commit}\" >/dev/null 2>&1 || ! names=$(git diff --no-ext-diff --no-renames --name-only \"$base\" -- 2>/dev/null); then echo \"relayflow: could not tell whether the implementer changed anything; carrying on.\" >&2; echo unknown; exit 0; fi; changes=$(printf '%s\\n' \"$names\" | while IFS= read -r p; do [ -n \"$p\" ] || continue; keep=yes; for w in $paths; do case \"$p\" in (\"$w\"|\"$w\"/*) if [ \"$w\" = .relayflow ] || ! git cat-file -e \"$base:$p\" 2>/dev/null; then keep=no; fi ;; esac; done; if [ \"$keep\" = yes ]; then printf '%s\\n' \"$p\"; fi; done); if [ -n \"$changes\" ]; then echo changed; exit 0; fi; loose=$(git ls-files --others --exclude-standard 2>/dev/null | head -n 5 | tr '\\n' ' '); if [ -n \"$loose\" ]; then echo \"relayflow: untracked files were never added or committed, so they are not a change: $loose\" >&2; fi; echo \"relayflow: the implementer changed nothing in the repository.\" >&2; echo unchanged")).trim() === "unchanged") { // Its own last message stands in when it wrote no .relayflow/needs-input.md. + // The question is at its end, so the relay gets the end: from a line + // start, with the whole message's size in bytes when it was cut. const said = (implementation as unknown as { summary?: unknown } | undefined)?.summary; - const agentSummary = typeof said === "string" ? said.slice(-4000) : ""; - const needsInput = (await f.run("agent_summary=" + shellQuote(agentSummary) + "; " + "export LC_ALL=C; mkdir -p .relayflow; out=.relayflow/needs-input.out; relayflow_clean() { sed \"s/$(printf '\\033')\\[[0-9;?]*[A-Za-z]//g\" | tr -d '\\000-\\010\\013-\\037\\177' | tr '\\t' ' ' | awk 'NF { sub(/^ +/, \"\"); sub(/ +$/, \"\"); print }'; }; src=none; if [ -s .relayflow/needs-input.md ] && [ -n \"$(relayflow_clean < .relayflow/needs-input.md)\" ]; then src=needs-input.md; relayflow_clean < .relayflow/needs-input.md > \"$out\"; elif [ -n \"$(printf '%s\\n' \"${agent_summary:-}\" | relayflow_clean)\" ]; then src=implementer-summary; printf '%s\\n' \"$agent_summary\" | relayflow_clean > \"$out\"; else : > \"$out\"; fi; echo \"relayflow needs-input-source: $src\"; total=$(wc -c < \"$out\" | tr -d \" \"); if [ \"$total\" -gt 4000 ]; then head -c 4000 \"$out\" > \"$out.cut\"; if [ \"$(tail -c 1 \"$out.cut\" | wc -l | tr -d \" \")\" -eq 1 ]; then cp \"$out.cut\" \"$out.kept\"; elif [ \"$(tr -cd \"\\n\" < \"$out.cut\" | wc -c | tr -d \" \")\" -gt 0 ]; then sed '$d' \"$out.cut\" > \"$out.kept\"; else sed \"s/$(printf '[\\300-\\377]')$(printf '[\\200-\\277]')*\\$//\" \"$out.cut\" | awk 'NF { print }' > \"$out.kept\"; fi; mv -f \"$out.kept\" \"$out\"; rm -f \"$out.cut\"; fi; head -n 60 \"$out\" > \"$out.kept\"; mv -f \"$out.kept\" \"$out\"; kept=$(wc -c < \"$out\" | tr -d \" \"); sed \"s/^/relayflow needs-input: /\" \"$out\"; if [ \"$kept\" -lt \"$total\" ]; then echo \"relayflow needs-input-truncated: $kept of $total bytes\"; fi; rm -f \"$out\"; exit 0")).trim(); + const message = typeof said === "string" ? said : ""; + let agentSummary = message.slice(-16000); + let summaryBytes = ""; + if (agentSummary.length < message.length) { + agentSummary = agentSummary.includes("\n") ? agentSummary.slice(agentSummary.indexOf("\n") + 1) : agentSummary.replace(/^[\uDC00-\uDFFF]/, ""); + summaryBytes = String(Array.from(message).reduce((bytes, character) => { + const point = character.codePointAt(0) ?? 0; + return bytes + (point < 0x80 ? 1 : point < 0x800 ? 2 : point < 0x10000 ? 3 : 4); + }, 0)); + } + const needsInput = (await f.run("agent_summary=" + shellQuote(agentSummary) + "; agent_summary_bytes=" + summaryBytes + "; " + "export LC_ALL=C; mkdir -p .relayflow; out=.relayflow/needs-input.out; relayflow_clean() { sed \"s/$(printf '\\033')\\[[0-9;?]*[A-Za-z]//g\" | tr -d '\\000-\\010\\013-\\037\\177' | tr '\\t' ' ' | awk 'NF { sub(/^ +/, \"\"); sub(/ +$/, \"\"); print }'; }; src=none; if [ -s .relayflow/needs-input.md ] && [ -n \"$(relayflow_clean < .relayflow/needs-input.md)\" ]; then src=needs-input.md; relayflow_clean < .relayflow/needs-input.md > \"$out\"; elif [ -n \"$(printf '%s\\n' \"${agent_summary:-}\" | relayflow_clean)\" ]; then src=implementer-summary; printf '%s\\n' \"$agent_summary\" | relayflow_clean > \"$out\"; else : > \"$out\"; fi; echo \"relayflow needs-input-source: $src\"; size=$(wc -c < \"$out\" | tr -d \" \"); total=$size; case \"${agent_summary_bytes:-}\" in (\"\"|*[!0-9]*) ;; (*) if [ \"$src\" = implementer-summary ] && [ \"$agent_summary_bytes\" -gt \"$total\" ]; then total=$agent_summary_bytes; fi ;; esac; if [ \"$size\" -gt 4000 ] && [ \"$src\" = implementer-summary ]; then tail -c 4000 \"$out\" > \"$out.cut\"; if [ \"$(tail -c $((4000 + 1)) \"$out\" | head -c 1 | wc -l | tr -d \" \")\" -eq 1 ]; then cp \"$out.cut\" \"$out.kept\"; elif [ \"$(tr -cd \"\\n\" < \"$out.cut\" | wc -c | tr -d \" \")\" -gt 1 ]; then sed 1d \"$out.cut\" > \"$out.kept\"; else sed \"s/^$(printf '[\\200-\\277]')*//\" \"$out.cut\" | awk 'NF { sub(/^ +/, \"\"); print }' > \"$out.kept\"; fi; mv -f \"$out.kept\" \"$out\"; rm -f \"$out.cut\"; elif [ \"$size\" -gt 4000 ]; then head -c 4000 \"$out\" > \"$out.cut\"; if [ \"$(tail -c 1 \"$out.cut\" | wc -l | tr -d \" \")\" -eq 1 ]; then cp \"$out.cut\" \"$out.kept\"; elif [ \"$(tr -cd \"\\n\" < \"$out.cut\" | wc -c | tr -d \" \")\" -gt 0 ]; then sed '$d' \"$out.cut\" > \"$out.kept\"; else sed \"s/$(printf '[\\300-\\377]')$(printf '[\\200-\\277]')*\\$//\" \"$out.cut\" | awk 'NF { print }' > \"$out.kept\"; fi; mv -f \"$out.kept\" \"$out\"; rm -f \"$out.cut\"; fi; if [ \"$src\" = implementer-summary ]; then tail -n 60 \"$out\" > \"$out.kept\"; else head -n 60 \"$out\" > \"$out.kept\"; fi; mv -f \"$out.kept\" \"$out\"; kept=$(wc -c < \"$out\" | tr -d \" \"); sed \"s/^/relayflow needs-input: /\" \"$out\"; if [ \"$kept\" -lt \"$total\" ]; then echo \"relayflow needs-input-truncated: $kept of $total bytes\"; fi; rm -f \"$out\"; exit 0")).trim(); console.error("Stopped: the implementer made no changes, so there was nothing to check or publish. No checks ran, no branch was pushed and no pull request was opened. Its question or reason follows, when it gave one: answer it on the ticket and run again.\n" + needsInput); return f.done("needs_human"); } @@ -163,7 +177,8 @@ export default flow("software-factory", // change (a first package.json with a test script). Resolving only before // the change reported "no checks ran" on a pull request that added tests // (cloud-e2e-sandbox#31), so a "none" is looked at again. - if (checkPlan === "none") await f.run(resolveChecks); + // A script written now is the ecosystem default, so the report says so. + if (checkPlan === "none" && (await f.run(resolveChecks)).trim() === "default") checkSetup = "default"; // Run the checks. A failure is not the end of the run: the tests are // how this flow learns what is wrong, so the repair agent reads the output