From 2b9713a6b28cd26eda9c92a36434ab5f94be33a8 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 16 Aug 2026 02:09:39 -0400 Subject: [PATCH] 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 --- .changeset/happy-ravens-run.md | 5 + gsd-core/references/workstream-flag.md | 5 +- src/active-workstream-store.cts | 9 ++ tests/active-workstream-store.unit.test.cjs | 127 ++++++++++++++++++++ tests/helpers.cjs | 4 +- 5 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 .changeset/happy-ravens-run.md diff --git a/.changeset/happy-ravens-run.md b/.changeset/happy-ravens-run.md new file mode 100644 index 000000000..f89c4d3c1 --- /dev/null +++ b/.changeset/happy-ravens-run.md @@ -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) diff --git a/gsd-core/references/workstream-flag.md b/gsd-core/references/workstream-flag.md index fab5ba2e6..8f3aaff42 100644 --- a/gsd-core/references/workstream-flag.md +++ b/gsd-core/references/workstream-flag.md @@ -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 diff --git a/src/active-workstream-store.cts b/src/active-workstream-store.cts index 4422e694f..6062ac483 100644 --- a/src/active-workstream-store.cts +++ b/src/active-workstream-store.cts @@ -20,6 +20,15 @@ const WORKSTREAM_SESSION_ENV_KEYS: ReadonlyArray = [ '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', diff --git a/tests/active-workstream-store.unit.test.cjs b/tests/active-workstream-store.unit.test.cjs index 88e0269fe..b9ca7a7d3 100644 --- a/tests/active-workstream-store.unit.test.cjs +++ b/tests/active-workstream-store.unit.test.cjs @@ -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'); + }); +}); diff --git a/tests/helpers.cjs b/tests/helpers.cjs index 7ac17f530..aa8109d3b 100644 --- a/tests/helpers.cjs +++ b/tests/helpers.cjs @@ -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',