Implements the execa Agent Note's four sub-changes:
- execa (root devDep + loader-smoke dep) replaces the hand-rolled
spawn-collect-timeout choreography in loader-smoke, apps/cli and
cli-demo/acp-demo built-bin e2e, lsp-local and code-runtime-worker
built-lib e2e, the tui pty-harness outer collector, the jsonrpc
keyless smoke, and crash-recovery's child spawn. Genuinely custom
parts stay custom: cli-demo's interrupt-on-marker, jsonrpc's
line-predicate protocol driving, crash-recovery's SIGKILL-at-failpoint.
The two loader-smoke /* v8 ignore */ OS-error branches are gone.
- llm-mock-server CLI tokenizes via node:util parseArgs; numeric
coercion/bounds/cross-option constraints stay manual; pinned
error-message tests updated to the parseArgs texts.
- both loadRootEnv copies in apps/web/tests are deleted: the owning
vitest configs (web unconditionally, snapshot in record mode)
already load the repo-root .env before these files run.
- the four poll loops (acp-snapshot harness waits + crash-recovery
waitForFile) ride vi.waitFor with explicit {interval, timeout}.
99 lines
4.0 KiB
TypeScript
99 lines
4.0 KiB
TypeScript
import { access, mkdtemp, readFile, rm } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { execa } from 'execa'
|
|
import { Context } from 'cordis'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import SessionStore, {
|
|
SessionId, TOOL_OUTCOME_UNKNOWN,
|
|
type SessionEvent,
|
|
} from '@deepseek-ai/dsh-session'
|
|
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
|
|
|
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
|
|
const childScript = fileURLToPath(new URL('./fixtures/crash-child.ts', import.meta.url))
|
|
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
|
|
const sessionId = SessionId('semantic-checkpoint-crash')
|
|
const roots: string[] = []
|
|
const CHILD_FAILPOINT_TIMEOUT_MS = 30_000
|
|
|
|
async function waitForFile(path: string): Promise<void> {
|
|
await vi.waitFor(async () => {
|
|
await access(path).catch((error: unknown) => {
|
|
throw new Error(`crash child did not reach failpoint ${path}`, { cause: error })
|
|
})
|
|
}, { interval: 10, timeout: CHILD_FAILPOINT_TIMEOUT_MS })
|
|
}
|
|
|
|
async function crashAt(mode: 'request' | 'tool'): Promise<{ root: string; markerText: string }> {
|
|
const root = await mkdtemp(join(tmpdir(), `dsh-semantic-${mode}-`))
|
|
roots.push(root)
|
|
const marker = join(root, 'failpoint')
|
|
// The SIGKILL-at-failpoint choreography stays custom: the child must die
|
|
// mid-write, so no timeout or graceful termination may reach it first.
|
|
const child = execa(process.execPath, ['--import', tsxLoader, childScript, mode, root, marker], {
|
|
cwd: repoRoot,
|
|
env: { TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') },
|
|
stdin: 'ignore',
|
|
stdout: 'ignore',
|
|
reject: false,
|
|
})
|
|
try {
|
|
await waitForFile(marker)
|
|
const markerText = await readFile(marker, 'utf8')
|
|
child.kill('SIGKILL')
|
|
const exit = await child
|
|
expect({ code: exit.exitCode ?? null, signal: exit.signal ?? null }).toEqual({ code: null, signal: 'SIGKILL' })
|
|
return { root, markerText }
|
|
} catch (error: unknown) {
|
|
child.kill('SIGKILL')
|
|
throw new Error(`crash child failed: ${(await child).stderr}`, { cause: error })
|
|
}
|
|
}
|
|
|
|
async function load(root: string): Promise<SessionEvent[]> {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
|
|
try {
|
|
return (await ctx.sessionPersistence.load(sessionId)).events
|
|
} finally {
|
|
await ctx.fiber.dispose()
|
|
}
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
|
|
})
|
|
|
|
describe.skipIf(process.platform === 'win32')('semantic checkpoint hard-crash recovery', () => {
|
|
it('persists the complete request before model dispatch', async () => {
|
|
const crashed = await crashAt('request')
|
|
expect(crashed.markerText).toBe('request-dispatched')
|
|
const events = await load(crashed.root)
|
|
expect(events.map(event => event.type)).toEqual([
|
|
'turn/start', 'user/message', 'step/start', 'request/header', 'step/end', 'turn/end',
|
|
])
|
|
expect(events.at(-1)).toMatchObject({
|
|
type: 'turn/end', data: { reason: { kind: 'interrupted' } },
|
|
})
|
|
})
|
|
|
|
it('persists tool intent before a side effect and repairs its missing result as unknown', async () => {
|
|
const crashed = await crashAt('tool')
|
|
expect(crashed.markerText).toBe('tool-side-effect')
|
|
const events = await load(crashed.root)
|
|
expect(events.some(event => event.type === 'assistant/message')).toBe(true)
|
|
expect(events.some(event => event.type === 'tool/call')).toBe(true)
|
|
const result = events.find(event => event.type === 'tool/result')
|
|
expect(result?.type === 'tool/result' && result.data.error).toEqual({
|
|
name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN,
|
|
})
|
|
if (result?.type !== 'tool/result' || result.data.content[0]?.type !== 'text') {
|
|
throw new Error('expected a text tool result')
|
|
}
|
|
expect(result.data.content[0].text).toContain('Do not retry blindly.')
|
|
})
|
|
})
|