Repository navigation
Worktree-bound sessions fail to reattach after HEAD moves (e.g. a commit) #1797
Description
Summary
A session bound to a git worktree stops working as soon as anyone moves that worktree's HEAD, for example when the agent runs git commit through Shell. Every later run on the session fails before inference with server: placement selector not found.
Cause
The local placement provider uses the worktree's HEAD SHA as the ref's Revision, and it requires that exact SHA when reattaching:
internal/app/placement.go(bindWorktree) buildsEnvironmentRef{Kind: local, ID: <worktree path>, Revision: <HEAD sha>}.internal/app/placement.go(currentWorktree) matches a reattach only ifchoice.Path == ref.ID && choice.Head == ref.Revision.internal/adapter/server/service.go(ReattachPlacement) passes the error through unchanged. Nothing ever updates a main session's persisted ref.engineAndEnvironmentForreattaches at the start of every run, so every run after HEAD moves fails.
Checking HEAD is correct when the successor session is bound: it makes sure a selector still matches the worktree the user picked (ErrPlacementChanged, covered by TestInvariant_local_placement_identity_and_atomic_worktree_binding). At reattach time, though, the check pins the worktree's content, and the session's own work invalidates that pin. ADR 0291 says reattach should fail on a provider inventory generation mismatch. A commit is not that.
Impact
- A successor session created with
ClearSession/ForkSessionand a worktree selector becomes unusable after the first commit, rebase, pull, or checkout in that worktree. The agent committing through Shell is the most likely trigger. - Scheduled runs whose stored
EnvironmentRefcame from such a session break the same way at fire time (scheduler_fire.go→ReattachPlacementInScope). - The error text is misleading: no selector is involved at reattach.
- The default workspace session is not affected, because its revision is a constant (
configuredLocalPlacementRef).
Reproduce
This offline test uses real git and the production gitWorktreeLister. Save it as internal/app/zz_repro_worktree_head_test.go and run go test ./internal/app/ -run TestRepro_WorktreeRefSurvivesCommit -count=1 -v.
package app
import (
"context"
"os/exec"
"path/filepath"
"testing"
"github.com/stacklok/mecatl/engine/adapter/memfs"
"github.com/stacklok/mecatl/engine/tool"
"github.com/stacklok/mecatl/internal/adapter/server"
)
func TestRepro_WorktreeRefSurvivesCommit(t *testing.T) {
git := func(dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", append([]string{"-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"}, args...)...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
root := t.TempDir()
git(root, "init", "-q", "-b", "main")
git(root, "commit", "-q", "--allow-empty", "-m", "init")
wt := filepath.Join(t.TempDir(), "feature")
git(root, "worktree", "add", "-q", "-b", "feature", wt)
issuer, err := server.NewWorktreeSelectorIssuer(make([]byte, 32))
if err != nil {
t.Fatal(err)
}
provider := &localPlacementProvider{
scope: "test", root: root, selectors: issuer,
worktrees: gitWorktreeLister{shell: "/bin/sh"},
workspace: func(r string) tool.Workspace { return memfs.NewWorkspace(r) },
}
ctx := context.Background()
sourceRef := configuredLocalPlacementRef(root)
listed, err := provider.ListWorktrees(ctx, server.PlacementDiscoveryRequest{Source: "source", SourceRef: sourceRef, Scope: "test"})
if err != nil {
t.Fatal(err)
}
var sel string
for _, c := range listed {
if c.Branch == "refs/heads/feature" {
sel = c.Selector
}
}
if sel == "" {
t.Fatalf("feature worktree not listed: %+v", listed)
}
bound, err := provider.Bind(ctx, server.PlacementBindRequest{Selector: server.SelectWorktree("source", sourceRef, sel), Scope: "test", Operation: server.PlacementOperationSuccessor})
if err != nil {
t.Fatal(err)
}
if _, err := provider.Reattach(ctx, server.PlacementReattachRequest{Ref: bound.Ref, Scope: "test"}); err != nil {
t.Fatalf("reattach before commit: %v", err)
}
// The agent (via Shell) commits in its own worktree.
git(wt, "commit", "-q", "--allow-empty", "-m", "agent work")
if _, err := provider.Reattach(ctx, server.PlacementReattachRequest{Ref: bound.Ref, Scope: "test"}); err != nil {
t.Fatalf("reattach after commit in the bound worktree FAILED: %v", err)
}
}Observed on main (b0cf03a):
bound ref: {Kind:local ID:…/feature Revision:a8d1a33c…}
reattach after commit in the bound worktree FAILED: server: placement selector not found
--- FAIL: TestRepro_WorktreeRefSurvivesCommit
Expected: reattach succeeds. The session still points at the same worktree; only its content changed.
Possible fix
Keep the HEAD check at bind time only. At reattach time, identify the worktree by something stable: its path plus its administrative directory (.git/worktrees/<name>), or another marker that survives commits but changes when the worktree is removed and recreated. Revision would then carry that marker, or a constant, instead of HEAD. Updating the ref after every run would also work, but it turns the exact durable identity into a moving value and adds a write per run. The repro test above can become the regression test.
Found while reviewing #1795, where the domain model treats EnvironmentRef as the durable identity of where a session's files live.
Activity
tgrunnagle commentedon Oct 18, 2026
Confirmed independently at e17d839 through the public gRPC API (TypeScript SDK over UDS,
mecated --mock --trust-project, durable--store-dir):ClearSessionandForkSessionwith a selector forwt-featkind: "local"Shellgit commit, ask resolvedallow_oncewt-featHEAD movesServerError placement_selector_not_foundat run startplacement_selector_not_foundGetSession+ snapshot (placement still shows the old revision), runplacement_selector_not_foundfor bothThe forked-session row matters for multi-session clients: a commit made by any session or by the user in a terminal breaks every session placed on that worktree. We are a downstream desktop client that places one session per worktree, and this blocks our worktree flow; matching reattach on
(path, branch)and refreshingRevision, as the issue suggests, would fix it for us.