Skip to content

chore(process): .session/ holds 28 handoff files and none match a live branch — the start-protocol step reads a graveyard #13848

Closed

Description

@mrveiss

Found while running the session-end protocol.

.session/ holds 28 tracked HANDOFF-*.md files. Zero of them correspond to a branch that still exists:

$ for f in .session/HANDOFF-*.md; do
    b=$(basename "$f" .md | sed 's/^HANDOFF-//')
    git ls-remote --heads origin "$b" | grep -q . && echo "LIVE: $b"
  done
handoff files: 28 | for branches that still exist: 0

They span roughly two months (oldest HANDOFF-chore-triage-umbrellas.md, June 11) and are all committed to the base branch.

Why this is a real cost, not clutter

The session-start protocol makes reading them mandatory:

Read predecessor handoffs: check .session/ directory on the base branch for HANDOFF files from unmerged sessions touching your area. If one exists and its branch is unmerged, decide: continue their branch (rebase it onto base first) or start fresh — never duplicate their work blind.

That step currently has a 100% false-positive rate. A new session doing it correctly reads 28 dead files to find zero live ones. The predictable outcome is that sessions stop doing the step — and then the one handoff that does matter gets missed, which is the exact failure the mechanism exists to prevent ("Duplicate work on the same problem").

The protocol has a disposal rule for worktrees ("the next session's start-protocol removes it after the branch merges") but no equivalent for handoff files. Nothing reaps them, so the signal-to-noise ratio only degrades.

Fix

Give handoff files the same lifecycle the protocol already gives worktrees: once a handoff's branch is merged or gone, the handoff has served its purpose and should be removed. Options, cheapest first:

  1. Reap at session start — the existing sweep step already enumerates branches; deleting a handoff whose branch no longer exists is the same check.
  2. Reap at merge — a workflow step on merge to the base branch removes .session/HANDOFF-<branch>.md.
  3. Stop tracking them — if handoffs are only ever read by the next session on the same machine, .gitignore them; but this loses cross-machine handoff, which is the point.

Option 1 or 2. This is disposal of landed work, not deletion of unfinished work: each of these 28 describes a branch whose work is merged, so the file has no remaining reader.

Worth confirming before bulk removal that none of the 28 documents a status: blocked handoff whose work never landed — a blocked handoff for a deleted branch is stranded work, and that one should become an issue rather than be deleted.

Acceptance criteria

  • A handoff file is removed automatically once its branch is merged or deleted.
  • The existing 28 are triaged: merged → removed; any status: blocked with unlanded work → filed as an issue first, then removed.
  • The session-start protocol step is accurate again — files present in .session/ correspond to live, unmerged branches.
  • Verified by deliberate failure: create a handoff for a branch, delete the branch, confirm the reaper removes the file; create one for a live branch and confirm it survives.

Activity

  1. mrveiss commented on Aug 25, 2026

    @mrveiss
    OwnerAuthor

    Triage complete: all 32 handoffs record landed work. Reaper opened as #15034 — leaving this open

    Worked as part of the stranded-work sweep on umbrella #14078.

    The count is 32, not 28

    .session/ holds 32 tracked HANDOFF-*.md files plus README.md; four arrived after this issue
    was written. Confirmed: zero name a branch that still exists locally or on origin.

    The check this issue asked for before any removal

    Worth confirming before bulk removal that none of the 28 documents a status: blocked handoff
    whose work never landed.

    Done, all 32. None is blocked. Thirty-one are complete (some with a scope qualifier); one,
    HANDOFF-issue-12647.md, is partial.

    Every named PR is MERGED: #9932, #10033, #10072, #10077, #10112, #10125, #10433, #10482, #11068,
    #12620, #13163, #13164, #13666, #13693, #13731, #13735, #13752, #13760, #14032, #14338, #14347.

    Four handoffs name no PR, so they were checked against the base branch directly — these were the
    candidates for stranded work, and none of them is:

    Handoff What it claimed Verified in base
    issue-10008 pr: DEFERRED — open PR queue at 6 (project limit 5); branch ready + mergeable landed anyway: ConnectionStatus(str, Enum) is at autobot_shared/status_enums.py:494, tagged (#10008), and services/ai_stack_client.py references it 10 times
    issue-14111-stable-shard pr: (none yet), remaining: decide whether ci.yml adopts this; it is inert until something passes --shard-splits the decision was taken: .github/workflows/ci.yml runs -p repo_tests.stable_shard --shard-splits 12 --shard-group … at lines 283-284 and 326, and repo_tests/stable_shard.py exists. Not inert
    issue-12924, issue-12925 no pr: field issues #12924 and #12925 both CLOSED
    issue-12647 (partial) last de-fork slice of #12647 PR #13163 MERGED, issue #12647 CLOSED, and its three sibling slice handoffs (-org, -team, -user) all record complete — -org states explicitly that it is the last de-fork file

    Two referenced issues remain open, #13623 and #13685, but both are umbrellas whose child PRs
    merged; their handoffs have no remaining reader either.

    Nothing in .session/ is stranded work. All 32 are disposable.

    The mechanism — PR #15034

    Took option 1 (reap at session start). scripts/cleanup-worktrees.sh already runs at session start
    and already answers "is this branch gone?" three times; a fourth phase costs one source line and
    reuses a sweep operators already trust. Option 2 could not see deleted branches, only merged ones;
    option 3 loses cross-machine handoff.

    The disposal rule is deliberately narrower than "branch gone":

    Handoff Reaper does
    branch still exists (local or origin) keeps it — it still has a reader
    branch gone, status: complete removes it
    branch gone, status: blocked / partial / no parseable status keeps it and prints STRANDED

    STRANDED is the deliberate dead end: work that never landed and whose branch is gone becomes an
    issue, not a deletion. A handoff the reaper cannot parse is never a handoff it may delete.

    Ships with scripts/lib/session-handoffs_test.sh — 22 hermetic assertions against a throwaway repo
    under mktemp -d, including the deliberate-failure case AC4 asks for (handoff for a live branch
    survives a sweep; delete the branch; next sweep reaps it) — registered in repo_tests/shell_lib_test.py
    so CI runs it. .session/README.md and the CLAUDE_WORKFLOW.md start protocol are updated to match.

    Why this is still open

    The PR ships the mechanism; it does not hand-delete the 32. That is deliberate — the reaper's first
    run against real data is the proof it works, whereas hand-deleting the files and shipping an
    untested reaper proves nothing.

    Remaining before this closes:

  2. mrveiss commented on Aug 25, 2026

    @mrveiss
    OwnerAuthor

    Delivered by PR #15034, merged to Dev_new_gui as 627dca93e. Evidence below is read from the merged base, not from the PR branch.

    # Criterion Evidence
    1 A handoff is removed automatically once its branch is merged or deleted scripts/lib/session-handoffs.sh defines handoff_disposition (keep-live / keep-unknown / keep-unlanded / reap) and reap_session_handoffs; scripts/cleanup-worktrees.sh:275-283 wires it as Phase 4 of the start-of-session sweep, honouring --dry-run. "Automatic" here means the next session's sweep, which is Option 1 as this issue framed it
    2 The existing handoffs triaged: merged → removed; unlanded → issue first, then removed git ls-tree origin/Dev_new_gui .session/ now returns only README.md — all 32 gone. Landing was proven per file before deletion, not assumed from "branch is gone": 30 via a merged PR from that exact branch, and the two slash-named ones via #10033 and #9932 directly, since --head cannot match a dash-flattened filename
    3 The session-start protocol step is accurate again Follows from 2 — zero stale files remain, so "read predecessor handoffs" no longer has a 100% false-positive rate
    4 Verified by deliberate failure scripts/lib/session-handoffs_test.sh builds a throwaway repo per run and asserts both directions: a live branch keeps its handoff, and deleting that branch then re-sweeping reaps it. passed=34 failed=0. Registered in CI via repo_tests/shell_lib_test.py, which also fails if a new *_test.sh is not added to SHELL_SUITES

    The one file the reaper refused to delete is why criterion 2 reads the way it does. HANDOFF-issue-12647.md said status: partial; #12647 is closed; four of its user_management files are still forked across both backends. That is real unfinished work whose only record was a file nothing reads — precisely the failure this issue describes. Filed as #15068 with the drift table and the carried-over traps, and only then removed. "Blocked → issue first, then removed" was followed literally.

    Three defects found in review and fixed before merge, each with a contrast mutation proving the new tests are not vacuous:

    • grep -m1 '^status:' matched the first such line anywhere in the file — and .session/README.md documents the schema as a literal status: complete | blocked | partial line, so a handoff written by pasting the template parsed as complete and was reaped even when it said blocked. Parsing is now scoped to the leading key: value block, skips fenced blocks, and refuses to guess when two status: lines compete. Reverting to grep -m1 fails 4 named assertions.
    • git show-ref exits 1 for a missing ref and ≥2 for a hard error; collapsing both into "branch gone" let one broken git call reap the whole directory. Now a third outcome, keep-unknown; and git fetch --prune ... || true no longer swallows failure — Phase 4 refuses to run on stale refs. Collapsing the exit code back fails 3 assertions, including unresolvable branch is never reaped flipping to reap.
    • A filename differing only in case from its branch read as "gone". Fixed with a loose second pass over for-each-ref.

    Closing: all four criteria met with code evidence from the merged base.

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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions