From 4ac2aad59597ce1b9e5b12327d41a1069fa01ef7 Mon Sep 17 00:00:00 2001 From: Kingsword Date: Sat, 31 Oct 2026 15:11:24 +0800 Subject: [PATCH 1/2] fix(headless): preserve prompt output and forward repeated shutdown signals --- README.md | 3 + docs/HEADLESS.md | 61 +++++++++++ docs/HOST_INTEGRATION.md | 4 + package.json | 1 + scripts/check-package.ts | 1 + scripts/check-runtime.ts | 4 + scripts/package-contents.ts | 1 + scripts/runtime-headless-patches.ts | 21 ++++ scripts/sync-runtime.ts | 8 ++ src/launcher.ts | 8 +- src/runtime-config-bridge.ts | 1 + src/runtime-headless-lifecycle.ts | 23 +++++ test/README.md | 5 +- test/fixtures/network-reset-server.mjs | 9 +- test/fixtures/runtime-keepalive.mjs | 3 - test/node/headless-lifecycle.test.cjs | 46 +++++++++ test/runtime/launcher.test.ts | 56 +++++++++++ test/runtime/network-retry.test.ts | 134 +++++++++++++++++++++---- 18 files changed, 359 insertions(+), 30 deletions(-) create mode 100644 docs/HEADLESS.md create mode 100644 scripts/runtime-headless-patches.ts create mode 100644 src/runtime-headless-lifecycle.ts delete mode 100644 test/fixtures/runtime-keepalive.mjs create mode 100644 test/node/headless-lifecycle.test.cjs diff --git a/README.md b/README.md index 4add6560..02ad5925 100644 --- a/README.md +++ b/README.md @@ -500,6 +500,9 @@ Without `--effort`, `--model` uses the model's default level. Both flags require `--prompt` (or `-p`) and leave the saved default for new sessions unchanged; the run's model and effort are recorded on that session. +For automation and long-running `-p` jobs, see [headless execution](./docs/HEADLESS.md) +for output completion, cancellation, background execution and validation limits. + ZCode reads MCP servers, hooks, plugins, permissions, network, storage and display settings from `~/.zcode/cli/setting.json` (or `%USERPROFILE%\.zcode\cli\setting.json` on Windows). Project settings use diff --git a/docs/HEADLESS.md b/docs/HEADLESS.md new file mode 100644 index 00000000..a41e511c --- /dev/null +++ b/docs/HEADLESS.md @@ -0,0 +1,61 @@ +# Headless execution + +`zcode -p 'task'` runs without a TUI. It is a foreground command, not a daemon, +and headless mode is separate from the browser's `--browser-use=headless` setting. + +## Long-running jobs + +- The prompt lifecycle keeps the Node event loop alive until prompt work and + cleanup finish, including waits that otherwise have only unreferenced timers. + This keepalive is not a task timeout or progress heartbeat. +- The launcher adds no overall job deadline. Model, tool, network, workflow and + cleanup policies still apply. Use a host supervisor for an overall deadline; + model retry settings are not a job timeout. +- Text and JSON output normally arrive at completion. For observable progress, + use `--output-format stream-json` and consume the JSONL stream incrementally. + A quiet interval during a tool call or retry is not by itself a failure. +- Consume stdout **and** stderr. Completion waits for queued writes to reach + their consumers rather than allowing the runtime's exit watchdog to discard + them. If a consumer stops reading, completion can wait until it resumes or + the job is cancelled. This does not guarantee the consumer has persisted data. +- With `stream-json`, require both exit status 0 and a terminal `result` record. + A missing result is incomplete even if the process reports status 0. + +On POSIX systems, `SIGINT`, `SIGTERM` and `SIGHUP` are forwarded to the runtime +until it actually exits, including repeated signals during cleanup. Cancellation +must not be interpreted as success; conventional exit statuses are 130, 143 +and 129 respectively. `SIGKILL` cannot run cleanup. + +Headless mode does not make terminal logout safe automatically. Use a service +manager or explicitly detach a job if it must survive the shell/SSH connection: + +```sh +nohup zcode -p 'your task' --output-format stream-json \ + >run.jsonl 2>run.stderr { "docs/CONFIGURATION.zh-CN.md", "docs/PROVIDER_CONFIG.md", "docs/PROVIDER_CONFIG.zh-CN.md", + "docs/HEADLESS.md", "package.json", "src/app-server-client.ts", "src/command.ts", diff --git a/scripts/check-runtime.ts b/scripts/check-runtime.ts index 3ce85946..1ebc0b14 100755 --- a/scripts/check-runtime.ts +++ b/scripts/check-runtime.ts @@ -21,6 +21,7 @@ import { hasRuntimeCliSettingsFile, hasRuntimeCliHelpContract, hasRuntimeHttpNoContentGuard, + hasRuntimeHeadlessLifecycle, hasRuntimeNetworkRetryGuard, hasRuntimeResolvedContextWindow, hasRuntimeRecursiveWorkspacePaths, @@ -31,6 +32,7 @@ import { hasRuntimeTrackedDetachedAgentLifecycle, patchRuntimeGoalFailurePause, patchRuntimeHttpNoContent, + patchRuntimeHeadlessLifecycle, patchRuntimeLoginModelDefaults, patchRuntimeNetworkRetryClassification, patchRuntimeOfficialMcpAvailability, @@ -92,6 +94,8 @@ if (patchRuntimeLoginModelDefaults(runtimeSource) !== runtimeSource || (patchEnabled("goal-failure-pause") && patchRuntimeGoalFailurePause(runtimeSource) !== runtimeSource) || patchRuntimeHttpNoContent(runtimeSource) !== runtimeSource || !hasRuntimeHttpNoContentGuard(runtimeSource) + || patchRuntimeHeadlessLifecycle(runtimeSource) !== runtimeSource + || !hasRuntimeHeadlessLifecycle(runtimeSource) || patchRuntimeNetworkRetryClassification(runtimeSource) !== runtimeSource || !hasRuntimeNetworkRetryGuard(runtimeSource) || patchRuntimeSqliteBusyTimeout(runtimeSource) !== runtimeSource diff --git a/scripts/package-contents.ts b/scripts/package-contents.ts index b8a78e48..36f6c482 100644 --- a/scripts/package-contents.ts +++ b/scripts/package-contents.ts @@ -23,6 +23,7 @@ export const publishedFiles = [ "docs/PROVIDER_CONFIG.md", "docs/PROVIDER_CONFIG.zh-CN.md", "docs/HOST_INTEGRATION.md", + "docs/HEADLESS.md", ...attributionFiles, "zcode-runtime.lock.json", "README.md", diff --git a/scripts/runtime-headless-patches.ts b/scripts/runtime-headless-patches.ts new file mode 100644 index 00000000..40f70f19 --- /dev/null +++ b/scripts/runtime-headless-patches.ts @@ -0,0 +1,21 @@ +const marker = ".withHeadlessPromptLifecycle("; + +export function hasRuntimeHeadlessLifecycle(runtime: string): boolean { + return runtime.includes(marker); +} + +export function patchRuntimeHeadlessLifecycle(runtime: string): string { + if (hasRuntimeHeadlessLifecycle(runtime)) return runtime; + const start = /[A-Za-z_$][\w$]*=[A-Za-z_$][\w$]*\(async\([^)]*\)=>\{if\([A-Za-z_$][\w$]*\.trim\(\)\.length===0\)return ([A-Za-z_$][\w$]*)\.stderr\.write/u.exec(runtime); + const end = start ? runtime.indexOf('},"runPrompt")', start.index) : -1; + if (!start || end < 0) { + throw new Error("ZCode runtime is incompatible with the headless lifecycle patch (runPrompt anchors missing)."); + } + const bodyStart = start.index + start[0].indexOf("=>{") + 3; + const helper = 'require(require("node:path").join(__dirname,"cli-config.cjs"))'; + return runtime.slice(0, bodyStart) + + `return await ${helper}.withHeadlessPromptLifecycle(${start[1]},async()=>{` + + runtime.slice(bodyStart, end) + + "})" + + runtime.slice(end); +} diff --git a/scripts/sync-runtime.ts b/scripts/sync-runtime.ts index 23bbdb03..3de7d959 100755 --- a/scripts/sync-runtime.ts +++ b/scripts/sync-runtime.ts @@ -14,6 +14,8 @@ import { } from "../src/runtime-capabilities.ts"; import { parseReleaseVersion, syncedReleaseVersion } from "./release-version.ts"; import { markRuntimeModified } from "./runtime-attribution.ts"; +import { hasRuntimeHeadlessLifecycle, patchRuntimeHeadlessLifecycle } from "./runtime-headless-patches.ts"; +export { hasRuntimeHeadlessLifecycle, patchRuntimeHeadlessLifecycle } from "./runtime-headless-patches.ts"; import { hasRuntimeSessionLifecycle, patchRuntimeSessionLifecycle } from "./runtime-session-lifecycle-patches.ts"; export { hasRuntimeSessionLifecycle, patchRuntimeSessionLifecycle } from "./runtime-session-lifecycle-patches.ts"; import { hasRuntimeBigmodelOAuth, patchRuntimeBigmodelOAuth } from "./runtime-bigmodel-oauth-patches.ts"; @@ -1907,6 +1909,12 @@ export const runtimePatchPlan: readonly RuntimePatchDefinition[] = [ apply: patchRuntimeStreamEofFinishGuard, verify: hasRuntimeStreamEofFinishGuard }, + { + id: "headless-lifecycle", + requirement: "required", + apply: patchRuntimeHeadlessLifecycle, + verify: hasRuntimeHeadlessLifecycle + }, { id: "sqlite-busy-timeout", requirement: "required", diff --git a/src/launcher.ts b/src/launcher.ts index 586f35c4..c8b05861 100644 --- a/src/launcher.ts +++ b/src/launcher.ts @@ -420,14 +420,14 @@ async function runRuntime( let forwardedSignal = false; const forwardSignal = (signal: NodeJS.Signals) => { forwardedSignal = true; - if (!child.killed) child.kill(signal); + if (child.exitCode === null && child.signalCode === null) child.kill(signal); }; const onSigint = () => forwardSignal("SIGINT"); const onSigterm = () => forwardSignal("SIGTERM"); const onSighup = () => forwardSignal("SIGHUP"); - process.once("SIGINT", onSigint); - process.once("SIGTERM", onSigterm); - if (process.platform !== "win32") process.once("SIGHUP", onSighup); + process.on("SIGINT", onSigint); + process.on("SIGTERM", onSigterm); + if (process.platform !== "win32") process.on("SIGHUP", onSighup); try { const code = await waitForChild( child, diff --git a/src/runtime-config-bridge.ts b/src/runtime-config-bridge.ts index 30ad695c..6110709e 100644 --- a/src/runtime-config-bridge.ts +++ b/src/runtime-config-bridge.ts @@ -15,6 +15,7 @@ export { exchangeBigmodelOAuthCode } from "./bigmodel-oauth.ts"; export { browserOpenCommand } from "./browser-open.ts"; export { sharedOAuthCredentialReader, zaiBusinessAccessToken } from "./shared-oauth.ts"; export { inspectRuntimeConfiguration, writeConfigurationDoctor } from "./config-diagnostics.ts"; +export { withHeadlessPromptLifecycle } from "./runtime-headless-lifecycle.ts"; function record(value: unknown): Record | undefined { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined; diff --git a/src/runtime-headless-lifecycle.ts b/src/runtime-headless-lifecycle.ts new file mode 100644 index 00000000..582259bf --- /dev/null +++ b/src/runtime-headless-lifecycle.ts @@ -0,0 +1,23 @@ +import type { Writable } from "node:stream"; + +function flushOutput(stream: Writable): Promise { + return new Promise((resolve, reject) => { + stream.write("", (error) => error ? reject(error) : resolve()); + }); +} + +export async function withHeadlessPromptLifecycle( + output: { stdout: Writable; stderr: Writable }, + run: () => Promise +): Promise { + const keepAlive = setInterval(() => {}, 1_000); + try { + return await run(); + } finally { + try { + await Promise.all([flushOutput(output.stdout), flushOutput(output.stderr)]); + } finally { + clearInterval(keepAlive); + } + } +} diff --git a/test/README.md b/test/README.md index 480e46f3..b6a2c6c1 100644 --- a/test/README.md +++ b/test/README.md @@ -55,12 +55,12 @@ Counts describe this reset, not an assertion that future suites must have a fixe | File | Failure this must prevent | | --- | --- | -| `launcher.test.ts` | Keyless prompts run, missing sessions leak diagnostics, or a local plugin cannot be installed and discovered. | +| `launcher.test.ts` | Keyless prompts run, missing sessions leak diagnostics, repeated cancellation leaves the runtime alive, or a local plugin cannot be installed and discovered. | | `app-server-auth.test.ts` | Real protocol turns use the wrong credential owner, fail to rotate keys, or fall back from missing credentials to host authentication. | | `settings-file.test.ts` | Runtime writes and trust changes escape a host-selected settings store or change native MCP precedence. | | `config-migration.test.ts` | Migration loses providers, overwrites conflicting/invalid configuration, or runs twice concurrently. | | `shared-oauth.test.ts` | Native login stores plaintext/wrong tokens, ignores Desktop account changes, or damages shared login state on an HTTP failure. | -| `network-retry.test.ts` | A reset/unfinished stream silently truncates output, duplicates partial output on retry, or reports success after exhaustion. | +| `network-retry.test.ts` | Retry/slow pipes lose a headless result, partial output is duplicated, or exhaustion/cancellation reports success. | | `session-lifecycle.test.ts` | An untouched session is saved, rename/resume loses data, or failed work becomes unresumable. | | `session-model-recovery.test.ts` | An invalid saved model sends a request, cannot be repaired and persisted, or falls back from an unavailable explicit model. | | `guarded-permission-tui.test.ts` | A destructive command executes before single-use approval. | @@ -70,6 +70,7 @@ Counts describe this reset, not an assertion that future suites must have a fixe | File | Failure this must prevent | | --- | --- | +| `headless-lifecycle.test.cjs` | Unreferenced work exits early, output errors disappear, or lifecycle handles keep a finished process alive. | | `session-lifecycle.test.cjs` | Concurrent first writes create duplicate sessions or failed title writes claim success. | | `sqlite-session-store.test.cjs` | Migration races corrupt the database, lock exhaustion commits data, killed writes survive rollback, or native timeout policy disappears. | | `sqlite-recovery.test.cjs` | Retrying duplicates records/counters, enters a caller's transaction, blocks the event loop, loses update order, or persists work after close. | diff --git a/test/fixtures/network-reset-server.mjs b/test/fixtures/network-reset-server.mjs index b80df9b4..ab10b8a3 100644 --- a/test/fixtures/network-reset-server.mjs +++ b/test/fixtures/network-reset-server.mjs @@ -42,6 +42,11 @@ const server = createServer(async (request, response) => { streamingRequests += 1; process.stdout.write(`REQUEST ${streamingRequests}\n`); + if (streamingRequests === 1 && process.env.ZCODE_TEST_HTTP_RETRY === "1") { + response.writeHead(503, { "content-type": "application/json", connection: "close" }); + response.end(JSON.stringify({ error: { message: "Temporary fixture overload" } })); + return; + } response.writeHead(200, { "content-type": "text/event-stream" }); if (streamingRequests === 1) { response.write(streamChunk("chatcmpl-reset", "PARTIAL_SHOULD_BE_DISCARDED")); @@ -49,7 +54,9 @@ const server = createServer(async (request, response) => { return; } - response.write(streamChunk("chatcmpl-recovered", "RECOVERED_FINAL")); + response.write(streamChunk("chatcmpl-recovered", process.env.ZCODE_TEST_RESPONSE_BYTES + ? "R".repeat(Number(process.env.ZCODE_TEST_RESPONSE_BYTES)) + : "RECOVERED_FINAL")); response.write(streamChunk("chatcmpl-recovered", "", "stop")); response.end("data: [DONE]\n\n"); }); diff --git a/test/fixtures/runtime-keepalive.mjs b/test/fixtures/runtime-keepalive.mjs deleted file mode 100644 index b6d0db52..00000000 --- a/test/fixtures/runtime-keepalive.mjs +++ /dev/null @@ -1,3 +0,0 @@ -// Keep the headless Node process alive long enough for Undici to surface the -// reset; the interactive TUI already has terminal handles that serve this role. -setTimeout(() => {}, 5_000); diff --git a/test/node/headless-lifecycle.test.cjs b/test/node/headless-lifecycle.test.cjs new file mode 100644 index 00000000..a400cb4c --- /dev/null +++ b/test/node/headless-lifecycle.test.cjs @@ -0,0 +1,46 @@ +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const path = require("node:path"); + +const root = path.resolve(__dirname, "../.."); + +test("headless lifecycle keeps unreferenced work alive and releases its handle on success and failure", () => { + const child = spawnSync(process.execPath, ["-e", ` + const { withHeadlessPromptLifecycle } = require("./vendor/cli-config.cjs"); + const run = async () => { + const result = await withHeadlessPromptLifecycle(process, () => new Promise(resolve => { + setTimeout(() => resolve("settled"), 50).unref(); + })); + process.stdout.write(result + "\\n"); + try { + await withHeadlessPromptLifecycle(process, () => new Promise((resolve, reject) => { + setTimeout(() => reject(new Error("fixture failure")), 50).unref(); + })); + } catch (error) { + process.stderr.write(error.message + "\\n"); + } + }; + run().catch(() => { process.exitCode = 1; }); + `], { cwd: root, encoding: "utf8", timeout: 3_000 }); + + assert.ifError(child.error); + assert.equal(child.status, 0, child.stderr); + assert.equal(child.stdout, "settled\n"); + assert.equal(child.stderr, "fixture failure\n"); +}); + +test("headless output failures reject and do not leak the keepalive handle", () => { + const child = spawnSync(process.execPath, ["-e", ` + const { Writable } = require("node:stream"); + const { withHeadlessPromptLifecycle } = require("./vendor/cli-config.cjs"); + const broken = new Writable({ write(chunk, encoding, callback) { callback(new Error("output failed")); } }); + broken.on("error", () => {}); + withHeadlessPromptLifecycle({ stdout: broken, stderr: process.stderr }, async () => 0) + .then(() => { process.exitCode = 1; }, error => { process.stderr.write(error.message); }); + `], { cwd: root, encoding: "utf8", timeout: 3_000 }); + + assert.ifError(child.error); + assert.equal(child.status, 0, child.stderr); + assert.equal(child.stderr, "output failed"); +}); diff --git a/test/runtime/launcher.test.ts b/test/runtime/launcher.test.ts index 295c1cca..1a2af140 100644 --- a/test/runtime/launcher.test.ts +++ b/test/runtime/launcher.test.ts @@ -62,6 +62,62 @@ async function run(args: string[], input = "", environment: Record { + test.skipIf(process.platform === "win32")("forwards repeated termination signals until the runtime exits", async () => { + if (!node) throw new Error("Node.js is required for launcher signal tests."); + const directory = await mkdtemp(join(tmpdir(), "zcode-launcher-signals-")); + const fakeNode = join(directory, "fake-node"); + await writeFile(fakeNode, [ + `#!${node}`, + "let signals = 0;", + "process.on('SIGTERM', () => {", + " process.stdout.write(++signals === 1 ? 'FIRST_SIGNAL\\n' : 'SECOND_SIGNAL\\n');", + " if (signals === 2) process.exitCode = 143, clearInterval(keepAlive);", + "});", + "const keepAlive = setInterval(() => {}, 1000);", + "process.stdout.write('READY ' + process.pid + '\\n');", + "" + ].join("\n")); + await chmod(fakeNode, 0o755); + const child = Bun.spawn([process.execPath, "bin/zcode.ts", "-p", "offline signal test"], { + cwd: root, + env: { ...runtimeTestEnv(directory), ZCODE_NODE: fakeNode }, + stdin: "ignore", stdout: "pipe", stderr: "pipe" + }); + let runtimePid: number | undefined; + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + if (runtimePid) try { process.kill(runtimePid, "SIGKILL"); } catch {} + }, 8_000); + try { + const output = (async () => { + let stdout = "", pending = ""; + for await (const chunk of child.stdout) { + const text = new TextDecoder().decode(chunk); + stdout += text; + pending += text; + let newline: number; + while ((newline = pending.indexOf("\n")) >= 0) { + const line = pending.slice(0, newline); + pending = pending.slice(newline + 1); + if (line.startsWith("READY ")) { + runtimePid = Number(line.slice(6)); + child.kill("SIGTERM"); + } else if (line === "FIRST_SIGNAL") child.kill("SIGTERM"); + } + } + return stdout; + })(); + const [code, stdout, stderr] = await Promise.all([child.exited, output, new Response(child.stderr).text()]); + expect(stdout, stderr).toContain("SECOND_SIGNAL"); + expect(code).toBe(143); + } finally { + clearTimeout(timeout); + child.kill("SIGKILL"); + if (runtimePid) try { process.kill(runtimePid, "SIGKILL"); } catch {} + await rm(directory, { recursive: true, force: true }); + } + }, 15_000); + test("rejects a keyless prompt before starting the runtime or creating a session", async () => { const directory = await mkdtemp(join(tmpdir(), "zcode-keyless-prompt-")); const fakeNode = join(directory, "fake-node"); diff --git a/test/runtime/network-retry.test.ts b/test/runtime/network-retry.test.ts index ed234df5..d4495bc6 100644 --- a/test/runtime/network-retry.test.ts +++ b/test/runtime/network-retry.test.ts @@ -92,8 +92,12 @@ interface RuntimeOutputEntry { async function runNetworkFixture(options: { fixture?: string; - keepAlive?: boolean; + launcher?: boolean; + cancelOnRetry?: NodeJS.Signals; modelRetries?: number; + retryDelayMs?: number; + slowReadMs?: number; + outputFormat?: "text" | "json" | "stream-json"; providerName: string; serverEnv?: Record; temporaryPrefix: string; @@ -156,47 +160,76 @@ async function runNetworkFixture(options: { await mkdir(configDirectory, { recursive: true }); await writeFile(join(configDirectory, "setting.json"), `${JSON.stringify(config, null, 2)}\n`); const runtimeArgs = [ - ...(options.keepAlive === false - ? [] - : ["--import", join(root, "test", "fixtures", "runtime-keepalive.mjs")]), - "vendor/zcode.cjs", - "--prompt", + options.launcher ? "bin/zcode.js" : "vendor/zcode.cjs", + options.launcher ? "-p" : "--prompt", "Return the fixture response.", "--cwd", workspace, "--no-color", "--output-format", - "stream-json", + options.outputFormat ?? "stream-json", "--surface", "terminal", "--mode", "build" ]; - const child = Bun.spawn([node, ...runtimeArgs], { + const child = spawn(node, runtimeArgs, { cwd: root, env: { ...env, NO_UPDATE_NOTIFIER: "1", ZCODE_DISABLE_UPDATE_CHECK: "1", - ZCODE_MODEL_RETRY_BASE_DELAY_MS: "0", + ZCODE_MODEL_RETRY_BASE_DELAY_MS: String(options.retryDelayMs ?? 0), ZCODE_MODEL_RETRY_MAX_RETRIES: String(options.modelRetries ?? 1), ZCODE_NODE: node }, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe" + stdio: ["ignore", "pipe", "pipe"] }); - const [code, stdout, stderr] = await Promise.all([ - child.exited, - new Response(child.stdout).text(), - new Response(child.stderr).text() - ]); - const output = stdout.trim().split("\n").filter(Boolean).map( + const timeout = setTimeout(() => child.kill("SIGKILL"), 25_000); + const read = async (stream: NodeJS.ReadableStream, delay = 0, observe = false): Promise => { + if (delay) await new Promise((resolve) => setTimeout(resolve, delay)); + const chunks: Buffer[] = []; + let pending = ""; + let cancelled = false; + for await (const chunk of stream) { + chunks.push(Buffer.from(chunk)); + if (!observe || !options.cancelOnRetry || cancelled) continue; + pending += chunk.toString(); + let newline: number; + while ((newline = pending.indexOf("\n")) >= 0) { + const entry = JSON.parse(pending.slice(0, newline)) as RuntimeOutputEntry; + pending = pending.slice(newline + 1); + if (entry.payload?.type === "model_retry_scheduled") { + cancelled = true; + child.kill(options.cancelOnRetry); + break; + } + } + } + return Buffer.concat(chunks).toString("utf8"); + }; + let code: number | null; + let stdout: string; + let stderr: string; + try { + [code, stdout, stderr] = await Promise.all([ + new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (exitCode, signal) => resolve(exitCode ?? (signal === "SIGTERM" ? 143 : null))); + }), + read(child.stdout!, options.slowReadMs, true), + read(child.stderr!) + ]); + } finally { + clearTimeout(timeout); + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + } + const output = options.outputFormat && options.outputFormat !== "stream-json" ? [] : stdout.trim().split("\n").filter(Boolean).map( (line) => JSON.parse(line) as RuntimeOutputEntry ); const serverOutput = server.output(); return { - code, + code: code ?? 1, diagnostics: `${stderr}\nserver:\n${serverOutput}${server.stderr()}`, output, requestCount: serverOutput.split("\n").filter((line) => line.startsWith("REQUEST ")).length, @@ -241,10 +274,71 @@ test("recovers a graceful mid-stream EOF that never reports a finish reason", as expect(result?.response).not.toContain("PARTIAL_SHOULD_BE_DISCARDED"); }, 30_000); +test("keeps a headless run alive during a long network retry backoff", async () => { + const run = await runNetworkFixture({ + modelRetries: 1, + retryDelayMs: 10_000, + serverEnv: { ZCODE_TEST_HTTP_RETRY: "1" }, + providerName: "Headless backoff fixture", + temporaryPrefix: "zcode-headless-backoff-" + }); + + expect(run.code, run.diagnostics).toBe(0); + expect(run.requestCount, run.stdout).toBe(2); + expect(run.output.some((entry) => entry.payload?.type === "model_retry_scheduled"), run.stdout).toBe(true); + expect(run.output.findLast((entry) => entry.type === "result")?.response).toBe("RECOVERED_FINAL"); +}, 30_000); + +test.each([0, 10_000])("flushes a large headless result with a %ims pipe read delay", async (slowReadMs) => { + const run = await runNetworkFixture({ + providerName: "Headless slow output fixture", + launcher: slowReadMs > 0, + serverEnv: { ZCODE_TEST_RESPONSE_BYTES: String(512 * 1024) }, + slowReadMs, + temporaryPrefix: "zcode-headless-output-" + }); + + expect(run.code, run.diagnostics).toBe(0); + const response = run.output.findLast((entry) => entry.type === "result")?.response; + expect(response?.length, run.output.map((entry) => entry.type).join(", ")).toBe(512 * 1024); + expect(response?.replaceAll("R", "")).toBe(""); +}, 30_000); + +test.each(["text", "json"] as const)("flushes launcher -p %s output to a slow pipe", async (outputFormat) => { + const run = await runNetworkFixture({ + launcher: true, + outputFormat, + providerName: "Headless output format fixture", + serverEnv: { ZCODE_TEST_RESPONSE_BYTES: String(512 * 1024) }, + slowReadMs: 10_000, + temporaryPrefix: "zcode-headless-format-" + }); + const response = outputFormat === "json" ? JSON.parse(run.stdout).response as string : run.stdout.trimEnd(); + + expect(run.code, run.diagnostics).toBe(0); + expect(response.length).toBe(512 * 1024); + expect(response.replaceAll("R", "")).toBe(""); +}, 30_000); + +test.skipIf(process.platform === "win32")("cancels a launcher -p run during backoff without reporting success", async () => { + const run = await runNetworkFixture({ + launcher: true, + cancelOnRetry: "SIGTERM", + modelRetries: 1, + retryDelayMs: 10_000, + providerName: "Headless cancellation fixture", + serverEnv: { ZCODE_TEST_HTTP_RETRY: "1" }, + temporaryPrefix: "zcode-headless-cancel-" + }); + + expect(run.code, run.diagnostics).toBe(143); + expect(run.requestCount).toBe(1); + expect(run.output.some((entry) => entry.type === "result")).toBe(false); +}, 30_000); + test("exits unsuccessfully when graceful EOF recovery is exhausted", async () => { const run = await runNetworkFixture({ fixture: "network-eof-server.mjs", - keepAlive: false, modelRetries: 0, providerName: "EOF exhaustion fixture", serverEnv: { ZCODE_TEST_ALWAYS_EOF: "1" }, From f9dec53659e62933b9d39b1ecc35634b1e67533c Mon Sep 17 00:00:00 2001 From: Kingsword Date: Sat, 31 Oct 2026 19:41:19 +0800 Subject: [PATCH 2/2] fix(release): allow and require headless documentation in tarballs --- scripts/pack-release.ts | 2 ++ test/release-contract.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/scripts/pack-release.ts b/scripts/pack-release.ts index 0ba4b3d9..c11be33c 100644 --- a/scripts/pack-release.ts +++ b/scripts/pack-release.ts @@ -44,6 +44,7 @@ const requiredFiles = [ "docs/PROVIDER_CONFIG.md", "docs/PROVIDER_CONFIG.zh-CN.md", "docs/HOST_INTEGRATION.md", + "docs/HEADLESS.md", "package.json", "vendor/extraction.json", "vendor/node_modules/@zcode/tui/dist/index.js", @@ -65,6 +66,7 @@ const allowedRoots = new Set([ "docs/PROVIDER_CONFIG.md", "docs/PROVIDER_CONFIG.zh-CN.md", "docs/HOST_INTEGRATION.md", + "docs/HEADLESS.md", "package.json", "zcode-runtime.lock.json" ]); diff --git a/test/release-contract.test.ts b/test/release-contract.test.ts index e1436990..fffba9b5 100644 --- a/test/release-contract.test.ts +++ b/test/release-contract.test.ts @@ -1,6 +1,8 @@ import { expect, test } from "bun:test"; import { parseRuntimeLock, selectRuntimeLock } from "../scripts/sync-runtime.ts"; import { parseReleaseVersion, syncedReleaseVersion } from "../scripts/release-version.ts"; +import { validatePackResult, type PackResult } from "../scripts/pack-release.ts"; +import { pluginLicenseFiles, publishedFiles } from "../scripts/package-contents.ts"; const manifest = await Bun.file(new URL("../package.json", import.meta.url)).json(); const lock = parseRuntimeLock(await Bun.file(new URL("../zcode-runtime.lock.json", import.meta.url)).json()); @@ -22,3 +24,30 @@ test("a lagging update manifest cannot downgrade the pinned runtime", () => { test("a runtime upgrade preserves the CLI build number", () => { expect(syncedReleaseVersion("9.0.0", "8.0.0-41")).toBe("9.0.0-41"); }); + +test("the tarball contract accepts published documentation and rejects missing or unreviewed files", () => { + const paths = [ + ...publishedFiles.filter((path) => path !== "vendor"), + ...pluginLicenseFiles, + "package.json", + "vendor/extraction.json", + "vendor/node_modules/@zcode/tui/dist/index.js", + "vendor/node_modules/@zcode/tui/package.json", + "vendor/zcode.cjs", + "vendor/cli-config.cjs", + "vendor/provider/zcode-builtin.json" + ]; + const result: PackResult = { + name: manifest.name, version: manifest.version, filename: "fixture.tgz", + size: 0, unpackedSize: 0, integrity: "fixture", shasum: "fixture", + files: paths.map((path) => ({ path, mode: path === "bin/zcode.js" ? 0o755 : 0o644, size: 0 })) + }; + + expect(() => validatePackResult(result, manifest)).not.toThrow(); + expect(() => validatePackResult({ + ...result, files: result.files.filter((file) => file.path !== "docs/HEADLESS.md") + }, manifest)).toThrow("npm tarball is missing: docs/HEADLESS.md"); + expect(() => validatePackResult({ + ...result, files: [...result.files, { path: "docs/unreviewed.md", mode: 0o644, size: 0 }] + }, manifest)).toThrow("npm tarball contains an unreviewed path: docs/unreviewed.md"); +});