fix(#3557): accept claude code session id in the workstream session probe (#3570)

* test(#3557): failing-first regression for claude code session key probe

* test(#3557): assert adapter source vocabulary in session probe test

* fix(#3557): accept claude code session id in the workstream session probe

* test(#3557): pin the new session key against both immediate neighbors

* chore(#3557): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-16 02:09:39 -04:00
committed by GitHub
parent 9448736872
commit 2b9713a6b2
5 changed files with 147 additions and 3 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3570
---
**Concurrent Claude Code sessions no longer share one active-workstream pointer** — Claude Code exports its session id as `CLAUDE_CODE_SESSION_ID`, but the session-identity probe only listened for `CLAUDE_SESSION_ID`, so session-scoped workstream isolation never engaged on Claude Code: every session in a working tree resolved through the single shared `.planning/active-workstream` pointer, and a `STATE.md` update belonging to one workstream could be written silently into another's directory. The probe now accepts `CLAUDE_CODE_SESSION_ID` (no other key's precedence changed); concurrent sessions each keep their own session-scoped pointer again. (#3557)

View File

@@ -20,7 +20,8 @@ Claude/Codex instances are active on the same repo at the same time. One session
silently repoint another session's `STATE.md`, `ROADMAP.md`, and phase paths.
GSD now prefers a session-scoped pointer keyed by runtime/session identity
(`GSD_SESSION_KEY`, `CODEX_THREAD_ID`, `CLAUDE_CODE_SSE_PORT`, terminal session IDs,
(`GSD_SESSION_KEY`, `CODEX_THREAD_ID`, `CLAUDE_CODE_SESSION_ID`,
`CLAUDE_CODE_SSE_PORT`, terminal session IDs,
or the controlling TTY). This keeps concurrent sessions isolated while preserving
legacy compatibility for runtimes that do not expose a stable session key.
@@ -29,7 +30,7 @@ legacy compatibility for runtimes that do not expose a stable session key.
When GSD resolves the session-scoped pointer in step 3 above, it uses this order:
1. Explicit runtime/session env vars such as `GSD_SESSION_KEY`, `CODEX_THREAD_ID`,
`CLAUDE_SESSION_ID`, `CLAUDE_CODE_SSE_PORT`, `OPENCODE_SESSION_ID`,
`CLAUDE_SESSION_ID`, `CLAUDE_CODE_SESSION_ID`, `CLAUDE_CODE_SSE_PORT`, `OPENCODE_SESSION_ID`,
`GEMINI_SESSION_ID`, `CURSOR_SESSION_ID`, `WINDSURF_SESSION_ID`,
`TERM_SESSION_ID`, `WT_SESSION`, `TMUX_PANE`, and `ZELLIJ_SESSION_NAME`
2. `TTY` or `SSH_TTY` if the shell/runtime already exposes the terminal path

View File

@@ -20,6 +20,15 @@ const WORKSTREAM_SESSION_ENV_KEYS: ReadonlyArray<string> = [
'GSD_SESSION_KEY',
'CODEX_THREAD_ID',
'CLAUDE_SESSION_ID',
// #3557 — Claude Code (≥ 2.1.132) exports its session id to Bash-tool
// subprocesses as CLAUDE_CODE_SESSION_ID. Without it the probe returned
// null on Claude Code, so every concurrent session in a working tree
// shared the single .planning/active-workstream pointer and cross-
// workstream STATE.md writes landed silently in the wrong file. Inserted
// beside the other runtime keys without reordering any existing entry;
// ahead of CLAUDE_CODE_SSE_PORT so the canonical id wins when both are
// present (runtime identity outranks terminal identity in this list).
'CLAUDE_CODE_SESSION_ID',
'CLAUDE_CODE_SSE_PORT',
'OPENCODE_SESSION_ID',
'GEMINI_SESSION_ID',

View File

@@ -1076,3 +1076,130 @@ describe('_resetControllingTtyCacheForTests: proves BOTH cache fields cleared (#
}
});
});
// ── #3557: Claude Code exports its session id as CLAUDE_CODE_SESSION_ID ──────
//
// The session-key probe listed CLAUDE_SESSION_ID / CLAUDE_CODE_SSE_PORT but not
// CLAUDE_CODE_SESSION_ID (exported by Claude Code ≥ 2.1.132), so on Claude Code
// the probe returned null and every concurrent session in a working tree shared
// the single .planning/active-workstream pointer — cross-workstream STATE.md
// writes landed silently in the wrong workstream's file.
describe('#3557 CLAUDE_CODE_SESSION_ID session key', () => {
let tmpDir;
let saved;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3557-'));
saved = saveSessionEnv();
clearSessionEnv();
});
afterEach(() => {
restoreSessionEnv(saved);
cleanup(tmpDir);
});
// Deterministic non-TTY stdin regardless of the host terminal (pattern from
// the getWorkstreamSessionKey describe above, incl. the #1191 memo seam).
function withNonTtyStdin(fn) {
const origDescriptor = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY');
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true, writable: true });
_resetControllingTtyCacheForTests();
try {
return fn();
} finally {
if (origDescriptor) Object.defineProperty(process.stdin, 'isTTY', origDescriptor);
else delete (process.stdin).isTTY;
_resetControllingTtyCacheForTests();
}
}
// Row 1 — the regression-first probe assertion (criterion 1).
test('session key resolves from CLAUDE_CODE_SESSION_ID alone', () => {
process.env.CLAUDE_CODE_SESSION_ID = 'sess-alpha-123';
const key = withNonTtyStdin(() => getWorkstreamSessionKey());
assert.equal(key, 'claude-code-session-id-sess-alpha-123',
'Claude Code\'s exported session id must produce a session-scoped key');
});
// Row 2 — end-to-end: the session adapter engages and the shared pointer is
// untouched; the session tmp dir is created on first write (criterion 2).
test('resolution writes the session-scoped pointer, never the shared file', () => {
makePlanningDir(tmpDir, 'workstream-a', 'workstream-b');
fs.writeFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'workstream-b');
process.env.CLAUDE_CODE_SESSION_ID = 'sess-alpha-123';
withNonTtyStdin(() => {
const adapter = createSessionScopedPointerAdapter(tmpDir);
assert.notEqual(adapter, null, 'a session key must select the session-scoped adapter');
adapter.write('workstream-a');
const resolved = resolveActiveWorkstream(tmpDir, [], {});
assert.equal(resolved.ws, 'workstream-a',
'the session-scoped pointer must win over the stale shared pointer');
assert.equal(resolved.source, 'store',
'adapter-backed resolution reports source "store"; the shared file '
+ 'staying on workstream-b below is what proves the session scoping');
});
assert.equal(fs.readFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'utf8').trim(),
'workstream-b', 'the shared pointer file must be untouched');
});
// Row 3 — two sessions cannot observe each other's pointer (criterion 3).
test('distinct CLAUDE_CODE_SESSION_ID values cannot observe each other', () => {
makePlanningDir(tmpDir, 'workstream-a', 'workstream-b');
const alpha = createSessionScopedPointerAdapter(tmpDir, 'claude-code-session-id-sess-alpha-123');
const beta = createSessionScopedPointerAdapter(tmpDir, 'claude-code-session-id-sess-beta-456');
alpha.write('workstream-a');
beta.write('workstream-b');
assert.equal(alpha.read(), 'workstream-a');
assert.equal(beta.read(), 'workstream-b');
});
// Row 4 — no identity at all: legacy headless fallback unchanged (criterion 4).
test('no session identity falls back to the shared pointer', () => {
makePlanningDir(tmpDir, 'workstream-b');
fs.writeFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'workstream-b');
const key = withNonTtyStdin(() => getWorkstreamSessionKey());
assert.equal(key, null);
const resolved = resolveActiveWorkstream(tmpDir, [], {});
assert.equal(resolved.ws, 'workstream-b');
assert.equal(resolved.source, 'store');
});
// Row 5 — the new key must not disturb existing precedence (criterion 6).
test('existing key precedence unchanged by the new key', () => {
process.env.GSD_SESSION_KEY = 'explicit-key';
process.env.CLAUDE_CODE_SESSION_ID = 'sess-alpha-123';
const key = withNonTtyStdin(() => getWorkstreamSessionKey());
assert.equal(key, 'gsd-session-key-explicit-key',
'GSD_SESSION_KEY stays ahead of runtime keys in the probe order');
// Pin the new key's position against BOTH immediate neighbors — a swap
// with either would silently change which signal wins when Claude Code
// exports more than one (review finding on #3557).
delete process.env.GSD_SESSION_KEY;
process.env.CLAUDE_SESSION_ID = 'legacy-id';
process.env.CLAUDE_CODE_SESSION_ID = 'sess-alpha-123';
assert.equal(withNonTtyStdin(() => getWorkstreamSessionKey()),
'claude-session-id-legacy-id',
'CLAUDE_SESSION_ID stays ahead of CLAUDE_CODE_SESSION_ID');
delete process.env.CLAUDE_SESSION_ID;
process.env.CLAUDE_CODE_SESSION_ID = 'sess-alpha-123';
process.env.CLAUDE_CODE_SSE_PORT = '9999';
assert.equal(withNonTtyStdin(() => getWorkstreamSessionKey()),
'claude-code-session-id-sess-alpha-123',
'CLAUDE_CODE_SESSION_ID stays ahead of CLAUDE_CODE_SSE_PORT');
});
// Row 6 — helper scrub lists must know the new key, or the suite is
// nondeterministic when run under Claude Code itself (criterion 5).
test('helpers scrub CLAUDE_CODE_SESSION_ID with the other session vars', () => {
const { SESSION_ENV_KEYS, SESSION_IDENTITY_ENV_KEYS } = require('./helpers.cjs');
assert.ok(SESSION_ENV_KEYS.includes('CLAUDE_CODE_SESSION_ID'),
'SESSION_ENV_KEYS must scrub CLAUDE_CODE_SESSION_ID');
assert.ok(SESSION_IDENTITY_ENV_KEYS.includes('CLAUDE_CODE_SESSION_ID'),
'SESSION_IDENTITY_ENV_KEYS must scrub CLAUDE_CODE_SESSION_ID');
});
});

View File

@@ -17,6 +17,7 @@ const SESSION_IDENTITY_ENV_KEYS = [
'GSD_SESSION_KEY',
'CODEX_THREAD_ID',
'CLAUDE_SESSION_ID',
'CLAUDE_CODE_SESSION_ID',
'CLAUDE_CODE_SSE_PORT',
'OPENCODE_SESSION_ID',
'GEMINI_SESSION_ID',
@@ -897,7 +898,8 @@ function resetRuntimeWarningCaches() {
* (#2850 code review finding: the two copies had already silently diverged).
*/
const SESSION_ENV_KEYS = [
'GSD_SESSION_KEY', 'CODEX_THREAD_ID', 'CLAUDE_SESSION_ID', 'CLAUDE_CODE_SSE_PORT',
'GSD_SESSION_KEY', 'CODEX_THREAD_ID', 'CLAUDE_SESSION_ID', 'CLAUDE_CODE_SESSION_ID',
'CLAUDE_CODE_SSE_PORT',
'OPENCODE_SESSION_ID', 'GEMINI_SESSION_ID', 'CURSOR_SESSION_ID', 'WINDSURF_SESSION_ID',
'TERM_SESSION_ID', 'WT_SESSION', 'TMUX_PANE', 'ZELLIJ_SESSION_NAME',
'TTY', 'SSH_TTY', 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS',