Skip to content

Cloud runs' work is adopted onto the run's record (fix #1601) - #1603

Open
suleimansh wants to merge 2 commits into
mainfrom
cloud-branch-adoption
Open

Cloud runs' work is adopted onto the run's record (fix #1601)#1603
suleimansh wants to merge 2 commits into
mainfrom
cloud-branch-adoption

Conversation

@suleimansh

Copy link
Copy Markdown
Member

🤖 agent PR

Fixes #1601, along the direction proposed in the issue comment.

The problem

A web run's cloud session works on a claude/* branch of the cloud's own naming, never the designated tf-agent-* branch. Nothing ever told the run's record, so every surface keyed to the recorded branch — the session row, PR resolution, CI watch, merge — said "Nothing committed" while the work sat on origin; the armed draft PR never opened; and teardown pushed one empty tf-agent-* ref to origin per web run just to satisfy the retention rule.

The fix, in three moves

  1. The hand-off anchor (driver/cloud.ts). The pre-hand-off push no longer pushes plain HEAD: it pushes an anchor — an empty commit on top of HEAD, unique to the run, minted with commit-tree so no local branch moves. The session clones at it, so the branch it works on descends from it — and that ancestry is an exact match, not a guess. The anchor rides the driver's result into a new cloud-anchor event and onto the meta (AgentMeta.cloudAnchor). A repo where the anchor can't be minted hands off plain HEAD, exactly as before.

  2. The adoption pass (cloud-work.ts, new; wired as a daemon background job every ~10 min). For each settled web run still owed an answer (within a 48h window), one ls-remote lists origin's claude/* heads; the head descending from the run's anchor is adopted: the branch is patched onto the run's archive (the same patch-in-place recordAgentPr uses), the PR the session opened is recorded — and when the run was armed for a PR the session never opened, the daemon opens the draft PR itself via the new push-free openRemoteBranchPullRequest (the branch exists only on the remote; gh pr create --head is the whole action). Zero or two matching heads → retried next pass; conservative everywhere.

  3. Teardown stops the debris (worktrees.ts). A web run's checkout goes without pushing its empty run branch, once it provably holds nothing: a clean tree whose tip is inside what the hand-off pushed (an ancestor of the recorded anchor). Any doubt falls back to the ordinary commit-and-push rule. The scratch-ref sweep (cloud-scratch-refs.ts) learns the anchor's one quirk: its tip is never reachable from main (no merge ever lands an empty commit), so a tip that changes nothing against a landed parent now clears the work gate — otherwise cloud-* refs would be kept forever.

Everything downstream lights up through the existing single lookup (agentBranchFor prefers the recorded branch; AgentMeta.pr feeds resolveAgentPr): no per-surface changes were needed.

Not in this PR

Verified

  • Suite green: 1522 node + 772 dashboard, 0 failures. Both typechecks clean.
  • New tests: adoption matching (exact / zero / ambiguous), armed-draft-PR opening and its no-work guard, unarmed runs, window expiry, no-remote; anchor minting/push/report and both fallbacks; web teardown without push (real git) and its fall-back on a dirty tree; anchor-tip scratch-ref deletion and its not-empty counterpart; the push-free PR opener; meta fold + archive patch.

SPEC walkthrough follows as a comment.

@suleimansh

suleimansh commented Aug 19, 2026

Copy link
Copy Markdown
Member Author

🤖 agent comment

SPEC walkthrough. Per modified SPEC file: where it sits, the diff, and why. One sentence covers all of it: the run's record now learns the branch its cloud session actually worked on, by exact ancestry from a commit unique to the hand-off — and everything that only existed to cope with not knowing gets simpler.


1. src/cloud-work.SPEC.md + src/cloud-work.test.SPEC.md — new, for the new module (the daemon's adoption pass). The whole file is the story:

Adopts the branch a cloud session actually worked on: each settled web run is matched to the `claude/*` branch that grew out of its hand-off, and that branch — and its pull request — is recorded on the run.

- A web run hands the task to claude.ai and ends; the cloud session does the work on a branch of its own naming, never the branch the run was born on. Without adoption, every surface keyed to the run's branch — its dashboard row, its PR, CI watch, merge — stared at an empty branch and said "nothing committed" while the work sat on origin.
- The match is exact, never guessed: the hand-off pushed a commit unique to the run for the session to clone at, so the session's branch — and only it — descends from that commit. A run matching no branch (the session has not pushed, or never will) or more than one is simply asked again next pass, and a run past the window (two days) stops being asked about.
- What gets recorded: the branch, and the pull request the session opened for it. A run that was armed for a PR the session never opened gets its draft PR opened by the daemon — the armed handoff finally resolving against the facts — unless the branch carries nothing beyond the hand-off itself.
- Daemon-side by necessity: the branch does not exist yet when the run's own process ends — the cloud VM is still provisioning — so a later pass patches the run's record, the same way a late-opened PR already is.
- Adoptions and failures are said out loud; a run still waiting is not, because waiting is its normal state.

The test SPEC lists what the tests prove: exact/zero/ambiguous matching, the armed draft PR and its nothing-beyond-the-hand-off guard, unarmed runs, the window, no-remote never throwing, and the service's announce-only-changes rule.


2. src/driver/cloud.SPEC.md — intro: "A driver that hands the whole task to Claude Code on the web". Its last TLDR bullet described the pre-hand-off push of plain HEAD; that push now carries the anchor:

-- Before the hand-off, HEAD is pushed to origin under the agent's own id: the CLI's default revision pin is the current local branch — which an agent workspace's local-only branch fails — and a slash-carrying ref never resolves on the cloud side even when pushed (anthropics/claude-code#87235), so the ref is minted slash-free and handed over explicitly. A push that fails degrades to the old behavior and says so, naming `--teleport` as the recovery path.
+- Before the hand-off, the anchor is pushed to origin under the agent's own id: an empty commit on top of HEAD, unique to this run and minted without moving any branch. The session clones at it, so the branch it does its work on — a name of the cloud's own choosing — descends from it, and that ancestry is how the daemon later recognizes which branch is this run's. The ref is explicit and slash-free because the CLI's default revision pin is the current local branch — which an agent workspace's local-only branch fails — and a slash-carrying ref never resolves on the cloud side even when pushed (anthropics/claude-code#87235). A push that fails degrades to the old behavior and says so, naming `--teleport` as the recovery path; a repo where the anchor cannot be minted hands off plain HEAD, and the run is simply never matched to its branch.

This is the mechanism the whole PR rests on: the pushed commit is unique to the run, so the session's branch is recognizable later by plain ancestry — no name heuristics, no time windows deciding matches. The two degradations (push failed, anchor unmintable) keep exactly the old behavior.

3. src/driver/cloud.test.SPEC.md — the coverage sentence's handshake clause grows to match: the anchor pushed and reported on the result, plain HEAD when it cannot be minted, no ref when the push fails.


4. src/cloud-work wiring in src/daemon-services.SPEC.md — intro: "Everything the daemon runs in the background beside serving the dashboard". One bullet added to the service list:

 - An hourly sweep deletes the dead refs Claude-web hand-offs leave on origin, once they are old enough and provably hold no work.
+- Settled web runs are matched to the `claude/*` branch that grew out of their hand-off, and the branch and its PR are adopted onto the run's record — with the armed draft PR opened when the session never opened one.

New background job, so the daemon's service inventory has to say so.


5. src/worktrees.SPEC.md — intro: "Cleans up the per-agent checkouts a project retains"; its first bullet is the one rule ("only what is on the remote may go"). One bullet added:

+- A web run's checkout is the one carve-out from the push: the hand-off already pushed everything the cloud session clones at, and the work lands on the session's own remote branch — so pushing the empty local run branch would only put a dead ref on origin per web run. It goes without a push once it provably holds nothing (a clean tree whose tip is inside what the hand-off pushed); any doubt falls back to the ordinary rule.

This is what stops the debris at the source: the one-empty-tf-agent-*-ref-per-web-run accretion the issue names. The carve-out is proof-gated, and doubt falls back to the old push — never worse than before.

6. src/worktrees.test.SPEC.md — coverage sentence grows: a web run's clean checkout goes without pushing, one holding more than its hand-off carried falls back.


7. src/cloud-scratch-refs.SPEC.md — intro: "Deletes the two dead refs every Claude-web hand-off leaves on origin". Two changes:

-- A web run pushes a `cloud-*` ref for the cloud session to clone at, and its run branch reaches origin when the worktree is reclaimed. The session then works on its own branch and opens its PR from there, so nothing ever consumes either ref again.
+- A web run pushes a `cloud-*` ref for the cloud session to clone at; the session then works on its own branch and opens its PR from there, so nothing ever consumes the ref again. Run branches used to reach origin too, pushed empty when a web run's worktree was reclaimed — teardown no longer pushes them, and the sweep clears the ones already there.
+- The hand-off anchor is the one tip the default branch never absorbs — an empty commit no merge ever lands — so it clears the work gate its own way: a tip that changes nothing against its parent, on a parent that landed, holds no work.

First: the run-branch half of the sweep's diet dries up (teardown stops pushing), but the sweep keeps clearing what is already on origins. Second is a consequence the anchor forces: the sweep's work gate was "tip reachable from the default branch", and an anchor tip never is (a squash merge rewrites the session's history without it) — without this rule every cloud-* ref would be kept as "holds work" forever.

8. src/cloud-scratch-refs.test.SPEC.md — coverage sentence gains the anchor-tip clause (empty commit on a landed parent counts as landed; a tip that changes anything does not).


9. src/dashboard/agent-handoff.SPEC.md — intro: "How a finished agent's work is handed back to the human". One bullet added, after the patch-the-archive one:

+- A branch that exists only on the remote — a cloud session's own, pushed from a VM this machine never sees — can still get its draft PR opened: there is nothing to push first, the PR request itself is the whole action.

The existing PR opener pushes first, which fails for a branch with no local copy; the adoption pass needs this push-free variant to open the armed draft.

10. src/dashboard/agent-handoff.test.SPEC.md — coverage sentence gains that variant and its reported (not thrown) gh refusal.


11. src/store/agent-store.SPEC.md — intro: "Agent persistence: an agent's history is its append-only event log". One flow sentence widens:

-- An archived snapshot can be patched afterwards with a fact discovered once the agent's process is gone, such as the pull request opened for its work.
+- An archived snapshot can be patched afterwards with a fact discovered once the agent's process is gone, such as the pull request opened for its work or the branch a cloud session's work landed on.

Same patch-in-place mechanism, second fact.

12. src/events.SPEC.md — the durable-record bullet's example list gains "the hand-off anchor a cloud run's branch is later recognized by": the anchor travels as an event for the same reason the branch and PR do — only events reach the stored history the daemon reads after the process is gone.


13. FEATURES-SPEC.md — one row added under Remote execution and sharing:

 | 124 | Web runs trust the project for Claude Code automatically — no manual trust step |
+| 125 | A cloud run's row follows the session's real branch and PR, with its armed draft PR opened when the session opens none |

User-facing behavior: the row that said "Nothing committed" about finished cloud work now shows the branch and the PR.

A web run's cloud session works on a claude/* branch of its own naming,
never the designated run branch, so every surface keyed to the recorded
branch said "nothing committed" while the work sat on origin — and the
armed draft PR never opened.

The hand-off now pushes an anchor (an empty commit unique to the run,
minted without moving any branch) as the ref the session clones at, so
the session's branch is recognizable later by plain ancestry. A daemon
pass matches each settled web run to the one claude/* head descending
from its anchor and patches the run's archive with the branch and its
PR — opening the armed draft PR itself when the session never did.

Web-run teardown stops pushing the empty tf-agent-* branch to origin;
the scratch-ref sweep clears the ones already there and learns that an
anchor tip (an empty commit on a landed parent) holds no work.
@suleimansh
suleimansh force-pushed the cloud-branch-adoption branch from cceef13 to 2dcb2a9 Compare August 20, 2026 10:43
…ile the next sync wipes

Seen live: the adoption pass patched the run's archived record straight
into the tf-data checkout; a minute later the data sync's rebase refused
the dirty tree and the funnel hard-reset it, so the run flipped back to
its birth branch every pass. One `patchArchivedAgent` replaces
`recordAgentPr` + `adoptAgentBranch`, and `patchArchivedAgentOnDataBranch`
is the funneled form (sync, patch, commit, push) the pass and the Open PR
button now use. The pass also treats a failed `gh pr list` as "could not
tell" rather than "none" (it opened a second draft PR otherwise), leaves
alone a run whose record names some other branch, and writes one commit
per adoption. The driver mints and pushes the anchor in one step — the
anchor-or-HEAD fallback was a second hand-off path that could not occur.
SPECs that still described the pre-#1601 pair of dead refs, and the test
SPECs missing the new cases, are brought current.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@suleimansh

Copy link
Copy Markdown
Member Author

🤖 agent comment

Dogfooded live twice today, plus a code review. The mechanism held; one real bug came out of it and is fixed in 084b140. Evidence first, then the SPEC changes of this round (intro + TLDR, diff, meaning — per file).

What the two live runs showed

Run 1 (2026-08-20T10-51-55-496Z), before the fix:

Run 2 (2026-08-20T11-54-36-221Z), after the fix: anchor 263d9a5c pushed as cloud-1-5bfe0f24, no tf-agent-* branch; the session pushed claude/create-test26-smoke-test-zy9qnp; the pass adopted it at 12:05:03Z as commit 69d69269 [The Framework] adopt session 2026-08-20T11-54-36-221Z's cloud work on tf-data — checkout clean, pushed (0 ahead of origin) — and opened the armed draft PR (#1608, closed afterwards as a smoke artefact). Six minutes and several data-syncs later the record still read claude/create-test26-smoke-test-zy9qnp + PR #1608, and the daemon log had exactly one adoption line.

What changed in 084b140

  • One patchArchivedAgent replaces recordAgentPr + adoptAgentBranch (they were the same 18 lines with one field swapped); patchArchivedAgentOnDataBranch is the funneled form — sync, patch, commit, push — and both the adoption pass and the Open PR button use it. One commit per adoption: [The Framework] adopt session <id>'s cloud work.
  • A failed gh pr list is "could not tell", not "none": the pass records the branch but opens no PR that turn (otherwise a transient gh failure opened a second draft PR on a branch that already had one). Listing is ghPrsForBranchOrThrow; the swallowing ghPrsForBranch stays for the read-only surfaces.
  • A run whose record names some other branch — neither the one it was born on nor the matched head — is left alone, instead of getting a PR recorded against a branch it does not live on.
  • The driver mints and pushes the anchor in one step. The anchor-or-HEAD fallback was a second hand-off path that could not occur (a checkout that cannot commit-tree on HEAD has no HEAD to push either) — AGENTS.md's no-dual-path rule.
  • Tests: archived-agent-patch.test.ts against real git (the patch lands as a pushed commit, the checkout is clean, and the fact survives a later sync); two new cloud-work cases (failed listing; foreign branch). Suite 1482 + 772 green.
  • Not in this PR, filed as Cloud work adoption: per-head git spawns every pass, and the daemon clock stretches under slow jobs #1607: the pass costs two git spawns per run × per claude/* head every 10 min and nothing prunes those heads; and the daemon clock merges ticks while a job is slow, which stretched the 10-minute cadence to 26 minutes today.

SPEC changes in this round

1. src/archived-agent-patch.SPEC.md + .test.SPEC.md — new, whole file:

Records a fact learned about a run after it ended — the pull request its work is on, the branch a cloud session landed it on — onto the run's archived record, as a commit on the data branch.

## TLDR

- The write goes through the data branch's one write funnel — sync with origin, patch, commit, push — so the fact is shared with every machine and survives the next sync; written straight into the checkout it would be wiped within a minute, since the sync hard-resets a dirty checkout.

The test SPEC: a patch lands as a pushed commit with the checkout clean and survives the next sync; a run with no archive is reported as not patched and commits nothing.

2. src/cloud-work.SPEC.md — the adoption pass. Intro + the TLDR line that changed, in context:

Adopts the branch a cloud session actually worked on: each settled web run is matched to the `claude/*` branch that grew out of its hand-off, and that branch — and its pull request — is recorded on the run.

## TLDR
- A web run hands the task to claude.ai and ends; the cloud session does the work on a branch of its own naming ...
- The match is exact, never guessed: the hand-off pushed a commit unique to the run ...
- What gets recorded: the branch, and the pull request the session opened for it. A run that was armed for a PR the session never opened gets its draft PR opened by the daemon ...
- Daemon-side by necessity ...
- Adoptions and failures are said out loud; a run still waiting is not ...
-- What gets recorded: the branch, and the pull request the session opened for it. A run that was armed for a PR the session never opened gets its draft PR opened by the daemon — the armed handoff finally resolving against the facts — unless the branch carries nothing beyond the hand-off itself.
+- What gets recorded, as one commit on the data branch so every machine learns it: the branch, and the pull request the session opened for it. A run that was armed for a PR the session never opened gets its draft PR opened by the daemon — the armed handoff finally resolving against the facts — unless the branch carries nothing beyond the hand-off itself, or the session's pull requests could not be listed that pass: not knowing is never read as none, since the cost would be a second pull request.

Meaning: the third line is where the record is written — the data branch (file 1) — and gains the second case in which the armed PR is not opened this pass: the listing failed. The "exact, never guessed" rule of the line above now also covers the PR side.

3. src/cloud-scratch-refs.SPEC.md — the hourly sweep of dead refs. Intro and first TLDR line still described the pre-#1601 world (every hand-off leaving a pair of refs) and narrated history; both are now statements of what the code does:

-Deletes the two dead refs every Claude-web hand-off leaves on origin — the pre-hand-off `cloud-*` ref and the run branch — once it is provably safe, so they stop accumulating one pair per web run.
+Deletes the dead refs web runs leave on origin — the `cloud-*` ref a hand-off pushes for the session to clone at, and any run branch that holds no work — once it is provably safe, so they stop accumulating.
-- ... Run branches used to reach origin too, pushed empty when a web run's worktree was reclaimed — teardown no longer pushes them, and the sweep clears the ones already there.
+- ... A web run's own branch never reaches origin — its checkout is reclaimed without a push once the cloud session has what it needs — while a local run's branch does, and is swept only once its work has landed.

Meaning: the sweep's two candidate classes are the cloud-* anchors (every web run) and run branches (local runs only, gated on "its work landed" — the third TLDR line). The rest of the file is unchanged.

4. src/driver/cloud.SPEC.md — the web driver. One TLDR sentence, the failure clause of the anchor push:

-... A push that fails degrades to the old behavior and says so, naming `--teleport` as the recovery path; a repo where the anchor cannot be minted hands off plain HEAD, and the run is simply never matched to its branch.
+... A hand-off whose anchor cannot be minted or pushed goes ahead with no ref and says so, naming `--teleport` as the recovery path; such a run is simply never matched to its branch.

Meaning: one failure mode instead of two — mint-and-push is one step, and its failure is the existing "no ref, notice, --teleport" path. The test SPEC drops the plain-HEAD case accordingly.

5. Test SPECsagent-telemetry.test.SPEC.md gains the anchor event the tests already covered; cloud-work.test.SPEC.md gains the failed-listing, foreign-branch and still-owed-PR cases.

@suleimansh

Copy link
Copy Markdown
Member Author

@brillout ready for your read. Dogfooded live twice today: run 1 caught a real bug (the adoption was written into the tf-data checkout and wiped by the next sync — recordAgentPr has had the same flaw since #1595), run 2 confirmed the fix. Evidence and per-SPEC walkthroughs are in the two agent comments above. Most worth your eyes: cloud-work.SPEC.md, and whether the anchor-commit approach beats the alternatives listed in #1601. Two non-blocking observations from the dogfood went to #1607.

@brillout

Copy link
Copy Markdown
Member

Saving the claude/ branch via TF data sounds good to me. (I didn't understand all of the SPEC changes, but I guess we can merge.)

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.

Cloud runs land work on claude.ai's own branch, not the designated tf-agent branch — and the armed PR never opens

2 participants