* 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:
5
.changeset/happy-ravens-run.md
Normal file
5
.changeset/happy-ravens-run.md
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user