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>
189 lines
12 KiB
TypeScript
189 lines
12 KiB
TypeScript
import { expect } from "@playwright/test";
|
|
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { randomUUID } from "node:crypto";
|
|
import { sendChatMessage, type ChatFlowInput, type ChatIssue, type ChatRun } from "./chat-flow.js";
|
|
import { setSettingsToggle } from "./settings-toggle.js";
|
|
import { resolveDefaultAgentWorkspaceDir } from "../../server/src/home-paths.js";
|
|
|
|
type Comment = { id: string; body: string; authorAgentId?: string; createdByRunId?: string };
|
|
type Context = {
|
|
input: ChatFlowInput; marker: string; issue(): ChatIssue;
|
|
idle(count: number): Promise<void>; allRuns(): Promise<ChatRun[]>; comments(): Promise<Comment[]>;
|
|
};
|
|
|
|
/** Seed only the disabled setting; the browser performs the user's opt-in. */
|
|
export async function enableChatThroughSettings(input: ChatFlowInput) {
|
|
await input.api.patch("/api/instance/settings/experimental", { enableAgentChat: false, enableClassicTaskInterface: false });
|
|
await setChatEnabled(input, true);
|
|
}
|
|
|
|
async function setChatEnabled(input: ChatFlowInput, enabled: boolean) {
|
|
await input.page.goto(`/${input.fixtures.company.issuePrefix}/company/settings/instance/experimental`, { waitUntil: "domcontentloaded" });
|
|
const toggle = input.page.getByRole("switch", { name: "Toggle agent chat experimental setting" });
|
|
await setSettingsToggle(toggle, enabled);
|
|
await expect.poll(async () => (await input.api.get<{ enableAgentChat: boolean }>("/api/instance/settings/experimental")).enableAgentChat).toBe(enabled);
|
|
}
|
|
|
|
export function assertInterruptedChat(input: {
|
|
first: string; followup: string; reference: string; marker: string; issueId: string;
|
|
boundaryRun: ChatRun; activeAtFollowup: ChatRun; comments: Comment[]; runs: ChatRun[];
|
|
revisedPlan?: string;
|
|
}) {
|
|
expect(input.boundaryRun.status).toBe("running");
|
|
expect(input.activeAtFollowup).toMatchObject({ id: input.boundaryRun.id, status: "running" });
|
|
for (const message of [input.first, input.followup]) {
|
|
expect(input.comments.filter(comment => !comment.authorAgentId && comment.body === message)).toHaveLength(1);
|
|
}
|
|
const replies = input.comments.filter(comment => comment.authorAgentId);
|
|
const final = replies.at(-1)!;
|
|
expect(final).toBeTruthy();
|
|
expect(final.body).toContain(input.reference);
|
|
expect(final.body).toContain(input.marker);
|
|
expect(replies.filter(reply => reply.body.includes(input.marker))).toHaveLength(1);
|
|
expect(input.runs.length).toBeGreaterThanOrEqual(1);
|
|
expect(input.runs.length).toBeLessThanOrEqual(2);
|
|
expect(input.runs.every(run => run.status === "succeeded" && run.runtimeMode === "native")).toBe(true);
|
|
expect(new Set(input.runs.map(run => run.id)).size).toBe(input.runs.length);
|
|
expect(input.runs.every(run => run.agentId === input.boundaryRun.agentId && run.contextSnapshot?.issueId === input.issueId)).toBe(true);
|
|
expect(final.authorAgentId).toBe(input.boundaryRun.agentId);
|
|
const responseRun = input.runs.length === 1
|
|
? input.boundaryRun
|
|
: input.runs.find(run => run.id !== input.boundaryRun.id)!;
|
|
expect(final.createdByRunId).toBe(responseRun.id);
|
|
expect(input.runs.some(run => run.id === input.boundaryRun.id)).toBe(true);
|
|
if (input.revisedPlan !== undefined) {
|
|
const body = input.revisedPlan.trim().replace(/^```(?:json)?\s*/, "").replace(/\s*```$/, "");
|
|
expect(JSON.parse(body)).toEqual({ launchDay: "Friday", reference: input.reference, revision: input.marker });
|
|
}
|
|
}
|
|
|
|
export async function publishChatBrief(gate: string, content: string) {
|
|
const staging = `${gate}.${randomUUID()}.tmp`;
|
|
try {
|
|
await writeFile(staging, content, { encoding: "utf8", flag: "wx" });
|
|
await rename(staging, gate);
|
|
} finally {
|
|
await rm(staging, { force: true });
|
|
}
|
|
}
|
|
|
|
export async function prepareChatBrief(workspacePath: string, nonce: string, timeoutMs = 120_000) {
|
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 300_000) throw new Error("Brief wait must be bounded between 1 and 300000ms");
|
|
await mkdir(workspacePath, { recursive: true });
|
|
const gate = path.join(workspacePath, `chat-brief-${nonce}.txt`);
|
|
const ready = `${gate}.waiting`;
|
|
const scriptPath = path.join(workspacePath, `wait-for-brief-${nonce}.cjs`);
|
|
const script = `const fs=require("node:fs");fs.writeFileSync(${JSON.stringify(ready)},"waiting");const end=Date.now()+${timeoutMs};const timer=setInterval(()=>{if(fs.existsSync(${JSON.stringify(gate)})){console.log(fs.readFileSync(${JSON.stringify(gate)},"utf8"));clearInterval(timer)}else if(Date.now()>end){clearInterval(timer);process.exitCode=1}},100);`;
|
|
await writeFile(scriptPath, script, "utf8");
|
|
return { gate, ready, scriptPath };
|
|
}
|
|
|
|
/**
|
|
* A bounded brief wait that resolves only from the agent's working directory.
|
|
* Sandboxed native providers can read their own task directory but not agent
|
|
* homes or unrelated project roots, so the command names no host path. The
|
|
* fixture publishes to `at(<the run's workspace>)` once that directory is known.
|
|
*/
|
|
export function workspaceChatBrief(nonce: string, timeoutMs = 120_000) {
|
|
if (!/^[A-Za-z0-9_-]+$/.test(nonce)) throw new Error("Brief nonce must be a plain file name segment");
|
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 300_000) throw new Error("Brief wait must be bounded between 1 and 300000ms");
|
|
const name = `chat-brief-${nonce}.txt`;
|
|
const script = `const fs=require("node:fs"),g="${name}",end=Date.now()+${Math.floor(timeoutMs)};fs.writeFileSync(g+".waiting","waiting");const t=setInterval(()=>{if(fs.existsSync(g)){console.log(fs.readFileSync(g,"utf8"));clearInterval(t)}else if(Date.now()>end){clearInterval(t);process.exitCode=1}},100)`;
|
|
return {
|
|
command: `node -e '${script}'`,
|
|
at: (workspacePath: string) => ({ gate: path.join(workspacePath, name), ready: path.join(workspacePath, `${name}.waiting`) }),
|
|
};
|
|
}
|
|
|
|
export async function runChatInterruption(context: Context & { refreshIssue(): Promise<void> }) {
|
|
const { input, marker, idle, allRuns, comments } = context;
|
|
// Unprojected host /tmp files are intentionally hidden from native Codex.
|
|
// These projectless chats use the normal agent-home workspace, not the
|
|
// harness's separate project fixture directory. Keep the asset inside it.
|
|
const agentWorkspace = resolveDefaultAgentWorkspaceDir(input.fixtures.agent.id);
|
|
const relative = path.relative(path.dirname(input.workspacePath), agentWorkspace);
|
|
if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("Chat fixture workspace escaped the isolated instance");
|
|
const { gate, ready, scriptPath } = await prepareChatBrief(agentWorkspace, input.nonce);
|
|
const reference = `BRIEF${randomUUID().replaceAll("-", "")}`;
|
|
// Real provider tool execution waits on an ordinary fixture file. No runner
|
|
// hooks, provider results, database records, or task outcomes are fabricated.
|
|
// Keep JavaScript in a seeded fixture file: rich-text input serializes raw
|
|
// operators as Markdown escapes, which should not alter a timing fixture.
|
|
const command = `node ${scriptPath}`;
|
|
const first = `Our launch is planned for Monday. First run this bounded command to wait for the brief reference file I am supplying: ${command}\nAfter it returns, acknowledge the reference from the file here. This is discussion only; do not create projects or tasks.`;
|
|
const revise = input.execution.task.id === "revise-while-running";
|
|
const followup = revise
|
|
? `Change the launch day to Friday. Save the plan on this conversation as JSON with exactly launchDay, reference (from the brief file), and revision ("${marker}"). Then reply here with the brief reference and ${marker}. Do not create projects or execution tasks.`
|
|
: `After reading the brief, reply here with its reference and ${marker}. Keep this in the current conversation; do not create projects or tasks.`;
|
|
let boundaryRun: ChatRun | undefined;
|
|
let activeAtFollowup: ChatRun | undefined;
|
|
try {
|
|
await sendChatMessage(input.page, first);
|
|
await expect.poll(async () => {
|
|
if (await readFile(ready, "utf8").catch(() => "") !== "waiting") return false;
|
|
boundaryRun = (await allRuns()).find(run => run.status === "running");
|
|
return Boolean(boundaryRun);
|
|
}, { timeout: 120_000 }).toBe(true);
|
|
expect(boundaryRun!.contextSnapshot?.paperclipWorkspace).toMatchObject({ cwd: agentWorkspace });
|
|
await context.refreshIssue();
|
|
await sendChatMessage(input.page, followup);
|
|
await expect.poll(async () => (await comments()).filter(comment => !comment.authorAgentId && comment.body === followup).length,
|
|
{ timeout: 30_000 }).toBe(1);
|
|
activeAtFollowup = await input.api.get<ChatRun>(`/api/heartbeat-runs/${boundaryRun!.id}`);
|
|
await input.evidence("chat-interruption-boundary.json", { first, followup, boundaryRun, activeAtFollowup, comments: await comments() });
|
|
expect(activeAtFollowup.status, "Follow-up must persist while the first provider run is active").toBe("running");
|
|
await publishChatBrief(gate, reference);
|
|
await expect.poll(async () => (await comments()).filter(comment => comment.authorAgentId).at(-1)?.body ?? "", { timeout: 240_000 }).toContain(marker);
|
|
await idle(1); // Providers may queue a new run or steer the current run.
|
|
const plan = revise ? await input.api.get<{ body: string }>(`/api/issues/${context.issue().id}/documents/plan`) : undefined;
|
|
const evidence = { first, followup, reference, marker, issueId: context.issue().id, boundaryRun: boundaryRun!, activeAtFollowup,
|
|
comments: await comments(), runs: await allRuns(), ...(plan ? { revisedPlan: plan.body } : {}) };
|
|
await input.evidence("chat-interruption.json", evidence);
|
|
assertInterruptedChat(evidence);
|
|
expect(await input.api.get(`/api/companies/${input.fixtures.company.id}/issues`)).toEqual([]);
|
|
expect(await input.api.get(`/api/companies/${input.fixtures.company.id}/projects`)).toEqual([]);
|
|
} finally {
|
|
// Always release a provider that is still waiting, including failed UI runs.
|
|
await publishChatBrief(gate, reference);
|
|
const chatPath = `/api/companies/${input.fixtures.company.id}/chats/${input.fixtures.agent.id}`;
|
|
const current = await input.api.get<ChatIssue | null>(chatPath).catch(() => null);
|
|
await input.evidence("chat-interruption-state.json", {
|
|
issue: current,
|
|
runs: await allRuns().catch(error => ({ readError: String(error) })),
|
|
comments: current ? await input.api.get(`/api/issues/${current.id}/comments?order=asc`).catch(error => ({ readError: String(error) })) : [],
|
|
plan: current && revise ? await input.api.get(`/api/issues/${current.id}/documents/plan`).catch(error => ({ readError: String(error) })) : null,
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function runChatSettingsLifecycle(context: Context) {
|
|
const { input, idle, comments, allRuns, marker } = context;
|
|
const remembered = `REMEMBER${marker}`;
|
|
const route = `/${input.fixtures.company.issuePrefix}/chats/${input.fixtures.agent.id}`;
|
|
await sendChatMessage(input.page, `Remember ${remembered} in this conversation. Acknowledge only; no tasks or projects.`);
|
|
await idle(1);
|
|
const before = { issue: context.issue(), comments: await comments(), runs: await allRuns() };
|
|
await setChatEnabled(input, false);
|
|
// Full document navigation clears the client query cache. Unlike SPA
|
|
// navigation, this verifies the disabled entry page without cached history.
|
|
await input.page.goto(route, { waitUntil: "domcontentloaded" });
|
|
await expect(input.page.getByText(/Agent Chat is disabled/)).toBeVisible();
|
|
const blocked = await input.api.request.post(`/api/issues/${before.issue.id}/comments`, { data: { body: `DISABLED${marker}` } });
|
|
expect(blocked.status()).toBe(404);
|
|
expect(await comments()).toEqual(before.comments);
|
|
expect((await allRuns()).map(run => ({ id: run.id, status: run.status })))
|
|
.toEqual(before.runs.map(run => ({ id: run.id, status: run.status })));
|
|
await setChatEnabled(input, true);
|
|
await input.page.goto(route, { waitUntil: "domcontentloaded" });
|
|
await sendChatMessage(input.page, `What reference did I ask you to remember? Reply with it and ${marker}. No new work.`);
|
|
await idle(2);
|
|
expect(context.issue().id).toBe(before.issue.id);
|
|
expect(context.issue().conversationSessionGeneration).toBe(before.issue.conversationSessionGeneration);
|
|
const reply = (await comments()).filter(comment => comment.authorAgentId).at(-1)?.body ?? "";
|
|
expect(reply).toContain(remembered);
|
|
expect(reply).toContain(marker);
|
|
expect(await input.api.get(`/api/companies/${input.fixtures.company.id}/issues`)).toEqual([]);
|
|
await input.evidence("chat-settings-lifecycle.json", { before, after: { issue: context.issue(), comments: await comments(), runs: await allRuns() } });
|
|
}
|