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 > - The Runner Product E2E suite proves that agent work survives a control-plane restart > - A restart chaos campaign found three restart cells that fail in the test harness, not in the product > - A harness failure hides the real restart result, so the campaign cannot classify these cells > - This pull request repairs the harness drift in those cells and names the one failure the harness cannot prevent > - The benefit is that these cells now test restart behavior, or fail with a clear cause ## Linked Issues or Issue Description Refs #14811 (connector setup became one screen). Refs #19475 (completion-update restart fixture). **What happened?** 1. `lifecycle-baseline.runner-codex.local.tool-review-restart` clicked a "Save and continue" button. #14811 removed that button. The cell failed during setup. After that fix, the cell also failed after the restart: the expanded review card does not survive the post-restart reload, so "Approve & run" was not visible. 2. `local-session-integrity.runner-acpx-claude.local.structured-question-restart-resume` failed with `page.goto: net::ERR_ABORTED` after the controller restart. The old document's Vite client reloads when the replacement server starts, and this aborts the test navigation. The code tolerated only `TimeoutError`. 3. `completion-updates.runner-acpx-claude.local.handoff-completion-restart` waited for the source agent to run an instructed wait command. Claude replied without the command (2/2 attempts), so the restart never occurred. The cell then failed with a generic timeout. **Expected behavior** Each cell reaches its restart boundary and tests restart behavior. If the boundary cannot be reached, the cell names the cause. **Steps to reproduce** Run `pnpm test:e2e:runner -- --max-parallel 1 --max-automatic-retries 0 --id <cell>` for each cell above on `master`. ## What Changed - `tests/runner-e2e/connection-reviews.ts`: accept the one-screen "Check link" step and the older "Save and continue" step. This uses the same regex approach as `connection-flow.ts`. - `tests/runner-e2e/runner.spec.ts` (tool review): click "Review request" after the restart reload, on the document that acts on the card. - `tests/runner-e2e/chat-restart.ts`: add `gotoAfterServerRestart`. It retries only `net::ERR_ABORTED`, within the caller's deadline and at most 5 attempts. It still accepts `TimeoutError`. It rethrows all other errors. The structured-question restart uses it. The existing sentinel and durable-state assertions still prove the new document. - `tests/runner-e2e/completion-updates.ts` and `completion-update-flow.ts`: add `completionRestartGateSkipped` and `completionRestartGateTimeoutDetail`. The gate poll stops at once with `restart boundary not reached (agent skipped gate)` when no completion turn is active and the turn that carries this task's Done facts (`chatCompletionUpdates`, as in `completionDelivery`) succeeded or published its reply before the gate reports `waiting`. A failed or cancelled turn is not blamed on the agent, because delivery can retry it; the timeout detail names it instead. The post-restart chat navigation now uses `gotoAfterServerRestart`. - A fixture-level gate is not possible without a product hook. The native run finalizer publishes the completion reply inside its own transaction, through the issue service closure. The isolated-server entrypoint can patch only class prototypes and HTTP responses, and the reply uses no tool call or HTTP request. - `tests/runner-e2e/README.md`: document the skipped-gate failure. - Docs: use `pnpm --filter @paperclipai/paperclip-runner run build:runner-binaries` in `tests/runner-e2e/README.md`, `tests/restart-chaos/README.md`, `tests/restart-chaos/main.ts` and `doc/DEVELOPING.md`. - No product code changes. ## Verification - Unit: `pnpm exec vitest run --config tests/runner-e2e/vitest.config.ts tests/runner-e2e/chat-restart.test.ts tests/runner-e2e/completion-updates.test.ts tests/runner-e2e/connection-reviews.test.ts` (36 passed). - Typecheck: `node scripts/typecheck-test-projects.mjs tests/runner-e2e` passed. - Live cells, local, `--max-parallel 1 --max-automatic-retries 0`, macOS, load average 35–65: - `lifecycle-baseline.runner-codex.local.tool-review-restart`: PASS (first run found the review-card reload drift, fixed in the second commit). - `local-session-integrity.runner-acpx-claude.local.structured-question-restart-resume`: PASS. A first attempt failed before the restart with `PRP command session.open timed out` at load ~36 (environment). - `completion-updates.runner-acpx-claude.local.handoff-completion-restart`: FAIL as designed, twice (before and after the review fix), with `restart boundary not reached (agent skipped gate): completion turn … ended succeeded without the reference gate reporting waiting`. Claude skipped the wait both times, so this cell still does not exercise a restart. ## Risks - Low risk. Test harness and docs only. - The navigation retry could hide a real abort. It is limited to `net::ERR_ABORTED`, 5 attempts and the deadline, and the test still asserts that a new document loaded. - The ACPX Claude completion restart cell still does not exercise a restart when the agent skips the gate. It now reports this cause by name. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with tool use (shell, file edit, browser test runs). ## 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>
127 lines
10 KiB
TypeScript
127 lines
10 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { COMPLETION_RESTART_GATE_SKIPPED, completionDelivery, completionOutputUsesReleasedBrief, completionRestartGateSkipped, completionRestartGateTimeoutDetail, type CompletionObservation } from "./completion-updates.js";
|
|
import { runnerMatrix } from "./catalog.js";
|
|
|
|
const output = "Welcome to our free Friday garden meetup for beginners. Join us at 10:30; reference GARDEN123.";
|
|
const example: CompletionObservation = {
|
|
sourceId: "source", marker: "GARDEN123",
|
|
worker: { id: "worker", identifier: "FIR-2", status: "done", completedAt: "2026-09-24T10:01:00Z" },
|
|
documents: [{ id: "doc", issueId: "worker", key: "welcome-note", body: output }],
|
|
runs: [{ id: "reply-run", agentId: "lead", status: "succeeded", contextSnapshot: { issueId: "source", chatCompletionUpdates: [{ id: "worker", status: "done" }] } }],
|
|
comments: [{ id: "reply", issueId: "source", authorAgentId: "lead", createdByRunId: "reply-run", createdAt: "2026-09-24T10:02:00Z", body: `The note is ready: ${output}` }],
|
|
};
|
|
const failures = (e: CompletionObservation) => completionDelivery(e).checks.filter(c => !c.passed).map(c => c.id);
|
|
describe("completion-update delivery oracle", () => {
|
|
it("requires a released brief fact rather than only the known request marker", () => {
|
|
for (const time of ["10:30", "10.30", "10h30", "ten-thirty", "ten thirty", "half past ten", "half-past-ten", "half past 10"])
|
|
expect(completionOutputUsesReleasedBrief(`Meet at ${time}. GARDEN123`)).toBe(true);
|
|
for (const body of ["Welcome on Friday. GARDEN123", "Meet at 11:30. GARDEN123"])
|
|
expect(completionOutputUsesReleasedBrief(body)).toBe(false);
|
|
});
|
|
it("accepts actual output and task links without requiring a canned response", () => {
|
|
expect(failures(example)).toEqual([]);
|
|
for (const body of ["Riley finished it. [Read the note](/FIR/issues/FIR-2)", "Here is [the result](/FIR/issues/worker?document=welcome-note)"])
|
|
expect(failures({ ...example, comments: [{ ...example.comments[0], body }], renderedLinks: [{ commentId: "reply", href: "/FIR/issues/FIR-2" }] })).toEqual([]);
|
|
});
|
|
it.each([
|
|
["no source reply", { comments: [] }, "completion-source-response"],
|
|
["worker not done", { worker: { ...example.worker, status: "in_review" } }, "completion-worker-done"],
|
|
["no completion timestamp", { worker: { ...example.worker, completedAt: null } }, "completion-worker-done"],
|
|
["no output", { documents: [] }, "completion-output-saved"],
|
|
["plan is not output", { documents: [{ ...example.documents[0], key: "plan" }] }, "completion-output-saved"],
|
|
["foreign document", { documents: [{ ...example.documents[0], issueId: "other" }] }, "completion-output-saved"],
|
|
["premature reply", { comments: [{ ...example.comments[0], createdAt: "2026-09-24T10:00:00Z" }] }, "completion-source-response"],
|
|
["user reply", { comments: [{ ...example.comments[0], authorAgentId: null, authorUserId: "user" }] }, "completion-source-response"],
|
|
["worker thread reply", { comments: [{ ...example.comments[0], issueId: "worker" }] }, "completion-source-response"],
|
|
["wrong run", { runs: [{ ...example.runs[0], contextSnapshot: { issueId: "worker" } }] }, "completion-source-response"],
|
|
["failed reply run", { runs: [{ ...example.runs[0], status: "failed" }] }, "completion-source-response"],
|
|
["late handoff reply", { runs: [{ ...example.runs[0], contextSnapshot: { issueId: "source" } }] }, "completion-source-correlated"],
|
|
["other task's callback", { runs: [{ ...example.runs[0], contextSnapshot: { issueId: "source", chatCompletionUpdates: [{ id: "other", status: "done" }] } }] }, "completion-source-correlated"],
|
|
["handoff promise", { comments: [{ ...example.comments[0], body: "FIR-2 is running. I'll post back when it finishes. GARDEN123" }] }, "completion-result-access"],
|
|
["unrelated link", { comments: [{ ...example.comments[0], body: "Done! [Output](/FIR/issues/FIR-3) GARDEN123" }] }, "completion-result-access"],
|
|
["identifier substring", { comments: [{ ...example.comments[0], body: "Done! [Output](/FIR/issues/FIR-20)" }] }, "completion-result-access"],
|
|
] as const)("rejects %s", (_label, change, check) => {
|
|
expect(failures({ ...example, ...change } as CompletionObservation)).toContain(check);
|
|
});
|
|
it("accepts descriptive output keys and excludes only planning document keys", () => {
|
|
for (const key of ["plants-welcome-note", "welcome-note-summary", "proposal-followup-note"])
|
|
expect(failures({ ...example, documents: [{ ...example.documents[0], key }] })).toEqual([]);
|
|
for (const key of ["plan", "summary", "proposal"])
|
|
expect(failures({ ...example, documents: [{ ...example.documents[0], key }] })).toContain("completion-output-saved");
|
|
});
|
|
it("explicitly leaves semantic accuracy to review even if delivery/access pass", () => {
|
|
const delivery = completionDelivery({ ...example, comments: [{ ...example.comments[0], body: "It is still running. [Task](/FIR/issues/FIR-2)" }], renderedLinks: [{ commentId: "reply", href: "/FIR/issues/FIR-2" }] });
|
|
expect(delivery.checks.every(c => c.passed)).toBe(true);
|
|
expect(delivery.semanticReview.status).toBe("required");
|
|
expect(delivery.semanticReview.rubric[0]).toContain("accurately");
|
|
});
|
|
it("retains earlier delivery when a later clarification omits its link", () => {
|
|
const earlier: CompletionObservation["comments"][number] = { ...example.comments[0], body: "Finished in FIR-2." };
|
|
const later = { ...earlier, id: "correction", createdAt: "2026-09-24T10:03:00Z", body: "To clarify, it is a draft for you to use; it has not been published." };
|
|
const result = completionDelivery({ ...example, comments: [earlier, later], renderedLinks: [{ commentId: earlier.id, href: "/FIR/issues/FIR-2" }] });
|
|
expect(result.checks.every(c => c.passed)).toBe(true);
|
|
expect(result.response).toEqual(earlier);
|
|
expect(result.latestResponse).toEqual(later);
|
|
expect(result.responses).toHaveLength(2);
|
|
});
|
|
it("accepts auto-linked identifiers only with matching browser evidence", () => {
|
|
const e = { ...example, comments: [{ ...example.comments[0], body: "Finished in FIR-2." }] };
|
|
expect(failures(e)).toContain("completion-result-access");
|
|
expect(failures({ ...e, renderedLinks: [{ commentId: "reply", href: "/FIR/issues/FIR-2" }] })).toEqual([]);
|
|
expect(failures({ ...e, renderedLinks: [{ commentId: "earlier", href: "/FIR/issues/FIR-2" }] })).toContain("completion-result-access");
|
|
expect(failures({ ...e, renderedLinks: [{ commentId: "reply", href: "/FIR/issues/FIR-20" }] })).toContain("completion-result-access");
|
|
});
|
|
it("registers ten explicit-only local native cells without adding scheduled work", () => {
|
|
const cells = runnerMatrix.filter(c => c.suite.id === "completion-updates");
|
|
expect(cells).toHaveLength(10);
|
|
expect(new Set(cells.map(c => c.task.id))).toEqual(new Set(["interview-plan-accept", "handoff-completion-idle", "handoff-completion-busy", "handoff-completion-multiple", "handoff-completion-restart"]));
|
|
for (const cell of cells) {
|
|
expect(cell.suite.manualOnly).toBe(true);
|
|
expect(cell.profile.generation).toBe("native");
|
|
expect(cell.environment.id).toBe("local");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("completion restart gate", () => {
|
|
const turn = (status: string, id = "turn", workerId: string | null = "worker") => ({ id, status,
|
|
contextSnapshot: { issueId: "source", wakeReason: "chat_task_completed", ...(workerId ? { chatCompletionUpdates: [{ id: workerId, status: "done" }] } : {}) } });
|
|
const base = { sourceId: "source", workerId: "worker", runs: [] as Record<string, any>[], comments: [] as Record<string, any>[] };
|
|
it("keeps waiting while the completion turn is pending or running at the gate", () => {
|
|
expect(completionRestartGateSkipped({ ...base, ready: "" })).toBeUndefined();
|
|
expect(completionRestartGateSkipped({ ...base, ready: "", runs: [turn("queued", "turn", null)] })).toBeUndefined();
|
|
expect(completionRestartGateSkipped({ ...base, ready: "", runs: [turn("running")] })).toBeUndefined();
|
|
expect(completionRestartGateSkipped({ ...base, ready: "waiting", runs: [turn("succeeded")] })).toBeUndefined();
|
|
});
|
|
it("names a correlated completion turn that succeeded without reaching the gate", () => {
|
|
expect(completionRestartGateSkipped({ ...base, ready: "", runs: [turn("succeeded")] }))
|
|
.toBe(`${COMPLETION_RESTART_GATE_SKIPPED}: completion turn turn ended succeeded without the reference gate reporting waiting`);
|
|
});
|
|
it("keeps waiting while a later completion turn is still active", () => {
|
|
expect(completionRestartGateSkipped({ ...base, ready: "", runs: [turn("succeeded", "first"), turn("queued", "retry", null)] })).toBeUndefined();
|
|
});
|
|
it("does not blame the agent for a turn that failed before the gate, and names it on timeout", () => {
|
|
const state = { ...base, ready: "", runs: [turn("failed")] };
|
|
expect(completionRestartGateSkipped(state)).toBeUndefined();
|
|
expect(completionRestartGateTimeoutDetail(state))
|
|
.toBe("restart boundary not reached: completion turn(s) turn ended failed before the reference gate reported waiting");
|
|
expect(completionRestartGateTimeoutDetail({ ...state, ready: "waiting" })).toBeUndefined();
|
|
expect(completionRestartGateTimeoutDetail(undefined)).toBeUndefined();
|
|
});
|
|
it("names a correlated agent reply and ignores unrelated replies", () => {
|
|
const comments = [
|
|
{ id: "handoff", authorAgentId: "lead", createdByRunId: "initial" },
|
|
{ id: "user", authorUserId: "board" },
|
|
];
|
|
expect(completionRestartGateSkipped({ ...base, ready: "", runs: [turn("failed")], comments })).toBeUndefined();
|
|
expect(completionRestartGateSkipped({ ...base, ready: "", runs: [turn("failed")],
|
|
comments: [...comments, { id: "reply", authorAgentId: "lead", createdByRunId: "turn" }] }))
|
|
.toBe(`${COMPLETION_RESTART_GATE_SKIPPED}: agent reply reply was published without the reference gate reporting waiting`);
|
|
});
|
|
it("ignores other conversations, other tasks and ordinary chat turns", () => {
|
|
const other = { id: "o", status: "succeeded", contextSnapshot: { issueId: "other", wakeReason: "chat_task_completed", chatCompletionUpdates: [{ id: "worker" }] } };
|
|
const ordinary = { id: "u", status: "succeeded", contextSnapshot: { issueId: "source", wakeReason: "chat_message" } };
|
|
expect(completionRestartGateSkipped({ ...base, ready: "", runs: [other, ordinary, turn("succeeded", "t2", "another-worker")] })).toBeUndefined();
|
|
});
|
|
});
|