Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,11 +148,22 @@
declared, an unidentified call still resolves against the connection's
pin, as before.

- **`session_start` no longer claims it re-pinned the connection.** It
printed "Re-pinned this connection" even when only the calling agent's own
pin moved; it now prints `Re-pinned: <from> → <to>`. The no-identity
notices, `plumb doctor`'s shared-connection fix and the client instruction
templates now describe the current refusal rule.
- **`session_start` says which pin a re-pin moved, and where your next
relative path goes.** It printed "Re-pinned this connection" even when only
the calling agent's own pin moved. It also never said when an anonymous
re-pin on a shared connection moved the other agents' workspaces with it. With
`scope: "connection"`, an agent holding its own pin was shown its own root as
"from", and the header named the connection's new root although the agent's
relative paths still resolved against its own. The daemon now reports which
pin it moved, that pin's previous root and how many other agents followed it.
Both the full and the brief packet print "Re-pinned your pin" or "Re-pinned
this connection's pin (N other agents follow it)", plus a line naming what the
caller's next relative-path call resolves against. The `# Workspace:` header
names the caller's own root. Two callers racing to the same root no longer
both report moving the pin. A connection-scoped move now also clears the
caller's own refused-declaration marker. The no-identity notices,
`plumb doctor`'s shared-connection fix and the client instruction templates
now describe the current refusal rule. Closes #517.

- **Agent identity now reaches plumb through Claude desktop's connector, and a
worktree edit no longer lands in another checkout.** Claude desktop runs one
Expand Down
4 changes: 2 additions & 2 deletions internal/cli/conn_agent_language_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ func TestSameRootLanguageSwitchPreservesShardTrackers(t *testing.T) {
// A same-root language change, as an ordinary re-pin would produce it. It
// reports a change — the shard's language really did move — which is what
// makes keeping the trackers a deliberate exception rather than a no-op.
changed, refused := s.repinAgent(ctxA, root, "go", sessionstate.PinSourceSessionStart, false)
_, changed, refused := s.repinAgent(ctxA, root, "go", sessionstate.PinSourceSessionStart, false)
if refused != nil {
t.Fatalf("same-root language change on a shard: %v", refused)
}
Expand All @@ -146,7 +146,7 @@ func TestSameRootLanguageSwitchPreservesShardTrackers(t *testing.T) {
// The contrast: moving the agent to another project does start clean.
other := freshTempDir(t)
mustGitDir(t, other)
if moved, refused := s.repinAgent(ctxA, other, "go", sessionstate.PinSourceSessionStart, true); refused != nil || !moved {
if _, moved, refused := s.repinAgent(ctxA, other, "go", sessionstate.PinSourceSessionStart, true); refused != nil || !moved {
t.Fatalf("agent move: changed=%v err=%v", moved, refused)
}
if s.writeTrackerFor(ctxA).Wrote(written) {
Expand Down
28 changes: 19 additions & 9 deletions internal/cli/conn_agent_shard.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,10 +286,14 @@ func (s *connSession) rateLimiterFor(ctx context.Context) *tools.RateLimiter {
// connection being unusable, and flagging it would raise a dashboard alert
// against the coordinator for a peer's call. The log line is greppable, carries
// the agent id and both roots, and does not expire.
func (s *connSession) repinAgent(ctx context.Context, root, language string, origin sessionstate.PinSource, force bool) (changed bool, refused error) {
//
// prev is the shard's root BEFORE the call, read under the same sh.mu
// acquisition that moves it, so session_start can report the pin's previous
// root without a second, racy read (issue #517).
func (s *connSession) repinAgent(ctx context.Context, root, language string, origin sessionstate.PinSource, force bool) (prev string, changed bool, refused error) {
sh := s.repinShard(ctx)
if sh == nil {
return false, nil
return "", false, nil
}
// The roster sync registers or moves a session.Info, which takes a flock on
// the session directory. Doing that while holding sh.mu wedges the whole
Expand All @@ -306,7 +310,7 @@ func (s *connSession) repinAgent(ctx context.Context, root, language string, ori
}()
sh.mu.Lock()
defer sh.mu.Unlock()
prev := sh.root
prev = sh.root
// The guard keys on the pin ORIGIN, which a seeded shard inherits wholesale:
// shardFor copies the CONNECTION's pin and its origin onto a new shard, and
// attachOrRepinTo's same-root promotion branch upgrades a roots-held pin to
Expand Down Expand Up @@ -360,7 +364,7 @@ func (s *connSession) repinAgent(ctx context.Context, root, language string, ori
s.log().Warn("daemon: per-agent session_start re-pin refused — this agent's pin is sticky (issue #182)",
"agent", sh.id, "pinned", prev, "requested", root,
"remedy", "call session_start again with force: true to move THIS agent, or run one plumb serve per agent")
return false, refused
return prev, false, refused
}
if root == prev && language == sh.language {
// Nothing moves — but naming the root the shard already holds is still
Expand All @@ -380,7 +384,7 @@ func (s *connSession) repinAgent(ctx context.Context, root, language string, ori
// issue #472 describes, by a path no live-move test exercises.
// confirmShardPin cannot host this: it returns early for a shard that is
// already selfPinned, which a restored-and-reconfirming one is.
return false, nil
return prev, false, nil
}
changed = true
// The agent has CHOSEN this root (even back to the seeded one, via a
Expand All @@ -405,7 +409,7 @@ func (s *connSession) repinAgent(ctx context.Context, root, language string, ori
s.rehydrateReadsForAgent(sh, root)
s.persistPinForAgent(sh, root, language, origin)
syncRoot, syncLang = root, language
return changed, nil
return prev, changed, nil
}

// seedShardOnLink hydrates the linkage owner's shard from the connection's
Expand Down Expand Up @@ -454,13 +458,17 @@ func (s *connSession) seedShardOnLink(linkage string) {
// (shardsMu before sh.mu, s.mu innermost), so the per-tool-call hot path's lock
// pattern is unchanged; the writes mirror repinAgent's success path, held under
// one sh.mu acquisition each.
func (s *connSession) followConnectionShards(prevRoot string) {
//
// Returns the ids of the agents whose shards followed, so session_start can
// tell the caller how many other agents its connection move took with it
// (issue #517).
func (s *connSession) followConnectionShards(prevRoot string) (followed []string) {
if prevRoot == "" {
return
return nil
}
v := s.view()
if v.acquiredRoot == "" || v.acquiredRoot == prevRoot {
return
return nil
}
s.shardsMu.Lock()
defer s.shardsMu.Unlock()
Expand Down Expand Up @@ -490,9 +498,11 @@ func (s *connSession) followConnectionShards(prevRoot string) {
// Same rule persistReadShard states: the shard's root is read under sh.mu.
root, language := sh.root, sh.language
sh.mu.Unlock()
followed = append(followed, sh.id)
s.rehydrateReadsForAgent(sh, root)
s.persistPinForAgent(sh, root, language, v.pinOrigin)
}
return followed
}

// persistReadShard mirrors a per-agent recorded read to the durable store, keyed
Expand Down
6 changes: 3 additions & 3 deletions internal/cli/conn_attribution_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ func TestAfterToolFilesTheRowUnderTheAgentsOwnWorkspace(t *testing.T) {
s.recordLogicalAgentAttach("agent-here")
s.recordLogicalAgentCall("agent-elsewhere")
ctx := mcp.WithLogicalAgent(context.Background(), "agent-elsewhere")
if moved, refused := s.repinAgent(ctx, agentRoot, "", sessionstate.PinSourceSessionStart, true); refused != nil || !moved {
if _, moved, refused := s.repinAgent(ctx, agentRoot, "", sessionstate.PinSourceSessionStart, true); refused != nil || !moved {
t.Fatalf("agent pin: moved=%v refused=%v", moved, refused)
}
if got := s.workspaceFor(ctx); got != agentRoot {
Expand Down Expand Up @@ -236,7 +236,7 @@ func TestAfterToolPrefersThePathArgumentOverTheAgentsRoot(t *testing.T) {
s.recordLogicalAgentAttach("agent-here")
s.recordLogicalAgentCall("agent-elsewhere")
ctx := mcp.WithLogicalAgent(context.Background(), "agent-elsewhere")
if moved, refused := s.repinAgent(ctx, agentRoot, "", sessionstate.PinSourceSessionStart, true); refused != nil || !moved {
if _, moved, refused := s.repinAgent(ctx, agentRoot, "", sessionstate.PinSourceSessionStart, true); refused != nil || !moved {
t.Fatalf("agent pin: moved=%v refused=%v", moved, refused)
}
if got := s.workspaceFor(ctx); got != agentRoot {
Expand Down Expand Up @@ -305,7 +305,7 @@ func TestAfterToolFilesAGitCallUnderItsRepo(t *testing.T) {
s.recordLogicalAgentAttach("agent-here")
s.recordLogicalAgentCall("agent-elsewhere")
ctx := mcp.WithLogicalAgent(context.Background(), "agent-elsewhere")
if moved, refused := s.repinAgent(ctx, agentRoot, "", sessionstate.PinSourceSessionStart, true); refused != nil || !moved {
if _, moved, refused := s.repinAgent(ctx, agentRoot, "", sessionstate.PinSourceSessionStart, true); refused != nil || !moved {
t.Fatalf("agent pin: moved=%v refused=%v", moved, refused)
}

Expand Down
9 changes: 6 additions & 3 deletions internal/cli/conn_canonicalroot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,8 @@ func TestCanonicalRoot_AliasedRepinIsANoOpNotAStickyRefusal(t *testing.T) {
t.Fatalf("first explicit pin: %v", err)
}

root, err := s.repinWorkspace(context.Background(), alias, "", false, false)
rootRep, err := s.repinWorkspace(context.Background(), alias, "", false, false)
root := rootRep.Root
if err != nil {
t.Fatalf("re-pinning to the SAME project by its other spelling must be a no-op, "+
"not a sticky-pin refusal: %v", err)
Expand Down Expand Up @@ -145,7 +146,8 @@ func TestCanonicalRoot_SyntheticRootIsCanonicalised(t *testing.T) {

s := newPersistSession(t, store, ss, "proxySynth")
defer s.close()
root, err := s.repinWorkspace(context.Background(), alias, "", false, false)
rootRep, err := s.repinWorkspace(context.Background(), alias, "", false, false)
root := rootRep.Root
if err != nil {
t.Fatalf("explicit pin on a markerless folder: %v", err)
}
Expand Down Expand Up @@ -251,7 +253,8 @@ func TestCanonicalRoot_NonexistentRootStillPins(t *testing.T) {

s := newPersistSession(t, store, ss, "proxyMissing")
defer s.close()
root, err := s.repinWorkspace(context.Background(), missing, "", false, false)
rootRep, err := s.repinWorkspace(context.Background(), missing, "", false, false)
root := rootRep.Root
if err != nil {
t.Fatalf("a nonexistent root must still pin: %v", err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/conn_commands_agent_cwd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func TestRunCommandResolvesTheCallingAgentsRoot(t *testing.T) {
s.recordLogicalAgentCall("coordinator")
s.recordLogicalAgentCall("subagent")
ctx := mcp.WithLogicalAgent(context.Background(), "subagent")
if _, err := s.repinAgent(ctx, worktree, "", sessionstate.PinSourceSessionStart, false); err != nil {
if _, _, err := s.repinAgent(ctx, worktree, "", sessionstate.PinSourceSessionStart, false); err != nil {
t.Fatalf("pinning the subagent to its worktree: %v", err)
}
if got := s.workspaceFor(ctx); filepath.Clean(got) != filepath.Clean(worktree) {
Expand Down
6 changes: 4 additions & 2 deletions internal/cli/conn_pin_restart_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ func TestPin_SurvivesDaemonRestartByteIdentical(t *testing.T) {
mustGitDir(t, root)

before := newPersistSession(t, store, ss, "proxyX")
pinned, err := before.repinWorkspace(context.Background(), root, "", false, false)
pinnedRep, err := before.repinWorkspace(context.Background(), root, "", false, false)
pinned := pinnedRep.Root
if err != nil {
t.Fatalf("repinWorkspace: %v", err)
}
Expand Down Expand Up @@ -118,7 +119,8 @@ func TestPin_RestoreDoesNotResolveAfresh(t *testing.T) {
mustGitDir(t, child)

before := newPersistSession(t, store, ss, "proxyX")
pinned, err := before.repinWorkspace(context.Background(), child, "", false, false)
pinnedRep, err := before.repinWorkspace(context.Background(), child, "", false, false)
pinned := pinnedRep.Root
if err != nil {
t.Fatalf("repinWorkspace: %v", err)
}
Expand Down
Loading
Loading