diff --git a/packages/dsh-loopx-plugin/src/cli.ts b/packages/dsh-loopx-plugin/src/cli.ts index 60b4e0bcb4..506a36a9d9 100644 --- a/packages/dsh-loopx-plugin/src/cli.ts +++ b/packages/dsh-loopx-plugin/src/cli.ts @@ -1,4 +1,5 @@ import { spawn } from 'node:child_process' +import { statSync } from 'node:fs' // 1.2.4 includes the released Windows peer-file fix. Bootstrap and every // runtime consumer must use the same floor, including an existing global CLI. @@ -21,12 +22,57 @@ export class LoopXCliError extends Error { message: string, readonly retryable: boolean, readonly exitCode?: number | undefined, + readonly diagnostics: LoopXCliDiagnostics = {}, ) { super(message) this.name = 'LoopXCliError' } } +export interface LoopXCliDiagnostics { + osCode?: string | undefined + candidate?: 'configured-command' | 'managed-launcher' | 'path-loopx' | 'python-module' | undefined + cwdExists?: boolean | undefined + causeKind?: LoopXCliErrorKind | undefined + probes?: readonly LoopXCliError[] | undefined +} + +export function spawnCwdExists(cwd?: string): boolean | undefined { + try { + return statSync(cwd ?? process.cwd(), { throwIfNoEntry: false })?.isDirectory() ?? false + } catch { + return undefined + } +} + +/** Only closed labels and scalars belong in the driver's diagnostic record. */ +function cliFailureFields(error: unknown): string { + const failure = error instanceof LoopXCliError ? error : undefined + const details = failure?.diagnostics + const kind = (value: unknown): string => [ + 'aborted', 'missing', 'timeout', 'transport', 'output_limit', 'exit', + 'typed_failure', 'invalid_json', 'invalid_schema', + ].includes(value as string) ? String(value) : 'internal' + const code = [ + 'ENOENT', 'EPERM', 'EACCES', 'ETIMEDOUT', 'EAGAIN', 'EBUSY', 'EMFILE', + 'ENFILE', 'ENOMEM', 'ENOTDIR', 'E2BIG', 'ENOEXEC', 'EINVAL', 'ECANCELED', 'EIO', + ].includes(details?.osCode ?? '') ? details!.osCode : 'none' + const candidate = [ + 'configured-command', 'managed-launcher', 'path-loopx', 'python-module', + ].includes(details?.candidate ?? '') ? details!.candidate : 'unknown' + const exitCode = Number.isSafeInteger(failure?.exitCode) ? failure!.exitCode : 'none' + const cwd = typeof details?.cwdExists === 'boolean' ? details.cwdExists : 'unknown' + return `kind=${kind(failure?.kind)} cause_kind=${kind(details?.causeKind ?? failure?.kind)} code=${code} exit_code=${exitCode} candidate=${candidate} cwd_exists=${cwd}` +} + +export function cliErrorFields(error: unknown): string { + const probes = error instanceof LoopXCliError + ? error.diagnostics.probes ?? [] : [] + const selected = probes.length > 8 ? [probes[0]!, ...probes.slice(-7)] : probes + const fields = selected.map(probe => cliFailureFields(probe).replaceAll(' ', ',')).join(';') + return `${cliFailureFields(error)} probes=${fields || 'none'} probes_omitted=${probes.length - selected.length}` +} + export interface FileResult { readonly exitCode: number readonly stdout: string @@ -52,6 +98,7 @@ export interface LoopXCommand { readonly prefix: readonly string[] readonly skillCommand: string readonly version: string + readonly candidate?: LoopXCliDiagnostics['candidate'] } export interface ManagedLoopXLauncher { @@ -72,9 +119,9 @@ const FORCE_KILL_GRACE_MS = 250 function transportKind(error: NodeJS.ErrnoException): LoopXCliError { if (error.code === 'ENOENT') { - return new LoopXCliError('missing', 'required executable is unavailable', false) + return new LoopXCliError('missing', 'required executable is unavailable', false, undefined, { osCode: error.code }) } - return new LoopXCliError('transport', 'could not start the LoopX command', true) + return new LoopXCliError('transport', 'could not start the LoopX command', true, undefined, { osCode: error.code }) } export const runFile: FileRunner = (file, args, options) => new Promise((resolve, reject) => { @@ -266,6 +313,7 @@ export async function runJsonCommand( let lastError: LoopXCliError | undefined for (let attempt = 0; attempt < attempts; attempt += 1) { + const cwdExists = spawnCwdExists(options.cwd) try { const result = await runner( command.file, @@ -296,6 +344,8 @@ export async function runJsonCommand( const failure = error instanceof LoopXCliError ? error : new LoopXCliError('transport', 'LoopX command failed to execute', true) + failure.diagnostics.cwdExists = cwdExists + failure.diagnostics.candidate ??= command.candidate lastError = failure if (!failure.retryable || attempt + 1 >= attempts) throw failure await wait(retryDelays[attempt] ?? retryDelays.at(-1) ?? 0, options.signal) @@ -367,7 +417,12 @@ export async function resolveLoopXCommand( })), ] + const probes: LoopXCliError[] = [] for (const candidate of candidates) { + const label = configured ? 'configured-command' + : managedCandidates.includes(candidate) ? 'managed-launcher' + : candidate.file === 'loopx' ? 'path-loopx' : 'python-module' + const cwdExists = spawnCwdExists() try { const result = await runner( candidate.file, @@ -383,13 +438,25 @@ export async function resolveLoopXCommand( throw new LoopXCliError('aborted', 'operation was cancelled', false) } const version = result.exitCode === 0 ? supportedLoopXVersion(result.stdout) : undefined - if (version) return { ...candidate, version } + if (version) return { ...candidate, version, candidate: label } + throw new LoopXCliError( + result.exitCode === 0 ? 'invalid_schema' : 'exit', + 'LoopX version probe did not verify', false, result.exitCode, + ) } catch (error: unknown) { if (options.signal?.aborted) { throw new LoopXCliError('aborted', 'operation was cancelled', false) } if (error instanceof LoopXCliError && error.kind === 'aborted') throw error + const failure = error instanceof LoopXCliError ? error + : new LoopXCliError('transport', 'LoopX version probe failed to execute', false) + probes.push(new LoopXCliError(failure.kind, 'version probe failed', false, failure.exitCode, { + osCode: failure.diagnostics.osCode, candidate: label, cwdExists, + })) } } - throw new LoopXCliError('missing', `LoopX CLI ${MINIMUM_LOOPX_VERSION} or newer is unavailable`, false) + const last = probes.at(-1) + throw new LoopXCliError('missing', `LoopX CLI ${MINIMUM_LOOPX_VERSION} or newer is unavailable`, false, last?.exitCode, { + ...last?.diagnostics, causeKind: last?.kind, probes, + }) } diff --git a/packages/dsh-loopx-plugin/src/driver.ts b/packages/dsh-loopx-plugin/src/driver.ts index a2464ff583..a9ea8927f6 100644 --- a/packages/dsh-loopx-plugin/src/driver.ts +++ b/packages/dsh-loopx-plugin/src/driver.ts @@ -9,6 +9,7 @@ import type {} from '@deepseek-ai/dsh-session' import type { SessionEvent, UserMessage } from '@deepseek-ai/dsh-session' import { LoopXCliError, + cliErrorFields, resolveLoopXCommand, runJsonCommand, supportedLoopXVersion, @@ -112,6 +113,18 @@ interface WaitPlan { } type EvaluationPlan = QueuePlan | WaitPlan +type AdmissionStage = 'resolve-command' | 'resolve-binding' | 'quota' | 'heartbeat' + | 'pre-step-recheck' | 'maintenance-slot' + +function preStepRejection( + reason: string, messages: UserMessage[], submitted: UserMessage, startedAt: number, +): string { + const kinds = [...new Set(messages.filter(message => message.id !== submitted.id).map(message => { + const kind = message.source?.kind + return typeof kind === 'string' && /^[a-z][a-z0-9-]{0,47}$/u.test(kind) ? kind : 'unknown' + }))].sort().slice(0, 16) + return `pre_step_rejected stage=pre-step-recheck reason=${reason} other_kinds=${kinds.join(',') || 'none'} elapsed_ms=${Math.round(performance.now() - startedAt)}` +} export interface LoopXDriverOptions { readonly isLiveAgent: (agent: Agent) => boolean @@ -632,7 +645,11 @@ export class LoopXContinuationDriver { ?? (() => `dsh-loopx-${randomUUID()}`) this.retryDelaysMs = options.retryDelaysMs ?? [200, 750] this.runDetached = options.runDetached ?? (operation => operation()) - this.warn = options.warn ?? (() => {}) + this.warn = message => { + try { options.warn?.(message) } catch { + // Diagnostic exporters must not interrupt admission or message restoration. + } + } } observeAgent(agent: Agent): void { @@ -807,8 +824,10 @@ export class LoopXContinuationDriver { : messages.find(message => message.id === reservation.messageId) ?? messages.find(message => isDriverMessage(message)) if (submitted === undefined) return next() + const startedAt = performance.now() if (reservation === undefined || !sameReservation(submitted, reservation) || reservation.phase !== 'claimed') { + this.warn(`dsh-loopx-driver: ${preStepRejection(reservation === undefined ? 'missing-reservation' : !sameReservation(submitted, reservation) ? 'reservation-mismatch' : 'reservation-not-claimed', messages, submitted, startedAt)}`) if (reservation !== undefined && submitted.id === reservation.messageId) { this.retireReservation(state) } @@ -816,12 +835,14 @@ export class LoopXContinuationDriver { return { kind: 'reject' } } if (messages.some(message => message.id !== submitted.id && !isHostContextMessage(message))) { + this.warn(`dsh-loopx-driver: ${preStepRejection('competing-messages', messages, submitted, startedAt)}`) this.retireReservation(state) this.restoreOtherMessages(agent, messages, submitted) return { kind: 'reject' } } if (!(await this.authorityStillAllows(state, reservation, signal))) { + this.warn(`dsh-loopx-driver: ${preStepRejection('authority-before-next', messages, submitted, startedAt)}`) this.retireReservation(state) this.restoreOtherMessages(agent, messages, submitted) this.requestEvaluation(state) @@ -842,6 +863,7 @@ export class LoopXContinuationDriver { return decision } if (decision.kind === 'reject') { + this.warn(`dsh-loopx-driver: ${preStepRejection('downstream-reject', messages, submitted, startedAt)}`) state.pauseAfterTurnError = true this.retireReservation(state) return decision @@ -850,11 +872,13 @@ export class LoopXContinuationDriver { if (decision.messages.filter(message => !isHostContextMessage(message)).length !== 1 || admitted === undefined || !sameReservation(admitted, reservation)) { + this.warn(`dsh-loopx-driver: ${preStepRejection('downstream-batch', decision.messages, submitted, startedAt)}`) this.retireReservation(state) this.restoreOtherMessages(agent, decision.messages, submitted) return { kind: 'reject' } } if (!(await this.authorityStillAllows(state, reservation, signal))) { + this.warn(`dsh-loopx-driver: ${preStepRejection('authority-after-next', decision.messages, submitted, startedAt)}`) this.retireReservation(state) this.restoreOtherMessages(agent, decision.messages, submitted) return { kind: 'reject' } @@ -982,6 +1006,9 @@ export class LoopXContinuationDriver { private async evaluate(state: DriverState): Promise { if (!this.ready(state)) return false + const startedAt = performance.now() + let stage: AdmissionStage = 'maintenance-slot' + let admissionSignal: AbortSignal | undefined const controller = new AbortController() state.controller = controller let evaluationStarted = false @@ -991,7 +1018,8 @@ export class LoopXContinuationDriver { plan = await state.agent.runMaintenance(async maintenanceSignal => { evaluationStarted = true const signal = AbortSignal.any([controller.signal, maintenanceSignal]) - return this.buildPlan(state, signal) + admissionSignal = signal + return this.buildPlan(state, signal, value => { stage = value }) }) } catch (error: unknown) { if (!evaluationStarted && !controller.signal.aborted @@ -1016,7 +1044,7 @@ export class LoopXContinuationDriver { this.command = undefined } if (!controller.signal.aborted && !cancelled) { - this.warn(`dsh-loopx-driver: LoopX admission failed: ${renderThrown(error)}`) + this.warn(`dsh-loopx-driver: admission_failed stage=${stage} ${cliErrorFields(error)} signal_aborted=${admissionSignal?.aborted ?? controller.signal.aborted} elapsed_ms=${Math.round(performance.now() - startedAt)}`) } return evaluationStarted } finally { @@ -1067,11 +1095,14 @@ export class LoopXContinuationDriver { private async buildPlan( state: DriverState, signal: AbortSignal, + setStage: (stage: AdmissionStage) => void, ): Promise { const session = state.agent.session this.ensureReady(state, signal, session) + setStage('resolve-command') const command = await this.loopXCommand(signal) this.ensureReady(state, signal, session) + setStage('resolve-binding') const bindingPayload = await this.resolveBinding(command, state.agent, signal) this.ensureReady(state, signal, session) if (bindingPayload.ok !== true) { @@ -1087,6 +1118,7 @@ export class LoopXContinuationDriver { } const turnInstanceId = this.makeTurnInstanceId() + setStage('quota') const quota = await this.readQuota(command, state.agent, binding, turnInstanceId, { signal }) this.ensureReady(state, signal, session) if (quota.ok !== true) { @@ -1107,6 +1139,7 @@ export class LoopXContinuationDriver { if (quota.should_run !== true) { return schedulerWait(quota, state.schedulerToken, state.unchangedPolls) } + setStage('heartbeat') const heartbeat = await this.readHeartbeat( command, state.agent, @@ -1145,6 +1178,7 @@ export class LoopXContinuationDriver { signal: AbortSignal, ): Promise { if (!this.reservationIsCurrent(state, reservation, signal)) return false + const startedAt = performance.now() try { const command = await this.loopXCommand(signal) const bindingPayload = await this.resolveBinding(command, state.agent, signal) @@ -1170,7 +1204,7 @@ export class LoopXContinuationDriver { this.command = undefined } if (!(error instanceof LoopXCliError && error.kind === 'aborted')) { - this.warn(`dsh-loopx-driver: pre-step authority check failed: ${renderThrown(error)}`) + this.warn(`dsh-loopx-driver: admission_failed stage=pre-step-recheck ${cliErrorFields(error)} elapsed_ms=${Math.round(performance.now() - startedAt)}`) } return false } diff --git a/packages/dsh-loopx-plugin/tests/admission-closeout.integration.spec.ts b/packages/dsh-loopx-plugin/tests/admission-closeout.integration.spec.ts index 956e497921..ab4b392d64 100644 --- a/packages/dsh-loopx-plugin/tests/admission-closeout.integration.spec.ts +++ b/packages/dsh-loopx-plugin/tests/admission-closeout.integration.spec.ts @@ -554,7 +554,10 @@ describe('DSH admission-to-closeout integration', () => { expect(reachedNext).toBe(true) expect(admission).toEqual({ kind: 'reject' }) expect(guards).toBe(3) - expect(warnings).toEqual([]) + expect(warnings).toHaveLength(1) + expect(warnings[0]).toContain( + 'pre_step_rejected stage=pre-step-recheck reason=authority-after-next other_kinds=none', + ) expect(testFixture.queued).toHaveLength(0) } finally { await driver.dispose() @@ -624,9 +627,11 @@ describe('DSH admission-to-closeout integration', () => { if (scenario === 'digest mismatch' || scenario === 'inline digest mismatch') { expect(reads).toBe(1) expect(testFixture.queued).toHaveLength(0) - expect(warnings).toEqual([expect.stringContaining( - scenario === 'digest mismatch' ? 'invalid page' : 'selection digest did not match', - )]) + expect(warnings).toHaveLength(1) + expect(warnings[0]).toContain( + 'admission_failed stage=quota kind=invalid_schema cause_kind=invalid_schema', + ) + expect(warnings[0]).not.toMatch(/invalid page|selection digest did not match/u) } else if (scenario === 'held') { expect(reads).toBe(2) expect(testFixture.queued).toHaveLength(0) diff --git a/packages/dsh-loopx-plugin/tests/cli.spec.ts b/packages/dsh-loopx-plugin/tests/cli.spec.ts index cb8357a553..d6442da47c 100644 --- a/packages/dsh-loopx-plugin/tests/cli.spec.ts +++ b/packages/dsh-loopx-plugin/tests/cli.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { LoopXCliError, + cliErrorFields, resolveLoopXCommand, runFile, runJsonCommand, @@ -15,6 +16,77 @@ const command: LoopXCommand = { version: 'loopx 0.5.0', } +describe('safe command diagnostics', () => { + it('preserves the primary timeout and final exit among nine candidates', async () => { + let attempts = 0 + const failure = await resolveLoopXCommand({ + env: { LOOPX_BIN: '', PYTHON_BIN: undefined }, + managedLauncher: { + path: '/fixture/private-launcher.py', + pythonBins: Array.from({ length: 6 }, (_, index) => `fixture-python-${index}`), + }, + runner: (_file, _args, options) => { + attempts += 1 + // Use real child processes behind the candidate adapter on every platform. + return runFile(process.execPath, ['-e', attempts === 1 + ? 'setTimeout(() => {}, 10_000)' + : 'process.stderr.write("PRIVATE process output"); process.exitCode = 1'], { + ...options, timeoutMs: attempts === 1 ? 50 : 5_000, + }) + }, + }).catch(error => error) + expect(attempts).toBe(9) + expect(failure.diagnostics.probes).toHaveLength(9) + const record = cliErrorFields(failure) + const probes = record.split(' probes=')[1]!.split(' ')[0]!.split(';') + expect(probes).toHaveLength(8) + expect(probes[0]).toContain('kind=timeout,cause_kind=timeout') + expect(probes[0]).toContain('candidate=managed-launcher') + expect(probes.at(-1)).toContain('kind=exit,cause_kind=exit') + expect(probes.at(-1)).toContain('candidate=python-module') + expect(record).toContain('probes_omitted=1') + for (const privateValue of ['PRIVATE', 'fixture-python', 'private-launcher', process.cwd(), process.execPath, '--version']) { + expect(record).not.toContain(privateValue) + } + }) + + it('retains the primary probe failure as well as the final Windows fallback exit', async () => { + const failure = await resolveLoopXCommand({ + env: { LOOPX_BIN: '', PYTHON_BIN: '' }, + runner: async file => { + if (file === 'loopx') throw new LoopXCliError('timeout', 'PRIVATE output', true) + return { exitCode: 1, stdout: '', stderr: 'PRIVATE module diagnostic' } + }, + }).catch(error => error) + expect(failure).toMatchObject({ kind: 'missing', diagnostics: { + candidate: 'python-module', causeKind: 'exit', cwdExists: true, + } }) + const record = cliErrorFields(failure) + expect(record).toContain('kind=missing cause_kind=exit code=none exit_code=1 candidate=python-module cwd_exists=true') + expect(record).toContain('kind=timeout,cause_kind=timeout,code=none,exit_code=none,candidate=path-loopx') + expect(record).not.toContain('PRIVATE') + }) + + it('records a missing spawn cwd without exposing it', async () => { + const failure = await runJsonCommand({ + ...command, file: process.execPath, prefix: ['-e', 'process.stdout.write("{}")'], candidate: 'path-loopx', + }, [], { cwd: `${process.cwd()}/absent-diagnostic-cwd/child` }).catch(error => error) + expect(failure).toMatchObject({ kind: 'missing', diagnostics: { + osCode: 'ENOENT', candidate: 'path-loopx', cwdExists: false, + } }) + expect(cliErrorFields(failure)).toContain('code=ENOENT exit_code=none candidate=path-loopx cwd_exists=false') + expect(cliErrorFields(failure)).not.toContain('absent-diagnostic-cwd') + }) + + it('excludes paths, text and unrecognised labels from the record', () => { + const failure = new LoopXCliError('transport', 'PRIVATE command output', true, 7, { + osCode: 'PRIVATE/path', candidate: 'PRIVATE/path' as 'path-loopx', cwdExists: true, + }) + expect(cliErrorFields(failure)).toContain('code=none exit_code=7 candidate=unknown cwd_exists=true') + expect(cliErrorFields(failure)).not.toContain('PRIVATE') + }) +}) + describe('runJsonCommand', () => { it.each(['valid-version', 'nonzero-exit', 'rejected-probe'])( 'honours cancellation at the %s probe completion boundary', async outcome => { diff --git a/packages/dsh-loopx-plugin/tests/driver.spec.ts b/packages/dsh-loopx-plugin/tests/driver.spec.ts index c2c892a6bd..2fd7f8cfa6 100644 --- a/packages/dsh-loopx-plugin/tests/driver.spec.ts +++ b/packages/dsh-loopx-plugin/tests/driver.spec.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto' import { describe, expect, it, vi } from 'vitest' -import type { Context } from '@deepseek-ai/cordis' +import { Context, Logger, LoggerService } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionEvent, UserMessage } from '@deepseek-ai/dsh-session' @@ -466,6 +466,157 @@ function makeDriver(fixture: RunnerFixture): LoopXContinuationDriver { }) } +describe('driver diagnostic records', () => { + it.each(['before', 'downstream'])('restores user input with a throwing Cordis exporter in the %s batch', async phase => { + const fixture = runnerFixture() + const host = fakeAgent() + const service = new LoggerService(new Context()) + const exporter = { export: vi.fn(() => { throw new Error('exporter unavailable') }) } + const removeExporter = service.exporter(exporter) + const logger = new Logger({ name: 'fixture', level: 2 }, service) + const driver = new LoopXContinuationDriver({ + isLiveAgent: () => true, runner: fixture.runner, + warn: message => { logger.warn(message) }, + }) + try { + observeActivated(driver, host) + await waitFor(() => host.nextTurn.length === 1) + const submitted = host.nextTurn.shift()! + const other = userMessage('pending user input') + const next = vi.fn(async () => ({ kind: 'enter' as const, messages: [submitted, other] })) + host.setStatus('running') + driver.onInboxClaimed(host.agent, submitted) + await expect(driver.onPreStep(host.agent, phase === 'before' ? [submitted, other] : [submitted], + new AbortController().signal, next, + )).resolves.toEqual({ kind: 'reject' }) + expect(exporter.export).toHaveBeenCalledOnce() + expect(host.nextStep).toEqual([other]) + expect(next).toHaveBeenCalledTimes(phase === 'before' ? 0 : 1) + await expect(driver.onPreStep(host.agent, [submitted], new AbortController().signal, next)) + .resolves.toEqual({ kind: 'reject' }) + expect(host.nextStep).toEqual([other]) + expect(host.cancelCalls).toBe(0) + } finally { + removeExporter() + await driver.dispose() + } + }) + + it('records the maintenance signal independently of the Driver controller', async () => { + const host = fakeAgent() + const maintenance = new AbortController() + const warnings: string[] = [] + vi.spyOn(host.agent, 'runMaintenance').mockImplementation(task => task(maintenance.signal)) + const driver = new LoopXContinuationDriver({ + isLiveAgent: () => true, + resolveCommand: async () => { + maintenance.abort() + throw new LoopXCliError('missing', 'PRIVATE output', false) + }, + warn: message => warnings.push(message), + }) + try { + observeActivated(driver, host) + await waitFor(() => warnings.length > 0) + expect(warnings[0]).toContain('stage=resolve-command kind=missing') + expect(warnings[0]).toContain('signal_aborted=true') + } finally { await driver.dispose() } + }) + + it.each([ + ['resolve-command', '--version'], ['resolve-binding', 'resolve-agent-thread'], + ['quota', 'should-run'], ['heartbeat', 'heartbeat-prompt'], + ])('identifies %s failures without publishing command output', async (stage, arg) => { + const fixture = runnerFixture() + const host = fakeAgent() + const warnings: string[] = [] + const driver = new LoopXContinuationDriver({ + isLiveAgent: () => true, retryDelaysMs: [0, 0], + runner: async (file, args, options) => { + if (args.includes(arg)) throw new LoopXCliError('missing', 'PRIVATE command output', false, undefined, { osCode: 'ENOENT' }) + return fixture.runner(file, args, options) + }, + warn: message => warnings.push(message), + }) + try { + observeActivated(driver, host) + await waitFor(() => warnings.length > 0) + expect(warnings).toHaveLength(1) + expect(warnings[0]).toContain(`admission_failed stage=${stage} kind=missing`) + expect(warnings[0]).toMatch(/code=ENOENT .*candidate=(path-loopx|python-module) cwd_exists=(true|false).* elapsed_ms=\d+$/u) + expect(warnings.join('')).not.toContain('PRIVATE') + expect(host.nextTurn).toHaveLength(0) + } finally { await driver.dispose() } + }) + + it('distinguishes a typed maintenance-slot failure', async () => { + const host = fakeAgent() + const warnings: string[] = [] + vi.spyOn(host.agent, 'runMaintenance').mockImplementation(() => { throw new LoopXCliError('transport', 'PRIVATE', true) }) + const driver = new LoopXContinuationDriver({ isLiveAgent: () => true, warn: message => warnings.push(message) }) + try { + observeActivated(driver, host) + await waitFor(() => warnings.length > 0) + expect(warnings[0]).toContain('stage=maintenance-slot kind=transport') + expect(warnings.join('')).not.toContain('PRIVATE') + } finally { await driver.dispose() } + }) + + it.each(['before', 'downstream'])('records only competing source kinds in the %s batch', async phase => { + const fixture = runnerFixture() + const host = fakeAgent() + const warnings: string[] = [] + const driver = new LoopXContinuationDriver({ + isLiveAgent: () => true, runner: fixture.runner, retryDelaysMs: [0, 0], + warn: message => warnings.push(message), + }) + try { + observeActivated(driver, host) + await waitFor(() => host.nextTurn.length === 1) + const submitted = host.nextTurn.shift()! + const other = userMessage('PRIVATE message text') + host.setStatus('running') + driver.onInboxClaimed(host.agent, submitted) + expect(await driver.onPreStep(host.agent, phase === 'before' ? [submitted, other] : [submitted], + new AbortController().signal, async () => ({ kind: 'enter', messages: [submitted, other] }), + )).toEqual({ kind: 'reject' }) + expect(warnings[0]).toContain(`reason=${phase === 'before' ? 'competing-messages' : 'downstream-batch'} other_kinds=user`) + expect(warnings.join('')).not.toContain('PRIVATE') + } finally { await driver.dispose() } + }) +}) + +describe('pre-step authority diagnostics', () => { + it('records a failed recheck before rejecting its continuation', async () => { + const fixture = runnerFixture() + const host = fakeAgent() + const warnings: string[] = [] + let failRecheck = false + const driver = new LoopXContinuationDriver({ + isLiveAgent: () => true, retryDelaysMs: [0, 0], + runner: (file, args, options) => { + if (failRecheck) throw new LoopXCliError('missing', 'PRIVATE path', false, undefined, { osCode: 'ENOENT' }) + return fixture.runner(file, args, options) + }, + warn: message => warnings.push(message), + }) + try { + observeActivated(driver, host) + await waitFor(() => host.nextTurn.length === 1) + const submitted = host.nextTurn.shift()! + host.setStatus('running') + driver.onInboxClaimed(host.agent, submitted) + failRecheck = true + expect(await driver.onPreStep(host.agent, [submitted], new AbortController().signal, + async () => ({ kind: 'enter', messages: [submitted] }), + )).toEqual({ kind: 'reject' }) + expect(warnings[0]).toContain('admission_failed stage=pre-step-recheck kind=missing') + expect(warnings[1]).toContain('reason=authority-before-next other_kinds=none') + expect(warnings.join('')).not.toContain('PRIVATE') + } finally { await driver.dispose() } + }) +}) + describe('same-session LoopX driver', () => { it('does not report a cancelled maintenance probe as a missing CLI', async () => { const host = fakeAgent()