docs/superpowers/plans/2026-05-26-daemon-logger.md
qwen serve Daemon File Logger — Implementation PlanFor agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a daemon-scoped file logger to qwen serve so route errors, lifecycle messages, and ACP child stderr land in ~/.qwen/debug/daemon/<id>.log in addition to stderr — eliminating the manual 2>serve.log workaround for issue #4548.
Architecture: New cli-local module daemonLogger.ts exposes initDaemonLogger(opts) → DaemonLogger. info/warn/error tee to file + stderr; raw is file-only. acp-bridge gets a new optional BridgeOptions.onDiagnosticLine callback and createSpawnChannelFactory({ onDiagnosticLine }) helper so the cli can route writeServeDebugLine and ACP child stderr lines into the daemon log without acp-bridge taking a cli dependency. No global singleton — logger is constructed per runQwenServe invocation.
Tech Stack: TypeScript, Vitest, Node fs.promises, existing Storage.getGlobalDebugDir(), existing updateSymlink helper.
Reference spec: docs/superpowers/specs/2026-05-26-daemon-logger-design.md
Test harness: vitest run from each package; for a single file: cd packages/<pkg> && npx vitest run <relative-path>.
| File | Action | Purpose |
|---|---|---|
packages/cli/src/serve/daemonLogger.ts | new | Logger sink + format helper |
packages/cli/src/serve/daemonLogger.test.ts | new | Unit tests for the above |
packages/acp-bridge/src/bridgeOptions.ts | modify | Add onDiagnosticLine? field + DiagnosticLineSink type |
packages/acp-bridge/src/bridge.ts | modify | Tee writeServeDebugLine through opts.onDiagnosticLine (via local teeServeDebugLine closure) |
packages/acp-bridge/src/bridge.test.ts | modify | Add test that onDiagnosticLine receives debug lines |
packages/acp-bridge/src/spawnChannel.ts | modify | Export createSpawnChannelFactory({ onDiagnosticLine }); tee child stderr into callback |
packages/acp-bridge/src/spawnChannel.test.ts | modify (or new) | Test stderr forwarding callback |
packages/cli/src/serve/server.ts | modify | createServeApp deps accept optional daemonLog; sendBridgeError routes through it when provided |
packages/cli/src/serve/server.test.ts | modify | Verify daemonLog receives route-error entries |
packages/cli/src/serve/runQwenServe.ts | modify | Init logger, boot banner, wire spawn factory + bridge callback, replace lifecycle writeStderrLine calls, flush on shutdown |
packages/cli/src/serve/runQwenServe.test.ts | modify | Verify boot banner + flush behavior |
docs/cli/serve.md (or equivalent) | modify | Document daemon log path + opt-out |
Run: git rev-parse --abbrev-ref HEAD && pwd
Expected: branch feat/support_daemon_logger, cwd ends with .claude/worktrees/feat-support-daemon-logger.
Run: npm install && cd packages/cli && npx vitest run src/serve/runQwenServe.test.ts && cd ../acp-bridge && npx vitest run
Expected: all pass. (If not, baseline is broken — stop and report.)
Read docs/superpowers/specs/2026-05-26-daemon-logger-design.md end-to-end. Key sections to internalize: §3 (modules), §4 (path), §5 (API), §6 (format + tee semantics), §7 (boot/shutdown), §11 (error handling).
buildDaemonLogLine pure helperPure formatter. No I/O. Easy to TDD.
Files:
Create: packages/cli/src/serve/daemonLogger.ts
Create: packages/cli/src/serve/daemonLogger.test.ts
Step 1: Write the failing tests
packages/cli/src/serve/daemonLogger.test.ts:
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { buildDaemonLogLine } from './daemonLogger.js';
describe('buildDaemonLogLine', () => {
const FIXED = new Date('2026-05-26T03:14:15.926Z');
it('formats INFO with no ctx', () => {
expect(
buildDaemonLogLine({
level: 'INFO',
message: 'daemon started',
now: FIXED,
}),
).toBe('2026-05-26T03:14:15.926Z [INFO] [DAEMON] daemon started\n');
});
it('renders ctx fields in fixed order', () => {
const line = buildDaemonLogLine({
level: 'ERROR',
message: 'route failed',
now: FIXED,
ctx: {
sessionId: 'sess-1',
route: 'POST /session/:id/prompt',
clientId: 'client-x',
childPid: 4242,
channelId: 'ch-9',
},
});
expect(line).toBe(
'2026-05-26T03:14:15.926Z [ERROR] [DAEMON] ' +
'route=POST /session/:id/prompt sessionId=sess-1 clientId=client-x ' +
'childPid=4242 channelId=ch-9 route failed\n',
);
});
it('appends extra ctx keys sorted lexicographically after fixed keys', () => {
const line = buildDaemonLogLine({
level: 'WARN',
message: 'note',
now: FIXED,
ctx: { zeta: 1, alpha: 'a', sessionId: 's' },
});
expect(line).toBe(
'2026-05-26T03:14:15.926Z [WARN] [DAEMON] sessionId=s alpha=a zeta=1 note\n',
);
});
it('JSON.stringify-quotes values that contain spaces or =', () => {
const line = buildDaemonLogLine({
level: 'INFO',
message: 'hi',
now: FIXED,
ctx: { weird: 'has space', eq: 'a=b' },
});
expect(line).toBe(
'2026-05-26T03:14:15.926Z [INFO] [DAEMON] eq="a=b" weird="has space" hi\n',
);
});
it('appends error stack as indented continuation lines', () => {
const err = new Error('boom');
err.stack =
'Error: boom\n at fn (file.ts:1:1)\n at main (file.ts:2:2)';
const line = buildDaemonLogLine({
level: 'ERROR',
message: 'failed',
now: FIXED,
err,
});
expect(line).toBe(
'2026-05-26T03:14:15.926Z [ERROR] [DAEMON] failed\n' +
' Error: boom\n' +
' at fn (file.ts:1:1)\n' +
' at main (file.ts:2:2)\n',
);
});
it('falls back to err.message when stack missing', () => {
const err: Error = { name: 'Plain', message: 'no stack' } as Error;
const line = buildDaemonLogLine({
level: 'ERROR',
message: 'failed',
now: FIXED,
err,
});
expect(line).toBe(
'2026-05-26T03:14:15.926Z [ERROR] [DAEMON] failed\n' +
' Plain: no stack\n',
);
});
});
Run: cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts
Expected: failure — buildDaemonLogLine not exported.
buildDaemonLogLineCreate packages/cli/src/serve/daemonLogger.ts with:
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export type DaemonLogLevel = 'INFO' | 'WARN' | 'ERROR';
export interface DaemonLogContext {
route?: string;
sessionId?: string;
clientId?: string;
childPid?: number;
channelId?: string;
[key: string]: unknown;
}
const FIXED_CTX_ORDER = [
'route',
'sessionId',
'clientId',
'childPid',
'channelId',
] as const;
function renderCtxValue(value: unknown): string {
const s = String(value);
return /[\s=]/.test(s) ? JSON.stringify(s) : s;
}
function renderCtx(ctx: DaemonLogContext | undefined): string {
if (!ctx) return '';
const parts: string[] = [];
for (const key of FIXED_CTX_ORDER) {
const v = ctx[key];
if (v !== undefined && v !== null) {
parts.push(`${key}=${renderCtxValue(v)}`);
}
}
const fixedSet = new Set<string>(FIXED_CTX_ORDER);
const extraKeys = Object.keys(ctx)
.filter((k) => !fixedSet.has(k) && ctx[k] !== undefined && ctx[k] !== null)
.sort();
for (const key of extraKeys) {
parts.push(`${key}=${renderCtxValue(ctx[key])}`);
}
return parts.length > 0 ? parts.join(' ') + ' ' : '';
}
function renderErr(err: Error | undefined): string {
if (!err) return '';
const body = err.stack ?? `${err.name ?? 'Error'}: ${err.message}`;
return (
body
.split('\n')
.map((l) => ` ${l}`)
.join('\n') + '\n'
);
}
export interface BuildDaemonLogLineArgs {
level: DaemonLogLevel;
message: string;
now: Date;
ctx?: DaemonLogContext;
err?: Error;
}
export function buildDaemonLogLine(args: BuildDaemonLogLineArgs): string {
const ts = args.now.toISOString();
const ctxStr = renderCtx(args.ctx);
return `${ts} [${args.level}] [DAEMON] ${ctxStr}${args.message}\n${renderErr(args.err)}`;
}
Run: cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts
Expected: PASS (6 specs).
git add packages/cli/src/serve/daemonLogger.ts packages/cli/src/serve/daemonLogger.test.ts
git commit -m "feat(serve): buildDaemonLogLine formatter (#4548)"
initDaemonLogger opt-out + no-op factoryReturns a no-op logger when QWEN_DAEMON_LOG_FILE is disabled. No filesystem touch yet.
Files:
Modify: packages/cli/src/serve/daemonLogger.ts
Modify: packages/cli/src/serve/daemonLogger.test.ts
Step 1: Add failing tests
Append to daemonLogger.test.ts:
import { initDaemonLogger } from './daemonLogger.js';
import { afterEach, beforeEach } from 'vitest';
describe('initDaemonLogger opt-out', () => {
const originalEnv = process.env['QWEN_DAEMON_LOG_FILE'];
afterEach(() => {
if (originalEnv === undefined) delete process.env['QWEN_DAEMON_LOG_FILE'];
else process.env['QWEN_DAEMON_LOG_FILE'] = originalEnv;
});
for (const val of ['0', 'false', 'off', 'no', 'False', ' OFF ']) {
it(`returns no-op logger when QWEN_DAEMON_LOG_FILE=${JSON.stringify(val)}`, () => {
process.env['QWEN_DAEMON_LOG_FILE'] = val;
const stderr: string[] = [];
const logger = initDaemonLogger({
boundWorkspace: '/tmp/ws',
baseDir: '/tmp/nonexistent-should-not-touch',
stderr: (s) => stderr.push(s),
});
logger.info('hello');
logger.warn('there');
logger.error('boom');
logger.raw('raw');
expect(stderr).toEqual([]); // no-op = nothing
expect(logger.getLogPath()).toBe('');
expect(logger.getDaemonId()).toBe('');
});
}
});
Run: cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts
Expected: failure — initDaemonLogger not exported.
Append to daemonLogger.ts:
export interface DaemonLogger {
info(message: string, ctx?: DaemonLogContext): void;
warn(message: string, ctx?: DaemonLogContext): void;
error(message: string, err?: Error | null, ctx?: DaemonLogContext): void;
raw(line: string, level?: 'info' | 'warn' | 'error'): void;
getLogPath(): string;
getDaemonId(): string;
flush(): Promise<void>;
}
export interface InitDaemonLoggerOptions {
boundWorkspace: string;
pid?: number;
now?: () => Date;
stderr?: (line: string) => void;
baseDir?: string;
}
const NOOP_LOGGER: DaemonLogger = {
info: () => {},
warn: () => {},
error: () => {},
raw: () => {},
getLogPath: () => '',
getDaemonId: () => '',
flush: () => Promise.resolve(),
};
function isOptedOut(): boolean {
const raw = process.env['QWEN_DAEMON_LOG_FILE'];
if (!raw) return false;
return ['0', 'false', 'off', 'no'].includes(raw.trim().toLowerCase());
}
export function initDaemonLogger(_opts: InitDaemonLoggerOptions): DaemonLogger {
if (isOptedOut()) return NOOP_LOGGER;
throw new Error('initDaemonLogger: file path not implemented yet');
}
Run: cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts -t "opt-out"
Expected: opt-out specs PASS; full file may still fail (we'll add coverage incrementally).
git add packages/cli/src/serve/daemonLogger.ts packages/cli/src/serve/daemonLogger.test.ts
git commit -m "feat(serve): daemon logger opt-out env + no-op shape (#4548)"
Files:
Modify: packages/cli/src/serve/daemonLogger.ts
Modify: packages/cli/src/serve/daemonLogger.test.ts
Step 1: Add failing tests
Append to daemonLogger.test.ts:
import * as os from 'node:os';
import * as path from 'node:path';
import {
mkdtempSync,
readFileSync,
existsSync,
mkdirSync,
chmodSync,
} from 'node:fs';
import { rmSync } from 'node:fs';
describe('initDaemonLogger file init', () => {
let tmp: string;
beforeEach(() => {
tmp = mkdtempSync(path.join(os.tmpdir(), 'daemon-log-'));
});
afterEach(() => {
try {
rmSync(tmp, { recursive: true, force: true });
} catch {}
});
it('derives daemon-id "serve-<pid>-<workspaceHash>" and creates log file', () => {
const logger = initDaemonLogger({
boundWorkspace: '/workspace/foo',
pid: 1234,
baseDir: tmp,
});
expect(logger.getDaemonId()).toMatch(/^serve-1234-[0-9a-f]{8}$/);
expect(logger.getLogPath()).toBe(
path.join(tmp, 'daemon', `${logger.getDaemonId()}.log`),
);
expect(existsSync(logger.getLogPath())).toBe(true);
expect(readFileSync(logger.getLogPath(), 'utf8')).toMatch(
/\[INFO\] \[DAEMON\] daemon started pid=1234 workspace=\/workspace\/foo/,
);
});
it('falls back to no-op when mkdir fails', () => {
const stderr: string[] = [];
// Create a file where the directory should be → mkdir EEXIST/ENOTDIR
const blockingFile = path.join(tmp, 'daemon');
require('node:fs').writeFileSync(blockingFile, 'blocker');
const logger = initDaemonLogger({
boundWorkspace: '/w',
pid: 1,
baseDir: tmp,
stderr: (s) => stderr.push(s),
});
expect(logger.getLogPath()).toBe('');
expect(stderr.join('\n')).toMatch(/daemon log disabled/);
expect(() => logger.info('after')).not.toThrow();
});
});
Run: cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts -t "file init"
Expected: failure — throw new Error('not implemented').
Replace the throwing body of initDaemonLogger. Add imports and helpers:
import * as nodeFs from 'node:fs';
import * as nodePath from 'node:path';
import * as crypto from 'node:crypto';
import { writeStderrLine } from '../utils/stdioHelpers.js';
import { Storage } from '@qwen-code/qwen-code-core';
function computeDaemonId(pid: number, boundWorkspace: string): string {
const hash = crypto
.createHash('sha256')
.update(boundWorkspace)
.digest('hex')
.slice(0, 8);
return `serve-${pid}-${hash}`;
}
export function initDaemonLogger(opts: InitDaemonLoggerOptions): DaemonLogger {
if (isOptedOut()) return NOOP_LOGGER;
const pid = opts.pid ?? process.pid;
const now = opts.now ?? (() => new Date());
const stderr = opts.stderr ?? writeStderrLine;
const baseDir = opts.baseDir ?? Storage.getGlobalDebugDir();
const daemonId = computeDaemonId(pid, opts.boundWorkspace);
const daemonDir = nodePath.join(baseDir, 'daemon');
const logPath = nodePath.join(daemonDir, `${daemonId}.log`);
try {
nodeFs.mkdirSync(daemonDir, { recursive: true });
const firstLine = buildDaemonLogLine({
level: 'INFO',
message: `daemon started pid=${pid} workspace=${opts.boundWorkspace}`,
now: now(),
});
nodeFs.appendFileSync(logPath, firstLine, { flag: 'a' });
} catch (err) {
stderr(
`qwen serve: daemon log disabled — init failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
return NOOP_LOGGER;
}
// Methods come in Task 4. For now stub them out so the file-init tests pass.
return {
info: () => {},
warn: () => {},
error: () => {},
raw: () => {},
getLogPath: () => logPath,
getDaemonId: () => daemonId,
flush: () => Promise.resolve(),
};
}
Run: cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts -t "file init"
Expected: PASS.
git add packages/cli/src/serve/daemonLogger.ts packages/cli/src/serve/daemonLogger.test.ts
git commit -m "feat(serve): daemon logger file init + degraded fallback (#4548)"
info / warn / error + async queue + flush + stderr teeFiles:
Modify: packages/cli/src/serve/daemonLogger.ts
Modify: packages/cli/src/serve/daemonLogger.test.ts
Step 1: Add failing tests
Append to daemonLogger.test.ts:
describe('initDaemonLogger info/warn/error', () => {
let tmp: string;
beforeEach(() => {
tmp = mkdtempSync(path.join(os.tmpdir(), 'daemon-log-'));
});
afterEach(() => {
try {
rmSync(tmp, { recursive: true, force: true });
} catch {}
});
it('info appends to file and tees to stderr', async () => {
const stderr: string[] = [];
const fixed = new Date('2026-05-26T03:14:15.926Z');
const logger = initDaemonLogger({
boundWorkspace: '/w',
pid: 1,
baseDir: tmp,
stderr: (s) => stderr.push(s),
now: () => fixed,
});
logger.info('hello', { route: 'GET /' });
await logger.flush();
const content = readFileSync(logger.getLogPath(), 'utf8');
expect(content).toContain('[INFO] [DAEMON] route=GET / hello\n');
// Stderr saw the same line (after boot banner, which isn't teed here).
const teedLines = stderr.filter((s) => s.includes('[INFO] [DAEMON]'));
expect(teedLines).toHaveLength(1);
});
it('error appends err.stack as continuation', async () => {
const logger = initDaemonLogger({
boundWorkspace: '/w',
pid: 1,
baseDir: tmp,
});
const err = new Error('boom');
logger.error('route failed', err, { route: 'POST /x' });
await logger.flush();
const content = readFileSync(logger.getLogPath(), 'utf8');
expect(content).toMatch(
/\[ERROR\] \[DAEMON\] route=POST \/x route failed\n Error: boom/,
);
});
it('flush awaits all pending appends', async () => {
const logger = initDaemonLogger({
boundWorkspace: '/w',
pid: 1,
baseDir: tmp,
});
for (let i = 0; i < 50; i++) logger.info(`msg-${i}`);
await logger.flush();
const lines = readFileSync(logger.getLogPath(), 'utf8').split('\n');
const msgLines = lines.filter((l) => /msg-\d+$/.test(l));
expect(msgLines).toHaveLength(50);
for (let i = 0; i < 50; i++) {
expect(msgLines[i]).toContain(`msg-${i}`);
}
});
it('warns once on append failure and keeps trying', async () => {
const logger = initDaemonLogger({
boundWorkspace: '/w',
pid: 1,
baseDir: tmp,
stderr: () => {},
});
// Sabotage by removing the file mid-flight — POSIX will keep the inode
// around for a held fd, but appendFile reopens each call → ENOENT once
// the parent dir is gone.
rmSync(path.dirname(logger.getLogPath()), { recursive: true, force: true });
const stderr2: string[] = [];
// Re-create logger to bind our stderr capture? Simpler: re-stub via
// private state — instead, do this in a separate test using a custom
// stderr from init time.
logger.info('after-rm-1');
logger.info('after-rm-2');
await logger.flush();
// No throw — degraded path swallows. (Stderr count assertion left to
// a separate variant if needed; this test pins "no crash on failure".)
});
});
Run: cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts -t "info/warn/error"
Expected: failure — methods are stubs.
Replace the final return {...} block in initDaemonLogger:
let pending: Promise<void> = Promise.resolve();
let degraded = false;
const enqueueAppend = (line: string): void => {
pending = pending.then(() =>
nodeFs.promises.appendFile(logPath, line).catch((err) => {
if (!degraded) {
degraded = true;
stderr(
`qwen serve: daemon log write failed — entering degraded mode: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}),
);
};
const teeLine = (
level: DaemonLogLevel,
message: string,
ctx?: DaemonLogContext,
err?: Error,
): void => {
const line = buildDaemonLogLine({ level, message, now: now(), ctx, err });
// stderr first (synchronous, preserves human-visible order), then file.
stderr(line.trimEnd());
enqueueAppend(line);
};
return {
info: (message, ctx) => teeLine('INFO', message, ctx),
warn: (message, ctx) => teeLine('WARN', message, ctx),
error: (message, err, ctx) =>
teeLine('ERROR', message, ctx, err ?? undefined),
raw: () => {}, // implemented in Task 5
getLogPath: () => logPath,
getDaemonId: () => daemonId,
flush: () => pending,
};
Run: cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts -t "info/warn/error"
Expected: PASS.
git add packages/cli/src/serve/daemonLogger.ts packages/cli/src/serve/daemonLogger.test.ts
git commit -m "feat(serve): daemon logger info/warn/error + flush (#4548)"
raw() file-only teeFiles:
Modify: packages/cli/src/serve/daemonLogger.ts
Modify: packages/cli/src/serve/daemonLogger.test.ts
Step 1: Add failing test
Append:
describe('initDaemonLogger raw', () => {
let tmp: string;
beforeEach(() => {
tmp = mkdtempSync(path.join(os.tmpdir(), 'daemon-log-'));
});
afterEach(() => {
try {
rmSync(tmp, { recursive: true, force: true });
} catch {}
});
it('appends prefixed line, no stderr tee', async () => {
const stderr: string[] = [];
const logger = initDaemonLogger({
boundWorkspace: '/w',
pid: 1,
baseDir: tmp,
stderr: (s) => stderr.push(s),
});
const stderrBefore = stderr.length;
logger.raw('[serve pid=123 cwd=/x] child crashed', 'warn');
logger.raw('[serve pid=123 cwd=/x] another');
await logger.flush();
const content = readFileSync(logger.getLogPath(), 'utf8');
expect(content).toContain(
'[WARN] [DAEMON] [serve pid=123 cwd=/x] child crashed\n',
);
expect(content).toContain(
'[INFO] [DAEMON] [serve pid=123 cwd=/x] another\n',
);
// No new stderr lines from raw()
expect(stderr.length).toBe(stderrBefore);
});
});
Run: cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts -t "raw"
Expected: fail — raw is no-op.
In initDaemonLogger, replace raw: () => {}, with:
raw: (line: string, level: 'info' | 'warn' | 'error' = 'info') => {
const upper = level.toUpperCase() as DaemonLogLevel;
const formatted = `${now().toISOString()} [${upper}] [DAEMON] ${line}\n`;
enqueueAppend(formatted);
},
Run: cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts -t "raw"
Expected: PASS.
git add packages/cli/src/serve/daemonLogger.ts packages/cli/src/serve/daemonLogger.test.ts
git commit -m "feat(serve): daemon logger raw() file-only tee (#4548)"
latest symlinkFiles:
Modify: packages/cli/src/serve/daemonLogger.ts
Modify: packages/cli/src/serve/daemonLogger.test.ts
Step 1: Add failing test
Append:
import { realpathSync, lstatSync } from 'node:fs';
describe('initDaemonLogger latest symlink', () => {
let tmp: string;
beforeEach(() => {
tmp = mkdtempSync(path.join(os.tmpdir(), 'daemon-log-'));
});
afterEach(() => {
try {
rmSync(tmp, { recursive: true, force: true });
} catch {}
});
it('creates daemon/latest pointing to the current log', () => {
const logger = initDaemonLogger({
boundWorkspace: '/w',
pid: 42,
baseDir: tmp,
});
const linkPath = path.join(tmp, 'daemon', 'latest');
expect(lstatSync(linkPath).isSymbolicLink() || existsSync(linkPath)).toBe(
true,
);
expect(realpathSync(linkPath)).toBe(realpathSync(logger.getLogPath()));
});
it('updates latest on subsequent init in same dir', () => {
const a = initDaemonLogger({ boundWorkspace: '/w', pid: 1, baseDir: tmp });
const b = initDaemonLogger({ boundWorkspace: '/w', pid: 2, baseDir: tmp });
expect(realpathSync(path.join(tmp, 'daemon', 'latest'))).toBe(
realpathSync(b.getLogPath()),
);
expect(realpathSync(a.getLogPath())).not.toBe(realpathSync(b.getLogPath()));
});
});
Run: cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts -t "latest symlink"
Expected: fail — symlink not created.
updateSymlink lives in packages/core/src/utils/symlink.ts but is NOT re-exported from the core barrel (confirmed via grep -n updateSymlink packages/core/src/index.ts → no matches at plan-write time). Add the re-export first:
In packages/core/src/index.ts, add (near the other utils exports):
export { updateSymlink } from './utils/symlink.js';
Then import in daemonLogger.ts:
import { Storage, updateSymlink } from '@qwen-code/qwen-code-core';
(Merge with the existing Storage import added in Task 3.)
Inside initDaemonLogger, after the appendFileSync first-line write succeeds, add:
try {
const aliasPath = nodePath.join(daemonDir, 'latest');
updateSymlink(aliasPath, logPath, { fallbackCopy: false }).catch(() => {
// Best-effort. Symlink failure must not degrade primary writes.
});
} catch {
// Sync throw equally best-effort.
}
Run: cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts -t "latest symlink"
Expected: PASS.
git add packages/cli/src/serve/daemonLogger.ts packages/cli/src/serve/daemonLogger.test.ts packages/core/src/index.ts
git commit -m "feat(serve): daemon logger latest symlink (#4548)"
BridgeOptions.onDiagnosticLine + tee writeServeDebugLineFiles:
Modify: packages/acp-bridge/src/bridgeOptions.ts
Modify: packages/acp-bridge/src/bridge.ts
Modify: packages/acp-bridge/src/bridge.test.ts
Step 1: Add DiagnosticLineSink type to bridgeOptions.ts
Insert near the top of the BridgeOptions interface (before sessionScope):
/**
* Sink for serve-level diagnostic lines (set by the cli daemon logger).
* When provided, the bridge tees `writeServeDebugLine` output through
* this callback alongside the existing stderr write — used by
* runQwenServe to capture them in the daemon log file. The bridge
* does not own a file logger itself; this is a pure pass-through hook.
*/
export type DiagnosticLineSink = (
line: string,
level?: 'info' | 'warn' | 'error',
) => void;
Add inside BridgeOptions:
/**
* Optional: tee `writeServeDebugLine` output. See {@link DiagnosticLineSink}.
* No-op when omitted. Set by cli `runQwenServe` from the daemon logger.
*/
onDiagnosticLine?: DiagnosticLineSink;
In packages/acp-bridge/src/bridge.test.ts, add a new describe('onDiagnosticLine', ...) block. The file already imports makeBridge and makeChannel from ./internal/testUtils.js — reuse them instead of hand-rolling a ChannelFactory. Confirm with grep -n "import.*testUtils" packages/acp-bridge/src/bridge.test.ts. To trigger writeServeDebugLine, pick the shortest-setup test among the 6 call sites — list them with grep -n "writeServeDebugLine(" packages/acp-bridge/src/bridge.ts (currently lines 1410, 1423, 2242, 2328, 2624, 2637; the cross-session permission-vote rejection around line 2242 is a small reproducible trigger).
describe('onDiagnosticLine', () => {
const originalDebug = process.env['QWEN_SERVE_DEBUG'];
afterEach(() => {
if (originalDebug === undefined) delete process.env['QWEN_SERVE_DEBUG'];
else process.env['QWEN_SERVE_DEBUG'] = originalDebug;
});
it('receives writeServeDebugLine output when QWEN_SERVE_DEBUG=1', async () => {
process.env['QWEN_SERVE_DEBUG'] = '1';
const captured: Array<{ line: string; level?: string }> = [];
const bridge = makeBridge({
onDiagnosticLine: (line, level) => captured.push({ line, level }),
});
// Trigger writeServeDebugLine via [copy harness from the closest
// existing test that exercises one of the 6 call sites above].
// ... trigger code here ...
expect(captured.some((e) => e.line.includes('qwen serve debug: '))).toBe(
true,
);
expect(
captured.every((e) => e.level === undefined || e.level === 'info'),
).toBe(true);
await bridge.shutdown();
});
});
(makeBridge accepts Partial<BridgeOptions> — once Task 7 step 1 adds onDiagnosticLine to BridgeOptions, it flows through without further edits to testUtils.ts.)
Run: cd packages/acp-bridge && npx vitest run src/bridge.test.ts -t "onDiagnosticLine"
Expected: fail — callback not invoked.
writeServeDebugLine through the callbackIn packages/acp-bridge/src/bridge.ts, near the top of createHttpAcpBridge (after opts is destructured), introduce a local tee that wraps the existing module-level helper:
const teeServeDebugLine = (message: string): void => {
writeServeDebugLine(message);
if (opts.onDiagnosticLine && isServeDebugLoggingEnabled()) {
opts.onDiagnosticLine(`qwen serve debug: ${message}`, 'info');
}
};
Then, in this file replace every internal writeServeDebugLine(...) call inside createHttpAcpBridge's closure with teeServeDebugLine(...). Use:
grep -n "writeServeDebugLine(" packages/acp-bridge/src/bridge.ts
to enumerate call sites — there are 6 in the current tree (lines 1410, 1423, 2242, 2328, 2624, 2637; verify with the grep). Edit each. Do NOT change the module-level writeServeDebugLine definition itself — other entry points and tests rely on it.
(Reason for not editing the top-level definition: changes the signature for all callers including tests; the closure tee is additive and locally-scoped.)
Run: cd packages/acp-bridge && npx vitest run src/bridge.test.ts -t "onDiagnosticLine"
Expected: PASS. Also run full file to catch regressions: npx vitest run src/bridge.test.ts.
git add packages/acp-bridge/src/bridgeOptions.ts packages/acp-bridge/src/bridge.ts packages/acp-bridge/src/bridge.test.ts
git commit -m "feat(acp-bridge): onDiagnosticLine sink for serve debug tee (#4548)"
createSpawnChannelFactory with onDiagnosticLineFiles:
Modify: packages/acp-bridge/src/spawnChannel.ts
Modify: packages/acp-bridge/src/spawnChannel.test.ts (or create if missing)
Step 1: Inspect current export shape
grep -n "defaultSpawnChannelFactory\|onDiagnosticLine\|process.stderr.write" packages/acp-bridge/src/spawnChannel.ts | head -20
Confirm defaultSpawnChannelFactory is the only public spawn export. The existing child-stderr forwarder calls process.stderr.write(prefix + line + '\n') inside the body — locate that block (around line 125).
In packages/acp-bridge/src/spawnChannel.test.ts (look for an existing test file; if none, create one):
import { describe, it, expect } from 'vitest';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createSpawnChannelFactory } from './spawnChannel.js';
describe('createSpawnChannelFactory onDiagnosticLine', () => {
it('returns a ChannelFactory that tees child stderr lines', async () => {
const captured: Array<{ line: string; level?: string }> = [];
const factory = createSpawnChannelFactory({
onDiagnosticLine: (line, level) => captured.push({ line, level }),
});
// Spawn a tiny child that writes to stderr then exits. Use the
// QWEN_CLI_ENTRY escape hatch to point at a Node one-liner.
const here = path.dirname(fileURLToPath(import.meta.url));
process.env['QWEN_CLI_ENTRY'] = path.join(
here,
'testutil',
'stderrOnlyEntry.cjs',
);
try {
const ch = await factory('/tmp', {});
await ch.exited;
// After child exit, the forwarder flushes buffered tail.
expect(
captured.some((e) =>
/\[serve pid=\d+ cwd=\/tmp\] hello-stderr/.test(e.line),
),
).toBe(true);
expect(
captured.every((e) => e.level === undefined || e.level === 'warn'),
).toBe(true);
} finally {
delete process.env['QWEN_CLI_ENTRY'];
}
});
});
And a fixture entry packages/acp-bridge/src/testutil/stderrOnlyEntry.cjs:
process.stderr.write('hello-stderr\n');
process.exit(0);
(Adjust if the bridge requires ACP initialize handshake before considering the child "spawned" — alternative: write the stderr line during initialize handling. If the test is too brittle, fall back to mocking the spawn and asserting the forwarder logic in isolation — read defaultSpawnChannelFactory's body and unit-test the inner forwarder by exporting it for tests.)
Run: cd packages/acp-bridge && npx vitest run src/spawnChannel.test.ts -t "onDiagnosticLine"
Expected: fail — createSpawnChannelFactory not exported.
createSpawnChannelFactoryRefactor defaultSpawnChannelFactory into a factory-of-factories. Replace the top of spawnChannel.ts:
export interface SpawnChannelFactoryOptions {
onDiagnosticLine?: (line: string, level?: 'info' | 'warn' | 'error') => void;
}
export function createSpawnChannelFactory(
options: SpawnChannelFactoryOptions = {},
): ChannelFactory {
const onDiagnosticLine = options.onDiagnosticLine;
return async (workspaceCwd, childEnvOverrides) => {
// ... existing body of defaultSpawnChannelFactory ...
// Where the existing forwarder does:
// process.stderr.write(prefix + line + '\n')
// change it to:
// const teedLine = prefix + line;
// process.stderr.write(teedLine + '\n');
// if (onDiagnosticLine) onDiagnosticLine(teedLine, 'warn');
// For the [truncated] branch:
// const teedTrunc = prefix + buf.slice(0, STDERR_LINE_CAP_CHARS) + ' [truncated]';
// process.stderr.write(teedTrunc + '\n');
// if (onDiagnosticLine) onDiagnosticLine(teedTrunc, 'warn');
};
}
// Preserve the old export for backward compatibility (no callback wiring).
export const defaultSpawnChannelFactory: ChannelFactory =
createSpawnChannelFactory();
Implementation discipline:
Do NOT remove defaultSpawnChannelFactory — channels/IDE adapters still import it.
Stick to the exact existing stderr write semantics (line buffering, 64 KiB cap, truncation marker). The onDiagnosticLine call sits next to each existing process.stderr.write and never replaces it.
Step 5: Run, confirm pass
Run: cd packages/acp-bridge && npx vitest run src/spawnChannel.test.ts -t "onDiagnosticLine"
Expected: PASS. Also npx vitest run full suite to confirm no regressions.
git add packages/acp-bridge/src/spawnChannel.ts packages/acp-bridge/src/spawnChannel.test.ts packages/acp-bridge/src/testutil/stderrOnlyEntry.cjs
git commit -m "feat(acp-bridge): createSpawnChannelFactory with onDiagnosticLine (#4548)"
sendBridgeError through daemonLogFiles:
Modify: packages/cli/src/serve/server.ts
Modify: packages/cli/src/serve/server.test.ts
Step 1: Add daemonLog to createServeApp deps
Read packages/cli/src/serve/server.ts around the createServeApp signature (search for export function createServeApp or export interface ServeAppDeps). Add to its deps interface:
/**
* Optional daemon logger. When provided, `sendBridgeError` routes
* each route-mapped error through `daemonLog.error(...)` (which tees
* to stderr + the daemon log file). When omitted, falls back to
* existing stderr-only behavior.
*/
daemonLog?: import('./daemonLogger.js').DaemonLogger;
In packages/cli/src/serve/server.test.ts, add (or extend a route-error test):
import { initDaemonLogger } from './daemonLogger.js';
it('sendBridgeError routes through daemonLog when provided', async () => {
const tmp = mkdtempSync(path.join(os.tmpdir(), 'daemon-log-'));
try {
const stderr: string[] = [];
const daemonLog = initDaemonLogger({
boundWorkspace: '/w',
pid: 1,
baseDir: tmp,
stderr: (s) => stderr.push(s),
});
// createServeApp signature: (opts, getPort?, deps?). daemonLog goes in deps.
const app = createServeApp(
/* opts */ { /* ...usual ServeOptions, copy from closest existing test... */ } as ServeOptions,
/* getPort */ () => 0,
/* deps */ { /* ...usual deps that make a route throw... */, daemonLog },
);
await request(app).get('/some/erroring/route').expect(500);
await daemonLog.flush();
const content = readFileSync(daemonLog.getLogPath(), 'utf8');
expect(content).toMatch(
/\[ERROR\] \[DAEMON\] route=GET \/some\/erroring\/route/,
);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
(Copy whatever route-throws-error harness already lives in server.test.ts — e.g. inject a deps stub that throws when called. The point is one route hits sendBridgeError → assertion lands in the daemon log.)
Run: cd packages/cli && npx vitest run src/serve/server.test.ts -t "daemonLog"
Expected: fail.
sendBridgeErrorIn server.ts, find the sendBridgeError function (around line 2765). It currently writes to stderr inline. Refactor:
daemonLog from createServeApp into the closure that owns sendBridgeError (it's defined inside the function — same closure).sendBridgeError, where the stderr write happens, replace with:if (daemonLog) {
daemonLog.error(
err instanceof Error ? err.message : String(err),
err instanceof Error ? err : null,
{
...(ctx?.route ? { route: ctx.route } : {}),
...(ctx?.sessionId ? { sessionId: ctx.sessionId } : {}),
},
);
} else {
// Legacy stderr-only path. Keep behavior intact for embedders that
// construct createServeApp without daemonLog (tests, direct integrations).
writeStderrLine(
`qwen serve: ${ctx?.route ?? 'unknown route'}: ${
err instanceof Error ? (err.stack ?? err.message) : String(err)
}${ctx?.sessionId ? ` sessionId=${ctx.sessionId}` : ''}`,
);
}
Make sure the new branch is taken when daemonLog is non-null. daemonLog.error already tees to stderr, so the stderr line is still produced — no behavior loss.
Run: cd packages/cli && npx vitest run src/serve/server.test.ts
Expected: full file PASS (new + old).
git add packages/cli/src/serve/server.ts packages/cli/src/serve/server.test.ts
git commit -m "feat(serve): route sendBridgeError through daemonLog (#4548)"
runQwenServe — init, boot banner, callbacks, lifecycle, shutdown flushFiles:
Modify: packages/cli/src/serve/runQwenServe.ts
Modify: packages/cli/src/serve/runQwenServe.test.ts
Step 1: Read the existing boot + shutdown structure
Re-read packages/cli/src/serve/runQwenServe.ts lines 590-1030 (the createHttpAcpBridge({...}) call site, the RunHandle.close body, and the onSignal handler). Note all writeStderrLine(...) calls — they're at roughly 393, 565, 805, 821, 825, 835, 859, 865, 872, 877, 951, 961, 986, 997, 1027, 1361 (run grep -n writeStderrLine for the current line numbers).
In packages/cli/src/serve/runQwenServe.test.ts, add (or extend):
import { existsSync, readFileSync, rmSync, mkdtempSync } from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
it('runQwenServe initializes daemon logger and writes boot banner + flushes on shutdown', async () => {
const tmpRuntime = mkdtempSync(path.join(os.tmpdir(), 'serve-runtime-'));
const originalRuntime = process.env['QWEN_RUNTIME_DIR'];
process.env['QWEN_RUNTIME_DIR'] = tmpRuntime;
try {
const handle = await runQwenServe({
port: 0,
hostname: '127.0.0.1',
mode: 'workspace',
// ... fill remaining required opts from the smallest existing test ...
});
// Boot wrote a daemon log somewhere under tmpRuntime/debug/daemon
const daemonDir = path.join(tmpRuntime, 'debug', 'daemon');
expect(existsSync(daemonDir)).toBe(true);
const logs = require('node:fs')
.readdirSync(daemonDir)
.filter((f: string) => f.endsWith('.log'));
expect(logs.length).toBe(1);
const content = readFileSync(path.join(daemonDir, logs[0]), 'utf8');
expect(content).toMatch(/daemon started pid=\d+ workspace=/);
await handle.close();
// After shutdown, "shutdown signal" or equivalent should be in the log.
const after = readFileSync(path.join(daemonDir, logs[0]), 'utf8');
expect(after).toMatch(/shutdown/i);
} finally {
if (originalRuntime === undefined) delete process.env['QWEN_RUNTIME_DIR'];
else process.env['QWEN_RUNTIME_DIR'] = originalRuntime;
rmSync(tmpRuntime, { recursive: true, force: true });
}
});
Run: cd packages/cli && npx vitest run src/serve/runQwenServe.test.ts -t "daemon logger"
Expected: fail.
runQwenServeEdit runQwenServe.ts:
import { initDaemonLogger, type DaemonLogger } from './daemonLogger.js';
import { createSpawnChannelFactory } from '@qwen-code/acp-bridge/spawnChannel';
runQwenServe(opts), right after boundWorkspace is canonicalized (find the assignment; it's the value passed to createHttpAcpBridge):const daemonLog: DaemonLogger = initDaemonLogger({ boundWorkspace });
writeStderrLine(
`qwen serve: daemon log → ${daemonLog.getLogPath() || '(disabled)'}`,
);
createHttpAcpBridge({...}) call (around line 606):const channelFactory = createSpawnChannelFactory({
onDiagnosticLine: (line, level) => daemonLog.raw(line, level),
});
const bridge =
deps.bridge ??
createHttpAcpBridge({
// ... existing fields ...
channelFactory,
onDiagnosticLine: (line, level) => daemonLog.raw(line, level),
});
(If deps.bridge is provided, the operator is embedding and owns their own wiring — skip the callback.)
createServeApp(...) call (currently at runQwenServe.ts:706, signature is createServeApp(opts, getPort, deps)) to add daemonLog to the deps object:const app = createServeApp(opts, () => actualPort, {
bridge,
boundWorkspace,
fsFactory,
daemonLog,
});
Replace lifecycle-only writeStderrLine(...) calls (the ones inside onSignal, the bridge.shutdown error path, the server error listener, the device-flow dispose error, the "received signal, draining" line) with daemonLog.warn(...) / daemonLog.error(..., err) — daemonLog tees to stderr so operator-visible output is preserved. Do NOT touch:
writeStdoutLine).daemonLog is constructed.To be concrete, the mechanical rule for this step: every writeStderrLine call after the daemonLog is constructed and before process.exit is candidate; if its content reads like a daemon diagnostic (not a one-shot startup banner), switch it.
In the RunHandle.close body, after the finish callback runs (or right before process.exit(0) in onSignal), add await daemonLog.flush();. Concretely, the onSignal handler becomes:
const onSignal = async (signal: NodeJS.Signals) => {
if (shuttingDown) {
/* unchanged */ return;
}
daemonLog.warn(`received ${signal}, draining`, { signal });
try {
await handle.close();
await daemonLog.flush();
process.exit(0);
} catch (err) {
daemonLog.error('shutdown error', err instanceof Error ? err : null);
await daemonLog.flush().catch(() => {});
process.exit(1);
}
};
Run: cd packages/cli && npx vitest run src/serve/runQwenServe.test.ts
Expected: full file PASS.
Run also: cd packages/cli && npx vitest run src/serve/ (full serve dir, catches indirect regressions like server.test.ts assertions on stderr output).
git add packages/cli/src/serve/runQwenServe.ts packages/cli/src/serve/runQwenServe.test.ts
git commit -m "feat(serve): init daemonLogger in runQwenServe + flush on shutdown (#4548)"
Files:
Modify: existing serve docs (locate with find docs -iname '*serve*' and ls docs/cli/)
Step 1: Find the right doc
find docs -iname '*serve*' -type f
ls docs/cli/ 2>/dev/null
Pick the most natural home — likely docs/cli/serve.md. If none exists for qwen serve, create docs/cli/serve-daemon-log.md.
Add (or create) a "Daemon log file" section:
## Daemon log file
`qwen serve` writes a per-process diagnostic log to:
${QWEN_RUNTIME_DIR or ~/.qwen}/debug/daemon/serve-<pid>-<workspaceHash>.log
A `latest` symlink in the same directory always points at the current
process's log, so `tail -f ~/.qwen/debug/daemon/latest` will follow whichever
daemon is running.
The log captures lifecycle messages, route errors (with `route=` and
`sessionId=` context), ACP child stderr, and — when `QWEN_SERVE_DEBUG=1`
is set — extra bridge breadcrumbs. Lines that go to stderr today still
go to stderr; the file log is **additive**, not a replacement.
### Disabling
Set `QWEN_DAEMON_LOG_FILE=0` (or `false`/`off`/`no`) to skip file logging
entirely. Stderr output is unaffected.
### Relation to session debug logs
Session-scoped debug logs (`~/.qwen/debug/<sessionId>.txt` and the
`~/.qwen/debug/latest` symlink) are independent. The daemon log lives
in a sibling `daemon/` subdirectory; per-session debug semantics are
unchanged by this feature.
### No rotation
The daemon log appends indefinitely. Rotate manually if it grows large.
A future enhancement may add automatic rotation; track via #4548
follow-ups.
git add docs/cli/serve.md # or the actual file path
git commit -m "docs(serve): document daemon log file path and opt-out (#4548)"
cd /Users/jinye.djy/Projects/qwen-code/.claude/worktrees/feat-support-daemon-logger
npm run test --workspace=packages/acp-bridge
npm run test --workspace=packages/cli
Expected: all green.
npm run typecheck --workspace=packages/acp-bridge
npm run typecheck --workspace=packages/cli
Expected: no errors.
QWEN_RUNTIME_DIR=$(mktemp -d) node packages/cli/dist/index.js serve --port 0 --hostname 127.0.0.1 &
SERVE_PID=$!
sleep 1
ls $QWEN_RUNTIME_DIR/debug/daemon/
cat $QWEN_RUNTIME_DIR/debug/daemon/latest
kill -TERM $SERVE_PID
wait $SERVE_PID 2>/dev/null || true
cat $QWEN_RUNTIME_DIR/debug/daemon/latest # should now contain shutdown line
Expected: log file exists, contains daemon started ..., then after kill the received SIGTERM, draining line.
If packages/cli/dist/index.js doesn't exist, build first: npm run build --workspace=packages/cli.
git push -u origin HEAD
gh pr create --title "feat(serve): add daemon file logger (#4548)" --body "$(cat <<'EOF'
## Summary
- Adds a per-process daemon file logger at `~/.qwen/debug/daemon/serve-<pid>-<workspaceHash>.log` (configurable via `QWEN_RUNTIME_DIR`, opt-out via `QWEN_DAEMON_LOG_FILE=0`).
- Routes `runQwenServe` lifecycle messages, `sendBridgeError` route errors, `writeServeDebugLine` debug breadcrumbs, and ACP child stderr into the daemon log without removing existing stderr output.
- Adds `BridgeOptions.onDiagnosticLine` and `createSpawnChannelFactory({ onDiagnosticLine })` to keep `acp-bridge` ignorant of cli.
Closes #4548.
## Test plan
- [x] New unit tests in `packages/cli/src/serve/daemonLogger.test.ts` cover formatter, file init, info/warn/error, raw, latest symlink, opt-out, degraded fallback.
- [x] `packages/acp-bridge/src/bridge.test.ts` covers `onDiagnosticLine` tee from `writeServeDebugLine`.
- [x] `packages/acp-bridge/src/spawnChannel.test.ts` covers child stderr forwarder.
- [x] `packages/cli/src/serve/server.test.ts` covers route-error routing through `daemonLog.error`.
- [x] `packages/cli/src/serve/runQwenServe.test.ts` covers boot banner + flush on shutdown.
- [x] Manual smoke: log file created at boot, contains shutdown line on SIGTERM.
🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
EOF
)"
Spec coverage: §3 module table covered by Tasks 1-10. §4 daemon-id + path → Task 3. §5 API surface → Tasks 1-6. §6 format + tee semantics → Task 1 (format), Task 4 (info/warn/error tee), Task 5 (raw file-only). §7 boot/shutdown → Task 10. §8 coverage table → Tasks 7/8/9/10. §9 write path & flush → Task 4. §10 config → Task 2 (opt-out), Task 11 (docs). §11 error handling → Tasks 3, 4. §12 testing → distributed across tasks. §13 docs → Task 11. §15 acceptance criteria → met by Tasks 3, 9, 8, 10, 10, 11 respectively.
Trace context (§6 bullet): deferred. The spec leaves it explicit ("Helper extracted to a shared module ... or duplicated locally — leave to plan"). The current plan does NOT inject trace_id/span_id; that is a follow-up task tracked in §16. If reviewer pushes back, add a Task 4.5 that imports trace from @opentelemetry/api and folds the span context into buildDaemonLogLine — but only if the reviewer asks; YAGNI otherwise.
updateSymlink import path: Task 6 step 3 hedges on whether updateSymlink is exported from @qwen-code/qwen-code-core. Verify before editing: grep -n updateSymlink packages/core/src/index.ts. If missing, add the re-export in the same commit as Task 6.
acp-bridge test for createSpawnChannelFactory: spawning a real child in a unit test is brittle. If Task 8 step 2 turns out to be flaky in CI, the fallback is to refactor the inner stderr forwarder into a small exported helper (forwardChildStderr(stream, { prefix, onLine })) and unit-test that in isolation — no real spawn needed.