From 09074c65adf3c5ea9990ba944521e59b243d69c1 Mon Sep 17 00:00:00 2001 From: Relayflow Lead Date: Tue, 22 Sep 2026 11:00:46 -0700 Subject: [PATCH 1/5] =?UTF-8?q?feat(examples):=20task-graph=20=E2=80=94=20?= =?UTF-8?q?a=20plan=20of=20subtasks=20run=20as=20parallel=20agents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- examples/task-graph/README.md | 71 ++++++ examples/task-graph/example-plan.json | 18 ++ .../task-graph/skill/run-task-graph/SKILL.md | 155 +++++++++++++ examples/task-graph/task-graph.flow.ts | 213 ++++++++++++++++++ examples/tsconfig.json | 1 + 5 files changed, 458 insertions(+) create mode 100644 examples/task-graph/README.md create mode 100644 examples/task-graph/example-plan.json create mode 100644 examples/task-graph/skill/run-task-graph/SKILL.md create mode 100644 examples/task-graph/task-graph.flow.ts diff --git a/examples/task-graph/README.md b/examples/task-graph/README.md new file mode 100644 index 000000000..277ca2124 --- /dev/null +++ b/examples/task-graph/README.md @@ -0,0 +1,71 @@ +# task-graph + +One big engineering task, about a day of work, runs as a graph of parallel coding agents. + +Most multi-agent setups work through subtasks one at a time. That's how "seven subtasks" turns into a +week. This flow takes a plan in which each subtask lists the subtasks it depends on (`dependsOn`), then: + +- Each subtask starts **the moment** every subtask it depends on has **merged**. +- Each subtask runs in its own git worktree, on its own branch, as its own Claude agent. +- A finished subtask merges back into the run's branch. If the merge conflicts, a dedicated agent + resolves it, and a gate checks that the subtask's branch really is merged. +- A subtask can report **follow-up subtasks**. They join the graph mid-run. +- Once everything has merged, the repository's tests run once, outside any agent, on the combined result. + +A subtask counts as done only when it has **a commit on its branch and a result file**. An agent saying +"done" isn't enough. + +## Run it + +```sh +npm install -g relayflows && agent-relay cloud login +npm install @relayflows/surface # next to the flow file + +# from the repository the work should happen in +flows run --cloud --sync-code task-graph.flow.ts --input example-plan.json +flows status --cloud # or https://agentrelay.com/cloud/dashboard/workflow/ +flows sync # the integrated result lands uncommitted in your checkout +``` + +`example-plan.json` is a 7-subtask "team invitations" feature. It has three parallel waves and a join. +Leave out `plan` and a planner agent reads the repository and writes the graph itself. + +To have every new Linear ticket planned and run automatically, with one PR per ticket: + +```sh +flows deploy task-graph.flow.ts --repo acme/api --on linear:team=ENG --approver you +``` + +Locally, from a checkout that has a `flows.json` naming the agent CLI: + +```sh +flows run task-graph.flow.ts --local-agent --input example-plan.json +``` + +## Give it to your agent + +`skill/run-task-graph/SKILL.md` is a Claude Code skill. Copy it to `~/.claude/skills/run-task-graph/` +(or to `.claude/skills/` in your repository). Your agent can then: + +1. read a Linear issue with its sub-issues and "blocked by" relations; +2. build the plan and show you the parallel waves before anything runs; +3. submit the run to Cloud, report progress, and bring the result back with `flows sync`. + +## Limits today + +- **Only one agent at a time — this blocks the parallel part.** `flows run --local-agent` attaches one + agent worker with `capacity: 1` (`packages/sdk/src/local-agent.ts`). Cloud's hosted runner uses the + same flag. A second agent that becomes ready while the first is running doesn't wait: it **parks** + with "no worker is attached for step type agent", and the run exits parked (exit 3). Until the worker + can run several agents at once, use `maxParallel: 1`. +- **`f.agent({ cwd })` is refused.** The SDK passes `cwd` in the step spec, but the kernel's `StepSpec` + has no such field (`invalid_spec: unknown field "cwd"`). So each agent is told its worktree path in + its task instead. +- **No budget header.** A budgeted authored flow currently admits one step at a time + (`packages/sdk/src/authored-budget.ts`), which would serialize the whole graph. Until that is fixed, + cap spend with `maxParallel` (default 4, maximum 8) and the 20-subtask ceiling. +- **Cloud labels each step by its position in the run, not by subtask.** The run page and + `flows status --cloud` show `agent-4`, `run-7`, and so on, not `api-create`. The Cloud run graph also + has no edges for TypeScript flows: it recovers dependencies only from YAML definitions. +- **Resume is untested.** With subtasks running concurrently, the order of step calls depends on timing. + Resuming this flow after a runner crash has not been tested. diff --git a/examples/task-graph/example-plan.json b/examples/task-graph/example-plan.json new file mode 100644 index 000000000..292d02c66 --- /dev/null +++ b/examples/task-graph/example-plan.json @@ -0,0 +1,18 @@ +{ + "task": { + "title": "Team invitations", + "body": "Admins can invite teammates by email. The invitee gets an email with a link, accepts, and joins the team with the role the admin chose. Invitations expire after 7 days and can be revoked." + }, + "maxParallel": 4, + "plan": { + "subtasks": [ + { "id": "schema", "title": "Invitations table and model", "detail": "Migration + model: email, team_id, role, token (unique), expires_at, accepted_at, revoked_at." }, + { "id": "email-template", "title": "Invitation email template and sender", "detail": "Template with inviter, team name and accept link; a send function with a test using the mail stub." }, + { "id": "api-create", "title": "POST /teams/:id/invitations", "detail": "Admin-only. Creates an invitation and sends the email. Tests for auth, duplicate invite, bad email.", "dependsOn": ["schema", "email-template"] }, + { "id": "api-accept", "title": "POST /invitations/:token/accept", "detail": "Validates token, expiry and revocation, adds the member with the invited role. Tests for every rejection path.", "dependsOn": ["schema"] }, + { "id": "api-revoke", "title": "DELETE /teams/:id/invitations/:invitationId", "detail": "Admin-only revoke. Tests that a revoked token can no longer be accepted.", "dependsOn": ["api-accept"] }, + { "id": "ui-invite", "title": "Invite modal on the team settings page", "detail": "Email + role picker, calls api-create, shows pending invitations with a revoke button.", "dependsOn": ["api-create", "api-revoke"] }, + { "id": "ui-accept", "title": "Accept-invitation page", "detail": "Landing page for the email link: signed-out, expired and revoked states.", "dependsOn": ["api-accept"] } + ] + } +} diff --git a/examples/task-graph/skill/run-task-graph/SKILL.md b/examples/task-graph/skill/run-task-graph/SKILL.md new file mode 100644 index 000000000..0fb4698bf --- /dev/null +++ b/examples/task-graph/skill/run-task-graph/SKILL.md @@ -0,0 +1,155 @@ +--- +name: run-task-graph +description: Run a large engineering task as a graph of parallel coding agents on Agent Relay Cloud. Turns a Linear issue (with its sub-issues and "blocked by" relations) or a written spec into a dependency plan, runs every subtask as soon as the subtasks it depends on have merged, and pulls the integrated result back into this checkout. Use when the user wants to parallelize a big ticket, run a Linear issue's sub-issues with agents, or watch several agents make progress on one task. +--- + +# Run a task graph on Agent Relay Cloud + +The flow is `task-graph.flow.ts` from github.com/AgentWorkforce/flows +(`examples/task-graph/`). What it does: + +- Each subtask runs as its own Claude agent, in its own git worktree and on its own branch. +- A subtask starts only once every subtask it depends on has **merged** into the run's branch. +- An agent may report follow-up subtasks. Those join the graph while the run is going. +- When everything has merged, the repo's test suite runs once on the combined result. + +Follow these steps in order. Do not skip step 3: the user should approve the graph before any agent starts. + +## 1. Preflight (once per machine) + +```sh +npm install -g relayflows # provides the `flows` CLI +agent-relay cloud login # or export FLOWS_CLOUD_TOKEN= +flows runs --limit 1 # proves the credential works +``` + +A `REFUSED [cloud_auth_missing]` or `[cloud_auth_rejected]` means the login step +did not work. Ask the user to redo it, and don't carry on until it succeeds. + +Put the flow in a scratch directory outside the repo, and install its one +dependency there: + +```sh +TG="${TMPDIR:-/tmp}/task-graph" && mkdir -p "$TG" && cd "$TG" +curl -fsSL https://raw.githubusercontent.com/AgentWorkforce/flows/main/examples/task-graph/task-graph.flow.ts -o task-graph.flow.ts +npm init -y >/dev/null && npm pkg set type=module && npm install --no-audit --no-fund @relayflows/surface +``` + +## 2. Build `plan.json` + +The input shape is: + +```json +{ + "task": { "title": "…", "body": "…", "url": "…" }, + "maxParallel": 4, + "plan": { "subtasks": [ + { "id": "schema", "title": "…", "detail": "what done means", "dependsOn": [] }, + { "id": "api", "title": "…", "detail": "…", "dependsOn": ["schema"] } + ] } +} +``` + +**From a Linear issue.** Use the Linear MCP/API to read the parent issue, its +sub-issues, and each sub-issue's relations. Then map them into the plan: + +- `task` comes from the parent issue: its title, its description as `body`, and its `url`. +- Each sub-issue becomes one subtask: + - `id` is the lowercased identifier, e.g. `ENG-142` → `eng-142`. + - `title` is the sub-issue's title. + - `detail` is its description plus its acceptance criteria. +- `dependsOn` comes from "blocked by" relations, but only between sibling + sub-issues. Drop blockers that are outside the parent issue. +- Skip sub-issues that are already Done or Canceled. +- If a sub-issue has sub-issues of its own, flatten them into the plan. The + child depends on whatever its parent depends on. + +**No sub-issues, or a written spec instead of Linear.** Omit `plan` +entirely. A planner agent then reads the repository and writes the graph itself. + +Rules. The flow refuses a plan that breaks any of these, so check them before submitting: + +- `id` matches `^[a-z0-9][a-z0-9-]{0,39}$` and is unique. +- Every `dependsOn` entry names another subtask in the plan. +- There are no cycles. +- There are at most 20 subtasks. +- `maxParallel` is between 1 and 8. + +Only list a dependency when a subtask needs the other's **code merged** +first. Every dependency you add removes some parallelism. + +## 3. Show the graph and get a yes + +Print the plan as waves: + +- Wave 1 is the subtasks with no dependencies. +- Wave 2 is the subtasks whose dependencies are all in wave 1. +- Continue the same way until every subtask has a wave. + +``` +wave 1: schema, email-template (parallel) +wave 2: api-create, api-accept (parallel) +wave 3: api-revoke +wave 4: ui-invite, ui-accept (parallel) +``` + +Tell the user the working tree will be uploaded. Uploads respect `.gitignore`, +untracked files are included, and `.git` and `node_modules` never are. Then ask +for confirmation. Nothing has run until they say yes. + +## 4. Submit + +Run this from the root of the user's repository: + +```sh +flows check "$TG/task-graph.flow.ts" +flows run --cloud --sync-code "$TG/task-graph.flow.ts" --input plan.json +``` + +It prints the run id. Give the user the live view: +`https://agentrelay.com/cloud/dashboard/workflow/` + +## 5. Watch + +```sh +flows status --cloud # every step: state, timing, gate verdict, cost +flows logs --step # one agent's transcript +``` + +Poll `flows status --cloud` every few minutes and report changes in terms of the +plan's subtask ids. Say which subtasks are running, which have merged, and +which follow-ups have appeared. + +A failed step means one of two things: + +- a subtask's gate failed: the agent produced no commit, or no result file; or +- a merge could not be resolved. + +For either one, show that step's `flows logs` and ask the user how to proceed. +Do not rerun the whole flow unasked. + +## 6. Bring the result home + +When the run completes, run this from the same repository: + +```sh +flows sync --dry-run # what would change +flows sync # apply it, uncommitted +git diff --stat +``` + +Walk the user through the diff before committing. It is agent output. If the +plan came from Linear, offer to move the merged sub-issues to Done. + +## Hands-off mode + +To have every new ticket in a Linear team planned and run automatically, with +one PR per ticket: + +```sh +flows deploy "$TG/task-graph.flow.ts" --repo --on linear:team= --approver +``` + +A ticket-triggered run receives the ticket as `issue` and uses the planner. +It pushes the run's branch and opens one pull request, with each subtask's +summary in the PR body. diff --git a/examples/task-graph/task-graph.flow.ts b/examples/task-graph/task-graph.flow.ts new file mode 100644 index 000000000..f447dca27 --- /dev/null +++ b/examples/task-graph/task-graph.flow.ts @@ -0,0 +1,213 @@ +// task-graph — one big engineering task, split into subtasks that run in +// parallel wherever their dependencies allow. +// +// A task (a Linear ticket, or plain text) becomes a plan: subtasks with +// `dependsOn` edges, either supplied as input or written by a planner agent. +// Each subtask starts the moment every subtask it depends on has MERGED, runs +// in its own git worktree on its own branch, and merges back into the run's +// branch when it is done. A subtask may spawn follow-up subtasks, which join the +// graph while it runs. When the graph drains, the full test suite runs once +// against the integrated result. +// +// Run it on Cloud against the current checkout (see README.md): +// flows run --cloud --sync-code --wait task-graph.flow.ts --input plan.json +// flows sync +// Or deploy it so every ticket in a Linear team gets planned and run: +// flows deploy task-graph.flow.ts --repo acme/api --on linear:team=ENG --approver you +import { flow } from "@relayflows/surface"; + +type Subtask = { id: string; title: string; detail?: string; dependsOn?: string[] }; +type Task = { title: string; body?: string; identifier?: string; url?: string }; +type Input = { + /** Plain task input for `flows run`. */ + task?: Task; + /** A Linear/GitHub trigger delivers the ticket here instead. */ + issue?: Task; + /** Skip the planner and run this graph as written. */ + plan?: { subtasks: Subtask[] }; + /** Subtasks running at once. Default 4. */ + maxParallel?: number; +}; + +const MAX_SUBTASKS = 20; +const ID = /^[a-z0-9][a-z0-9-]{0,39}$/; + +// Task text is user input: it never reaches a shell unquoted. +const shellWord = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; + +const SUBTASK_SHAPE = `{"id":"kebab-case-id","title":"…","detail":"what done means","dependsOn":["other-id"]}`; +const PLAN_SHAPE = `{"subtasks":[${SUBTASK_SHAPE}, …]}`; + +/** Returns an error message, or null when the subtasks form a valid DAG given `known` ids. */ +function planError(subtasks: Subtask[], known: ReadonlySet): string | null { + const ids = new Set(known); + for (const s of subtasks) { + if (typeof s.id !== "string" || !ID.test(s.id)) return `bad subtask id ${JSON.stringify(s.id)}`; + if (typeof s.title !== "string" || !s.title.trim()) return `subtask ${s.id} has no title`; + if (ids.has(s.id)) return `duplicate subtask id ${s.id}`; + ids.add(s.id); + } + for (const s of subtasks) { + for (const d of s.dependsOn ?? []) if (!ids.has(d)) return `${s.id} depends on unknown ${d}`; + } + // Cycle check over the new subtasks; `known` ids are already scheduled, so they cannot close a cycle. + const state = new Map(); + const byId = new Map(subtasks.map((s) => [s.id, s])); + const visit = (id: string): boolean => { + if (state.get(id) === "done" || !byId.has(id)) return true; + if (state.get(id) === "visiting") return false; + state.set(id, "visiting"); + const ok = (byId.get(id)?.dependsOn ?? []).every(visit); + state.set(id, "done"); + return ok; + }; + return subtasks.every((s) => visit(s.id)) ? null : "the plan has a dependency cycle"; +} + +// No `budget` header on purpose: today a budgeted authored flow admits one step +// at a time (sdk/src/authored-budget.ts), which would serialize the whole graph. +export default flow("task-graph", async (f, input) => { + const task = input.task ?? input.issue; + if (!task || typeof task.title !== "string" || !task.title.trim()) { + await f.run("echo 'Stopped: no task or issue arrived with this run.' >&2"); + return f.done("needs_human"); + } + const brief = `${task.title}\n\n${task.body ?? ""}${task.url ? `\n\n${task.url}` : ""}`; + const maxParallel = Math.max(1, Math.min(8, input.maxParallel ?? 4)); + + const root = (await f.run("pwd")).trim(); + const work = `${root}/.relayflow`; + // Flow bookkeeping and worktrees live under an excluded dir so no merge or `git add -A` picks them up. + await f.run( + `rm -rf ${shellWord(work)} && git worktree prune && mkdir -p ${shellWord(`${work}/results`)} && ` + + `{ grep -qxF '.relayflow/' .git/info/exclude 2>/dev/null || echo '.relayflow/' >> .git/info/exclude; }`, + ); + + // 1. The plan: given, or written by a planner agent. + let subtasks = input.plan?.subtasks; + if (!subtasks) { + await f.agent("planner", { + cli: "claude", + task: + `Read this repository, then split the task below into 3-${MAX_SUBTASKS / 2} subtasks that each ` + + `fit one focused agent session. Make dependsOn honest: only list a dependency when the subtask ` + + `really needs that code merged first — everything else runs in parallel. Do not write code.\n` + + `Write ONLY this JSON to ${work}/plan.json:\n${PLAN_SHAPE}\n\nTask:\n${brief}`, + }).gate({ type: "subprocess_gate", command: `node -e 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"))' ${shellWord(`${work}/plan.json`)}` }); + subtasks = (JSON.parse(await f.run(`cat ${shellWord(`${work}/plan.json`)}`)) as { subtasks: Subtask[] }).subtasks; + } + const invalid = Array.isArray(subtasks) ? planError(subtasks, new Set()) : "plan.subtasks is not a list"; + if (invalid || subtasks.length > MAX_SUBTASKS) { + await f.run(`echo ${shellWord(`Stopped: ${invalid ?? `more than ${MAX_SUBTASKS} subtasks`}.`)} >&2`); + return f.done("needs_human"); + } + + // 2. The scheduler. `merged` holds one promise per subtask that resolves once its branch is merged. + const merged = new Map>(); + const all: Subtask[] = []; + const summaries: string[] = []; + let running = 0; + const slotWaiters: Array<() => void> = []; + const acquireSlot = async (): Promise => { + if (running < maxParallel) { running++; return; } + await new Promise((resolve) => slotWaiters.push(resolve)); + }; + const releaseSlot = (): void => { + const next = slotWaiters.shift(); + if (next) next(); else running--; + }; + // Worktree creation and merges touch the run's branch, so they take turns. + let gitTail: Promise = Promise.resolve(); + const onRunBranch = (op: () => PromiseLike): Promise => { + const result = gitTail.then(op); + gitTail = result.catch(() => undefined); + return result; + }; + + const runSubtask = async (s: Subtask, parent?: string): Promise => { + await Promise.all((s.dependsOn ?? []).map((d) => merged.get(d))); + if (parent) await merged.get(parent); + await acquireSlot(); + try { + const tree = `${work}/wt/${s.id}`; + const branch = `task-graph/${s.id}`; + // Branched from the run's branch as it is NOW, so every dependency's code is already in it. + const base = (await onRunBranch(() => f.run( + `git worktree add -q -B ${shellWord(branch)} ${shellWord(tree)} HEAD && git rev-parse HEAD`, + ))).trim(); + const result = `${work}/results/${s.id}.json`; + const others = all.filter((o) => o.id !== s.id).map((o) => `- ${o.id}: ${o.title}`).join("\n"); + await f.agent(s.id, { + cli: "claude", + // Not `cwd: tree`: the kernel refuses that field today (unknown field "cwd"), so the path is in the task. + task: + `You are one subtask of a larger task. Your git worktree is ${tree} (branch ${branch}): ` + + `cd into it first, and read, edit, test and commit ONLY inside it — never in ${root}.\n` + + `Overall task:\n${brief}\n\nYOUR subtask (${s.id}): ${s.title}\n${s.detail ?? ""}\n\n` + + `Other subtasks are handled by other agents in parallel — stay inside yours:\n${others}\n\n` + + `Implement it with tests, run the relevant tests, and commit everything on ${branch}. ` + + `If you discover necessary work outside your scope, do not do it: list it as a follow-up.\n` + + `Finally write ${result} as JSON: {"summary":"what you did","followups":[${SUBTASK_SHAPE}, …]} ` + + `(followups may be empty; their ids must be new).`, + // Done means a result file AND at least one commit on the branch — not the agent saying so. + }).gate({ type: "subprocess_gate", command: `test -s ${shellWord(result)} && test "$(git -C ${shellWord(tree)} rev-list --count ${base}..HEAD)" -gt 0` }); + + // 3. Merge back. A conflict goes to an agent that must leave the branch merged. + const outcome = (await onRunBranch(() => f.run( + `git merge --no-ff -q -m ${shellWord(`task-graph: merge ${s.id}`)} ${shellWord(branch)} >/dev/null 2>&1 && echo merged || { git merge --abort; echo conflict; }`, + ))).trim(); + if (outcome !== "merged") { + await onRunBranch(() => f.agent(`${s.id}-merge`, { + cli: "claude", + task: `Merge branch ${branch} into the current branch and resolve every conflict so both sides' intent survives. ` + + `Run the affected tests, then commit the merge.`, + }).gate({ type: "subprocess_gate", command: `git merge-base --is-ancestor ${shellWord(branch)} HEAD` })); + } + await f.run(`git worktree remove --force ${shellWord(tree)}`); + + // 4. Follow-ups join the graph. They run after this subtask, plus whatever they declare. + const report = JSON.parse(await f.run(`cat ${shellWord(result)}`)) as { summary?: string; followups?: Subtask[] }; + summaries.push(`- **${s.id}** — ${report.summary ?? s.title}`); + const followups = Array.isArray(report.followups) ? report.followups : []; + const rejected = followups.length > MAX_SUBTASKS - all.length + ? `would exceed ${MAX_SUBTASKS} subtasks` + : planError(followups, new Set(merged.keys())); + if (followups.length > 0 && rejected) { + await f.run(`echo ${shellWord(`Follow-ups from ${s.id} not scheduled: ${rejected}.`)} >&2`); + } else { + for (const child of followups) schedule(child, s.id); + } + } finally { + releaseSlot(); + } + }; + + const schedule = (s: Subtask, parent?: string): void => { + all.push(s); + merged.set(s.id, runSubtask(s, parent)); + }; + for (const s of subtasks) all.push(s); + for (const s of subtasks) merged.set(s.id, runSubtask(s)); + + // Follow-ups can be added while we wait, so drain until the set stops growing. + for (let seen = 0; seen < merged.size;) { + seen = merged.size; + await Promise.all(merged.values()); + } + + // 5. The whole graph is merged: one integrated test run, outside any agent. + await f.run( + 'if [ -f package.json ] && node -e \'p=require("./package.json");process.exit(p.scripts&&p.scripts.test?0:1)\'; ' + + 'then npm ci --no-audit --no-fund && npm test; else echo "no test script; skipping"; fi', + { timeout: "15m" }, + ); + + // Triggered from a ticket: open one PR for the whole graph. A `flows run` leaves it to `flows sync`. + if (input.issue) { + const body = `${task.url ? `Ticket: ${task.url}\n\n` : ""}Subtasks, each merged from its own branch:\n\n${summaries.join("\n")}\n`; + await f.run(`printf '%s' ${shellWord(body)} > ${shellWord(`${work}/pr-body.md`)}`); + await f.run("git push --set-upstream origin HEAD", { timeout: "2m" }); + await f.run(`gh pr create --title ${shellWord(task.title.trim().slice(0, 240))} --body-file ${shellWord(`${work}/pr-body.md`)}`, { timeout: "2m" }); + } + f.done("success"); +}); diff --git a/examples/tsconfig.json b/examples/tsconfig.json index 43a2cca00..3f1899c38 100644 --- a/examples/tsconfig.json +++ b/examples/tsconfig.json @@ -22,6 +22,7 @@ "social-post-pipeline/*.ts", "software-factory/*.ts", "stale-issues/*.ts", + "task-graph/*.ts", "pr-review-pipeline/*.ts", "pr-reviewer/*.ts", "dependency-upgrade-bot/*.ts" From baa8476883bfdc86101183996eeb6f2524639cf9 Mon Sep 17 00:00:00 2001 From: Relayflow Lead Date: Tue, 22 Sep 2026 11:47:50 -0700 Subject: [PATCH 2/5] =?UTF-8?q?fix(examples):=20task-graph=20=E2=80=94=20b?= =?UTF-8?q?ound=20follow-ups,=20run=20agents=20in=20their=20worktree=20via?= =?UTF-8?q?=20cwd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- examples/task-graph/README.md | 22 +++++------ .../task-graph/skill/run-task-graph/SKILL.md | 2 + examples/task-graph/task-graph.flow.ts | 39 ++++++++++++------- 3 files changed, 37 insertions(+), 26 deletions(-) diff --git a/examples/task-graph/README.md b/examples/task-graph/README.md index 277ca2124..7b38f365e 100644 --- a/examples/task-graph/README.md +++ b/examples/task-graph/README.md @@ -9,7 +9,9 @@ week. This flow takes a plan in which each subtask lists the subtasks it depends - Each subtask runs in its own git worktree, on its own branch, as its own Claude agent. - A finished subtask merges back into the run's branch. If the merge conflicts, a dedicated agent resolves it, and a gate checks that the subtask's branch really is merged. -- A subtask can report **follow-up subtasks**. They join the graph mid-run. +- A planned subtask can report **follow-up subtasks** that the task can't ship without. They join the graph + mid-run, up to `maxFollowups` (default 3, `0` turns them off). Follow-ups can't spawn follow-ups of their + own. Without these limits, agents keep filing polish and docs follow-ups: a 4-subtask test run grew to 20. - Once everything has merged, the repository's tests run once, outside any agent, on the combined result. A subtask counts as done only when it has **a commit on its branch and a result file**. An agent saying @@ -53,19 +55,15 @@ flows run task-graph.flow.ts --local-agent --input example-plan.json ## Limits today -- **Only one agent at a time — this blocks the parallel part.** `flows run --local-agent` attaches one - agent worker with `capacity: 1` (`packages/sdk/src/local-agent.ts`). Cloud's hosted runner uses the - same flag. A second agent that becomes ready while the first is running doesn't wait: it **parks** - with "no worker is attached for step type agent", and the run exits parked (exit 3). Until the worker - can run several agents at once, use `maxParallel: 1`. -- **`f.agent({ cwd })` is refused.** The SDK passes `cwd` in the step spec, but the kernel's `StepSpec` - has no such field (`invalid_spec: unknown field "cwd"`). So each agent is told its worktree path in - its task instead. +- **Needs the next `relayflows` release.** Running agents at the same time and `f.agent({ cwd })` both land + in the SDK and kernel fix that follows 2.0.26. On 2.0.26, a second concurrent agent is set aside ("parked") + and `cwd` is refused. - **No budget header.** A budgeted authored flow currently admits one step at a time (`packages/sdk/src/authored-budget.ts`), which would serialize the whole graph. Until that is fixed, cap spend with `maxParallel` (default 4, maximum 8) and the 20-subtask ceiling. -- **Cloud labels each step by its position in the run, not by subtask.** The run page and - `flows status --cloud` show `agent-4`, `run-7`, and so on, not `api-create`. The Cloud run graph also - has no edges for TypeScript flows: it recovers dependencies only from YAML definitions. +- **Cloud labels each step by its position in the run, not by subtask, for now.** Until flows#553 and + cloud#3945 ship, the Cloud run page shows `agent-4`, `run-7`, and so on, with no edges. With them, it shows + each subtask's name and the edges between subtasks, but only once the run finishes. While the run is going, + nodes show live status without names or edges. - **Resume is untested.** With subtasks running concurrently, the order of step calls depends on timing. Resuming this flow after a runner crash has not been tested. diff --git a/examples/task-graph/skill/run-task-graph/SKILL.md b/examples/task-graph/skill/run-task-graph/SKILL.md index 0fb4698bf..b1e80f510 100644 --- a/examples/task-graph/skill/run-task-graph/SKILL.md +++ b/examples/task-graph/skill/run-task-graph/SKILL.md @@ -43,6 +43,7 @@ The input shape is: { "task": { "title": "…", "body": "…", "url": "…" }, "maxParallel": 4, + "maxFollowups": 3, "plan": { "subtasks": [ { "id": "schema", "title": "…", "detail": "what done means", "dependsOn": [] }, { "id": "api", "title": "…", "detail": "…", "dependsOn": ["schema"] } @@ -74,6 +75,7 @@ Rules. The flow refuses a plan that breaks any of these, so check them before su - There are no cycles. - There are at most 20 subtasks. - `maxParallel` is between 1 and 8. +- `maxFollowups` is between 0 and 10. Use 0 when the user wants exactly the plan and nothing more. Only list a dependency when a subtask needs the other's **code merged** first. Every dependency you add removes some parallelism. diff --git a/examples/task-graph/task-graph.flow.ts b/examples/task-graph/task-graph.flow.ts index f447dca27..276219cb5 100644 --- a/examples/task-graph/task-graph.flow.ts +++ b/examples/task-graph/task-graph.flow.ts @@ -27,6 +27,8 @@ type Input = { plan?: { subtasks: Subtask[] }; /** Subtasks running at once. Default 4. */ maxParallel?: number; + /** Follow-up subtasks agents may add mid-run. Default 3; 0 turns them off. */ + maxFollowups?: number; }; const MAX_SUBTASKS = 20; @@ -74,6 +76,7 @@ export default flow("task-graph", async (f, input) => { } const brief = `${task.title}\n\n${task.body ?? ""}${task.url ? `\n\n${task.url}` : ""}`; const maxParallel = Math.max(1, Math.min(8, input.maxParallel ?? 4)); + let followupBudget = Math.max(0, Math.min(10, input.maxFollowups ?? 3)); const root = (await f.run("pwd")).trim(); const work = `${root}/.relayflow`; @@ -137,18 +140,24 @@ export default flow("task-graph", async (f, input) => { ))).trim(); const result = `${work}/results/${s.id}.json`; const others = all.filter((o) => o.id !== s.id).map((o) => `- ${o.id}: ${o.title}`).join("\n"); + // Only planned subtasks may propose follow-ups, and only work the task cannot ship without: + // left open, agents file polish and docs follow-ups that spawn more of the same. + const mayPropose = parent === undefined && followupBudget > 0; await f.agent(s.id, { cli: "claude", - // Not `cwd: tree`: the kernel refuses that field today (unknown field "cwd"), so the path is in the task. + cwd: tree, task: - `You are one subtask of a larger task. Your git worktree is ${tree} (branch ${branch}): ` + - `cd into it first, and read, edit, test and commit ONLY inside it — never in ${root}.\n` + + `You are one subtask of a larger task, working in your own git worktree (${tree}, branch ${branch}). ` + + `Read, edit, test and commit only inside it.\n` + `Overall task:\n${brief}\n\nYOUR subtask (${s.id}): ${s.title}\n${s.detail ?? ""}\n\n` + `Other subtasks are handled by other agents in parallel — stay inside yours:\n${others}\n\n` + - `Implement it with tests, run the relevant tests, and commit everything on ${branch}. ` + - `If you discover necessary work outside your scope, do not do it: list it as a follow-up.\n` + - `Finally write ${result} as JSON: {"summary":"what you did","followups":[${SUBTASK_SHAPE}, …]} ` + - `(followups may be empty; their ids must be new).`, + `Implement it with tests, run the relevant tests, and commit everything on ${branch}. Do not do work outside your scope.\n` + + (mayPropose + ? `If you found work the overall task CANNOT ship without and no subtask above covers, list it as a follow-up ` + + `(at most 2; never polish, docs, refactors or nice-to-haves — most subtasks have none).\n` + + `Finally write ${result} as JSON: {"summary":"what you did","followups":[${SUBTASK_SHAPE}]} ` + + `(followups is usually empty; ids must be new).` + : `Finally write ${result} as JSON: {"summary":"what you did"}.`), // Done means a result file AND at least one commit on the branch — not the agent saying so. }).gate({ type: "subprocess_gate", command: `test -s ${shellWord(result)} && test "$(git -C ${shellWord(tree)} rev-list --count ${base}..HEAD)" -gt 0` }); @@ -168,15 +177,17 @@ export default flow("task-graph", async (f, input) => { // 4. Follow-ups join the graph. They run after this subtask, plus whatever they declare. const report = JSON.parse(await f.run(`cat ${shellWord(result)}`)) as { summary?: string; followups?: Subtask[] }; summaries.push(`- **${s.id}** — ${report.summary ?? s.title}`); - const followups = Array.isArray(report.followups) ? report.followups : []; - const rejected = followups.length > MAX_SUBTASKS - all.length - ? `would exceed ${MAX_SUBTASKS} subtasks` - : planError(followups, new Set(merged.keys())); - if (followups.length > 0 && rejected) { - await f.run(`echo ${shellWord(`Follow-ups from ${s.id} not scheduled: ${rejected}.`)} >&2`); - } else { + const proposed = mayPropose && Array.isArray(report.followups) ? report.followups : []; + // The budget is claimed synchronously, so two subtasks finishing together cannot both spend it. + const followups = proposed.slice(0, Math.min(followupBudget, MAX_SUBTASKS - all.length)); + const invalid = planError(followups, new Set(merged.keys())); + if (invalid === null) { + followupBudget -= followups.length; for (const child of followups) schedule(child, s.id); } + const dropped = invalid ?? (followups.length < proposed.length + ? `${proposed.length - followups.length} over the follow-up budget` : null); + if (dropped) await f.run(`echo ${shellWord(`Follow-ups from ${s.id} not scheduled: ${dropped}.`)} >&2`); } finally { releaseSlot(); } From 99cab6f4839869d0a7250ff2f90c30137f079a35 Mon Sep 17 00:00:00 2001 From: Relayflow Lead Date: Tue, 22 Sep 2026 12:32:55 -0700 Subject: [PATCH 3/5] =?UTF-8?q?fix(examples):=20task-graph=20=E2=80=94=20o?= =?UTF-8?q?rder-independent=20plans,=20own=20branch=20namespace,=20no=20si?= =?UTF-8?q?lent=20test=20skip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Read dependencies after the scheduling loop registers every subtask, so a plan need not be topologically sorted (Linear's is not). Fail closed on an unscheduled dependency instead of treating it as resolved. - Name subtask branches task-graph// and create them with -b, so a repository's existing branches are never reset. - Gate each subtask on a parseable result file, not just a non-empty one. - Serialize worktree removal with the other run-branch git operations. - Take testCommand; with neither it nor an npm test script, stop as needs_human rather than passing untested. Co-Authored-By: Claude Opus 5.5 (1M context) --- examples/task-graph/README.md | 4 ++ .../task-graph/skill/run-task-graph/SKILL.md | 4 ++ examples/task-graph/task-graph.flow.ts | 48 +++++++++++++------ 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/examples/task-graph/README.md b/examples/task-graph/README.md index 7b38f365e..c87033f0f 100644 --- a/examples/task-graph/README.md +++ b/examples/task-graph/README.md @@ -13,6 +13,10 @@ week. This flow takes a plan in which each subtask lists the subtasks it depends mid-run, up to `maxFollowups` (default 3, `0` turns them off). Follow-ups can't spawn follow-ups of their own. Without these limits, agents keep filing polish and docs follow-ups: a 4-subtask test run grew to 20. - Once everything has merged, the repository's tests run once, outside any agent, on the combined result. + The command is `testCommand`, or `npm ci && npm test` when there's an npm test script. With neither, the + run stops as `needs_human` rather than passing untested. +- Subtask branches are named `task-graph//`, so the flow never overwrites a branch your + repository already has. A subtask counts as done only when it has **a commit on its branch and a result file**. An agent saying "done" isn't enough. diff --git a/examples/task-graph/skill/run-task-graph/SKILL.md b/examples/task-graph/skill/run-task-graph/SKILL.md index b1e80f510..e098d4bca 100644 --- a/examples/task-graph/skill/run-task-graph/SKILL.md +++ b/examples/task-graph/skill/run-task-graph/SKILL.md @@ -44,6 +44,7 @@ The input shape is: "task": { "title": "…", "body": "…", "url": "…" }, "maxParallel": 4, "maxFollowups": 3, + "testCommand": "npm ci && npm test", "plan": { "subtasks": [ { "id": "schema", "title": "…", "detail": "what done means", "dependsOn": [] }, { "id": "api", "title": "…", "detail": "…", "dependsOn": ["schema"] } @@ -76,6 +77,9 @@ Rules. The flow refuses a plan that breaks any of these, so check them before su - There are at most 20 subtasks. - `maxParallel` is between 1 and 8. - `maxFollowups` is between 0 and 10. Use 0 when the user wants exactly the plan and nothing more. +- Set `testCommand` to the command that tests this repository. You can leave it out only when + `package.json` has a `test` script. Without either, the flow stops before testing and doesn't + pass by default. Only list a dependency when a subtask needs the other's **code merged** first. Every dependency you add removes some parallelism. diff --git a/examples/task-graph/task-graph.flow.ts b/examples/task-graph/task-graph.flow.ts index 276219cb5..908b41c13 100644 --- a/examples/task-graph/task-graph.flow.ts +++ b/examples/task-graph/task-graph.flow.ts @@ -29,6 +29,8 @@ type Input = { maxParallel?: number; /** Follow-up subtasks agents may add mid-run. Default 3; 0 turns them off. */ maxFollowups?: number; + /** Command that tests the integrated result. Default: `npm ci && npm test` when package.json has a test script. */ + testCommand?: string; }; const MAX_SUBTASKS = 20; @@ -80,10 +82,15 @@ export default flow("task-graph", async (f, input) => { const root = (await f.run("pwd")).trim(); const work = `${root}/.relayflow`; + // Subtask branches live under a namespace for this base commit, so they never reuse a branch the repo + // already has. A rerun from the same commit clears only this namespace, which only this flow writes. + const branchPrefix = `task-graph/${(await f.run("git rev-parse --short=10 HEAD")).trim()}`; // Flow bookkeeping and worktrees live under an excluded dir so no merge or `git add -A` picks them up. await f.run( `rm -rf ${shellWord(work)} && git worktree prune && mkdir -p ${shellWord(`${work}/results`)} && ` + - `{ grep -qxF '.relayflow/' .git/info/exclude 2>/dev/null || echo '.relayflow/' >> .git/info/exclude; }`, + `{ grep -qxF '.relayflow/' .git/info/exclude 2>/dev/null || echo '.relayflow/' >> .git/info/exclude; } && ` + + `git for-each-ref --format='%(refname:short)' ${shellWord(`refs/heads/${branchPrefix}/`)} | ` + + `while read -r b; do git branch -D -q "$b"; done`, ); // 1. The plan: given, or written by a planner agent. @@ -128,15 +135,19 @@ export default flow("task-graph", async (f, input) => { }; const runSubtask = async (s: Subtask, parent?: string): Promise => { - await Promise.all((s.dependsOn ?? []).map((d) => merged.get(d))); - if (parent) await merged.get(parent); + // Yield before reading dependencies: the scheduling loop registers every subtask first, so a plan + // need not list dependencies before dependents (a Linear-built plan does not). + await Promise.resolve(); + const deps = [...(s.dependsOn ?? []), ...(parent === undefined ? [] : [parent])].map((d) => merged.get(d)); + if (deps.some((d) => d === undefined)) throw new Error(`subtask ${s.id} depends on a subtask that was never scheduled`); + await Promise.all(deps); await acquireSlot(); try { const tree = `${work}/wt/${s.id}`; - const branch = `task-graph/${s.id}`; + const branch = `${branchPrefix}/${s.id}`; // Branched from the run's branch as it is NOW, so every dependency's code is already in it. const base = (await onRunBranch(() => f.run( - `git worktree add -q -B ${shellWord(branch)} ${shellWord(tree)} HEAD && git rev-parse HEAD`, + `git worktree add -q -b ${shellWord(branch)} ${shellWord(tree)} HEAD && git rev-parse HEAD`, ))).trim(); const result = `${work}/results/${s.id}.json`; const others = all.filter((o) => o.id !== s.id).map((o) => `- ${o.id}: ${o.title}`).join("\n"); @@ -158,8 +169,12 @@ export default flow("task-graph", async (f, input) => { `Finally write ${result} as JSON: {"summary":"what you did","followups":[${SUBTASK_SHAPE}]} ` + `(followups is usually empty; ids must be new).` : `Finally write ${result} as JSON: {"summary":"what you did"}.`), - // Done means a result file AND at least one commit on the branch — not the agent saying so. - }).gate({ type: "subprocess_gate", command: `test -s ${shellWord(result)} && test "$(git -C ${shellWord(tree)} rev-list --count ${base}..HEAD)" -gt 0` }); + // Done means a parseable result file AND at least one commit on the branch — not the agent saying so. + }).gate({ + type: "subprocess_gate", + command: `node -e 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"))' ${shellWord(result)} && ` + + `test "$(git -C ${shellWord(tree)} rev-list --count ${base}..HEAD)" -gt 0`, + }); // 3. Merge back. A conflict goes to an agent that must leave the branch merged. const outcome = (await onRunBranch(() => f.run( @@ -172,7 +187,7 @@ export default flow("task-graph", async (f, input) => { `Run the affected tests, then commit the merge.`, }).gate({ type: "subprocess_gate", command: `git merge-base --is-ancestor ${shellWord(branch)} HEAD` })); } - await f.run(`git worktree remove --force ${shellWord(tree)}`); + await onRunBranch(() => f.run(`git worktree remove --force ${shellWord(tree)}`)); // 4. Follow-ups join the graph. They run after this subtask, plus whatever they declare. const report = JSON.parse(await f.run(`cat ${shellWord(result)}`)) as { summary?: string; followups?: Subtask[] }; @@ -206,12 +221,17 @@ export default flow("task-graph", async (f, input) => { await Promise.all(merged.values()); } - // 5. The whole graph is merged: one integrated test run, outside any agent. - await f.run( - 'if [ -f package.json ] && node -e \'p=require("./package.json");process.exit(p.scripts&&p.scripts.test?0:1)\'; ' + - 'then npm ci --no-audit --no-fund && npm test; else echo "no test script; skipping"; fi', - { timeout: "15m" }, - ); + // 5. The whole graph is merged: one integrated test run, outside any agent. No test command is a stop, + // never a silent pass — the merged work is still on the run's branch for a human to test. + const hasNpmTest = (await f.run( + 'if [ -f package.json ] && node -e \'p=require("./package.json");process.exit(p.scripts&&p.scripts.test?0:1)\'; then echo yes; else echo no; fi', + )).trim() === "yes"; + const testCommand = input.testCommand ?? (hasNpmTest ? "npm ci --no-audit --no-fund && npm test" : undefined); + if (testCommand === undefined) { + await f.run("echo 'Stopped before testing: no npm test script. Pass testCommand for this repository.' >&2"); + return f.done("needs_human"); + } + await f.run(testCommand, { timeout: "15m" }); // Triggered from a ticket: open one PR for the whole graph. A `flows run` leaves it to `flows sync`. if (input.issue) { From bdd6624755d1257530875a24ef70c4b5b02b883e Mon Sep 17 00:00:00 2001 From: Relayflow Lead Date: Tue, 22 Sep 2026 12:43:36 -0700 Subject: [PATCH 4/5] =?UTF-8?q?fix(examples):=20task-graph=20=E2=80=94=20g?= =?UTF-8?q?ate=20the=20planner=20on=20a=20clean=20tree,=20resolve=20exclud?= =?UTF-8?q?e=20via=20--git-path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The planner gate now also requires an untouched working tree, so a planner that edits project files fails instead of leaking edits into the run. - Resolve info/exclude with git rev-parse --git-path so setup works from a linked worktree, where .git is a file. - Skill: the calling agent always writes the plan so the user approves the real graph; the in-flow planner is for hands-off deploys only. - README: rerun rather than resume; authored step ids follow call order. Co-Authored-By: Claude Opus 5.5 (1M context) --- examples/task-graph/README.md | 5 +++-- examples/task-graph/skill/run-task-graph/SKILL.md | 6 ++++-- examples/task-graph/task-graph.flow.ts | 11 +++++++++-- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/examples/task-graph/README.md b/examples/task-graph/README.md index c87033f0f..4526daa7b 100644 --- a/examples/task-graph/README.md +++ b/examples/task-graph/README.md @@ -69,5 +69,6 @@ flows run task-graph.flow.ts --local-agent --input example-plan.json cloud#3945 ship, the Cloud run page shows `agent-4`, `run-7`, and so on, with no edges. With them, it shows each subtask's name and the edges between subtasks, but only once the run finishes. While the run is going, nodes show live status without names or edges. -- **Resume is untested.** With subtasks running concurrently, the order of step calls depends on timing. - Resuming this flow after a runner crash has not been tested. +- **Don't resume; rerun.** Authored step ids come from call order, and with subtasks finishing + concurrently that order differs between runs. So `flows resume` can pair a step with another step's + journal. After a crashed runner, start a new run. Setup clears the previous run's worktrees and branches. diff --git a/examples/task-graph/skill/run-task-graph/SKILL.md b/examples/task-graph/skill/run-task-graph/SKILL.md index e098d4bca..074126d51 100644 --- a/examples/task-graph/skill/run-task-graph/SKILL.md +++ b/examples/task-graph/skill/run-task-graph/SKILL.md @@ -66,8 +66,10 @@ sub-issues, and each sub-issue's relations. Then map them into the plan: - If a sub-issue has sub-issues of its own, flatten them into the plan. The child depends on whatever its parent depends on. -**No sub-issues, or a written spec instead of Linear.** Omit `plan` -entirely. A planner agent then reads the repository and writes the graph itself. +**No sub-issues, or a written spec instead of Linear.** Write the plan yourself. Read the repository +and split the task into subtasks that each fit one focused agent session. Always pass `plan`, so the +user approves the real graph in step 3. If you leave `plan` out, the flow's own planner writes the graph +and it starts running with nobody approving it. That is only for hands-off mode, below. Rules. The flow refuses a plan that breaks any of these, so check them before submitting: diff --git a/examples/task-graph/task-graph.flow.ts b/examples/task-graph/task-graph.flow.ts index 908b41c13..c656588cc 100644 --- a/examples/task-graph/task-graph.flow.ts +++ b/examples/task-graph/task-graph.flow.ts @@ -88,7 +88,9 @@ export default flow("task-graph", async (f, input) => { // Flow bookkeeping and worktrees live under an excluded dir so no merge or `git add -A` picks them up. await f.run( `rm -rf ${shellWord(work)} && git worktree prune && mkdir -p ${shellWord(`${work}/results`)} && ` + - `{ grep -qxF '.relayflow/' .git/info/exclude 2>/dev/null || echo '.relayflow/' >> .git/info/exclude; } && ` + + // --git-path, not .git/info/exclude: in a linked worktree .git is a file, not a directory. + `exclude="$(git rev-parse --git-path info/exclude)" && mkdir -p "$(dirname "$exclude")" && ` + + `{ grep -qxF '.relayflow/' "$exclude" 2>/dev/null || echo '.relayflow/' >> "$exclude"; } && ` + `git for-each-ref --format='%(refname:short)' ${shellWord(`refs/heads/${branchPrefix}/`)} | ` + `while read -r b; do git branch -D -q "$b"; done`, ); @@ -103,7 +105,12 @@ export default flow("task-graph", async (f, input) => { `fit one focused agent session. Make dependsOn honest: only list a dependency when the subtask ` + `really needs that code merged first — everything else runs in parallel. Do not write code.\n` + `Write ONLY this JSON to ${work}/plan.json:\n${PLAN_SHAPE}\n\nTask:\n${brief}`, - }).gate({ type: "subprocess_gate", command: `node -e 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"))' ${shellWord(`${work}/plan.json`)}` }); + // The planner must only plan: a parseable plan AND an untouched working tree (.relayflow/ is excluded). + }).gate({ + type: "subprocess_gate", + command: `node -e 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"))' ${shellWord(`${work}/plan.json`)} && ` + + `test -z "$(git status --porcelain)"`, + }); subtasks = (JSON.parse(await f.run(`cat ${shellWord(`${work}/plan.json`)}`)) as { subtasks: Subtask[] }).subtasks; } const invalid = Array.isArray(subtasks) ? planError(subtasks, new Set()) : "plan.subtasks is not a list"; From a8d17141878ab0fbf1c335e12bc05be1194a5538 Mon Sep 17 00:00:00 2001 From: Relayflow Lead Date: Tue, 22 Sep 2026 13:42:53 -0700 Subject: [PATCH 5/5] =?UTF-8?q?fix(examples):=20task-graph=20=E2=80=94=20p?= =?UTF-8?q?lanner=20gate=20also=20requires=20HEAD=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- examples/task-graph/task-graph.flow.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/examples/task-graph/task-graph.flow.ts b/examples/task-graph/task-graph.flow.ts index c656588cc..be32da4ff 100644 --- a/examples/task-graph/task-graph.flow.ts +++ b/examples/task-graph/task-graph.flow.ts @@ -98,6 +98,7 @@ export default flow("task-graph", async (f, input) => { // 1. The plan: given, or written by a planner agent. let subtasks = input.plan?.subtasks; if (!subtasks) { + const before = (await f.run("git rev-parse HEAD")).trim(); await f.agent("planner", { cli: "claude", task: @@ -105,11 +106,12 @@ export default flow("task-graph", async (f, input) => { `fit one focused agent session. Make dependsOn honest: only list a dependency when the subtask ` + `really needs that code merged first — everything else runs in parallel. Do not write code.\n` + `Write ONLY this JSON to ${work}/plan.json:\n${PLAN_SHAPE}\n\nTask:\n${brief}`, - // The planner must only plan: a parseable plan AND an untouched working tree (.relayflow/ is excluded). + // The planner must only plan: a parseable plan, the same HEAD (no commits) and an untouched + // working tree (.relayflow/ is excluded). }).gate({ type: "subprocess_gate", command: `node -e 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"))' ${shellWord(`${work}/plan.json`)} && ` + - `test -z "$(git status --porcelain)"`, + `test "$(git rev-parse HEAD)" = ${before} && test -z "$(git status --porcelain)"`, }); subtasks = (JSON.parse(await f.run(`cat ${shellWord(`${work}/plan.json`)}`)) as { subtasks: Subtask[] }).subtasks; }