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 > - 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>
325 lines
23 KiB
TypeScript
325 lines
23 KiB
TypeScript
import { hireDelegateReuse } from "./hire-delegate-flow.js";
|
|
import { createHash } from "node:crypto";
|
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { expect } from "@playwright/test";
|
|
import { nativeCancellationHasNoLaterWork, nativeStopAcknowledged, nativeWorkEvents, stopAndRedirectThroughUi } from "../stop-redirect-flow.js";
|
|
import { activeStopWorkBoundary, stoppedRunAccountingFinalized, STOP_REVISION_WAIT, type StopWorkBoundary } from "./stop-redirect-evidence.js";
|
|
import { runnerTasks } from "../catalog.js";
|
|
import { everydayTasks, SLUGIFY_REQUIREMENTS, SLUGIFY_REVISION } from "../everyday-cases.js";
|
|
import { latestStoryDelivery, type StoryDelivery } from "../everyday-delivery.js";
|
|
import { gradeConnectionGuidanceDecline } from "../connection-guidance-evidence.js";
|
|
import { runPlanRevisionAcceptance } from "../plan-revision-flow.js";
|
|
import { decideProtectedActionThroughUi, readServiceBriefing, serviceBriefingChecks } from "../protected-action-flow.js";
|
|
import { editCreatedSkillThroughStudio } from "../skill-studio-flow.js";
|
|
import { downloadStoryArtifact, runArtifactOracle } from "../story-artifact.js";
|
|
import { collectRunEvents } from "../run-observations.js";
|
|
import { readBoundTaskFinalReply } from "../native-task-reply.js";
|
|
import { createTaskThroughUi, submitTaskReply } from "../user-actions.js";
|
|
import { agentJourneyRuns } from "./catalog.js";
|
|
import { JourneyError, type JourneyContext, type JourneyEvidence, type TargetSession } from "./types.js";
|
|
|
|
type Row = Record<string, any>;
|
|
type AgentJourney = Exclude<keyof typeof agentJourneyRuns,
|
|
"ask-question"
|
|
| "question-answer-resume"
|
|
| "followup-while-running"
|
|
| "chat-continuity"
|
|
| "file-input-answer"
|
|
| "routine-scheduled-run"
|
|
| "agent-settings-persistence"
|
|
| "repository-continuation"
|
|
| "saved-skill-use">;
|
|
interface TaskState { currentIssue: Row & { id: string; status: string }; taskRuns: Array<Row & { status: string }>; comments: Row[]; interactions: Array<Row & { id: string; status: string }>; attachments: StoryDelivery[] }
|
|
interface Options { deadlineAt: number; directory: string; capture(id: string, label: string, file: string): Promise<void> }
|
|
const active = (run: Row) => ["queued", "running"].includes(run.status);
|
|
|
|
/** These fixture replies are plain text. The rich editor escapes underscores in
|
|
* its persisted Markdown; compare the intended text while retaining exact count
|
|
* and author checks. Do not strip arbitrary markup or accept partial messages. */
|
|
export function fixtureReplyStoredOnce(before: Row[], after: Row[], userId: string, message: string) {
|
|
const added = after.filter(row => row.authorUserId === userId && !before.some(old => old.id === row.id));
|
|
return added.length === 1 && typeof added[0].body === "string" && added[0].body.replaceAll("\\_", "_") === message;
|
|
}
|
|
|
|
/**
|
|
* A wake the control plane retired before admission, for example while completed
|
|
* runs await accounting. It never started provider work, so it has no runtime
|
|
* binding to grade. Started, unexpected or user cancellations do not qualify.
|
|
*/
|
|
export function retiredBeforeProviderStart(run: Row) {
|
|
const result = run.resultJson;
|
|
return run.status === "cancelled" && run.startedAt === null
|
|
&& result?.cancellation?.source === "control_plane" && result.cancellation.expected === true
|
|
&& result.executionRecovery?.providerWorkStarted === false;
|
|
}
|
|
|
|
/** Runs that count toward a journey's expected work; retired wakes stay in the raw state and run limit. */
|
|
export function startedTaskRuns(state: Pick<TaskState, "taskRuns">) {
|
|
return state.taskRuns.filter(run => !retiredBeforeProviderStart(run));
|
|
}
|
|
|
|
/** The task is done after exactly `count` started runs, with nothing active or pending. */
|
|
export function agentTaskSettled(state: TaskState, count: number) {
|
|
return state.currentIssue.status === "done" && startedTaskRuns(state).length === count && state.taskRuns.every(run => !active(run))
|
|
&& state.interactions.every(row => row.status !== "pending");
|
|
}
|
|
|
|
/** Owned-task observations only: other work in a persistent QA company cannot affect this grade. */
|
|
export function validateAgentTaskState(context: JourneyContext, issueId: string, state: TaskState, maxRuns: number, allowedCancelled: string[] = []) {
|
|
const agent = context.agent!;
|
|
if (state.currentIssue.id !== issueId || state.currentIssue.companyId !== context.company.id
|
|
|| state.currentIssue.assigneeAgentId !== agent.id || state.taskRuns.length > maxRuns) throw new JourneyError("failed", "agent_task_binding_mismatch");
|
|
for (const run of state.taskRuns) {
|
|
const provider = run.runnerProfileJson?.nativeExecutionInput?.provider;
|
|
const boundIssueId = run.issueId ?? run.nativeIssueId ?? run.contextSnapshot?.issueId ?? run.contextSnapshot?.taskId;
|
|
if (run.companyId !== context.company.id || run.agentId !== agent.id || boundIssueId !== issueId) throw new JourneyError("failed", "agent_run_binding_mismatch");
|
|
// The retired wake stays in the raw record and in the run limit above.
|
|
if (retiredBeforeProviderStart(run)) continue;
|
|
if (!active(run) && (run.runtimeMode !== "native" || run.contextSnapshot?.paperclipEnvironment?.id !== context.environmentId
|
|
|| provider?.kind !== agent.provider || provider?.model !== agent.model)) throw new JourneyError("failed", "agent_run_binding_mismatch");
|
|
if (!["queued", "running", "succeeded"].includes(run.status) && !(run.status === "cancelled" && allowedCancelled.includes(run.id))) throw new JourneyError("failed", "agent_run_failed");
|
|
}
|
|
}
|
|
|
|
export async function waitFor<T>(deadlineAt: number, load: () => Promise<T>, accept: (value: T) => boolean, code: string): Promise<T> {
|
|
while (Date.now() < deadlineAt) {
|
|
const value = await load();
|
|
if (accept(value)) return value;
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
}
|
|
throw new JourneyError("failed", code);
|
|
}
|
|
|
|
export async function runAgentJourney(id: AgentJourney, context: JourneyContext, session: TargetSession,
|
|
campaignId: string, evidence: JourneyEvidence, options: Options) {
|
|
if (id === "hire-delegate-reuse") return hireDelegateReuse(context, session, campaignId, evidence, options);
|
|
const agent = context.agent;
|
|
if (!agent || agent.runtimeMode !== "native" || !context.environmentId) throw new JourneyError("unsupported", "native_agent_and_environment_required");
|
|
const nonce = campaignId.replaceAll("-", "_");
|
|
const maxRuns = agentJourneyRuns[id];
|
|
const cancelled: string[] = [];
|
|
await mkdir(options.directory, { recursive: true, mode: 0o700 });
|
|
const check = (id: string, passed = true) => {
|
|
evidence.checks.push({ id, passed });
|
|
if (!passed) throw new JourneyError("failed", id.replaceAll("-", "_"));
|
|
};
|
|
const retain = async (name: string, value: unknown) => writeFile(path.join(options.directory, name), JSON.stringify(value, null, 2), { mode: 0o600 });
|
|
const open = async (issue: Row) => context.page.goto(`${context.api.origin}/${context.company.issuePrefix}/issues/${issue.identifier ?? issue.id}`, { waitUntil: "domcontentloaded" });
|
|
const create = async (suffix: string, prompt: string, workMode: "standard" | "planning" = "standard") => {
|
|
const title = `Journey QA ${id}${suffix} ${campaignId}`;
|
|
const created = await createTaskThroughUi({ page: context.page, origin: context.api.origin, issuePrefix: context.company.issuePrefix,
|
|
agentName: agent.name, title, prompt, workMode, requireExplicitTitle: true,
|
|
beforeDispatch: () => session.own({ kind: "issue", name: title, journeyId: id }) });
|
|
await session.own({ kind: "issue", name: title, id: created.issueId, journeyId: id });
|
|
return context.api.get<Row>(`/api/issues/${created.issueId}`);
|
|
};
|
|
const read = async (issue: Row): Promise<TaskState> => {
|
|
const route = `/api/issues/${issue.id}`;
|
|
const [currentIssue, summaries, comments, interactions, attachments] = await Promise.all([
|
|
context.api.get<TaskState["currentIssue"]>(route), context.api.get<Row[]>(`${route}/runs`), context.api.get<Row[]>(`${route}/comments?order=asc`),
|
|
context.api.get<TaskState["interactions"]>(`${route}/interactions`), context.api.get<TaskState["attachments"]>(`${route}/attachments`),
|
|
]);
|
|
if (!Array.isArray(summaries) || summaries.length > maxRuns) throw new JourneyError("failed", "agent_run_limit_exceeded");
|
|
const taskRuns = await Promise.all(summaries.map(summary => context.api.get<TaskState["taskRuns"][number]>(`/api/heartbeat-runs/${summary.runId}`)));
|
|
const state = { currentIssue, taskRuns, comments, interactions, attachments };
|
|
await retain(`${issue.id}-latest.json`, state);
|
|
validateAgentTaskState(context, issue.id, state, maxRuns, cancelled);
|
|
return state;
|
|
};
|
|
const settled = (issue: Row, count: number) => waitFor(options.deadlineAt, () => read(issue), state =>
|
|
agentTaskSettled(state, count), "agent_task_not_completed");
|
|
const finalReply = async (issue: Row, state: TaskState, marker?: string) => {
|
|
const runs = state.taskRuns.filter(run => run.status === "succeeded");
|
|
if (!runs.length || runs.some(run => !Number.isFinite(Date.parse(run.finishedAt)))) throw new JourneyError("failed", "final_run_time_missing");
|
|
const run = [...runs].sort((a, b) => Date.parse(a.finishedAt) - Date.parse(b.finishedAt)).at(-1)!;
|
|
const companies = await context.api.get<Row[]>("/api/companies?scope=accessible");
|
|
if (!Array.isArray(companies) || !companies.some(row => row.id === context.company.id)
|
|
|| companies.some(row => typeof row.issuePrefix !== "string" || !row.issuePrefix)) throw new JourneyError("failed", "reply_company_context_missing");
|
|
await open(issue);
|
|
const receipt = await readBoundTaskFinalReply({ page: context.page, runId: run.id, agentId: agent.id,
|
|
load: async () => {
|
|
const [latest, events] = await Promise.all([read(issue), collectRunEvents<Row>((afterSeq, limit) =>
|
|
context.api.get(`/api/heartbeat-runs/${run.id}/events?afterSeq=${afterSeq}&limit=${limit}`))]);
|
|
return { run: latest.taskRuns.find(row => row.id === run.id)!, comments: latest.comments, events };
|
|
},
|
|
expectedText: marker, deadlineAt: options.deadlineAt, knownCompanyPrefixes: companies.map(row => row.issuePrefix) });
|
|
await retain(`${issue.id}-final-reply.json`, receipt);
|
|
return receipt;
|
|
};
|
|
const reply = async (issue: Row, message: string) => {
|
|
const before = (await read(issue)).comments;
|
|
await open(issue);
|
|
await submitTaskReply(context.page, message);
|
|
const after = await waitFor(Math.min(options.deadlineAt, Date.now() + 30_000), () => read(issue), state =>
|
|
state.comments.some(row => row.authorUserId === context.user.id && !before.some(old => old.id === row.id)), "reply_not_persisted");
|
|
const added = after.comments.filter(row => row.authorUserId === context.user.id && !before.some(old => old.id === row.id));
|
|
check("user-reply-stored-once", fixtureReplyStoredOnce(before, after.comments, context.user.id, message));
|
|
return added[0];
|
|
};
|
|
const download = async (issue: Row, state: TaskState, phase: string, mode: "base" | "separator", preserved: string[] = []) => {
|
|
const delivery = latestStoryDelivery(state.attachments, [issue.id], undefined, preserved);
|
|
if (!delivery) throw new JourneyError("failed", "artifact_delivery_missing");
|
|
await open(issue);
|
|
const receipt = await downloadStoryArtifact({ page: context.page, attachmentId: delivery.id, directory: options.directory,
|
|
phase, mode, deadlineAt: options.deadlineAt });
|
|
await retain(`${phase}-download.json`, receipt);
|
|
check(`${phase}-artifact-verified`, receipt.checks.length > 0 && receipt.checks.every(row => row.passed));
|
|
return receipt;
|
|
};
|
|
|
|
if (id === "plan-revise-accept") {
|
|
const fixture = runnerTasks.find(task => task.id === id)!;
|
|
const issue = await create("", fixture.buildPrompt(nonce), "planning");
|
|
const receipt = await runPlanRevisionAcceptance({ page: context.page, api: context.api, origin: context.api.origin,
|
|
issue: { id: issue.id, identifier: issue.identifier }, issuePrefix: context.company.issuePrefix, native: true,
|
|
planMarkers: fixture.buildPlanMarkers!(nonce), revisionRequest: fixture.buildRevisionRequest!(nonce), deadlineAt: options.deadlineAt,
|
|
load: () => read(issue), retryOnError: error => !(error instanceof JourneyError), reject: () => undefined, capture: options.capture });
|
|
await retain("plan-revision.json", receipt);
|
|
const final = await settled(issue, 3);
|
|
const marker = fixture.buildVisibleMarker(nonce);
|
|
check("plan-revision-and-approval", receipt.draftInteraction.id !== receipt.revisedInteraction.id);
|
|
await finalReply(issue, final, marker);
|
|
check("plan-final-answer");
|
|
} else if (id === "build-revise") {
|
|
await runArtifactOracle(null, "base", options.deadlineAt);
|
|
const issue = await create("", SLUGIFY_REQUIREMENTS);
|
|
const initial = await download(issue, await settled(issue, 1), "initial", "base");
|
|
await reply(issue, SLUGIFY_REVISION);
|
|
const revised = await download(issue, await settled(issue, 2), "revised", "separator", [initial.sha256]);
|
|
check("artifact-versions-differ", revised.attachmentId !== initial.attachmentId && revised.sha256 !== initial.sha256);
|
|
check("original-artifact-preserved", createHash("sha256").update(await context.api.bytes(`/api/attachments/${initial.attachmentId}/content`)).digest("hex") === initial.sha256);
|
|
} else if (id === "create-skill-studio") {
|
|
const skillName = `qa-skill-${campaignId}`;
|
|
const fixture = everydayTasks.find(task => task.id === id)!;
|
|
const prompt = fixture.buildPrompt(nonce).replaceAll("release-readiness-checklist", skillName).replaceAll("create-skill-e2e-001", campaignId);
|
|
await session.own({ kind: "skill", name: skillName });
|
|
const issue = await create("", prompt);
|
|
const completed = await settled(issue, 1);
|
|
const skills = await context.api.get<Row[]>(`/api/companies/${context.company.id}/skills`);
|
|
const owned = skills.filter(skill => skill.slug === skillName && skill.name === skillName);
|
|
check("created-skill-persisted", owned.length === 1);
|
|
const skill = owned[0];
|
|
await session.own({ kind: "skill", name: skillName, id: skill.id });
|
|
const activity = await context.api.get<Row[]>(`/api/issues/${issue.id}/activity`);
|
|
check("skill-creation-attributed", activity.filter(row => row.action === "company.skill_created" && row.entityId === skill.id
|
|
&& row.runId === completed.taskRuns[0]!.id && row.agentId === agent.id && row.details?.sourceIssueId === issue.id).length === 1);
|
|
await open(issue);
|
|
await editCreatedSkillThroughStudio({ page: context.page, skillId: skill.id, skillName,
|
|
marker: `Studio edit marker: ${campaignId}`, deadlineAt: options.deadlineAt,
|
|
read: () => context.api.get(`/api/companies/${context.company.id}/skills/${skill.id}`) });
|
|
check("skill-studio-edit-persisted");
|
|
} else if (id === "protected-actions") {
|
|
const service = context.fixtures?.reviewService;
|
|
if (!service) throw new JourneyError("unsupported", "review_service_required");
|
|
for (const decline of [false, true]) {
|
|
const phase = decline ? "decline" : "approve";
|
|
const callsBefore = service.invocationCount();
|
|
const connectionBefore = await context.api.get<Row>(`/api/tool-connections/${service.connectionId}`);
|
|
const fixture = everydayTasks.find(task => task.id === `service-${phase}`)!;
|
|
const issue = await create(`-${phase}`, `${fixture.buildPrompt(nonce)}\nUse only the connected service named "${service.name}". Do not use any other service.`);
|
|
const pending = await waitFor(options.deadlineAt, () => read(issue), state =>
|
|
startedTaskRuns(state).length === 1 && !state.taskRuns.some(active) && state.interactions.some(row => row.status === "pending"), "protected_action_not_requested");
|
|
check(`${phase}-waited-for-decision`, pending.currentIssue.status !== "done" && service.invocationCount() === callsBefore);
|
|
await open(issue); await context.page.reload();
|
|
const decision = await decideProtectedActionThroughUi({ page: context.page, rows: pending.interactions as any,
|
|
connectionId: service.connectionId, decline, invocationCount: service.invocationCount, expectedCallsBefore: callsBefore });
|
|
const final = await settled(issue, 2);
|
|
check(`${phase}-decision-persisted`, final.interactions.some(row => row.id === decision.id && row.status === (decline ? "rejected" : "accepted")));
|
|
check(`${phase}-service-call-count`, service.invocationCount() === callsBefore + (decline ? 0 : 1));
|
|
if (decline) {
|
|
const nativeFinalReply = await finalReply(issue, final);
|
|
const refreshed = await read(issue);
|
|
const connectionAfter = await context.api.get<Row>(`/api/tool-connections/${service.connectionId}`);
|
|
const unchanged = (row: Row) => JSON.stringify({ id: row.id, companyId: row.companyId, status: row.status, enabled: row.enabled,
|
|
installs: row.installs?.filter((install: Row) => install.targetType === "agent" && install.targetId === agent.id) });
|
|
const checks = gradeConnectionGuidanceDecline({ caseId: "service-decline", decisionId: decision.id,
|
|
decisions: refreshed.interactions as any, leadAgentId: agent.id, issueId: issue.id, replies: refreshed.comments, nativeFinalReply,
|
|
runs: refreshed.taskRuns as any, marker: service.marker, calls: service.invocationCount() - callsBefore, sameConnections: unchanged(connectionBefore) === unchanged(connectionAfter) });
|
|
await retain("service-decline-grade.json", checks);
|
|
check("decline-truthful-explanation", checks.every(row => row.passed));
|
|
} else {
|
|
await open(issue);
|
|
const bodies = await readServiceBriefing({ page: context.page, api: context.api, issueId: issue.id });
|
|
await retain("service-briefing.json", bodies);
|
|
check("approved-service-data-used", serviceBriefingChecks(bodies, service.marker).every(row => row.passed));
|
|
}
|
|
await open(issue);
|
|
}
|
|
} else {
|
|
await runArtifactOracle(null, "base", options.deadlineAt);
|
|
const issue = await create("", SLUGIFY_REQUIREMENTS);
|
|
const initial = await download(issue, await settled(issue, 1), "checkpoint", "base");
|
|
await reply(issue, `${SLUGIFY_REVISION}\n${STOP_REVISION_WAIT}`);
|
|
const state = await waitFor(options.deadlineAt, () => read(issue), value => value.taskRuns.some(run => run.status === "running"), "stop_boundary_not_active");
|
|
const original = state.taskRuns.find(run => run.status === "running")!;
|
|
await open(issue);
|
|
const originalEvents = () => collectRunEvents<Row>((afterSeq, limit) =>
|
|
context.api.get(`/api/heartbeat-runs/${original.id}/events?afterSeq=${afterSeq}&limit=${limit}`));
|
|
cancelled.push(original.id);
|
|
let beforeEvents: Row[];
|
|
let boundary: StopWorkBoundary;
|
|
const runActivity = async () => nativeWorkEvents(await originalEvents(), boundary);
|
|
const redirected = await stopAndRedirectThroughUi({ page: context.page, nonce,
|
|
beforeStop: async () => {
|
|
const observed = await waitFor(options.deadlineAt, async () => {
|
|
const events = await originalEvents();
|
|
await retain("stop-startup-events.json", events);
|
|
const current = await read(issue);
|
|
const run = current.taskRuns.find(row => row.id === original.id);
|
|
if (!run || !active(run)) throw new JourneyError("failed", "stop_boundary_already_settled");
|
|
return { state: current, boundary: activeStopWorkBoundary(run, events) };
|
|
}, value => Boolean(value.boundary), "stop_native_command_and_usage_not_observed");
|
|
boundary = observed.boundary!;
|
|
await retain("stop-boundary.json", boundary);
|
|
await retain("stop-before.json", observed.state);
|
|
},
|
|
awaitRetirement: async () => {
|
|
const stopped = await waitFor(options.deadlineAt, () => read(issue), value => {
|
|
const run = value.taskRuns.find(row => row.id === original.id);
|
|
return run?.status === "cancelled" && nativeStopAcknowledged(run, context.company.id, issue.id, context.user.id);
|
|
}, "stop_not_acknowledged");
|
|
await retain("stop-after.json", stopped);
|
|
beforeEvents = await waitFor(options.deadlineAt, async () => {
|
|
const events = await runActivity();
|
|
await retain("stop-cancellation-events.json", events);
|
|
return events;
|
|
}, events => events.some(row => row.payload?.prpEvent?.eventType === "turn.cancelled"), "stop_native_terminal_not_retained");
|
|
check("stop-native-terminal-retained", beforeEvents.some(row => row.payload?.prpEvent?.eventType === "turn.cancelled"));
|
|
if (!nativeCancellationHasNoLaterWork(beforeEvents, boundary)) check("stopped-run-no-later-events", false);
|
|
},
|
|
beforeRedirect: async () => {
|
|
const leases = await waitFor(options.deadlineAt, () => context.api.get<Row[]>(`/api/environments/${context.environmentId}/leases`), rows => {
|
|
const owned = rows.filter(row => row.companyId === context.company.id && row.issueId === issue.id && row.heartbeatRunId === original.id);
|
|
return owned.length > 0 && owned.every(row => ["released", "retained", "expired"].includes(row.status) && row.cleanupStatus === "success");
|
|
}, "stopped_run_resources_not_released");
|
|
await retain("stop-leases.json", leases.filter(row => row.issueId === issue.id && row.heartbeatRunId === original.id));
|
|
const accounted = await waitFor(options.deadlineAt, async () =>
|
|
(await read(issue)).taskRuns.find(row => row.id === original.id),
|
|
run => Boolean(run && stoppedRunAccountingFinalized(run, boundary)), "stopped_run_accounting_not_finalized");
|
|
await retain("stop-accounting.json", { runId: accounted!.id, costAccountingPending: accounted!.costAccountingPending,
|
|
costAccountedAt: accounted!.costAccountedAt, receiptId: accounted!.usageJson.accountingReceiptId,
|
|
sourceId: accounted!.usageJson.accountingReceiptSourceId, sequence: accounted!.usageJson.accountingReceiptSequence,
|
|
ready: accounted!.usageJson.accountingReceiptReady });
|
|
check("active-work-stopped");
|
|
},
|
|
reply: message => reply(issue, message),
|
|
});
|
|
const marker = redirected.marker;
|
|
const userReply = redirected.reply as Row;
|
|
const final = await settled(issue, 3);
|
|
const completion = await finalReply(issue, final, marker);
|
|
check("new-direction-completed", completion.runId !== original.id);
|
|
check("stopped-run-remained-terminal", final.taskRuns.find(run => run.id === original.id)?.status === "cancelled");
|
|
const afterEvents = await runActivity();
|
|
check("stopped-run-no-later-events", nativeCancellationHasNoLaterWork(afterEvents, boundary!)
|
|
&& JSON.stringify(beforeEvents!) === JSON.stringify(afterEvents));
|
|
check("stop-preserved-download", createHash("sha256").update(await context.api.bytes(`/api/attachments/${initial.attachmentId}/content`)).digest("hex") === initial.sha256);
|
|
const queue = await context.api.get<Row>(`/api/issues/${issue.id}/queued-comments`);
|
|
check("redirect-consumed-once", queue.entries.length === 0 && final.comments.filter(row => row.id === userReply.id).length === 1);
|
|
await open(issue); await context.page.reload();
|
|
await expect(context.page.getByText(marker, { exact: true }).last()).toBeVisible();
|
|
}
|
|
check("configured-model-and-environment");
|
|
}
|