feat(aos): give the session shadow a lifecycle, and reap a squash-landed branch (#1260) #1272

Merged
coilyco-ops merged 1 commit from aos/claude/mu55-shadow-reap into main 2026-08-26 07:33:09 +00:00
Member

Closes #1260. Tier 1 of the #1245 dispatch, and the one item that repo owns rather than aterm.

First, a correction to the issue's diagnosis

The issue says "There is no list, no release, no reap. A session cannot even declare itself finished." Two thirds of that is right. cleanDeadNativeSessions, cleanNativeArtifact, and runNativeWorkspaceSweep already exist and already work: the registered worktree count is down from the 195 the issue measured to 72, with none prunable. What was missing is a way to see the state, a way to declare a session finished, and a way to run the sweep without launching another session.

The branch half is worse than the issue described, and has a specific cause.

The branch leak, diagnosed

deleteNativeBranchIfRemote asked git rev-list <branch> --not --remotes=origin. That is exactly right until the forge squash-merges and deletes the remote branch, which this one does (default_merge_style: squash, default_delete_branch_after_merge: true). After that the branch's own commits are on no remote ref and never will be, so the answer is "unpushed" forever:

aos/claude/bk79   upstream configured: origin/aos/claude/bk79   remote ref: gone
                  rev-list --not --remotes=origin: 1 commit     -> kept forever

Patch identity does not rescue it either. aos/claude/mu55-dryrun-exits from earlier in this batch had 2 commits squashed into 1, and git cherry origin/main reports both as unmerged.

Content does. A branch is spent when it changes nothing the default branch lacks:

base=$(git merge-base origin/main <branch>)
paths=$(git diff --name-only $base <branch>)
git diff --quiet origin/main <branch> -- $paths

Three gates keep it conservative, each with a test:

  • the branch must have had a configured upstream that is now pruned, so a never-pushed branch that may hold the only copy of its commits stays out of the path
  • a path main has since changed again fails the test and the branch is kept
  • the existing rev-list answer is tried first and this is only the fallback

Session branches and the ID ledger

reapNativeMergedBranches skipped every aos/ branch, with the comment that reaping one would hand out an ID still in use. That is the mechanism by which they never went. Narrowed rather than removed: a session branch is skipped while a lease or a worktree still names it, which is what "in use" actually means. nativeLiveWorktrees now records branches alongside paths, and answers yes when uncertain.

Two tests changed to match, and both changes are behavior revisions worth reading:

  • TestNativeSweepReapsFullyPushedBranchesOnly now asserts a leased session branch survives and an unleased landed one does not
  • TestNativeLaunchRetriesOccupiedShortSessionIDs pushes its collision marker, because a pushed ref is what makes an ID taken now. A bare local branch nothing leases no longer is.

Three verbs

aos _native-shadow --list [--json]     agentic-os.native-shadows.v1
aos _native-shadow --release [<id>]    defaults to $AOS_NATIVE_SESSION
aos _native-shadow --reap [--dry-run]

--release marks the lease and never tears down a process that may still be running. The worktree goes on the next sweep, and a released lease skips the 24-hour grace, which exists only because a crash and a clean exit look identical from outside.

The measurement, and a correction to my own first reading

Live on this host, --list --json:

70 leases
 4 live
51 inside the 24h dead-session grace
14 held by commits that exist on no remote
 1 releasable right now

My first version of the report called 52 of 70 releasable, because it applied the artifact predicate and ignored the grace. That is a dry run that overstates what it would delete, which is worse than no dry run, so the report now runs the same predicate the sweep enforces. The corrected reading is also the more interesting one: the sweep is working, and the 4.6 GB is mostly sessions still inside their grace rather than a permanent leak.

What remains permanent is the 14 held by unpushed commits, which is correct and which the existing stuck-lease reporting already names, plus the branch refs this PR unpins.

Verification

  • go test -C aos-cli ./... clean, just aos-lint clean
  • uv run pytest 724 passed, pre-commit run --all-files clean
  • new native_branch_landed_test.go builds a real squash merge with a deleted remote branch and asserts it reaps, plus three negative controls: never pushed, pushed then abandoned without landing, and landed then re-changed on main
  • new native_shadow_lifecycle_test.go covers live, in-grace, past-grace, released, and unpushed-held sessions, and that release is idempotent and refuses an unknown id
  • --list and --reap --dry-run exercised against the real host state above. The destructive --reap was not run here; that is an operator action.
Closes #1260. Tier 1 of the #1245 dispatch, and the one item that repo owns rather than aterm. ## First, a correction to the issue's diagnosis The issue says "There is no list, no release, no reap. A session cannot even declare itself finished." Two thirds of that is right. `cleanDeadNativeSessions`, `cleanNativeArtifact`, and `runNativeWorkspaceSweep` already exist and already work: the registered worktree count is down from the 195 the issue measured to 72, with none prunable. What was missing is a way to see the state, a way to declare a session finished, and a way to run the sweep without launching another session. The branch half is worse than the issue described, and has a specific cause. ## The branch leak, diagnosed `deleteNativeBranchIfRemote` asked `git rev-list <branch> --not --remotes=origin`. That is exactly right until the forge squash-merges and deletes the remote branch, which this one does (`default_merge_style: squash`, `default_delete_branch_after_merge: true`). After that the branch's own commits are on no remote ref and never will be, so the answer is "unpushed" forever: ``` aos/claude/bk79 upstream configured: origin/aos/claude/bk79 remote ref: gone rev-list --not --remotes=origin: 1 commit -> kept forever ``` Patch identity does not rescue it either. `aos/claude/mu55-dryrun-exits` from earlier in this batch had 2 commits squashed into 1, and `git cherry origin/main` reports both as unmerged. Content does. A branch is spent when it changes nothing the default branch lacks: ``` base=$(git merge-base origin/main <branch>) paths=$(git diff --name-only $base <branch>) git diff --quiet origin/main <branch> -- $paths ``` Three gates keep it conservative, each with a test: * the branch must have had a configured upstream that is now pruned, so a never-pushed branch that may hold the only copy of its commits stays out of the path * a path main has since changed again fails the test and the branch is kept * the existing `rev-list` answer is tried first and this is only the fallback ## Session branches and the ID ledger `reapNativeMergedBranches` skipped every `aos/` branch, with the comment that reaping one would hand out an ID still in use. That is the mechanism by which they never went. Narrowed rather than removed: a session branch is skipped while a lease or a worktree still names it, which is what "in use" actually means. `nativeLiveWorktrees` now records branches alongside paths, and answers yes when uncertain. Two tests changed to match, and both changes are behavior revisions worth reading: * `TestNativeSweepReapsFullyPushedBranchesOnly` now asserts a leased session branch survives and an unleased landed one does not * `TestNativeLaunchRetriesOccupiedShortSessionIDs` pushes its collision marker, because a pushed ref is what makes an ID taken now. A bare local branch nothing leases no longer is. ## Three verbs ``` aos _native-shadow --list [--json] agentic-os.native-shadows.v1 aos _native-shadow --release [<id>] defaults to $AOS_NATIVE_SESSION aos _native-shadow --reap [--dry-run] ``` `--release` marks the lease and never tears down a process that may still be running. The worktree goes on the next sweep, and a released lease skips the 24-hour grace, which exists only because a crash and a clean exit look identical from outside. ## The measurement, and a correction to my own first reading Live on this host, `--list --json`: ``` 70 leases 4 live 51 inside the 24h dead-session grace 14 held by commits that exist on no remote 1 releasable right now ``` My first version of the report called 52 of 70 releasable, because it applied the artifact predicate and ignored the grace. That is a dry run that overstates what it would delete, which is worse than no dry run, so the report now runs the same predicate the sweep enforces. The corrected reading is also the more interesting one: the sweep is working, and the 4.6 GB is mostly sessions still inside their grace rather than a permanent leak. What remains permanent is the 14 held by unpushed commits, which is correct and which the existing stuck-lease reporting already names, plus the branch refs this PR unpins. ## Verification * `go test -C aos-cli ./...` clean, `just aos-lint` clean * `uv run pytest` 724 passed, `pre-commit run --all-files` clean * new `native_branch_landed_test.go` builds a real squash merge with a deleted remote branch and asserts it reaps, plus three negative controls: never pushed, pushed then abandoned without landing, and landed then re-changed on main * new `native_shadow_lifecycle_test.go` covers live, in-grace, past-grace, released, and unpushed-held sessions, and that release is idempotent and refuses an unknown id * `--list` and `--reap --dry-run` exercised against the real host state above. The destructive `--reap` was not run here; that is an operator action.
feat(aos): give the session shadow a lifecycle, and reap a squash-landed branch (#1260)
All checks were successful
ci / aos-eval-tests (pull_request) Successful in 7s
ci / ward-doctor (pull_request) Successful in 8s
ci / aos-cli-tests (pull_request) Successful in 29s
ci / gate (pull_request) Successful in 1m6s
583c99387c
Two things, both about shadows that only ever accumulate.

A session branch is deleted once its commits exist somewhere else, which
`rev-list <branch> --not --remotes=origin` answered until the forge started
squash-merging and deleting the remote branch. After that the branch's own
commits are on no remote ref and never will be, so the test read "unpushed"
forever. Patch identity cannot see it either, since a two-commit branch squashed
into one shows both unmerged under `git cherry`. Content can: the branch is
spent when it changes nothing the default branch lacks. Gated on a configured
upstream that has since been pruned, so a never-pushed branch stays out of the
path, and a path main changed again fails the test and keeps the branch.

Session branches are the ID ledger `reserveNativeSession` reads, so the sweep
still skips one a lease or a worktree names. That narrows a rule that used to
skip every `aos/` branch unconditionally, which is why they never went.

Three verbs, since a session could not previously declare itself finished:

  --list [--json]      every lease, its liveness, its worktrees, the commits on
                       no remote, and what holds a session that cannot go
  --release [<id>]     the session saying it is finished. Marks the lease, never
                       tears down a live process, and skips the 24h grace
  --reap [--dry-run]   the sweep, without launching another session to get it

Measured on this host before the change: 70 leases, 4 live, 51 inside the grace,
14 held by commits on no remote, 1 releasable now. An earlier reading of this
that called 52 releasable was wrong; it ignored the grace, and a dry run that
overstates what it deletes is worse than no dry run, so the report now applies
the same predicate the sweep enforces.

Closes #1260

Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Agent-Role: platform
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-flight-deck/agentic-os!1272
No description provided.