mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 01:50:13 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Product E2E journeys grade agent work in the QA staging smoke plan > - On the current QA deployment, two journeys failed after the product had already done the requested work > - protected-actions counted a wake that the control plane retired before it started as a bound run, and failed its runtime binding check > - question-answer-resume waited for its saved document in a view that no longer exists. The wait lasted 27 minutes, until the case deadline, and starved the run queue > - This pull request grades only runs that started provider work, and reads the document where the UI now shows it, with a bounded wait > - The benefit is that both journeys measure the product, and a UI readback failure costs about two minutes instead of a whole worker ## Linked Issues or Issue Description **What happened?** - protected-actions: the task succeeded with two runs. A third automation wake for the same agent and task was cancelled before it started, with the reason "Agent cannot start work while completed runs await accounting". Its `startedAt` was null, its `resultJson.cancellation` had `source: "control_plane"` and `expected: true`, and its `executionRecovery.providerWorkStarted` was false. That run has no runtime binding, so `validateAgentTaskState` failed with `agent_run_binding_mismatch`. - question-answer-resume: the agent saved the requested document and the task was done by 19:32. The journey then navigated to `#document-<key>` and waited until 19:59 for an element that never appeared. With the task chat shell and Artifacts V1, documents render in the properties pane's Artifacts tab. The run reported `journey_operation_failed` and `evidence_collection_timeout`, and the 60-minute job ended before four other cases could run. **Expected behavior** - A wake retired before admission is kept as evidence but is not graded as provider work. - The saved document is read in its current view, and a UI readback failure ends quickly. **Steps to reproduce** QA staging smoke launch 3074 on appcb8df178c8. The stack-side run records are described above. **Paperclip version or commit** master at03eb49053f. ## What Changed - `agent-flows.ts`: add `retiredBeforeProviderStart(run)`. The run must have status `cancelled` and `startedAt === null`, a control-plane cancellation with `expected: true`, and `executionRecovery.providerWorkStarted === false`. `validateAgentTaskState` still checks such a run's company, agent and task binding and counts it toward the run limit, then skips the runtime checks. - `clarification-flow.ts`: after reload, open the Artifacts tab with the existing `openDocumentAuthoring` helper before reading the document. task-dependency-resume uses the same path and passes on this deployment. The UI readback is capped at 2 minutes after the API returned the saved document. Agent work time is not changed. - `agent-evidence.test.ts`: the retired wake is accepted. Nine variants still fail: a started run, a missing `startedAt`, a failed status, an unexpected cancellation, an operator source, `providerWorkStarted: true`, missing recovery evidence, another agent, and another task. The run limit still applies. ## Verification - `npx vitest run --config tests/runner-e2e/vitest.config.ts tests/runner-e2e/product-journeys`: 46 files passed. - Negative control: without the skip, the new test fails. - `clarification-readback.spec.ts` (browser support): 6 tests passed. - `tsc -p tests/runner-e2e/tsconfig.json` shows no errors in the changed files. - Not verified live yet. QA uses the pinned harness, so the pin must move to a commit that contains this change. ## Risks - The exclusion is narrow, and every field it requires is positive evidence that the wake did no work. Any started or unexpected extra run still fails. - The readback cap is stricter, not looser. It applies only after the API has confirmed the document. Agent work and answer timing still use the existing deadlines. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with tool use (shell, browser, code editing). Extended reasoning was used. ## 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 - [ ] All Paperclip CI gates are green - [ ] 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>
130 lines
9.5 KiB
TypeScript
130 lines
9.5 KiB
TypeScript
import { mkdir, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { expect, type Locator } from "@playwright/test";
|
|
import { continuationScenario } from "../continuation-cases.js";
|
|
import { continuationAnswerCommitted, continuationCheckpointReady } from "../continuation-readiness.js";
|
|
import type { ContinuationCheckpoint } from "../continuation-scoring.js";
|
|
import { answerResumeQuestionThroughUi } from "../question-resume-ui.js";
|
|
import { createTaskThroughUi } from "../user-actions.js";
|
|
import { validateAgentTaskState } from "./agent-flows.js";
|
|
import { clarificationQuestionFailure, gradeClarificationEvidence, type ClarificationReadback } from "./clarification-evidence.js";
|
|
import { openDocumentAuthoring } from "./task-document-flow.js";
|
|
import { JourneyError, type JourneyContext, type JourneyEvidence, type TargetSession } from "./types.js";
|
|
|
|
type Row = Record<string, any>;
|
|
export const DOCUMENT_READBACK_MS = 120_000;
|
|
interface Options { deadlineAt: number; directory: string; capture(id: string, label: string, file: string): Promise<void> }
|
|
|
|
export async function waitForClarificationDocument(visible: Locator, answer: string, deadlineAt: number) {
|
|
const remaining = () => {
|
|
const timeout = deadlineAt - Date.now();
|
|
if (!Number.isFinite(timeout) || timeout <= 0) throw new JourneyError("failed", "clarification_document_not_ready");
|
|
return timeout;
|
|
};
|
|
await expect(visible).toHaveCount(1, { timeout: remaining() });
|
|
await expect(visible).toBeVisible({ timeout: remaining() });
|
|
await expect(visible).toContainText(answer, { timeout: remaining() });
|
|
await expect(visible).toContainText(/afternoon/i, { timeout: remaining() });
|
|
remaining();
|
|
}
|
|
|
|
export async function readClarificationState(context: JourneyContext, issueId: string) {
|
|
const route = `/api/issues/${issueId}`;
|
|
const [currentIssue, summaries, interactions, documents, attachments, children] = await Promise.all([
|
|
context.api.get<Row>(route), context.api.get<Row[]>(`${route}/runs`), context.api.get<Row[]>(`${route}/interactions`),
|
|
context.api.get<Row[]>(`${route}/documents`), context.api.get<Row[]>(`${route}/attachments`),
|
|
context.api.get<Row[]>(`/api/companies/${context.company.id}/issues?view=index&parentIds=${encodeURIComponent(issueId)}&limit=2`),
|
|
]);
|
|
if (!Array.isArray(summaries) || summaries.length > 3) throw new JourneyError("failed", "clarification_run_limit_exceeded");
|
|
const runs = await Promise.all(summaries.map(summary => context.api.get<Row>(`/api/heartbeat-runs/${summary.runId}`)));
|
|
validateAgentTaskState(context, issueId, { currentIssue: currentIssue as Row & { id: string; status: string },
|
|
taskRuns: runs as Array<Row & { status: string }>, interactions: interactions as Array<Row & { id: string; status: string }>, comments: [], attachments: [] }, 3);
|
|
if (!Array.isArray(children) || children.some(child => child.parentId !== issueId || child.companyId !== context.company.id)) {
|
|
throw new JourneyError("failed", "clarification_child_query_mismatch");
|
|
}
|
|
return { currentIssue, runs, interactions, documents, attachments, children };
|
|
}
|
|
|
|
export async function runClarificationJourney(context: JourneyContext, session: TargetSession,
|
|
campaignId: string, evidence: JourneyEvidence, options: Options) {
|
|
if (!context.agent || context.agent.runtimeMode !== "native" || !context.environmentId) {
|
|
throw new JourneyError("unsupported", "native_agent_and_environment_required");
|
|
}
|
|
const agent = context.agent, scenario = continuationScenario("question-answer-resume", campaignId.replaceAll("-", "_"));
|
|
const checkpoints: ContinuationCheckpoint[] = [];
|
|
let issue: Row | undefined, readback: ClarificationReadback | undefined;
|
|
await mkdir(options.directory, { recursive: true, mode: 0o700 });
|
|
const retain = (name: string, value: unknown) => writeFile(path.join(options.directory, name), JSON.stringify(value, null, 2), { mode: 0o600 });
|
|
const read = () => readClarificationState(context, issue!.id);
|
|
const settle = async (answeredId?: string, final = false) => {
|
|
let stable = "";
|
|
while (Date.now() < options.deadlineAt) {
|
|
const state = await read();
|
|
const pending = state.interactions.filter(i => i.kind === "ask_user_questions" && i.status === "pending");
|
|
const ready = continuationAnswerCommitted(state.interactions, answeredId)
|
|
&& continuationCheckpointReady({ issue: state.currentIssue as { status: string; executionRunId?: string }, runs: state.runs as Array<Row & { id: string; status: string }>, interactions: state.interactions })
|
|
&& (final ? state.currentIssue.status === "done" && !pending.length : pending.length === 1)
|
|
&& !state.currentIssue.scheduledRetry && !state.currentIssue.activeRecoveryAction;
|
|
const key = ready ? JSON.stringify([state.currentIssue.status, state.currentIssue.executionRunId,
|
|
state.runs.map(run => [run.id, run.status]), state.interactions.map(i => [i.id, i.status])]) : "";
|
|
if (key && key === stable) return state;
|
|
stable = key;
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
}
|
|
throw new JourneyError("failed", final ? "clarification_not_completed" : "clarification_question_not_ready");
|
|
};
|
|
const open = () => context.page.goto(`${context.api.origin}/${encodeURIComponent(context.company.issuePrefix)}/issues/${encodeURIComponent(issue!.identifier ?? issue!.id)}`, { waitUntil: "domcontentloaded" });
|
|
const snapshot = async (phase: "initial" | "answered" | "final", state: Awaited<ReturnType<typeof read>>) => {
|
|
const documents = await Promise.all(state.documents.map(document => context.api.get<Row>(`/api/issues/${issue!.id}/documents/${encodeURIComponent(document.key)}`)));
|
|
const point = { phase, issue: state.currentIssue, runs: structuredClone(state.runs), interactions: structuredClone(state.interactions),
|
|
documents, attachments: state.attachments, children: state.children, comments: [] } as unknown as ContinuationCheckpoint;
|
|
checkpoints.push(point);
|
|
await retain("clarification.json", { checkpoints });
|
|
await open();
|
|
await options.capture(phase, `Clarification ${phase}`, `clarification-${phase}.png`);
|
|
return point;
|
|
};
|
|
try {
|
|
const title = `Journey QA question-answer-resume ${campaignId}`;
|
|
const created = await createTaskThroughUi({ page: context.page, origin: context.api.origin,
|
|
issuePrefix: context.company.issuePrefix, agentName: agent.name, title, prompt: scenario.prompt,
|
|
workMode: "standard", requireExplicitTitle: true,
|
|
beforeDispatch: () => session.own({ kind: "issue", name: title, journeyId: "question-answer-resume" }) });
|
|
await session.own({ kind: "issue", name: title, id: created.issueId, journeyId: "question-answer-resume" });
|
|
issue = await context.api.get<Row>(`/api/issues/${created.issueId}`);
|
|
let state = await settle();
|
|
for (const [index, input] of [{ choice: "Afternoon" }, { text: scenario.answer, allowProviderCustomAnswer: true }].entries()) {
|
|
const point = await snapshot(index === 0 ? "initial" : "answered", state);
|
|
const question = state.interactions.find(i => i.kind === "ask_user_questions" && i.status === "pending")!;
|
|
const questionFailure = clarificationQuestionFailure(point, question, index === 0);
|
|
if (questionFailure) throw new JourneyError("failed", questionFailure);
|
|
if (point.documents.length || point.attachments.length || point.children.length || point.issue.status === "done") {
|
|
throw new JourneyError("failed", "clarification_premature_output");
|
|
}
|
|
await answerResumeQuestionThroughUi(context.page, question, input);
|
|
state = await settle(question.id, index === 1);
|
|
}
|
|
const final = await snapshot("final", state);
|
|
if (final.documents.length !== 1) throw new JourneyError("failed", "clarification_document_missing");
|
|
const document = final.documents[0]!;
|
|
const href = `/${encodeURIComponent(context.company.issuePrefix)}/issues/${encodeURIComponent(issue.identifier ?? issue.id)}#document-${encodeURIComponent(document.key)}`;
|
|
await context.page.goto(new URL(href, context.api.origin).href, { waitUntil: "domcontentloaded" });
|
|
await context.page.reload({ waitUntil: "domcontentloaded" });
|
|
// With the task chat shell and Artifacts V1, saved documents render in the
|
|
// properties pane's Artifacts tab, not the center column. Open it as a user would.
|
|
await openDocumentAuthoring(context);
|
|
const visible = context.page.locator(`[id=${JSON.stringify(`document-${document.key}`)}]:visible, [id=${JSON.stringify(`side-panel-content-document:${document.key}`)}]:visible`);
|
|
// The API already returned the saved document, so this is a bounded UI readback,
|
|
// not agent work time: a missing view must not hold the worker until the case deadline.
|
|
await waitForClarificationDocument(visible, scenario.answer, Math.min(options.deadlineAt, Date.now() + DOCUMENT_READBACK_MS));
|
|
const persisted = await context.api.get<Row>(`/api/issues/${issue.id}/documents/${encodeURIComponent(document.key)}`);
|
|
readback = { issueId: persisted.issueId, key: persisted.key, revisionId: persisted.latestRevisionId, body: persisted.body, visibleBody: await visible.innerText() };
|
|
await options.capture("reloaded", "Saved answers after reload", "clarification-reloaded.png");
|
|
} finally {
|
|
const checks = gradeClarificationEvidence(context, issue?.id ?? "", scenario.marker, checkpoints, readback);
|
|
evidence.checks.push(...checks.map(({ id, passed }) => ({ id, passed })));
|
|
await retain("clarification.json", { checkpoints, readback, checks });
|
|
}
|
|
if (evidence.checks.some(check => !check.passed)) throw new JourneyError("failed", "clarification_evidence_failed");
|
|
}
|