mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
✨ feat(hetero): add --raw-dump to persist agent raw stream-json for debugging (#15602)
* ✨ feat(hetero): add --raw-dump to persist agent raw stream-json for debugging The remote-device path (`spawnLhHeteroExec`) leaves no local execution record: `lh hetero exec` consumes the agent's stdout internally and only POSTs adapted events to the server, so a misbehaving remote run can't be inspected. The adapted/ingested view also can't distinguish a CC-side empty `tool_result` from an adapter extraction bug. Add `lh hetero exec --raw-dump <dir>`: spawnAgent gains an `onRawStdout` tee that captures the child's untouched stdout BEFORE the adapter; the CLI writes it (plus stderr + a meta.json) to `<dir>/<timestamp>-<operationId>/`, one file pair per spawn attempt. Fully best-effort — a dump failure never affects the run or exit code. Wire the desktop device path to pass `--raw-dump` (gated by the existing `shouldTraceCliOutput` toggle, into `resolveTraceRootDir`), so remote-device CC runs now leave a raw stream on the device — the same toggle/location the local trace path already uses. Reusable later for the server sandbox path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * 🔖 chore(cli): bump version to 0.0.27 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
|
||||
.\" Manual command details come from the Commander command tree.
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.26" "User Commands"
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.27" "User Commands"
|
||||
.SH NAME
|
||||
lh \- LobeHub CLI \- manage and connect to LobeHub services
|
||||
.SH SYNOPSIS
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lobehub/cli",
|
||||
"version": "0.0.26",
|
||||
"version": "0.0.27",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"lh": "./dist/index.js",
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { mkdtemp, readdir, readFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
import { Command } from 'commander';
|
||||
@@ -716,4 +719,56 @@ describe('hetero exec command', () => {
|
||||
// Second snapshot carries ONLY the second message — not "first messagesecond message".
|
||||
expect(textSnapshots).toEqual(['first message', 'second message']);
|
||||
});
|
||||
|
||||
it('--raw-dump writes a session folder with meta.json, wires onRawStdout, and tees stderr', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'hetero-rawdump-'));
|
||||
|
||||
mockSpawnAgent.mockReturnValue(
|
||||
createFakeHandle({
|
||||
events: [
|
||||
{
|
||||
data: { chunkType: 'text', content: 'hi' },
|
||||
operationId: 'op-raw',
|
||||
stepIndex: 0,
|
||||
timestamp: 1,
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
],
|
||||
exitCode: 0,
|
||||
stderrChunks: ['warning: something happened\n'],
|
||||
}),
|
||||
);
|
||||
|
||||
await runCmd([
|
||||
'hetero',
|
||||
'exec',
|
||||
'--type',
|
||||
'claude-code',
|
||||
'--prompt',
|
||||
'hi',
|
||||
'--operation-id',
|
||||
'op-raw',
|
||||
'--render',
|
||||
'none',
|
||||
'--raw-dump',
|
||||
root,
|
||||
]);
|
||||
|
||||
// The raw stdout tee is handed to spawnAgent (the package captures the
|
||||
// pre-adapter bytes — exercised in spawnAgent.test.ts).
|
||||
expect(typeof mockSpawnAgent.mock.calls[0][0].onRawStdout).toBe('function');
|
||||
|
||||
// One session folder per exec, keyed by the operation id.
|
||||
const sessions = await readdir(root);
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0]).toContain('op-raw');
|
||||
const sessionDir = path.join(root, sessions[0]!);
|
||||
|
||||
const meta = JSON.parse(await readFile(path.join(sessionDir, 'meta.json'), 'utf8'));
|
||||
expect(meta).toMatchObject({ agentType: 'claude-code', operationId: 'op-raw' });
|
||||
|
||||
// stderr is teed to the attempt's log file.
|
||||
const stderrDump = await readFile(path.join(sessionDir, 'attempt-1.stderr.log'), 'utf8');
|
||||
expect(stderrDump).toContain('warning: something happened');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { once } from 'node:events';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import type {
|
||||
@@ -59,6 +60,12 @@ interface ExecOptions {
|
||||
inputJson?: string;
|
||||
operationId?: string;
|
||||
prompt?: string;
|
||||
/**
|
||||
* When set, persist the agent process's RAW stdout/stderr (pre-adapter
|
||||
* stream-json) under `<rawDump>/<timestamp>-<operationId>/` for debugging.
|
||||
* Independent of `--render` and the server ingest path.
|
||||
*/
|
||||
rawDump?: string;
|
||||
/**
|
||||
* Output rendering mode.
|
||||
* jsonl — emit each `AgentStreamEvent` as a JSONL line on stdout (default
|
||||
@@ -291,6 +298,77 @@ class SerialServerIngester {
|
||||
}
|
||||
}
|
||||
|
||||
interface RawStreamDumpAttempt {
|
||||
/** Flush + close both file streams. Resolves once the bytes are on disk. */
|
||||
close: () => Promise<void>;
|
||||
writeStderr: (chunk: Buffer) => void;
|
||||
writeStdout: (chunk: Buffer) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the agent process's RAW stdout/stderr — the untouched stream-json,
|
||||
* BEFORE the adapter — to disk for post-hoc debugging. The adapted/ingested
|
||||
* view can't tell a CC-side empty `tool_result` apart from an adapter
|
||||
* extraction bug; the raw dump can.
|
||||
*
|
||||
* Enabled via `lh hetero exec --raw-dump <dir>`. Each exec gets its own
|
||||
* `<dir>/<timestamp>-<operationId>/` session folder; each spawn attempt (the
|
||||
* resume retry is a second attempt) writes `<label>.stdout.jsonl` /
|
||||
* `<label>.stderr.log`. Fully best-effort: any dump failure is logged and
|
||||
* swallowed so it never affects the run or its exit code.
|
||||
*
|
||||
* Future: the server-side sandbox runner (`spawnHeteroSandbox`) and the
|
||||
* desktop device path (`spawnLhHeteroExec`) can pass `--raw-dump` pointing at
|
||||
* a collectable location to capture remote runs the same way.
|
||||
*/
|
||||
class RawStreamDump {
|
||||
private constructor(private readonly dir: string) {}
|
||||
|
||||
static async create(
|
||||
root: string,
|
||||
operationId: string,
|
||||
meta: Record<string, unknown>,
|
||||
): Promise<RawStreamDump | undefined> {
|
||||
try {
|
||||
const safeTs = new Date().toISOString().replaceAll(/[.:]/g, '-');
|
||||
const dir = path.join(path.resolve(root), `${safeTs}-${operationId}`);
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(dir, 'meta.json'),
|
||||
`${JSON.stringify({ ...meta, operationId, startedAt: new Date().toISOString() }, null, 2)}\n`,
|
||||
);
|
||||
log.info(`Raw stream dump enabled → ${dir}`);
|
||||
return new RawStreamDump(dir);
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`Failed to initialize raw stream dump: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
openAttempt(label: string): RawStreamDumpAttempt {
|
||||
const stdout = createWriteStream(path.join(this.dir, `${label}.stdout.jsonl`));
|
||||
const stderr = createWriteStream(path.join(this.dir, `${label}.stderr.log`));
|
||||
// A failed dump write must never crash the run — drop write errors.
|
||||
stdout.on('error', () => {});
|
||||
stderr.on('error', () => {});
|
||||
return {
|
||||
close: () =>
|
||||
Promise.all([
|
||||
new Promise<void>((resolve) => stdout.end(() => resolve())),
|
||||
new Promise<void>((resolve) => stderr.end(() => resolve())),
|
||||
]).then(() => undefined),
|
||||
writeStderr: (chunk: Buffer) => {
|
||||
stderr.write(chunk);
|
||||
},
|
||||
writeStdout: (chunk: Buffer) => {
|
||||
stdout.write(chunk);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const exec = async (options: ExecOptions): Promise<void> => {
|
||||
if (!SUPPORTED_AGENT_TYPES.has(options.type)) {
|
||||
log.error(
|
||||
@@ -325,6 +403,17 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
|
||||
const operationId = options.operationId || randomUUID();
|
||||
|
||||
// Optional raw stream dump (pre-adapter stdout/stderr) for debugging.
|
||||
let rawDump: RawStreamDump | undefined;
|
||||
if (options.rawDump) {
|
||||
rawDump = await RawStreamDump.create(options.rawDump, operationId, {
|
||||
agentType: options.type,
|
||||
cwd: options.cwd || process.cwd(),
|
||||
resume: options.resume ?? null,
|
||||
topicId: options.topic ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Determine JSONL output mode.
|
||||
// Explicit --render flag always wins. Otherwise: emit JSONL in standalone
|
||||
// mode; suppress in server-ingest mode (sink handles the data path).
|
||||
@@ -368,6 +457,7 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
const runOneAgent = async (
|
||||
spawnOpts: Parameters<typeof spawnAgent>[0],
|
||||
interceptResumeErrors: boolean,
|
||||
runLabel: string,
|
||||
): Promise<{
|
||||
code: number | null;
|
||||
ingestError: boolean;
|
||||
@@ -376,12 +466,17 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
signal: NodeJS.Signals | null;
|
||||
stderrContent: string;
|
||||
}> => {
|
||||
// One raw-dump file pair per spawn attempt (the resume retry is a second
|
||||
// attempt). The stdout tee runs inside `spawnAgent` before the adapter.
|
||||
const dumpAttempt = rawDump?.openAttempt(runLabel);
|
||||
|
||||
// `spawnAgent` is async and can reject DURING image normalization — fetch
|
||||
// failures, missing local --image paths, decode errors.
|
||||
let handle: Awaited<ReturnType<typeof spawnAgent>>;
|
||||
try {
|
||||
handle = await spawnAgent(spawnOpts);
|
||||
handle = await spawnAgent({ ...spawnOpts, onRawStdout: dumpAttempt?.writeStdout });
|
||||
} catch (err) {
|
||||
await dumpAttempt?.close();
|
||||
log.error('Failed to start agent:', err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -398,6 +493,7 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
if (stderrContent.length < STDERR_CAP) {
|
||||
stderrContent += chunk.toString();
|
||||
}
|
||||
dumpAttempt?.writeStderr(chunk);
|
||||
});
|
||||
handle.stderr.pipe(process.stderr);
|
||||
|
||||
@@ -471,6 +567,7 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
await dumpAttempt?.close();
|
||||
process.exit(1);
|
||||
} finally {
|
||||
process.off('SIGINT', onSigint);
|
||||
@@ -479,6 +576,7 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
|
||||
const { code, signal } = await handle.exit;
|
||||
await stderrEnded;
|
||||
await dumpAttempt?.close();
|
||||
|
||||
// Fallback stderr detection: CC may exit non-zero without emitting a
|
||||
// result event (e.g. it writes to stderr and quits immediately).
|
||||
@@ -514,6 +612,7 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
resumeSessionId: options.resume,
|
||||
},
|
||||
interceptResume,
|
||||
'attempt-1',
|
||||
);
|
||||
|
||||
// ─── Auto-retry without --resume when the session cannot be used ─────────
|
||||
@@ -542,6 +641,7 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
// No resumeSessionId — start fresh
|
||||
},
|
||||
false, // no need to intercept resume errors on a fresh run
|
||||
'attempt-2-noresume',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -629,5 +729,9 @@ export function registerHeteroCommand(program: Command) {
|
||||
'--render <mode>',
|
||||
'Output mode: jsonl (emit events as JSONL on stdout) | none (suppress stdout). Defaults to jsonl in standalone, none in server-ingest mode.',
|
||||
)
|
||||
.option(
|
||||
'--raw-dump <dir>',
|
||||
'Persist the agent process RAW stdout/stderr (pre-adapter stream-json) under <dir>/<timestamp>-<operationId>/ for debugging. Each spawn attempt writes its own .stdout.jsonl / .stderr.log. Best-effort; never affects the run.',
|
||||
)
|
||||
.action(exec);
|
||||
}
|
||||
|
||||
@@ -1338,6 +1338,14 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
} = params;
|
||||
const workDir = cwd ?? process.cwd();
|
||||
|
||||
// When CLI tracing is enabled (dev builds, or the Help-menu toggle in
|
||||
// packaged builds), have `lh hetero exec` persist the agent process's RAW
|
||||
// stream-json (pre-adapter) on this device. The remote-device path
|
||||
// otherwise leaves no local record — the CLI consumes stdout internally and
|
||||
// only POSTs adapted events to the server — so without this there's nothing
|
||||
// to inspect when a remote run misbehaves.
|
||||
const rawDumpDir = this.shouldTraceCliOutput ? this.resolveTraceRootDir(workDir) : undefined;
|
||||
|
||||
const args = [
|
||||
'hetero',
|
||||
'exec',
|
||||
@@ -1354,6 +1362,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
'--cwd',
|
||||
workDir,
|
||||
...(resumeSessionId ? ['--resume', resumeSessionId] : []),
|
||||
...(rawDumpDir ? ['--raw-dump', rawDumpDir] : []),
|
||||
];
|
||||
|
||||
const env = {
|
||||
|
||||
Reference in New Issue
Block a user