diff --git a/cli/__tests__/adapters.claude.context-tokens.test.mjs b/cli/__tests__/adapters.claude.context-tokens.test.mjs index 1fbeaf3e58..f977ac0d59 100644 --- a/cli/__tests__/adapters.claude.context-tokens.test.mjs +++ b/cli/__tests__/adapters.claude.context-tokens.test.mjs @@ -1,6 +1,8 @@ import { describe, expect, test } from '@jest/globals'; import { EventEmitter } from 'events'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { + mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync, +} from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; @@ -51,6 +53,38 @@ describe('claude adapter reports the turn context from the child HOME', () => { } }); + test('a symlinked cwd is read under its physical path, where claude writes it', async () => { + const childHome = mkdtempSync(join(tmpdir(), 'claude-home-')); + const real = join(childHome, 'real-work'); + const link = join(childHome, 'linked-work'); + mkdirSync(real); + symlinkSync(real, link); + try { + const impl = (_cmd, args) => { + const flag = args.includes('--resume') ? '--resume' : '--session-id'; + const sid = args[args.indexOf(flag) + 1]; + // claude names the folder after process.cwd(), the resolved path. + const dir = join(childHome, '.claude', 'projects', realpathSync(real).replace(/[^a-zA-Z0-9]/g, '-')); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${sid}.jsonl`), `${JSON.stringify({ + type: 'assistant', + message: { usage: { input_tokens: 7, cache_creation_input_tokens: 0, cache_read_input_tokens: 500 } }, + })}\n`); + return fakeChild(); + }; + const res = await claude.spawn('hi', { environment: { model: 'test-model' }, + sessionId: 'sid-link', + cwd: link, + env: { HOME: childHome }, + memoryLongTerm: '', + _spawnImpl: impl, + }); + expect(res.contextTokens).toBe(507); + } finally { + rmSync(childHome, { recursive: true, force: true }); + } + }); + test('a turn with no transcript reports unknown, so the seat keeps resuming', async () => { const childHome = mkdtempSync(join(tmpdir(), 'claude-home-')); try { diff --git a/cli/__tests__/first-output-watchdog.test.mjs b/cli/__tests__/first-output-watchdog.test.mjs index 3b33546163..6f38aed0b9 100644 --- a/cli/__tests__/first-output-watchdog.test.mjs +++ b/cli/__tests__/first-output-watchdog.test.mjs @@ -182,6 +182,39 @@ describe('the claude adapter under the first-output watchdog', () => { } }); + test('at the grace deadline the pipes are dropped, so a holder the snapshot missed cannot block close', async () => { + const saved = process.env.COMMONLY_FIRST_OUTPUT_WINDOW_MS; + process.env.COMMONLY_FIRST_OUTPUT_WINDOW_MS = '50'; + const spawned = {}; + try { + const { error } = await claude.spawn('hello', { + environment: { model: 'test-model' }, + _spawnImpl: () => { + const proc = fakeChild(); + proc.kill = jest.fn(); + // Close fires only once both of our pipe ends are gone. + let open = 2; + const drop = () => { open -= 1; if (open === 0) setImmediate(() => proc.emit('close', null)); }; + proc.stdout.destroy = jest.fn(drop); + proc.stderr.destroy = jest.fn(drop); + spawned.child = proc; + return proc; + }, + _lockProbe: async () => true, + _stallKillGraceMs: 30, + _processTable: () => [], // ps saw no descendants + env: { ...process.env, HOME: sessionsTmpDir }, + cwd: sessionsTmpDir, + }).then(() => ({}), (err) => ({ error: err })); + expect(error.code).toBe('spawn_stalled'); + expect(spawned.child.stdout.destroy).toHaveBeenCalled(); + expect(spawned.child.stderr.destroy).toHaveBeenCalled(); + } finally { + if (saved === undefined) delete process.env.COMMONLY_FIRST_OUTPUT_WINDOW_MS; + else process.env.COMMONLY_FIRST_OUTPUT_WINDOW_MS = saved; + } + }); + test('a spawn that ignores SIGTERM gets SIGKILL after the grace', async () => { const spawned = {}; const saved = process.env.COMMONLY_FIRST_OUTPUT_WINDOW_MS; diff --git a/cli/src/lib/adapters/claude.js b/cli/src/lib/adapters/claude.js index a793bffec3..d692c6f35f 100644 --- a/cli/src/lib/adapters/claude.js +++ b/cli/src/lib/adapters/claude.js @@ -537,6 +537,11 @@ const runClaude = ({ stallKill = setTimeout(() => { killProcessTree(proc, 'SIGKILL', treeIo(processTable)); sweepStallSurvivors(); + // A pipe holder the snapshot missed (ps failed, or it was reparented + // before the stall) would still keep close from firing. Dropping our + // ends of the pipes bounds every stalled turn at window plus grace. + proc.stdout?.destroy?.(); + proc.stderr?.destroy?.(); }, stallKillGraceMs); stallKill.unref?.(); }, @@ -1262,7 +1267,7 @@ export default { // TASK-440: the context this turn left, read from the transcript the // child wrote under its own env (an isolated HOME for public seats). const contextTokens = claudeContextTokens({ - cwd: ctx.cwd, sessionId, configDir: claudeConfigDirFor({ ...env, HOME: childHome || env?.HOME }), + cwd: physicalPath(ctx.cwd), sessionId, configDir: claudeConfigDirFor({ ...env, HOME: childHome || env?.HOME }), }); return { text: stdout.trim(), newSessionId: sessionId, contextTokens }; } catch (err) { @@ -1324,7 +1329,7 @@ export default { survivorIo: ctx._survivorIo, }); const contextTokens = claudeContextTokens({ - cwd: ctx.cwd, sessionId: freshId, + cwd: physicalPath(ctx.cwd), sessionId: freshId, configDir: claudeConfigDirFor({ ...retry.env, HOME: retry.childHome || retry.env?.HOME }), }); return { text: stdout.trim(), newSessionId: freshId, contextTokens };