mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agent Chat returns delegated-task completions to the chat that started them, and the runner E2E `completion-updates` suite checks this with real providers > - The restart cell (`completion-updates.runner-codex.local.handoff-completion-restart`) failed before its server restart with "Timed out waiting for worker waiting while originating chat is idle" > - Since project-optional task workspaces (#15749), a task in a project without a workspace runs in its isolated task directory. A native chat runs in its isolated conversation directory. Native Codex can read only that directory. > - The fixture still wrote its brief wait scripts into the managed project workspace and the agent home. Codex got EPERM on both scripts, wrote the note from the task record, and finished. The worker never reached the gate, so the setup poll timed out. > - This pull request makes the waits name only cwd-relative files and publishes each brief into the run's own isolated task directory > - The benefit is that the restart cell reaches its real boundary again (durable Done, source provider held at the gate, server restart) and tests restart recovery instead of a stale fixture path ## Linked Issues or Issue Description **What happened?** The runner E2E cell `completion-updates.runner-codex.local.handoff-completion-restart` timed out in setup. The run ledger shows both agents report `permission_denied` (EPERM) for `node .../wait-for-brief-*.cjs`. The worker run cwd was `<instance>/isolated-workspaces/<company>/<issue>`. The fixture wrote the script to the managed project workspace and the agent home. **What did you expect to happen?** The worker waits at the brief gate while the chat is idle. Then the cell releases the brief, holds the completion reply at its gate, and restarts the server. **Steps to reproduce** `pnpm test:e2e:runner -- --max-automatic-retries 0 --id completion-updates.runner-codex.local.handoff-completion-restart` on master `6cafcaf89b`. **Classification** Test defect. The failure happens before the restart. Workspace isolation is the intended product behavior. ## What Changed - `tests/runner-e2e/chat-stories.ts`: add `workspaceChatBrief(nonce, timeoutMs)`. It returns a bounded `node -e` command that reads and writes only cwd-relative files, plus `at(dir)` to find the gate and ready files in a workspace. - `tests/runner-e2e/completion-update-flow.ts`: the worker and completion instructions use that command. The fixture reads readiness from, and publishes briefs to, `isolated-workspaces/<company>/<issue>` for each delegated task and the source chat. The setup poll state now records the worker run cwd. Cleanup releases every wait that is still open. - `tests/runner-e2e/chat-stories.test.ts`: regression test. It runs the command from a temporary cwd, checks that the command names no host path, and checks the nonce and timeout bounds. It was red before the helper existed. - `tests/runner-e2e/catalog.ts`: bump the `completion-updates` definition to version 28 and set `workerBriefWorkspace: "run-isolated-task-directory"`. - `tests/runner-e2e/README.md`: describe the new brief location. ## Verification - `npx vitest run --config tests/runner-e2e/vitest.config.ts tests/runner-e2e/chat-stories.test.ts tests/runner-e2e/completion-updates.test.ts tests/runner-e2e/catalog.test.ts`: 108 passed. - Live cell, native Codex `gpt-5.6-sol`, local runner: `pnpm test:e2e:runner -- --max-automatic-retries 0 --id completion-updates.runner-codex.local.handoff-completion-restart`. Results are in the Verification comment below. - Two earlier local attempts in this worktree failed in setup with `harness_test_failed`. The worktree did not have a built `paperclip-runnerd`. After `pnpm --filter @paperclipai/paperclip-runner build`, the cell passed. ## Risks - Low risk. The change is in test fixtures only. No product code changes. - The idle, busy, and multiple cells share this flow. They now also use the isolated task directory. That is the same directory the product selects for these tasks. - The agent must run the inline command from its cwd. The instructions say this, and the command fails closed (no gate) if the agent runs it elsewhere. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with tool use (shell, file edits). Default context window. Extended reasoning on. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
154 lines
9.1 KiB
TypeScript
154 lines
9.1 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { assertInterruptedChat, prepareChatBrief, publishChatBrief, workspaceChatBrief } from "./chat-stories.js";
|
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { execFile } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import { runnerMatrix } from "./catalog.js";
|
|
import { buildRunnerE2EProcessEnvironment } from "./harness-env.js";
|
|
|
|
vi.mock("node:fs/promises", async importOriginal => {
|
|
const actual = await importOriginal<typeof import("node:fs/promises")>();
|
|
return { ...actual, writeFile: vi.fn(actual.writeFile) };
|
|
});
|
|
|
|
const run = { id: "run", companyId: "company", agentId: "agent", status: "succeeded", runtimeMode: "native", contextSnapshot: { issueId: "chat" } };
|
|
const valid = {
|
|
first: "Read the brief", followup: "Change the launch day", reference: "BRIEF123", marker: "UPDATED123", issueId: "chat",
|
|
boundaryRun: { ...run, status: "running" }, activeAtFollowup: { ...run, status: "running" },
|
|
comments: [{ id: "first", body: "Read the brief" }, { id: "followup", body: "Change the launch day" },
|
|
{ id: "answer", authorAgentId: "agent", createdByRunId: "run", body: "BRIEF123 UPDATED123" }],
|
|
runs: [run], revisedPlan: JSON.stringify({ launchDay: "Friday", reference: "BRIEF123", revision: "UPDATED123" }),
|
|
};
|
|
|
|
describe("active chat follow-up oracle", () => {
|
|
it("creates the missing fixture directory and waits for the host-supplied brief", async () => {
|
|
const root = await mkdtemp(path.join(tmpdir(), "chat-brief-test-"));
|
|
try {
|
|
const { gate, ready, scriptPath } = await prepareChatBrief(path.join(root, "new workspace"), "fixture");
|
|
const command = promisify(execFile)(process.execPath, [scriptPath], { timeout: 5_000 });
|
|
try {
|
|
await expect.poll(() => readFile(ready, "utf8").catch(() => "")).toBe("waiting");
|
|
} finally {
|
|
await publishChatBrief(gate, "BRIEF fixture result");
|
|
expect((await command).stdout.trim()).toBe("BRIEF fixture result");
|
|
}
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
it("waits for a brief inside the agent's own working directory without host paths", async () => {
|
|
const root = await mkdtemp(path.join(tmpdir(), "chat-brief-relative-"));
|
|
try {
|
|
const brief = workspaceChatBrief("fixture-1", 5_000);
|
|
// A sandboxed agent can read only its own workspace. The command must
|
|
// not name the fixture host directory, and must resolve from its cwd.
|
|
expect(brief.command).not.toContain(root);
|
|
expect(brief.command).not.toContain(path.sep + "tmp");
|
|
const { gate, ready } = brief.at(root);
|
|
const command = promisify(execFile)("/bin/sh", ["-c", brief.command], { cwd: root, timeout: 5_000 });
|
|
try {
|
|
await expect.poll(() => readFile(ready, "utf8").catch(() => "")).toBe("waiting");
|
|
} finally {
|
|
await publishChatBrief(gate, "BRIEF relative result");
|
|
expect((await command).stdout.trim()).toBe("BRIEF relative result");
|
|
}
|
|
expect(() => workspaceChatBrief("../escape")).toThrow();
|
|
expect(() => workspaceChatBrief("ok", 0)).toThrow("bounded");
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
it("accepts either steering or one queued successor, with the saved correction", () => {
|
|
expect(() => assertInterruptedChat(valid)).not.toThrow();
|
|
expect(() => assertInterruptedChat({ ...valid, runs: [run, { ...run, id: "successor" }], comments: [...valid.comments.slice(0, 2), { ...valid.comments[2]!, createdByRunId: "successor" }] })).not.toThrow();
|
|
});
|
|
it("rejects follow-ups sent after the active boundary", () => {
|
|
expect(() => assertInterruptedChat({ ...valid, boundaryRun: run })).toThrow();
|
|
expect(() => assertInterruptedChat({ ...valid, activeAtFollowup: run })).toThrow();
|
|
expect(() => assertInterruptedChat({ ...valid, activeAtFollowup: { ...valid.activeAtFollowup, id: "other" } })).toThrow();
|
|
});
|
|
it("rejects lost or duplicated input, missing file evidence, and stale plans", () => {
|
|
expect(() => assertInterruptedChat({ ...valid, comments: [...valid.comments, { ...valid.comments[2]!, id: "duplicate-reply" }] })).toThrow();
|
|
expect(() => assertInterruptedChat({ ...valid, comments: [...valid.comments.slice(0, 2), { ...valid.comments[2]!, createdByRunId: "unrelated" }] })).toThrow();
|
|
expect(() => assertInterruptedChat({ ...valid, comments: valid.comments.filter(c => c.id !== "followup") })).toThrow();
|
|
expect(() => assertInterruptedChat({ ...valid, comments: [...valid.comments, { id: "duplicate", body: valid.followup }] })).toThrow();
|
|
expect(() => assertInterruptedChat({ ...valid, comments: [...valid.comments.slice(0, 2), { id: "answer", authorAgentId: "agent", body: "UPDATED123" }] })).toThrow();
|
|
expect(() => assertInterruptedChat({ ...valid, revisedPlan: valid.revisedPlan.replace("Friday", "Monday") })).toThrow();
|
|
expect(() => assertInterruptedChat({ ...valid, revisedPlan: "The updated plan is saved." })).toThrow();
|
|
});
|
|
it("rejects failed, unfinished, duplicated, or unrelated execution", () => {
|
|
for (const status of ["running", "queued", "failed", "cancelled"])
|
|
expect(() => assertInterruptedChat({ ...valid, runs: [{ ...run, status }] })).toThrow();
|
|
expect(() => assertInterruptedChat({ ...valid, runs: [] })).toThrow();
|
|
expect(() => assertInterruptedChat({ ...valid, runs: [run, run, run] })).toThrow();
|
|
expect(() => assertInterruptedChat({ ...valid, runs: [run, run] })).toThrow();
|
|
expect(() => assertInterruptedChat({ ...valid, runs: [{ ...run, id: "other" }] })).toThrow();
|
|
expect(() => assertInterruptedChat({ ...valid, runs: [{ ...run, contextSnapshot: { issueId: "other" } }] })).toThrow();
|
|
});
|
|
it("keeps setup and interruption stories native, local, opt-in, and outside API-tool overrides", () => {
|
|
const cells = runnerMatrix.filter(cell => cell.suite.id === "agent-chat-stories");
|
|
expect(cells).toHaveLength(6);
|
|
for (const cell of cells) {
|
|
expect(cell.suite.manualOnly).toBe(true);
|
|
expect(cell.profile.generation).toBe("native");
|
|
expect(cell.environment.id).toBe("local");
|
|
expect(buildRunnerE2EProcessEnvironment({}, [cell]).PAPERCLIP_RUNNER_API_TOOLS_ENABLED).toBeUndefined();
|
|
}
|
|
});
|
|
});
|
|
|
|
|
|
describe("bounded chat brief fixture", () => {
|
|
it("publishes the complete brief atomically while a real worker is waiting", async () => {
|
|
const directory = await mkdtemp(path.join(tmpdir(), "chat-brief-atomic-"));
|
|
const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
|
|
let releaseWrite!: () => void;
|
|
const pausedWrite = new Promise<void>(resolve => { releaseWrite = resolve; });
|
|
let exposedEmptyWrite!: (file: Parameters<typeof writeFile>[0]) => void;
|
|
const emptyWrite = new Promise<Parameters<typeof writeFile>[0]>(resolve => { exposedEmptyWrite = resolve; });
|
|
let running: Promise<{ stdout: string; stderr: string }> | undefined;
|
|
let publishing: Promise<void> | undefined;
|
|
try {
|
|
const brief = await prepareChatBrief(directory, "atomic", 5_000);
|
|
running = promisify(execFile)(process.execPath, [brief.scriptPath]);
|
|
await expect.poll(() => readFile(brief.ready, "utf8").catch(() => "")).toBe("waiting");
|
|
vi.mocked(writeFile).mockImplementationOnce(async (file, data, options) => {
|
|
await actual.writeFile(file, "");
|
|
exposedEmptyWrite(file);
|
|
await pausedWrite;
|
|
await actual.writeFile(file, data, typeof options === "object" ? { ...options, flag: "w" } : options);
|
|
});
|
|
publishing = publishChatBrief(brief.gate, "A complete multiword brief.");
|
|
const stagedFile = await emptyWrite;
|
|
expect(await readFile(stagedFile, "utf8")).toBe("");
|
|
await expect(readFile(brief.gate, "utf8")).rejects.toHaveProperty("code", "ENOENT");
|
|
releaseWrite();
|
|
await publishing;
|
|
expect((await running).stdout.trim()).toBe("A complete multiword brief.");
|
|
} finally {
|
|
releaseWrite();
|
|
await publishing;
|
|
await running?.catch(() => undefined);
|
|
vi.mocked(writeFile).mockImplementation(actual.writeFile);
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("releases a waiting worker and rejects an unbounded wait", async () => {
|
|
const directory = await mkdtemp(path.join(tmpdir(), "chat-brief-bounded-"));
|
|
try {
|
|
for (const timeout of [0, -1, Infinity, 300_001])
|
|
await expect(prepareChatBrief(directory, "invalid", timeout)).rejects.toThrow("bounded");
|
|
const brief = await prepareChatBrief(directory, "release", 2_000);
|
|
const running = promisify(execFile)(process.execPath, [brief.scriptPath]);
|
|
await expect.poll(() => readFile(brief.ready, "utf8").catch(() => "")).toBe("waiting");
|
|
await publishChatBrief(brief.gate, "Meet at half past ten.");
|
|
expect((await running).stdout.trim()).toBe("Meet at half past ten.");
|
|
const expires = await prepareChatBrief(directory, "expires", 1);
|
|
await expect(promisify(execFile)(process.execPath, [expires.scriptPath])).rejects.toMatchObject({ code: 1 });
|
|
} finally { await rm(directory, { recursive: true, force: true }); }
|
|
});
|
|
});
|