Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion cli/__tests__/adapters.claude.context-tokens.test.mjs
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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 {
Expand Down
33 changes: 33 additions & 0 deletions cli/__tests__/first-output-watchdog.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 7 additions & 2 deletions cli/src/lib/adapters/claude.js
Original file line number Diff line number Diff line change
Expand Up @@ -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?.();
},
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 };
Expand Down
Loading