Repository navigation
[feat] Add workflow workspaces, branches, and session continuation #1080
Copy link
Copy link
Open
Description
Parent: #818
Goal
Let workflow steps safely produce and compose code changes without mutating the user’s active checkout.
Scope
- Add
workspace: read-only | new | continueand documented inheritance. - Treat a command step with no workspace as read-only; fail the step if it changes the active checkout.
- Put workspace mode, base, dependency branches, and ownership in leases.
- Add a local worktree manager with deterministic, path-safe branch names.
- For
new, start from the first dependency branch and merge additional dependency branches before execution. - For
continue, bind the copy to its owning driver and workspace. - Own the branch side of
for_each: exposebranchesandresults[].branch, and start an item withafterfrom the branches it waits for. - Push successful change branches and report
{ branch, base, commits, pr_url? }. - Return
cleanup: trueonly when no later step continues the copy. - Preserve failed workspaces for inspection.
- Add
session: continueandsession: { from: <step> }, includingsession: continueacross loop passes, with validation and fresh-session fallback when resume is unavailable. - Run each tool with the agent configuration from the run’s base, even when the step starts from a branch written by an earlier agent.
- Never reset, clean, or switch the active product checkout.
Out of scope
Opening PRs and delivered-diff protected-path policy (#1090), automatic merge, arbitrary user workspace paths, and simultaneous multi-machine leasing.
Dependencies
- Blocked by: [feat] Run prompt and agent steps with the local driver #1077, [feat] Add DAG conditions and composable sub-workflows #1078
- Unblocks: [feat] Deliver workflow branches as pull requests #1090, [feat] Enforce workflow requirements and execution limits #1082, [feat] Add workflow scorers and recorded quality labels #1085, #10760
Acceptance criteria
- Parallel
newsteps use distinct worktrees and branches. - Dependency branches merge deterministically.
- A
continuestep cannot be taken by a driver that does not own its copy. - Failed work remains inspectable.
- Cleanup removes only TeamAI-owned worktrees.
- Invalid session references are rejected per workflow file.
- A command step with no workspace fails if it changes the active checkout.
- Branch fan-out exposes
branchesandresults[].branch, and an item withafterstarts from the branches it waits for. session: continuepreserves its lineage across loop passes.- A later step that holds a token uses agent configuration from the run’s base, so hooks or equivalent agent configuration committed by an earlier step do not run.
Verification
Focused real-git ancestry, merge, continuation, loop-session, fan-out branch, base-config, cleanup, interruption, and active-checkout tests; build/typecheck/lint; one real-CLI E2E joining two change branches. Update bilingual workspace documentation.
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels
Activity
SaulMoro commentedon Oct 13, 2026
Two things that might fit here:
for_eachexposesbranchesandresults[].branch, and an item withafterstarts from the branches it waits for (Discussion: Workflow Runtime Integration #818). Those need the branches added here, and [feat] Add bounded loops and parallel for_each #1079 doesn't depend on this one. The same goes forsession: continueacross loop passes. Maybe [feat] Add bounded loops and parallel for_each #1079 could depend on this one, or this one could own both parts?hsuchifeng commentedon Oct 14, 2026
A command step with no workspace is read-only. If it changes the active checkout, the step fails. The #1097 behavior that lets a command run in and change the current checkout ends when workspaces land.
This issue owns the branch side of
for_each:branches,results[].branch, and an item withafterstarting from the branches it waits for. It also ownssession: continueacross loop passes. #1079 remains command-only and does not depend on this issue.For the #1090 split, a step that starts from a branch an agent wrote still runs the tool with the agent config from the run’s base. A hook committed by an earlier step therefore does not run in a later step that holds a token. That check must happen before execution, so it belongs here. #1090 still rejects protected-path changes in the delivered diff before PR creation.
SaulMoro commentedon Oct 14, 2026
If #1120 lands as it is, only Claude-family steps report a session id. Codex and OpenCode steps report none, sosession: continueandsession: { from: <step> }would always fall back to a fresh session on those tools. Both CLIs print structured output (codex exec --json,opencode run --format json). This issue could read the session there, unless #1120 does it first.Update: resolved in #1120 (39a7dc1). Codex and OpenCode steps now report their session from
codex exec --jsonandopencode run --format json.SaulMoro commentedon Oct 14, 2026
Notes this issue builds on:
session: { from: <step> }is a step read. Treat it like the reads inifand templates: pull checks the step is one this step transitivelyneeds, and readiness (canRun) waits for it. Step ids resolve in the step's own workflow; an inline workflow also sees the enclosing one, and an inline id shadows an enclosing id.ship.check.classify). A workspace or branch name derived from the step id should use the path, and stay path-safe for the[n]thatfor_eachadds.needsof its own; its dependency is the parent'sworkflowstep'sneeds. Steps withneeds: []are roots and start from the run base.workflowstep is not leased either; its child steps are. Workspace inheritance has to say what a child step inherits from theworkflowstep.session: continueorsession: { from: <step> }carries the reference recorded for the earlier step (step,tool,session,driver). The driver decides how to honor it: resume by id when it runs the same tool on the same machine, its own mechanism if it has one (a host that runs several agents, such as T3 Code or Paseo, may store conversations and move them between its agents itself), TeamAI's session copy (what the local driver does for a different tool), or a fresh session. The engine never copies sessions.sessionin a report is the tool's native id, so any driver running that tool on that machine can resume it. A driver with its own handle reports that in a separate field. TeamAI's session copy reads only each tool's own session store, so a session that lives only in a driver's store is invisible to it. The driver's own mechanism comes before TeamAI's copy; whatever route it takes, it reports the tool and the native session id. Importing a conversation that exists only in a driver's store (as the session format the team repo archive stores) is left for when a driver needs it.claude -p --resume <id>andcodex exec resume <id>both resume from a different folder, keep the context, and append to the original session file. OpenCode moves a session withopencode session import --directory <dir>. It refuses an id that already exists, so a second copy needs new session and message ids.AgentSession.copy({ from, to: { platform, cwd } })derives the target id from the target platform, the target workspace and the source session, and never overwrites: an existing copy comes back asreused: true, so a retried step resumes the same copy. Failures are values (no_adapter,tool_unavailable,not_found,unreadable). On Windows, Cursor and WorkBuddy cannot be a copy target (their session lists need sqlite3), so a copy to them returnsno_adapter; they still work as a source.AgentSession.platformForToolmaps a workflow tool name to the session platform.reused: true. A step still blocked after ten seconds getscopy_busy, which the driver can retry.The engine rules that span files are in
docs/dev/workflow-engine.md, added by #1129.SaulMoro commentedon Oct 15, 2026
Notes from checking this against the revised proposal in #818, the scorer label agreed in #1085, #1137 and #1135. The first two matter most before planning.
continuealso needs the branches of itsneeds. The scope merges dependency branches only fornew. In both factory examples in Discussion: Workflow Runtime Integration #818,mergesits inside anewcopy with noworkspacekey, so it continues that copy. Itneeds: [build], and eachbuilditem pushed its own branch. As written, the merger runs on the round's copy without the task branches. One rule could cover both modes. Before a step runs, the driver merges every branch itsneedsproduced that the copy does not contain yet.newwith a container, a remote sandbox or a fresh clone. The whole run can also run inside a sandbox, such as a CI runner or a devcontainer. For any of these to work without the driver reading the graph, the lease can describe the workspace in git terms only: the mode, the base as{ ref, commit }, the branches to merge, the branch the step pushes, and the commit that agent configuration comes from. The engine names the branch, so every driver pushes to the same name and [feat] Publish and resume complete workflow state across machines #1084 can list it for recovery. A driver declares the modes it supports (can.workspace: [read-only, new, continue]), andnextleases aneworcontinuestep only to a driver that declares that mode. Otherwise the skill driver ([feat] Add the session skill driver and invocable workflows #1086) gets anewlease, releases it, and gets it back on its nextnext. The conformance suite can then check workspaces from git alone: the ancestry of the pushed branch, and no branch forread-only. A sandbox driver then passes the same scenarios aslocal.commandthat runsdocker runor calls a hosted sandbox can work asread-onlyand return what it made asoutput. Its branch does not feednew,branchesordeliver. If a team needs that, the sandbox is a driver. One line in the docs would keep anyone from building it as a step.needsorder and stop at the first conflict. The driver leaves that conflict in the workspace, and the lease lists the branches still to merge so the agent finishes them. Acommandstep that hits a conflict fails withmerge_conflict, naming the branch and the files.continuecopy is bound to the driver that holds it, and today that identity is the exact kind, machine and workspace. A driver that rebuilds its sandbox under a stable identity is fine. A driver whose identity changes with each sandbox, or a run resumed on another machine ([feat] Publish and resume complete workflow state across machines #1084), leaves the bound step with no driver that can take it, and nothing yet says what happens then. [feat] Publish and resume complete workflow state across machines #1084 keeps what the old driver pushed for manual recovery, so a new copy should not start from an unreported tip. One rule that fits both:retryorresumeof a bound step starts a new copy from the last commit a step reported. Discussion: Workflow Runtime Integration #818 binds sessions the same way and falls back to a fresh session only when resume is unavailable. Letting another driver take a session-bound step and start fresh would change that rule.post-checkouthook delivers the team's skills, agents, rules and MCP into a new worktree beforegit worktree addreturns (git-hook.ts,git-hook-new-worktree.test.ts). It skips worktrees under the data home, the team clone and<main checkout>/.teamai(hook-handlers.ts:163), which is where a workflow worktree manager would likely put them. Withcore.hooksPathset and Git older than 2.54, delivery waits for the next session, and that pull is asynchronous. The manager can either place worktrees where the hook runs, or prepare each one through the same pull before the agent starts. Discussion: Workflow Runtime Integration #818 already gives a sandbox driver the same job ("Runningteamai initandteamai pullin its environment").ready-issues,buildmakes anewcopy per item in every round, sorounds.build[2]is a different task each round. A name built from the run id and the step path collides from round 2 on.{ branch, base, commits }names branches, and branches move. Inrounds,merge,reviewand everyfixpass commit onto one branch. A scorer that judges the secondfixpass ([feat] Add workflow scorers and recorded quality labels #1085) needs the diff of that pass only. The driver commits what the step left uncommitted, and the report adds thefromandtocommits. Cleanup of a sampled step's workspace then waits for its scorer, which [feat] Add workflow scorers and recorded quality labels #1085 can own.deliver, another driver orpublishneeds?pullreject asession: { from }that names afor_eachstep outside its own item. This is not in the acceptance criteria yet. Benchmarks in [Proposal] Score agent sessions and run benchmarks as workflows #1137 start each trial at a pinned commit. With the base as{ ref, commit }, a laterworkspace: { new, from }fits without reshaping the lease. That part is not for this issue.SaulMoro commentedon Oct 15, 2026
For the branch names point above: #1079 gives each step a
passnumber, and the lease carries it. When a loop re-runs a child workflow or a fan-out, the new instances continue the numbering of their path, sorounds.build[2]in round 2 has a different pass from round 1, and a name from the run id, the step path and the pass does not collide.resultsis[{ item, outcome, outputs }], so this issue addsbranchto it.SaulMoro commentedon Oct 16, 2026
Three things from #1148 that change how this one gets built:
endorbreakcancels a lease, the driver sees the attempt incancelledon its next answer, and a later report getslease_cancelledand is not recorded. A branch that attempt pushed never reaches the run, so neither{ branch, base, commits }here nor [feat] Publish and resume complete workflow state across machines #1084 learns its name. The cleanup rule needs a case forcancelled. Keeping the workspace, as for a failed step, looks like the safe default.workflows.max_parallelcommands, but starts a prompt step only when no command runs, becauseread_only_dirtycompares the whole worktree before and after the prompt. A read-only check for commands with no workspace, built the same way, would make those commands run one at a time too. Once a prompt runs in its ownnewworktree, the hold can key on the workspace instead of the step kind.rependmoves what the step reported,sessionincluded, into itspasses, and the new pass starts without it. A sub-workflow orfor_eachrun again moves its old children torun.archive.session: continueacross passes reads from there.SaulMoro commentedon Oct 16, 2026
#1175 runs each
newandcontinuestep in a worktree under<checkout>/.teamai/workflow-worktrees/, and before the step it replaces the agent paths with the run base commit. One gap is left, and this PR or a follow-up can close it.teamai pulldelivers are git-excluded, not committed, so the base commit does not have them. Thepost-checkouthook skips.teamai/, andcore.hooksPathpoints at an empty directory, so nothing delivers them when the worktree is created. A step therefore starts without the team's assets. They arrive only if the tool's own session-start pull runs, in the background, and only when the tool's teamai hook lives outside the replaced paths. The worktree also has no pull record, so [feat] Record the agent each workflow step ran as #1176 cannot record which team revision the step ran with.new-worktreehook gives any other new worktree of the project (pullwithinlineandprepareCheckout,hook-handlers.ts:175). It reads the project's team clone, so the step gets the assets the member's checkout has, or the pinned commit when the workspace is pinned ([feat] Pin a workspace to a team ref withteamai pull --team-ref#1183). This is the second option from my earlier comment: "prepare each one through the same pull before the agent starts".newworkspace sees a team skill that the base commit does not contain, its pull record names the member checkout's team revision, and the step's branch does not commit the delivered files.SaulMoro commentedon Oct 16, 2026
#1184 (for #1176) adds two calls to
promptReportinlocal-driver.tsthat find the checkout through the run'sconfig, not through the directory the tool runs in. Since 545baf5 the workflow worktree gets its own delivery throughpull({ cwd: dir, prepareCheckout: true }), so it has a pull record of its own. Whichever of #1175 and #1184 merges second has to point these calls at that record:observeLaunch({ config, tool, cwd, definition })takesagent.teamfrom the pull record ofconfig's checkout (teamDelivery, keyed bycheckoutRecordKey(config), which is itsprojectRoot).localChangeshashes the files that record delivered, andmodel.requestedcomes from that record'sagentModels. Onlyagent.projectcomes fromcwd.loadAgentreads the deployed agent file fromresolveToolBaseDir(tool, config)as well, so the definition the driver hands the tool and its requested model also come from the member checkout. That line merges without a conflict. Withcwdmoved to the worktree andconfigleft as it is, a step in aneworcontinueworkspace publishes the member checkout's record, not the one the worktree was delivered with. The two name different revisions when the team clone moved after the member checkout's last pull.workflowLaunch(config, tool)binds the launch to the same checkout key. In the worktree, the tool's session-start hook compares that key with the worktree's own record, finds a different one, and pulls in the background while the step runs.Passing
loadAgent,observeLaunchandworkflowLaunchthe configdetectProjectConfig(dir)returns, the one the worktree's pull now uses, fixes all three. The step then records the revision the worktree was delivered with, and its tool skips the session-start pull, so the step keeps that revision while it runs.