Spaces:
Running
Re-pin: a session's folder is a tree, not one directory
Browse filesEvery 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>
- server/src/runner.js +49 -9
- server/test/repin.test.mjs +37 -0
|
@@ -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
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
| 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) =>
|
| 967 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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 |
|
|
@@ -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);
|