mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 17:19:33 +02:00
109 lines
4.8 KiB
TypeScript
109 lines
4.8 KiB
TypeScript
/**
|
|||
|
|
* Issue #205, round 2: `getClaudeCliVersion()` used to cache FAILURE forever.
|
||
|
|
*
|
||
|
|
* It stored `null` on any exception and guarded on `!== undefined`, so a single
|
||
|
|
* failed probe — the 5s exec timeout, a PATH-starved systemd/launchd
|
||
|
|
* environment, a transient fs hiccup — at the first Claude session start left
|
||
|
|
* `cliVersion` undefined for every Claude session until the server restarted.
|
||
|
|
* An undefined `cliVersion` silently disables wheel-forwarding to Claude's own
|
||
|
|
* transcript (`_shouldForwardWheelToApp`), which is the only route to history
|
||
|
|
* for a repaint-mode pane: a dead wheel on every device at once, which is what
|
||
|
|
* the reporter described (phone + iPad + laptop all broken together points at a
|
||
|
|
* SERVER-side cause, not a browser one).
|
||
|
|
*
|
||
|
|
* The probe itself can't run under vitest (it would spawn a real `claude`), so
|
||
|
|
* these drive the cache policy directly with an injected probe and clock.
|
||
|
|
*/
|
||
|
|
import { describe, expect, it, vi } from 'vitest';
|
||
|
|
import {
|
||
|
|
claudeVersionRetryDelayMs,
|
||
|
|
getClaudeCliVersion,
|
||
|
|
resolveClaudeCliVersion,
|
||
|
|
type ClaudeVersionProbeState,
|
||
|
|
} from '../src/utils/claude-cli-resolver.js';
|
||
|
|
|
||
|
|
const freshState = (): ClaudeVersionProbeState => ({ failures: 0, lastFailureAt: 0 });
|
||
|
|
|
||
|
|
describe('claude --version probe caching', () => {
|
||
|
|
it('probes once on success and never spawns again', () => {
|
||
|
|
const state = freshState();
|
||
|
|
const probe = vi.fn(() => '2.1.223');
|
||
|
|
|
||
|
|
expect(resolveClaudeCliVersion(state, 1_000, probe)).toBe('2.1.223');
|
||
|
|
expect(resolveClaudeCliVersion(state, 2_000, probe)).toBe('2.1.223');
|
||
|
|
expect(resolveClaudeCliVersion(state, 9_999_999, probe)).toBe('2.1.223');
|
||
|
|
expect(probe).toHaveBeenCalledTimes(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('RETRIES after a failed probe instead of poisoning the process', () => {
|
||
|
|
const state = freshState();
|
||
|
|
const probe = vi
|
||
|
|
.fn<() => string | null>()
|
||
|
|
.mockImplementationOnce(() => {
|
||
|
|
throw new Error('spawn claude ETIMEDOUT'); // the shipped failure mode
|
||
|
|
})
|
||
|
|
.mockImplementationOnce(() => '2.1.223');
|
||
|
|
|
||
|
|
// First session start: probe blows up, no version.
|
||
|
|
expect(resolveClaudeCliVersion(state, 1_000, probe)).toBeNull();
|
||
|
|
// Immediately after, the negative cache holds — no probe storm.
|
||
|
|
expect(resolveClaudeCliVersion(state, 30_000, probe)).toBeNull();
|
||
|
|
expect(probe).toHaveBeenCalledTimes(1);
|
||
|
|
|
||
|
|
// Once the retry window elapses, the next session start probes again and
|
||
|
|
// wheel-forwarding comes back without a server restart.
|
||
|
|
expect(resolveClaudeCliVersion(state, 61_000, probe)).toBe('2.1.223');
|
||
|
|
expect(probe).toHaveBeenCalledTimes(2);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('treats an unparseable version like a failure (retryable, not cached)', () => {
|
||
|
|
const state = freshState();
|
||
|
|
const probe = vi.fn<() => string | null>(() => null); // e.g. output without a x.y.z
|
||
|
|
|
||
|
|
expect(resolveClaudeCliVersion(state, 1_000, probe)).toBeNull();
|
||
|
|
expect(resolveClaudeCliVersion(state, 61_000, probe)).toBeNull();
|
||
|
|
expect(probe).toHaveBeenCalledTimes(2);
|
||
|
|
expect(state.version).toBeUndefined(); // nothing cached as "known bad"
|
||
|
|
});
|
||
|
|
|
||
|
|
it('clears the failure streak once a probe succeeds', () => {
|
||
|
|
const state = freshState();
|
||
|
|
const probe = vi
|
||
|
|
.fn<() => string | null>()
|
||
|
|
.mockImplementationOnce(() => null)
|
||
|
|
.mockImplementationOnce(() => '2.1.223');
|
||
|
|
|
||
|
|
resolveClaudeCliVersion(state, 1_000, probe);
|
||
|
|
expect(state.failures).toBe(1);
|
||
|
|
resolveClaudeCliVersion(state, 61_000, probe);
|
||
|
|
expect(state.failures).toBe(0);
|
||
|
|
expect(state.lastFailureAt).toBe(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('backs off so a genuinely missing binary cannot probe on every session start', () => {
|
||
|
|
expect(claudeVersionRetryDelayMs(0)).toBe(0);
|
||
|
|
expect(claudeVersionRetryDelayMs(1)).toBe(60_000);
|
||
|
|
expect(claudeVersionRetryDelayMs(2)).toBe(120_000);
|
||
|
|
expect(claudeVersionRetryDelayMs(3)).toBe(240_000);
|
||
|
|
// Capped, so it keeps retrying forever without ever spinning.
|
||
|
|
expect(claudeVersionRetryDelayMs(50)).toBe(15 * 60_000);
|
||
|
|
|
||
|
|
const state = freshState();
|
||
|
|
const probe = vi.fn<() => string | null>(() => null);
|
||
|
|
resolveClaudeCliVersion(state, 0, probe); // failure 1 → retry at 60s
|
||
|
|
resolveClaudeCliVersion(state, 30_000, probe); // still inside the window
|
||
|
|
expect(probe).toHaveBeenCalledTimes(1);
|
||
|
|
resolveClaudeCliVersion(state, 60_000, probe); // failure 2 → retry at 120s
|
||
|
|
resolveClaudeCliVersion(state, 119_000, probe);
|
||
|
|
expect(probe).toHaveBeenCalledTimes(2);
|
||
|
|
resolveClaudeCliVersion(state, 180_001, probe);
|
||
|
|
expect(probe).toHaveBeenCalledTimes(3);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('stays hermetic under vitest without recording a phantom failure', () => {
|
||
|
|
// The guard returns before the probe, and — unlike the old code, which wrote
|
||
|
|
// null into the cache here — leaves the cache untouched.
|
||
|
|
expect(getClaudeCliVersion()).toBeNull();
|
||
|
|
expect(getClaudeCliVersion()).toBeNull();
|
||
|
|
});
|
||
|
|
});
|