Z ZPI 文档 v0.3.8
介绍页

源码

packages/builtin-plugins/test/background-tasks.test.ts

typescript359 行查看原文
import { mkdtemp, readdir, readFile, rm, stat, truncate, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AgentToolUpdateCallback } from "@zpi-dev/agent";
import { BackgroundTasks } from "@zpi-dev/builtin-plugins/background/tasks";
import { SessionManager } from "@zpi-dev/coding-agent";
import { afterEach, expect, test, vi } from "vitest";
import { fakeModel } from "../../../tests/fake-server.ts";

const cleanup: (() => Promise<unknown>)[] = [];
afterEach(async () => {
  for (const close of cleanup.splice(0).reverse()) await close();
  vi.unstubAllEnvs();
});
async function fixture() {
  const dir = await mkdtemp(join(tmpdir(), "zpi-background-"));
  cleanup.push(() => rm(dir, { recursive: true, force: true }));
  const port = { publish: vi.fn(), notify: vi.fn(async () => {}), error: vi.fn() };
  const runtime = new BackgroundTasks(dir, port);
  cleanup.push(() => runtime.close());
  const invoke = (params: unknown, signal?: AbortSignal, cwd = dir, update?: AgentToolUpdateCallback) =>
    runtime.tool("owner", cwd).execute("call", params, signal, update, {
      cwd,
      model: fakeModel(""),
      sessionManager: SessionManager.inMemory(),
      isIdle: () => false,
      getSystemPrompt: () => "",
    });
  const launch = async (command = "echo ready; sleep 30") => {
    await invoke({ command, description: "Service", run_in_background: true });
    const task = runtime.list("owner").at(-1);
    if (!task) throw new Error("Missing background task");
    return task;
  };
  return { dir, runtime, port, invoke, launch };
}

test.skipIf(process.platform === "win32")(
  "tracks foreground shell descendants after their root exits and stops them on close",
  async () => {
    const f = await fixture();
    const result = await f.invoke({ command: "sleep 30 & echo $!" });
    const task = f.runtime.list("owner")[0];
    expect(task).toMatchObject({ status: "running", autoBackgrounded: true });
    expect(result.content[0]).toMatchObject({ text: expect.stringContaining(task.taskId) });
    const pid = Number((await f.runtime.read("owner", task.taskId)).output.trim());
    expect(Number.isSafeInteger(pid) && pid > 1 && pid !== process.pid).toBe(true);
    const alive = () => {
      try {
        process.kill(pid, 0);
        return true;
      } catch {
        return false;
      }
    };
    try {
      expect(alive()).toBe(true);
      await f.runtime.close();
      expect(alive()).toBe(false);
      expect(f.runtime.list("owner")[0].status).toBe("cancelled");
    } finally {
      if (alive()) process.kill(pid, "SIGTERM");
    }
  },
);

test("background launch detaches from turn abort and timeout; owner can read and stop the process tree", async () => {
  const f = await fixture();
  const abort = new AbortController();
  const result = await f.invoke(
    { command: "echo ready; sleep 30 & echo $!; wait", timeout: 10, run_in_background: true },
    abort.signal,
  );
  const task = f.runtime.list("owner")[0];
  expect(result.content[0]).toMatchObject({ text: expect.stringContaining(task.taskId) });
  abort.abort();
  await expect.poll(async () => (await f.runtime.read("owner", task.taskId)).output).toContain("ready");
  const pid = Number((await readFile(task.outputPath, "utf8")).trim().split("\n").at(-1));
  expect(task.status).toBe("running");
  await expect(f.runtime.stop("other", task.taskId)).rejects.toThrow("不存在");
  await expect(f.runtime.read("other", task.taskId)).rejects.toThrow("不存在");
  await f.runtime.stop("owner", task.taskId);
  expect(f.runtime.list("owner")[0].status).toBe("cancelled");
  await expect
    .poll(() => {
      try {
        process.kill(pid, 0);
        return true;
      } catch {
        return false;
      }
    })
    .toBe(false);
  await f.runtime.stop("owner", task.taskId);
  await expect(f.runtime.stop("owner", task.taskId, true)).rejects.toThrow("not running");
  expect(f.port.notify).not.toHaveBeenCalled();
});

test("foreground commands return output and failures; eligible deadline hands off the same process", async () => {
  const f = await fixture();
  expect(await f.invoke({ command: "printf hello" })).toMatchObject({
    isError: false,
    content: [{ text: "hello" }],
  });
  expect(await f.invoke({ command: "echo bad >&2; exit 7" })).toMatchObject({
    isError: true,
    content: [{ text: expect.stringContaining("exit code 7") }],
  });
  expect(f.runtime.list("owner")).toHaveLength(0);
  await f.invoke({ command: "echo once; sleep 0.15; echo done", timeout: 10 });
  const task = f.runtime.list("owner")[0];
  expect(task.autoBackgrounded).toBe(true);
  const result = await f.runtime.output("owner", { task_id: task.taskId, timeout: 1000 });
  expect(result.details).toMatchObject({
    retrieval_status: "success",
    task: { output: "once\ndone\n", exitCode: 0 },
  });
  expect(f.port.notify).not.toHaveBeenCalled();
  expect(f.port.error).not.toHaveBeenCalled();
});

test("sleep deadlines and foreground cancellation terminate instead of backgrounding", async () => {
  const f = await fixture();
  expect(await f.invoke({ command: "sleep 30", timeout: 10 })).toMatchObject({ isError: true });
  const abort = new AbortController();
  const run = f.invoke({ command: "echo running; sleep 30" }, abort.signal);
  setTimeout(() => abort.abort(), 30);
  await expect(run).rejects.toThrow();
  expect(f.runtime.list("owner")).toHaveLength(0);
});

test("foreground commands publish previews and retain turn cancellation", async () => {
  const f = await fixture();
  const abort = new AbortController();
  const update = vi.fn<AgentToolUpdateCallback>(() => abort.abort());
  await expect(f.invoke({ command: "echo ready; sleep 30" }, abort.signal, f.dir, update)).rejects.toThrow();
  expect(update).toHaveBeenCalledWith({
    content: [{ type: "text", text: "ready\n" }],
    details: undefined,
    isError: false,
  });
  expect(f.runtime.list("owner")).toHaveLength(0);
  expect(f.port.error).not.toHaveBeenCalled();
});

test.each([false, true])(
  "background handoff stops turn previews with run_in_background=%s",
  async (background) => {
    const f = await fixture();
    const abort = new AbortController();
    const update = vi.fn<AgentToolUpdateCallback>();
    await f.invoke(
      { command: "echo ready; sleep 30", timeout: 10, run_in_background: background },
      abort.signal,
      f.dir,
      update,
    );
    const task = f.runtime.list("owner")[0];
    abort.abort();
    const result = await f.runtime.output("owner", { task_id: task.taskId, timeout: 1200 });
    expect(result.details.retrieval_status).toBe("timeout");
    expect(result.details.task.output).toBe("ready\n");
    await f.runtime.stop("owner", task.taskId);
    expect(update).not.toHaveBeenCalled();
    expect(f.port.error).not.toHaveBeenCalled();
  },
);

test("TaskOutput supports nonblocking reads, timed waits and aborts without stopping the task", async () => {
  const f = await fixture();
  const task = await f.launch();
  expect(
    (await f.runtime.output("owner", { task_id: task.taskId, block: false })).details.retrieval_status,
  ).toBe("not_ready");
  expect(
    (await f.runtime.output("owner", { task_id: task.taskId, timeout: 5 })).details.retrieval_status,
  ).toBe("timeout");
  const abort = new AbortController();
  const wait = f.runtime.output("owner", { task_id: task.taskId }, abort.signal);
  abort.abort();
  await expect(wait).rejects.toThrow();
  expect(f.runtime.list("owner")[0].status).toBe("running");
});

test("unclaimed completion notifies exactly once, including failed background commands", async () => {
  const f = await fixture();
  const task = await f.launch("echo failure >&2; exit 6");
  await expect.poll(() => f.port.notify.mock.calls.length).toBe(1);
  expect(f.port.notify).toHaveBeenCalledWith(expect.objectContaining({ status: "failed", exitCode: 6 }));
  const result = await f.runtime.output("owner", { task_id: task.taskId });
  expect(result.details.task.output).toBe("failure\n");
  expect(f.port.notify).toHaveBeenCalledTimes(1);
});

test("aborted output wait does not consume later completion notification", async () => {
  const f = await fixture();
  const task = await f.launch("sleep 0.15; echo done");
  const abort = new AbortController();
  const waiting = f.runtime.output("owner", { task_id: task.taskId }, abort.signal);
  abort.abort();
  await expect(waiting).rejects.toThrow();
  await expect.poll(() => f.port.notify.mock.calls.length).toBe(1);
});

test("retains descendants after their launching shell exits and stops them on shutdown", async () => {
  const f = await fixture();
  const task = await f.launch("sleep 30 & echo $!");
  await expect.poll(async () => (await f.runtime.read("owner", task.taskId)).output.trim()).toMatch(/^\d+$/);
  expect(f.runtime.list("owner")[0].status).toBe("running");
  const pid = Number((await f.runtime.read("owner", task.taskId)).output.trim());
  await f.runtime.close();
  await expect
    .poll(() => {
      try {
        process.kill(pid, 0);
        return true;
      } catch {
        return false;
      }
    })
    .toBe(false);
  expect(f.runtime.list("owner")[0].status).toBe("cancelled");
});

test("large output uses running prefix and bounded UI tail, retaining a complete file", async () => {
  const f = await fixture();
  const task = await f.launch();
  await expect.poll(async () => (await f.runtime.read("owner", task.taskId)).output).toContain("ready");
  await writeFile(task.outputPath, `HEAD${"x".repeat(100000)}TAIL`);
  const preview = await f.runtime.read("owner", task.taskId);
  expect(preview.output).toHaveLength(65536);
  expect(preview.output).toMatch(/TAIL$/);
  expect(preview.truncated).toBe(true);
  const output = await f.runtime.output("owner", { task_id: task.taskId, block: false });
  expect(output.details.task.output).toMatch(/^HEAD/);
  expect(output.details.task.output).toContain("Output truncated");
  expect(output.details.task.output).not.toContain("TAIL");
  expect((await readFile(task.outputPath)).length).toBe(100008);
});

test("missing cwd is a foreground spawn failure and restored PIDs cannot be stopped", async () => {
  const f = await fixture();
  expect(
    await f.invoke({ command: "true", run_in_background: true }, undefined, join(f.dir, "missing")),
  ).toMatchObject({ isError: true });
  expect(f.runtime.list("owner")).toHaveLength(0);
  f.runtime.restore({
    taskId: "exec_old",
    sessionId: "owner",
    toolCallId: "old",
    command: "sleep 30",
    description: "Old",
    status: "running",
    pid: process.pid,
    startedAt: 1,
    outputPath: join(f.dir, "old.log"),
  });
  expect(f.runtime.list("owner")[0].status).toBe("lost");
  await f.runtime.stop("owner", "exec_old");
  expect(process.kill(process.pid, 0)).toBe(true);
});

test("stopping lets services flush cleanup output before forcefully terminating an unresponsive tree", async () => {
  const f = await fixture();
  const task = await f.launch("trap 'echo cleaned; exit 0' TERM; echo ready; while :; do sleep 1; done");
  await expect.poll(async () => (await f.runtime.read("owner", task.taskId)).output).toContain("ready");
  await f.runtime.stop("owner", task.taskId);
  expect((await f.runtime.read("owner", task.taskId)).output).toContain("cleaned");
  const stubborn = await f.launch("trap '' TERM; echo ready; while :; do sleep 1; done");
  await expect.poll(async () => (await f.runtime.read("owner", stubborn.taskId)).output).toContain("ready");
  await f.runtime.stop("owner", stubborn.taskId);
  expect(f.runtime.list("owner").at(-1)?.status).toBe("cancelled");
  expect(f.port.error).not.toHaveBeenCalled();
});

test("truncated foreground output retains the full log and gives its path", async () => {
  const f = await fixture();
  const result = await f.invoke({ command: "head -c 70000 /dev/zero | tr '\\0' x; printf END" });
  const text = result.content.map((part) => (part.type === "text" ? part.text : "")).join("");
  expect(text).toContain("END");
  const path = /Full output: (.+)\]/.exec(text)?.[1];
  if (!path) throw new Error("Missing output artifact");
  expect((await readFile(path)).length).toBe(70003);
});

test.each([70000, 8 * 1024 ** 2 + 1024])(
  "completed TaskOutput retains final errors when the log contains %i padding bytes",
  async (paddingBytes) => {
    const f = await fixture();
    const outputPath = join(f.dir, "completed.log");
    await writeFile(outputPath, `HEAD${"x".repeat(paddingBytes)}\nFINAL_ERROR: compiler failed\n`);
    f.runtime.restore({
      taskId: "exec_completed",
      sessionId: "owner",
      toolCallId: "call",
      command: "build",
      description: "Build",
      status: "failed",
      startedAt: 1,
      endedAt: 2,
      exitCode: 7,
      outputPath,
    });
    const result = await f.runtime.output("owner", { task_id: "exec_completed" });
    expect(result.details.retrieval_status).toBe("success");
    expect(result.details.task.output).not.toContain("HEAD");
    expect(result.details.task.output).toContain("FINAL_ERROR: compiler failed\n");
    expect(result.details.task.output).toContain(`Full output: ${outputPath}`);
    expect(result.details.task.output.length).toBeLessThan(33000);
    expect(result.content[0].text).toContain("FINAL_ERROR: compiler failed\n");
  },
);

// POSIX truncate extends the file with holes, so this tests the real limit without writing 5 GiB.
test.skipIf(process.platform === "win32").each([false, true])(
  "the output limit stops the process tree with run_in_background=%s",
  async (background) => {
    const f = await fixture();
    const run = f.invoke({
      command: "echo $$ > shell.pid; sleep 30 & echo $! > child.pid; wait",
      run_in_background: background,
    });
    await expect.poll(() => readFile(join(f.dir, "child.pid"), "utf8")).toMatch(/^\d+\n$/);
    const pids = await Promise.all(
      ["shell.pid", "child.pid"].map(async (name) => Number(await readFile(join(f.dir, name), "utf8"))),
    );
    const directory = join(f.dir, "agent", "tool-output", "owner");
    const [name] = await readdir(directory);
    const outputPath = join(directory, name);
    await truncate(outputPath, 5 * 1024 ** 3 + 1);
    expect((await stat(outputPath)).blocks * 512).toBeLessThan(1024 ** 2);

    if (background) {
      await run;
      await expect.poll(() => f.runtime.list("owner")[0].status, { timeout: 5000 }).toBe("failed");
      expect(f.runtime.list("owner")[0].error).toBe("Command output exceeded 5 GiB.");
    } else {
      expect(await run).toMatchObject({
        isError: true,
        content: [{ text: expect.stringContaining("Command output exceeded 5 GiB.") }],
      });
      expect(f.runtime.list("owner")).toHaveLength(0);
    }
    for (const pid of pids) {
      await expect
        .poll(() => {
          try {
            process.kill(pid, 0);
            return true;
          } catch {
            return false;
          }
        })
        .toBe(false);
    }
    expect(f.port.error).not.toHaveBeenCalled();
  },
);