Skip to content

[feat] Add workflow workspaces, branches, and session continuation #1080

Open

Description

@hsuchifeng

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 | continue and 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: expose branches and results[].branch, and start an item with after from the branches it waits for.
  • Push successful change branches and report { branch, base, commits, pr_url? }.
  • Return cleanup: true only when no later step continues the copy.
  • Preserve failed workspaces for inspection.
  • Add session: continue and session: { from: <step> }, including session: continue across 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

Acceptance criteria

  • Parallel new steps use distinct worktrees and branches.
  • Dependency branches merge deterministically.
  • A continue step 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 branches and results[].branch, and an item with after starts from the branches it waits for.
  • session: continue preserves 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.

Activity

  1. SaulMoro commented on Oct 13, 2026

    @SaulMoro
    Collaborator

    Two things that might fit here:

  2. hsuchifeng commented on Oct 14, 2026

    @hsuchifeng
    CollaboratorAuthor

    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 with after starting from the branches it waits for. It also owns session: continue across 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.

  3. SaulMoro commented on Oct 14, 2026

    @SaulMoro
    Collaborator

    If #1120 lands as it is, only Claude-family steps report a session id. Codex and OpenCode steps report none, so session: continue and session: { 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 --json and opencode run --format json.

  4. SaulMoro commented on Oct 14, 2026

    @SaulMoro
    Collaborator

    Notes this issue builds on:

    • session: { from: <step> } is a step read. Treat it like the reads in if and templates: pull checks the step is one this step transitively needs, 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.
    • Steps inside sub-workflows are leased by path (ship.check.classify). A workspace or branch name derived from the step id should use the path, and stay path-safe for the [n] that for_each adds.
    • "First dependency" needs a rule across sub-workflows. The first step of a child workflow has no needs of its own; its dependency is the parent's workflow step's needs. Steps with needs: [] are roots and start from the run base.
    • Skipped, end-only and waiting steps are never leased, so they never get a workspace. A workflow step is not leased either; its child steps are. Workspace inheritance has to say what a child step inherits from the workflow step.
    • Session continuity is the driver's choice. A lease for a step with session: continue or session: { 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. session in 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.
    • Same tool needs no copy. I checked by running: claude -p --resume <id> and codex exec resume <id> both resume from a different folder, keep the context, and append to the original session file. OpenCode moves a session with opencode session import --directory <dir>. It refuses an id that already exists, so a second copy needs new session and message ids.
    • Cross-tool copy for the local driver is being added in feat(session): safe session reads and copies for workflows, drivers, scorers, and benchmarks (#593 follow-up) #1135. 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 as reused: 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 returns no_adapter; they still work as a source. AgentSession.platformForTool maps a workflow tool name to the session platform.
    • Parallel steps can copy one session. In feat(session): safe session reads and copies for workflows, drivers, scorers, and benchmarks (#593 follow-up) #1135 the existence check, the write and the rollback of a copy hold a lock on that one copy (target platform and derived id). Two steps that copy the same source session to the same tool and workspace at once write it once: the second waits and gets reused: true. A step still blocked after ten seconds gets copy_busy, which the driver can retry.

    The engine rules that span files are in docs/dev/workflow-engine.md, added by #1129.

  5. SaulMoro commented on Oct 15, 2026

    @SaulMoro
    Collaborator

    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.

    • continue also needs the branches of its needs. The scope merges dependency branches only for new. In both factory examples in Discussion: Workflow Runtime Integration #818, merge sits inside a new copy with no workspace key, so it continues that copy. It needs: [build], and each build item 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 its needs produced that the copy does not contain yet.
    • The workspace is a contract, and the worktree manager is how the local driver meets it. Discussion: Workflow Runtime Integration #818 gives the workspace to the driver. A driver can meet new with 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]), and next leases a new or continue step only to a driver that declares that mode. Otherwise the skill driver ([feat] Add the session skill driver and invocable workflows #1086) gets a new lease, releases it, and gets it back on its next next. The conformance suite can then check workspaces from git alone: the ancestry of the pushed branch, and no branch for read-only. A sandbox driver then passes the same scenarios as local.
    • A step that starts its own sandbox is outside this model. A command that runs docker run or calls a hosted sandbox can work as read-only and return what it made as output. Its branch does not feed new, branches or deliver. If a team needs that, the sandbox is a driver. One line in the docs would keep anyone from building it as a step.
    • Conflicts. Discussion: Workflow Runtime Integration #818 says the step's agent resolves them. Git cannot leave several conflicting merges in one workspace. One way is to merge in needs order 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. A command step that hits a conflict fails with merge_conflict, naming the branch and the files.
    • An owner that does not come back. A continue copy 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: retry or resume of 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.
    • Where the local driver puts its worktrees. TeamAI's post-checkout hook delivers the team's skills, agents, rules and MCP into a new worktree before git worktree add returns (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. With core.hooksPath set 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 ("Running teamai init and teamai pull in its environment").
    • Branch names need the loop pass. In ready-issues, build makes a new copy per item in every round, so rounds.build[2] is a different task each round. A name built from the run id and the step path collides from round 2 on.
    • Report each step's commit range. { branch, base, commits } names branches, and branches move. In rounds, merge, review and every fix pass commit onto one branch. A scorer that judges the second fix pass ([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 the from and to commits. Cleanup of a sampled step's workspace then waits for its scorer, which [feat] Add workflow scorers and recorded quality labels #1085 can own.
    • Push. Discussion: Workflow Runtime Integration #818 pushes each step's branch when more than one driver works on a run. This scope pushes every successful change branch. In a local run with one driver, that sends every item of every round to the product remote. Could a local driver push only what deliver, another driver or publish needs?
    • Smaller: Discussion: Workflow Runtime Integration #818 has pull reject a session: { from } that names a for_each step 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 later workspace: { new, from } fits without reshaping the lease. That part is not for this issue.
  6. SaulMoro commented on Oct 15, 2026

    @SaulMoro
    Collaborator

    For the branch names point above: #1079 gives each step a pass number, 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, so rounds.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. results is [{ item, outcome, outputs }], so this issue adds branch to it.

  7. SaulMoro commented on Oct 16, 2026

    @SaulMoro
    Collaborator

    Three things from #1148 that change how this one gets built:

    • A cancelled attempt never reports. When an end or break cancels a lease, the driver sees the attempt in cancelled on its next answer, and a later report gets lease_cancelled and 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 for cancelled. Keeping the workspace, as for a failed step, looks like the safe default.
    • The read-only check is why prompts run alone. The local driver runs up to workflows.max_parallel commands, but starts a prompt step only when no command runs, because read_only_dirty compares 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 own new worktree, the hold can key on the workspace instead of the step kind.
    • Earlier passes keep their session elsewhere. When a loop runs a step again, repend moves what the step reported, session included, into its passes, and the new pass starts without it. A sub-workflow or for_each run again moves its old children to run.archive. session: continue across passes reads from there.
  8. SaulMoro commented on Oct 16, 2026

    @SaulMoro
    Collaborator

    #1175 runs each new and continue step 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.

    • The team's assets do not reach the worktree. The skills, rules, agents and MCP that teamai pull delivers are git-excluded, not committed, so the base commit does not have them. The post-checkout hook skips .teamai/, and core.hooksPath points 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.
    • Suggested fix. After the base configuration is applied, and before the overlay manifest is taken and the tool starts, give the worktree the delivery the new-worktree hook gives any other new worktree of the project (pull with inline and prepareCheckout, 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 with teamai pull --team-ref #1183). This is the second option from my earlier comment: "prepare each one through the same pull before the agent starts".
    • One criterion would hold it. A prompt step in a new workspace 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.
  9. SaulMoro commented on Oct 16, 2026

    @SaulMoro
    Collaborator

    #1184 (for #1176) adds two calls to promptReport in local-driver.ts that find the checkout through the run's config, not through the directory the tool runs in. Since 545baf5 the workflow worktree gets its own delivery through pull({ 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:

    • What the step ran as. observeLaunch({ config, tool, cwd, definition }) takes agent.team from the pull record of config's checkout (teamDelivery, keyed by checkoutRecordKey(config), which is its projectRoot). localChanges hashes the files that record delivered, and model.requested comes from that record's agentModels. Only agent.project comes from cwd. loadAgent reads the deployed agent file from resolveToolBaseDir(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. With cwd moved to the worktree and config left as it is, a step in a new or continue workspace 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.
    • The session-start 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, observeLaunch and workflowLaunch the config detectProjectConfig(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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions