thomwolf HF Staff Claude Opus 5 (1M context) commited on
Commit
1dec339
·
1 Parent(s): 99b8ff4

Re-pin: a session's folder is a tree, not one directory

Browse files

Every pin path asked `cwd === workdir` of a candidate conversation, and that
quietly broke every agent that works in a git worktree. The PTY starts in the
session's folder, the agent enters `.claude/worktrees/<name>`, and the
conversation a later `/clear` starts records that deeper cwd. Equality rejected
it from both directions — the SessionStart breadcrumb as 'cwd mismatch', the
transcript scan by skipping the file — so the pin stayed on the abandoned
conversation for the rest of the pane's life.

Two things went wrong with that. The reader reads the pin, so it kept showing
the pre-/clear conversation while the terminal showed the new one: one session,
two different conversations side by side. And the next launch runs
`--resume <pinned uuid>`, which would restore the old thread and discard
everything since — the failure this watcher was written to prevent.

Observed live: a session pinned edbfc11f… while claude was writing b1e23587…
under `.claude/worktrees/session-sharing`.

So ask for containment instead of equality, in one helper the three harnesses
share (claude's scan and crumb, codex's rollout head, opencode's database row).
Containment rather than a prefix test, so `/w/proj-a2` is not inside `/w/proj-a`.

`folderIsShared` widens with it: now that the scan reaches below the folder, a
live sibling of the same harness in a subdirectory — or in a parent, which is
what a session on the workspaces root is — is exactly as ambiguous as one in the
same directory, and gets the same refusal to guess. Nothing is lost where it
fires; breadcrumbs are the mechanism and the scan is their backstop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Files changed (2) hide show
  1. server/src/runner.js +49 -9
  2. server/test/repin.test.mjs +37 -0
server/src/runner.js CHANGED
@@ -661,6 +661,31 @@ async function hydrateTraceHistory(session, host) {
661
  }
662
  }
663
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
664
  // ---------- Codex conversation pinning ----------
665
  // Codex picks its own conversation id at launch and doesn't accept one up
666
  // front. The managed SessionStart hook below is the primary source of its exact
@@ -764,7 +789,7 @@ function tryCaptureCodexId(sessionId, workdir, sinceMs) {
764
  let meta;
765
  try { meta = JSON.parse(firstLine(c.p)); } catch { continue; }
766
  const mp = (meta && meta.payload) || {};
767
- if (mp.cwd !== workdir) continue;
768
  // A sibling's ongoing conversation in the same folder gets fresh writes
769
  // (mtime) during our capture window — require the rollout to have been
770
  // CREATED after this launch so we never claim someone else's thread.
@@ -948,8 +973,10 @@ export async function claudeCandidate(sessionId, workdir, sinceMs) {
948
  // in every real transcript measured. Reading line 1 made this check always
949
  // fail, so the re-pin could never actually claim anything.
950
  const head = await transcriptHeadCached(c.p);
951
- // Only claim a conversation started in THIS session's folder.
952
- if (!head || head.cwd !== workdir) continue;
 
 
953
  const start = Date.parse(head.timestamp || '') || 0;
954
  // Born in this launch window, or it's an older thread that merely received
955
  // writes — someone else's, or our own pre-relaunch one.
@@ -959,12 +986,22 @@ export async function claudeCandidate(sessionId, workdir, sinceMs) {
959
  return best;
960
  }
961
 
962
- // Another LIVE session of the same harness on the same folder makes a new
963
  // conversation there unattributable: we cannot tell whose /clear produced it.
964
  // Refuse to guess, the way share.js does when a folder has rivals.
 
 
 
 
 
 
 
965
  function folderIsShared(sessionId, workdir, cli) {
966
- return list().some((s) => s.id !== sessionId && s.cli === cli
967
- && path.join(WORKSPACES_DIR, s.path ?? s.id) === workdir && isRunning(s.id));
 
 
 
968
  }
969
 
970
  // Re-pinning is NOT a one-shot check, because the pin can go stale mid-session.
@@ -1086,8 +1123,10 @@ export function breadcrumbVerdict(crumb, sessionId, facts) {
1086
  if (!CONVERSATION_ID[facts.cli]?.test(conversationId || ''))
1087
  return { repin: null, why: 'no session_id' };
1088
  // A crumb written before a pane was moved to another folder must not follow
1089
- // it there — same folder-scoping rule the transcript scan applies.
1090
- if (crumb.payload?.cwd !== facts.workdir) return { repin: null, why: 'cwd mismatch' };
 
 
1091
  // Every supported adapter inherits the pane markers into child processes.
1092
  // Only the top-level agent process may speak for the pane; nested agents can
1093
  // otherwise re-pin their parent's Overview, trace and next resume target.
@@ -1242,7 +1281,8 @@ function applyBreadcrumb(session, host, workdir, crumb) {
1242
  const row = opencodeSessionInfo(crumb?.payload?.session_id);
1243
  if (!row) return { repin: null, why: 'session missing from database', retry: true };
1244
  if (row.parentId) return { repin: null, why: 'subagent session' };
1245
- if (row.directory !== workdir) return { repin: null, why: 'database cwd mismatch' };
 
1246
  patch = (id) => ({ opencodeSessionId: id });
1247
  }
1248
 
 
661
  }
662
  }
663
 
664
+ // ---------- whose folder is this conversation in? ----------
665
+ // Every pin path asks the same question of a candidate conversation: was it
666
+ // started by THIS pane? The answer is the folder it was born in — and a pane's
667
+ // folder is a tree, not one directory.
668
+ //
669
+ // This used to be `cwd === workdir`, and that quietly broke every agent that
670
+ // works in a git worktree. The PTY starts in the session's folder, the agent
671
+ // then enters `.claude/worktrees/<name>` (or just cd's somewhere below), and the
672
+ // conversation a later `/clear` starts records that DEEPER cwd. Equality
673
+ // rejected it from both directions — the SessionStart crumb as 'cwd mismatch',
674
+ // the transcript scan by skipping the file — so the pin stayed on the abandoned
675
+ // conversation for the rest of the pane's life. The reader kept showing the
676
+ // pre-/clear thread while the terminal showed the new one, and the next launch
677
+ // would `--resume` the old id and discard everything since. Observed live:
678
+ // session claude-code-4 pinned edbfc11f… while claude wrote b1e23587… in
679
+ // /data/workspaces/Agent-manager/.claude/worktrees/session-sharing.
680
+ //
681
+ // Containment, not prefix matching: `/w/proj-a2` must not count as inside
682
+ // `/w/proj-a`. Exported for server/test/repin.test.mjs.
683
+ export function cwdUnderWorkdir(cwd, workdir) {
684
+ if (typeof cwd !== 'string' || !cwd || typeof workdir !== 'string' || !workdir) return false;
685
+ const rel = path.relative(path.resolve(workdir), path.resolve(cwd));
686
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
687
+ }
688
+
689
  // ---------- Codex conversation pinning ----------
690
  // Codex picks its own conversation id at launch and doesn't accept one up
691
  // front. The managed SessionStart hook below is the primary source of its exact
 
789
  let meta;
790
  try { meta = JSON.parse(firstLine(c.p)); } catch { continue; }
791
  const mp = (meta && meta.payload) || {};
792
+ if (!cwdUnderWorkdir(mp.cwd, workdir)) continue;
793
  // A sibling's ongoing conversation in the same folder gets fresh writes
794
  // (mtime) during our capture window — require the rollout to have been
795
  // CREATED after this launch so we never claim someone else's thread.
 
973
  // in every real transcript measured. Reading line 1 made this check always
974
  // fail, so the re-pin could never actually claim anything.
975
  const head = await transcriptHeadCached(c.p);
976
+ // Only claim a conversation started in THIS session's folder — or in a
977
+ // directory below it, which is where an agent in a worktree lives (see
978
+ // cwdUnderWorkdir).
979
+ if (!head || !cwdUnderWorkdir(head.cwd, workdir)) continue;
980
  const start = Date.parse(head.timestamp || '') || 0;
981
  // Born in this launch window, or it's an older thread that merely received
982
  // writes — someone else's, or our own pre-relaunch one.
 
986
  return best;
987
  }
988
 
989
+ // Another LIVE session of the same harness whose folder OVERLAPS ours makes a new
990
  // conversation there unattributable: we cannot tell whose /clear produced it.
991
  // Refuse to guess, the way share.js does when a folder has rivals.
992
+ //
993
+ // Overlap, not equality, because the scan now accepts a conversation born
994
+ // anywhere below the folder (see cwdUnderWorkdir): a live sibling running in a
995
+ // subdirectory of ours — or in a parent of it, which is what a session on the
996
+ // workspaces root is — is exactly as ambiguous as one in the same directory.
997
+ // Refusing here costs nothing where it fires: the breadcrumb path is unaffected,
998
+ // and it is the mechanism now — the scan is its backstop.
999
  function folderIsShared(sessionId, workdir, cli) {
1000
+ return list().some((s) => {
1001
+ if (s.id === sessionId || s.cli !== cli || !isRunning(s.id)) return false;
1002
+ const other = path.join(WORKSPACES_DIR, s.path ?? s.id);
1003
+ return cwdUnderWorkdir(other, workdir) || cwdUnderWorkdir(workdir, other);
1004
+ });
1005
  }
1006
 
1007
  // Re-pinning is NOT a one-shot check, because the pin can go stale mid-session.
 
1123
  if (!CONVERSATION_ID[facts.cli]?.test(conversationId || ''))
1124
  return { repin: null, why: 'no session_id' };
1125
  // A crumb written before a pane was moved to another folder must not follow
1126
+ // it there — same folder-scoping rule the transcript scan applies, and the
1127
+ // same tree (a worktree under the folder is still this pane's work).
1128
+ if (!cwdUnderWorkdir(crumb.payload?.cwd, facts.workdir))
1129
+ return { repin: null, why: 'cwd outside the session folder' };
1130
  // Every supported adapter inherits the pane markers into child processes.
1131
  // Only the top-level agent process may speak for the pane; nested agents can
1132
  // otherwise re-pin their parent's Overview, trace and next resume target.
 
1281
  const row = opencodeSessionInfo(crumb?.payload?.session_id);
1282
  if (!row) return { repin: null, why: 'session missing from database', retry: true };
1283
  if (row.parentId) return { repin: null, why: 'subagent session' };
1284
+ if (!cwdUnderWorkdir(row.directory, workdir))
1285
+ return { repin: null, why: 'database cwd outside the session folder' };
1286
  patch = (id) => ({ opencodeSessionId: id });
1287
  }
1288
 
server/test/repin.test.mjs CHANGED
@@ -83,6 +83,34 @@ check('claimed uuid skipped', (await runner.claudeCandidate('s1', WORKDIR, SINCE
83
  console.log('\nnothing new in the window means no re-pin');
84
  check('no candidate', await runner.claudeCandidate('s1', path.join(cfg.WORKSPACES_DIR, 'empty'), SINCE), null);
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  // ---------- breadcrumbs: attribution the scan cannot do in shared folders ----------
87
  const E = 'eeeeeeee-0000-0000-0000-000000000005';
88
  const RUN = '11111111-2222-4333-8444-555555555555';
@@ -106,6 +134,15 @@ check('stale launch rejected', verdict(crumb({ runId: 'old-run' }), facts()).why
106
  check('wrong harness rejected', verdict(crumb({ cli: 'codex' }), facts()).why, 'cli mismatch');
107
  check('cwd mismatch rejected',
108
  verdict(crumb({ payload: { session_id: E, cwd: '/elsewhere', source: 'clear' } }), facts()).repin, null);
 
 
 
 
 
 
 
 
 
109
 
110
  console.log('\na nested claude -p cannot claim the pane (pid not under the pane root)');
111
  check('untrusted pid rejected', verdict(crumb(), facts({ pidTrusted: false })).repin, null);
 
83
  console.log('\nnothing new in the window means no re-pin');
84
  check('no candidate', await runner.claudeCandidate('s1', path.join(cfg.WORKSPACES_DIR, 'empty'), SINCE), null);
85
 
86
+ // ---------- the folder is a tree ----------
87
+ // An agent that enters a git worktree is still the same pane in the same folder,
88
+ // and the conversation its /clear starts records the DEEPER cwd. Equality here
89
+ // meant the pin could never follow that — the reader kept showing the pre-/clear
90
+ // thread while the terminal showed the new one, and the next launch would
91
+ // `--resume` the abandoned id.
92
+ console.log('\ncwdUnderWorkdir: the pane owns its whole tree, and nothing beside it');
93
+ const WT = path.join(WORKDIR, '.claude', 'worktrees', 'wt-1');
94
+ check('the folder itself', runner.cwdUnderWorkdir(WORKDIR, WORKDIR), true);
95
+ check('a worktree below it', runner.cwdUnderWorkdir(WT, WORKDIR), true);
96
+ check('a plain subdirectory', runner.cwdUnderWorkdir(path.join(WORKDIR, 'server'), WORKDIR), true);
97
+ check('a sibling sharing a name prefix',
98
+ runner.cwdUnderWorkdir(`${WORKDIR}2`, WORKDIR), false);
99
+ check('the parent folder', runner.cwdUnderWorkdir(cfg.WORKSPACES_DIR, WORKDIR), false);
100
+ check('somewhere else entirely', runner.cwdUnderWorkdir('/elsewhere', WORKDIR), false);
101
+ check('no cwd at all', runner.cwdUnderWorkdir(null, WORKDIR), false);
102
+ check('no workdir at all', runner.cwdUnderWorkdir(WORKDIR, ''), false);
103
+
104
+ console.log('\na /clear inside a worktree is followed');
105
+ const F = 'ffffffff-0000-0000-0000-000000000006';
106
+ transcript(F, { cwd: WT, startMs: NOW + 240_000, projDir: '-proj-a--claude-worktrees-wt-1' });
107
+ check('worktree conversation claimed', (await runner.claudeCandidate('s1', WORKDIR, SINCE))?.uuid, F);
108
+
109
+ console.log('\na neighbouring folder whose name starts the same is still not ours');
110
+ transcript('99999999-0000-0000-0000-000000000009',
111
+ { cwd: `${WORKDIR}2`, startMs: NOW + 300_000, projDir: '-proj-a2' });
112
+ check('prefix neighbour ignored', (await runner.claudeCandidate('s1', WORKDIR, SINCE))?.uuid, F);
113
+
114
  // ---------- breadcrumbs: attribution the scan cannot do in shared folders ----------
115
  const E = 'eeeeeeee-0000-0000-0000-000000000005';
116
  const RUN = '11111111-2222-4333-8444-555555555555';
 
134
  check('wrong harness rejected', verdict(crumb({ cli: 'codex' }), facts()).why, 'cli mismatch');
135
  check('cwd mismatch rejected',
136
  verdict(crumb({ payload: { session_id: E, cwd: '/elsewhere', source: 'clear' } }), facts()).repin, null);
137
+ check('a crumb from a parent folder rejected',
138
+ verdict(crumb({ payload: { session_id: E, cwd: cfg.WORKSPACES_DIR, source: 'clear' } }), facts()).why,
139
+ 'cwd outside the session folder');
140
+ check('a crumb from a prefix neighbour rejected',
141
+ verdict(crumb({ payload: { session_id: E, cwd: `${WORKDIR}2`, source: 'clear' } }), facts()).repin, null);
142
+
143
+ console.log('\na /clear reported from a worktree below the folder is this pane speaking');
144
+ check('worktree crumb accepted',
145
+ verdict(crumb({ payload: { session_id: E, cwd: WT, source: 'clear' } }), facts()).repin, E);
146
 
147
  console.log('\na nested claude -p cannot claim the pane (pid not under the pane root)');
148
  check('untrusted pid rejected', verdict(crumb(), facts({ pidTrusted: false })).repin, null);