Skip to content

fix(workflow): close the #1080 workspace gaps (#1246) - #1251

Open
SaulMoro wants to merge 41 commits into
Tencent:mainfrom
SaulMoro:feat/1246-workspace-gaps
Open

SaulMoro wants to merge 41 commits into
Tencent:mainfrom
SaulMoro:feat/1246-workspace-gaps

Conversation

@SaulMoro

@SaulMoro SaulMoro commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Closes the workflow workspace gaps listed in #1246. Rules after this PR:

Area Rule
Agent config A workspace step sees AGENT_PATHS from the run's base: AGENTS.md, CLAUDE.md, .agents, .githooks, each default toolPaths project directory and MCP file, and Copilot's .github agent paths, not .github/workflows. A symlinked parent fails base_config. When a step itself replaces such a parent with a symlink, restoring the agent files skips everything under it, so nothing outside the worktree is deleted.
Run base workflow start in a git checkout records run.base (commit, plus branch unless detached). A later base never changes it. An unreadable lease workspace fails workspace_base.
Leasing A step this driver cannot take is held back and the next ready one is tried. wait comes only when every ready step is held, with each reason.
Readers in a copy A read-only step inside a copy runs there, only for the copy's owner, and reports read. The driver puts the copy back, keeping what the step changed on refs/teamai/kept/<attempt> and refs/teamai/kept-modules/<attempt>/, or fails it workspace_base and leaves the copy marked.
What changed A snapshot, a keep, and a writer's commit see every submodule change: --ignore-submodules=none, and each submodule is staged by name, since git add -A skips ignore=all. A read-only step fails when HEAD's commit or its branch changes. A writer always stages, so status.showUntrackedFiles=no does not hide its new file, and --no-ext-diff keeps a trusted diff.external from calling its change empty.
Writer's branch A writer commits, reports changes, and pushes only from its leased branch. Off it, the step fails workspace_base, nothing is committed or pushed, and the worktree stays.
Copy removal report sets cleanup only when no step that can still run uses the copy, judged on the run as the next next settles it: loops have re-pended their steps, ready workflows and fan-outs have added theirs, and one not started counts by the copy around it unless it opens its own.
Parallel A headless prompt takes one max_parallel slot, and steps in other checkouts start beside it. Steps in its checkout wait. An interactive prompt holds every slot.
Push A retry with no new commit pushes a branch whose first push failed, also with color.branch=always (git branch --no-color). push_failed names the branch and the worktree that hold the commits, then says to push that branch from there or retry the step, and quotes git last.
Reports changes or read that do not match the lease return invalid_report. A workspace step uses the worktree's own config for its tool. Run records keep bad_session.

Evidence

Head cd909273: tsc --noEmit, lint, vitest run (476 files, 11453 tests) and build pass. Full e2e: 160 files pass. The two terminal Ctrl-C cases in workflow-signals.test.ts fail here at the same setup assertion on main (c6bd1264) too; CI runs them.

Real CLI (dist/index.js, init --self --provider git, Claude, local bare origin, workflow start <name> --driver local). The repo has a vendor submodule with ignore = all in .gitmodules.

Workflow Previous head (73a3a8b, rebased) This head
ship: change copy, implement writes ignored deps/, nested verify.test reads it test fails "deps/installed.txt missing" succeeded; copy removed after test
switch: writer runs git switch -c scratch succeeded, branch lacks the edit failed workspace_base "left workspace … on refs/heads/scratch, not on branch …"
vendor: reader commits in vendor succeeded, nothing kept failed read_only_dirty, commit kept under refs/teamai/kept-modules/<attempt>/

Head ae8b8606 adds --no-ext-diff and --no-color on top of cd909273. tsc --noEmit and lint pass, and so do vitest run src/__tests__/workflow (18 files, 1125 tests), commands-reference docs-examples, build and test:e2e -- workflow-workspaces. On the real CLI as above, with status.showUntrackedFiles=no and diff.ignoreSubmodules=all set, three things were checked. A writer whose only change is a new file commits it. A reader's commit in vendor fails read_only_dirty and stays off the branch. The next writer's vendor commit lands on it. With the machine's global git config, push.recurseSubmodules=check then fails that writer's push with push_failed, as the user's policy says.

Head 3ef4e188 adds the push_failed message (ba957ded) and the restore guard for a step-made parent symlink (3ef4e188). tsc --noEmit and lint pass, and so do vitest run src/__tests__/workflow (18 files, 1127 tests), build and test:e2e -- workflow-workspaces. Both changes were tested first, and both tests failed before the fix. On the real CLI, the run with the global config above now fails with "… but pushing it to origin failed. The commits stay on . Fix what blocked the push, then push from or retry the step. Git said: …". In a second real-CLI run, the base has .github/agents/a.md and the step runs mv .github <outside> && ln -s <outside> .github. The step succeeds, <outside>/agents/a.md is kept, and the branch records .github as a symlink (120000).

Table tests: copy removal (7 shapes), submodule ignore (3 settings × 4 observers), writer off branch (4), HEAD moves (2), steps beside a prompt (4). Every row but one guard row in each of the copy and prompt tables failed before its fix.

Left to CI: Codex, CodeBuddy, OpenCode, and the gitlab/github providers; the changed code does not depend on them. The prompt overlap is covered by the driver test only, since a real prompt needs a live agent.

Merge Danger

Door: two-way. Run files gain optional fields (read, run.checkout, an optional base.ref). No migration.

Blast Radius: workflows. Runs with workspaces, or prompts with max_parallel above 1, behave differently. Submodule ignore settings no longer hide a step's changes.

Closes #1246

@github-actions

Copy link
Copy Markdown
  • [P1 blocking] Keep copies needed by unexpanded sub-workflows — src/workflow/engine.ts:1197: Pending workflow steps are skipped, but their children are inserted only when they start. For change (workspace: new) → implement → nested verification workflow, reporting implement therefore authorizes premature cleanup. Ignored dependencies installed by implement, such as node_modules, are deleted, and verification runs in a recreated copy without them.

  • [P1 blocking] Override submodule-ignore settings in snapshots — src/workflow/local-driver.ts:235: With submodule.vendor.ignore=all, a reader editing vendor/inside.txt produces no snapshot delta. putBackCopy treats it as untouched, so the reader incorrectly succeeds without keeping or restoring its edits; final cleanup can delete them. Use --ignore-submodules=none consistently.

  • [P1 blocking] Validate the writer’s branch before committing — src/workflow/local-driver.ts:591: A workspace writer that runs git switch -c scratch commits on scratch, but its report and push still name the leased branch. The step reports success although that branch lacks the change, and a subsequent reader is refused because the worktree is on the wrong branch.

  • [P2 non-blocking] Allow independent commands alongside workspace prompts — src/workflow/local-driver.ts:899: The new overlap logic remains constrained by room(), which disables leasing whenever a prompt exists. With max_parallel: 2 and a workspace prompt first, an independent command cannot start until the prompt exits despite having a separate worktree and a free slot.

The PR description documents sufficient build and real-CLI verification. No commands from the PR were run.

The base came from whichever driver first sent one to next, with ref always HEAD. A missing ref then made the local driver drop the whole workspace and run the step in the member's checkout.
restoreUneditedConfig staged AGENT_PATHS with one git add. Git exits 128 when any listed path matches no file in the worktree or the index, and then stages nothing, so a copy kept the base agent file in its index after a step that edited none.
next answered wait at the first ready step whose workspace the driver could not take, so a driver without new never got a ready read-only step. Settling now returns every ready leaf in order, next leases the first this driver can take, and it waits only when every ready step is held back, naming each one.
A command such as `npm test` after an implement step inside a `new` copy
ran in the member checkout, so it tested code without the change.

A read-only step under a copy owner now leases a read-only contract on the
branch its need wrote (or the owner's branch), runs in that worktree on the
base agent files, commits nothing, and reports the commit it read as `read`.
A copy the reader changed is reset to that commit, so a later step there
does not commit the change. Needs that wrote two branches fail with
workspace_base.

Part of Tencent#1246 (F4).
…rkflows

A branch can commit .github as a symlink. The nested Copilot paths were then copied and removed through it, deleting files outside the worktree. A root-level hooks file no longer yields '.' as a path to reset.
…the reader changed

A reader that detached HEAD changed nothing the snapshot measured, so a later continue step committed off the branch. A reset also dropped a person's uncommitted edits, ignored git failures while reporting the copy as put back, and left initialized submodules where the reader moved them. The state is now kept on refs/teamai/kept/<attempt> (not the stash list, which every worktree shares) before the reset.
…m fails

When update-ref or stash create failed, the failure said the copy could NOT be put back and told the user to put it back, but not that the edits exist only in the copy. Read literally, that is a reset that destroys them. The result of leaving a read copy is now a tagged union (untouched, restored, unrestored), so each message follows from one state.
After a failed restore the driver still leased later steps into that copy, so a continue writer could commit on a detached HEAD while reporting the named branch, or commit what the reader left. leaveReadCopy now writes a marker in the worktree's own git dir. stageLease refuses a workspace lease while the copy is not on refs/heads/<branch> at the marker's commit with a clean status, and removes the marker once it is, so the person who puts the copy back only runs teamai workflow retry.
… destroying it

git stash create in the superproject never saves uncommitted or untracked files inside a submodule, yet submodule update --force and a foreach git clean discarded them, while the failure said the changes were kept. Before any keep or restore, each initialized submodule is checked recursively. When one has such files, nothing is reset, and the reader fails unrestored naming them, which also blocks later steps in that copy. The foreach clean is gone.
…put back

git clean -fd skips a repository the reader created inside the copy, every restore command still exits 0, and the next writer's git add -A committed it as a gitlink while the failure said the copy was put back. After the restore, the copy now counts as put back only when HEAD is refs/heads/<branch> at the commit it read and git status, submodules included, is empty. Otherwise the reader fails unrestored naming what is left, and the nested repository stays.
submodule update without --checkout follows a configured update=merge, rebase or none, so the reader's submodule commit survived the restore and a later writer committed that gitlink. The restore now passes --checkout. --force is dropped: a dirty submodule never reaches this point, and a clean one checks out without it.
symbolic-ref --short prints heads/<branch> when a tag has the branch's name, so a reader that changed nothing looked like it had moved HEAD and failed read_only_dirty.
…d its copy exits non-zero

The changes of a failing reader still went to refs/teamai/kept/<attempt> and were removed from the copy, but its report was only the exit status, so nothing said where they went. The command's exit status and output snippet stay first, followed by the same put-back or could NOT be put back sentence a passing reader gets.
…s branch

ensureWorktree returned an existing worktree without checking HEAD, so after a driver stopped before it put a read copy back, the next writer committed on a detached HEAD and reported the named branch. An existing worktree whose HEAD is not refs/heads/<branch> now fails workspace_base, naming the directory, where HEAD is, and the branch to check out.
The base_config message said to commit the symlinked parent as a directory on the branch and retry. When the symlink comes from the run base, every run branch starts with it and no step can fix it. That case now says to commit it as a directory on the base branch and start a new run. The branch case names the branch and the worktree to commit in.
local-driver.ts had crossed 1,000 lines. copyLeftOver, dirtySubmodules, the
unrestored marker and the keep-and-restore sequence are git-level handling of
a worktree and take no lease, so they move to worktree.ts as putBackCopy and
unrestoredCopy. leaveReadCopy keeps the snapshot, overlay and lease wiring.
No behaviour change.
…der started

The postcondition required an empty status, so a submodule an earlier writer
left dirty could never pass, and it read the user's status config, so
status.showUntrackedFiles=no hid a repository the reader left. The copy's
status is now captured at staging, before the overlay, and put back means that
status again, read with --untracked-files=all.
…reader changed

Every dirty submodule blocked the restore, so a submodule an earlier writer
left dirty failed a reader that never touched it. Only repositories among the
reader's changed paths are checked now. Inside each, status ignores only dirty
nested submodules, so a commit made in vendor/inner alone shows as a change in
vendor and the copy is left untouched instead of being reset. A submodule with
no checkout left counts as unkept. The reset and checkout no longer recurse
into submodules under submodule.recurse=true.
…copy is removed

A gitlink on the kept ref does not keep its commit reachable, and removing the
copy deletes the submodule's own store. Each submodule commit the kept tree
records and the read commit does not, including a repository the reader
created, is pushed into the workspace repository under
refs/teamai/kept-modules/<attempt>/<path> before any reset. A failed push is a
failed keep: nothing is reset and the message says the changes were NOT saved.
…river leaves it refused

The marker was written only after a failed put-back, so a driver killed while
the reader ran left no marker, and on resume a later writer committed the
reader's files. Staging a read-only lease now writes the marker, with the
copy's status, before the overlay, and fails workspace_base if it cannot. A
copy put back or left untouched clears it; an unrestored one keeps it with the
reason. A resumed copy that is back as it was found clears it by itself.
…is not a worktree

A copy whose directory was deleted stays registered, so worktree add refused
its path; it is now pruned and created again. A plain directory at the copy
path was told to check out the branch, which would switch the checkout around
it; it is now refused as not a workflow worktree. A marker that exists but
cannot be read now blocks the copy instead of counting as absent.
… and where submodule commits are kept

Ignored files stay, the copy is marked when the reader starts so a killed
driver still blocks later steps, and submodule commits are kept under
refs/teamai/kept-modules/<attempt>/.
…pt sentence

Keeps local-driver.ts under 1,000 lines: clearing the reader's mark when
nothing changed and the kept-ref sentence live beside the keep they describe.
…py untouched

At 9d4fff6 this was reported as kept while the submodule's uncommitted files
were lost. The changed-repositories check already fails it closed.
…ubmodules a reader changed

A status line names a path, not its content, so a marker whose copy was dirty when the reader started is no longer cleared by matching status; the person who puts the copy back deletes it. Module pushes ignore push.recurseSubmodules.
@SaulMoro
SaulMoro force-pushed the feat/1246-workspace-gaps branch from 73a3a8b to cd90927 Compare October 21, 2026 04:10
@SaulMoro SaulMoro changed the title [After #1175] fix(workflow): close the #1080 workspace gaps (#1246) fix(workflow): close the #1080 workspace gaps (#1246) Oct 21, 2026
@SaulMoro
SaulMoro marked this pull request as ready for review October 21, 2026 04:12
@github-actions

Copy link
Copy Markdown

No new findings in the scoped diff.

All four earlier findings are resolved: cleanup accounts for unexpanded workflows, submodule-ignore settings no longer hide changes, writers validate their leased branch before committing, and independent commands can run beside headless prompts.

The PR description documents sufficient build and real-CLI verification at cd909273. Deferred provider/agent coverage is not blocking.

Review was read-only; no PR code was run, built, or installed.

…e driver's git checks

With color.branch=always, git branch --contains printed escape codes around the name, so a retry never pushed a branch whose first push failed. With diff.external and diff.trustExitCode, an external diff that called two blobs equal made commitWork skip a writer's staged change. Also tests that status.showUntrackedFiles=no does not hide a writer's new file.
@github-actions

Copy link
Copy Markdown
  • [P1 blocking] Guard symlinked parents during restoration too — src/workflow/worktree.ts:180: With a tracked .github/agents/a.md, a workspace step running mv .github /tmp/shared && ln -s /tmp/shared .github passes this pre-step check. Afterwards, restoreUneditedConfig follows the new parent symlink, deletes /tmp/shared/agents/a.md, and removes the symlink through removeBlockingSymlink. Check parents again before restoration touches files; the next-step guard is too late to prevent deletion outside the worktree.

All four earlier findings are resolved and are not raised again.

The PR description documents sufficient build and real-CLI verification, including head ae8b8606. Deferred provider/agent coverage is not blocking.

Review was read-only; no PR code was run, built, or installed.

…to finish it

push_failed carried only git's stderr. It now names the branch and the worktree that hold the commits, and says to push that branch from there or retry the step. Git's own text comes last.
…ced with a symlink

The symlinked-parent check ran only before a step. A step that ran mv .github <outside> && ln -s <outside> .github passed it, and restoreUneditedConfig then followed the new link: it deleted .github/agents files outside the worktree and removed the link. Restoration now skips every agent path whose parent is a symlink, keeping the step's edit. The next step still fails base_config on it.
@github-actions

Copy link
Copy Markdown

No new findings in the scoped diff.

All five earlier findings are resolved, including the restoration guard for step-created parent symlinks, and are not raised again.

The PR description documents sufficient build and real-CLI verification at head 3ef4e188. Deferred provider/agent coverage is not blocking.

Review was read-only; no PR code was run, built, or installed.

@SaulMoro

Copy link
Copy Markdown
Collaborator Author

This was generated by AI during triage.

Heads-up on an overlap with #1234. Both PRs edit the diagnostics list in src/__tests__/workflow-records.test.ts. This PR drops bad_session. #1234 adds workflow_namespace_conflict and prompt_namespace_conflict. Each PR owns its own entries, and whichever merges second resolves the list.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] Close the #1080 gaps that #1175 leaves open

2 participants