fix: keep streamed output delivery non-blocking (#186)
This commit is contained in:
@@ -13,10 +13,10 @@
|
|||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"apps/dashboard/src/App.tsx": {
|
"apps/dashboard/src/App.tsx": {
|
||||||
"maxLines": 4612,
|
"maxLines": 684,
|
||||||
"maxFunctionLines": 583,
|
"maxFunctionLines": 425,
|
||||||
"maxComplexity": 79,
|
"maxComplexity": 11,
|
||||||
"maxNesting": 4,
|
"maxNesting": 3,
|
||||||
"owner": "dashboard shell hotspot"
|
"owner": "dashboard shell hotspot"
|
||||||
},
|
},
|
||||||
"apps/dashboard/src/i18n.ts": {
|
"apps/dashboard/src/i18n.ts": {
|
||||||
|
|||||||
@@ -53,10 +53,3 @@ export function hasAgentOutputPayload(output: {
|
|||||||
}
|
}
|
||||||
return output.result !== null && output.result !== undefined;
|
return output.result !== null && output.result !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isSilentAgentOutput(_output: {
|
|
||||||
output?: StructuredAgentOutput;
|
|
||||||
result?: string | object | null;
|
|
||||||
}): boolean {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|||||||
99
src/agent-runner-process.test.ts
Normal file
99
src/agent-runner-process.test.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import type { ChildProcess } from 'child_process';
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
import { PassThrough } from 'stream';
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { OUTPUT_END_MARKER, OUTPUT_START_MARKER } from './agent-protocol.js';
|
||||||
|
import { runSpawnedAgentProcess } from './agent-runner-process.js';
|
||||||
|
import type { AgentInput, AgentOutput } from './agent-runner.js';
|
||||||
|
import type { RegisteredGroup } from './types.js';
|
||||||
|
|
||||||
|
function buildProcess(): ChildProcess {
|
||||||
|
const proc = new EventEmitter() as ChildProcess & {
|
||||||
|
stdout: PassThrough;
|
||||||
|
stderr: PassThrough;
|
||||||
|
kill: ReturnType<typeof vi.fn>;
|
||||||
|
killed: boolean;
|
||||||
|
};
|
||||||
|
proc.stdout = new PassThrough();
|
||||||
|
proc.stderr = new PassThrough();
|
||||||
|
proc.killed = false;
|
||||||
|
proc.kill = vi.fn(() => {
|
||||||
|
proc.killed = true;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return proc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timed<T>(promise: Promise<T>): Promise<T> {
|
||||||
|
return Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('agent process did not resolve')), 100),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const group: RegisteredGroup = {
|
||||||
|
name: 'Agent Runner Test',
|
||||||
|
folder: 'agent-runner-test',
|
||||||
|
trigger: '@Agent',
|
||||||
|
added_at: '2026-01-01T00:00:00.000Z',
|
||||||
|
agentType: 'codex',
|
||||||
|
};
|
||||||
|
|
||||||
|
const input: AgentInput = {
|
||||||
|
prompt: 'run',
|
||||||
|
groupFolder: 'agent-runner-test',
|
||||||
|
chatJid: 'dc:test',
|
||||||
|
runId: 'run-1',
|
||||||
|
isMain: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('runSpawnedAgentProcess', () => {
|
||||||
|
let logsDir: string | null = null;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (logsDir) {
|
||||||
|
fs.rmSync(logsDir, { recursive: true, force: true });
|
||||||
|
logsDir = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves even when streamed output delivery rejects', async () => {
|
||||||
|
logsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-agent-runner-'));
|
||||||
|
const proc = buildProcess();
|
||||||
|
const onOutput = vi.fn<(_: AgentOutput) => Promise<void>>(async () => {
|
||||||
|
throw new Error('delivery failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
const resultPromise = runSpawnedAgentProcess({
|
||||||
|
proc,
|
||||||
|
group,
|
||||||
|
input,
|
||||||
|
processName: 'test-agent',
|
||||||
|
logsDir,
|
||||||
|
startTime: Date.now(),
|
||||||
|
onOutput,
|
||||||
|
});
|
||||||
|
|
||||||
|
(proc.stdout as PassThrough).write(
|
||||||
|
[
|
||||||
|
OUTPUT_START_MARKER,
|
||||||
|
JSON.stringify({ status: 'success', result: 'hello' }),
|
||||||
|
OUTPUT_END_MARKER,
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
proc.emit('close', 0, null);
|
||||||
|
|
||||||
|
await expect(timed(resultPromise)).resolves.toMatchObject({
|
||||||
|
status: 'success',
|
||||||
|
result: null,
|
||||||
|
});
|
||||||
|
expect(onOutput).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -39,6 +39,50 @@ function parseLegacyAgentOutput(stdout: string): AgentOutput {
|
|||||||
return JSON.parse(jsonLine) as AgentOutput;
|
return JSON.parse(jsonLine) as AgentOutput;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function logStreamedOutputDeliveryError(
|
||||||
|
err: unknown,
|
||||||
|
group: RegisteredGroup,
|
||||||
|
input: AgentInput,
|
||||||
|
): void {
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
group: group.name,
|
||||||
|
chatJid: input.chatJid,
|
||||||
|
runId: input.runId,
|
||||||
|
error: getErrorMessage(err),
|
||||||
|
},
|
||||||
|
'Streamed agent output delivery failed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeTimeoutLog(args: {
|
||||||
|
logsDir: string;
|
||||||
|
input: AgentInput;
|
||||||
|
group: RegisteredGroup;
|
||||||
|
processName: string;
|
||||||
|
duration: number;
|
||||||
|
code: number | null;
|
||||||
|
signal: NodeJS.Signals | null;
|
||||||
|
hadStreamingOutput: boolean;
|
||||||
|
}): void {
|
||||||
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(args.logsDir, `agent-${args.input.runId || 'adhoc'}-${ts}.log`),
|
||||||
|
[
|
||||||
|
`=== Agent Run Log (TIMEOUT) ===`,
|
||||||
|
`Timestamp: ${new Date().toISOString()}`,
|
||||||
|
`Group: ${args.group.name}`,
|
||||||
|
`ChatJid: ${args.input.chatJid}`,
|
||||||
|
`RunId: ${args.input.runId || 'n/a'}`,
|
||||||
|
`Process: ${args.processName}`,
|
||||||
|
`Duration: ${args.duration}ms`,
|
||||||
|
`Exit Code: ${args.code}`,
|
||||||
|
`Signal: ${args.signal}`,
|
||||||
|
`Had Streaming Output: ${args.hadStreamingOutput}`,
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function runSpawnedAgentProcess(
|
export function runSpawnedAgentProcess(
|
||||||
args: RunSpawnedAgentProcessArgs,
|
args: RunSpawnedAgentProcessArgs,
|
||||||
): Promise<AgentOutput> {
|
): Promise<AgentOutput> {
|
||||||
@@ -151,7 +195,9 @@ export function runSpawnedAgentProcess(
|
|||||||
'Streamed agent error output',
|
'Streamed agent error output',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
outputChain = outputChain.then(() => onOutput(parsed));
|
outputChain = outputChain
|
||||||
|
.then(() => onOutput(parsed))
|
||||||
|
.catch((err) => logStreamedOutputDeliveryError(err, group, input));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{
|
{
|
||||||
@@ -210,22 +256,16 @@ export function runSpawnedAgentProcess(
|
|||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
if (timedOut) {
|
if (timedOut) {
|
||||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
writeTimeoutLog({
|
||||||
fs.writeFileSync(
|
logsDir,
|
||||||
path.join(logsDir, `agent-${input.runId || 'adhoc'}-${ts}.log`),
|
input,
|
||||||
[
|
group,
|
||||||
`=== Agent Run Log (TIMEOUT) ===`,
|
processName,
|
||||||
`Timestamp: ${new Date().toISOString()}`,
|
duration,
|
||||||
`Group: ${group.name}`,
|
code,
|
||||||
`ChatJid: ${input.chatJid}`,
|
signal,
|
||||||
`RunId: ${input.runId || 'n/a'}`,
|
hadStreamingOutput,
|
||||||
`Process: ${processName}`,
|
});
|
||||||
`Duration: ${duration}ms`,
|
|
||||||
`Exit Code: ${code}`,
|
|
||||||
`Signal: ${signal}`,
|
|
||||||
`Had Streaming Output: ${hadStreamingOutput}`,
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hadStreamingOutput) {
|
if (hadStreamingOutput) {
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import {
|
import { getAgentOutputText, hasAgentOutputPayload } from './agent-output.js';
|
||||||
getAgentOutputText,
|
|
||||||
hasAgentOutputPayload,
|
|
||||||
isSilentAgentOutput,
|
|
||||||
} from './agent-output.js';
|
|
||||||
import {
|
import {
|
||||||
classifyClaudeAuthError,
|
classifyClaudeAuthError,
|
||||||
classifyRotationTrigger,
|
classifyRotationTrigger,
|
||||||
@@ -62,7 +58,6 @@ export function evaluateStreamedOutput(
|
|||||||
const countsAsFinalOutput =
|
const countsAsFinalOutput =
|
||||||
output.phase === undefined || output.phase === 'final';
|
output.phase === undefined || output.phase === 'final';
|
||||||
const outputText = getAgentOutputText(output);
|
const outputText = getAgentOutputText(output);
|
||||||
const silentOutput = isSilentAgentOutput(output);
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isPrimaryClaude &&
|
isPrimaryClaude &&
|
||||||
@@ -139,8 +134,7 @@ export function evaluateStreamedOutput(
|
|||||||
options.trackSuccessNullResult &&
|
options.trackSuccessNullResult &&
|
||||||
isPrimaryClaude &&
|
isPrimaryClaude &&
|
||||||
output.status === 'success' &&
|
output.status === 'success' &&
|
||||||
!state.sawOutput &&
|
!state.sawOutput
|
||||||
!silentOutput
|
|
||||||
) {
|
) {
|
||||||
nextState.sawSuccessNullResultWithoutOutput = true;
|
nextState.sawSuccessNullResultWithoutOutput = true;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user