mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-05 21:02:34 +01:00
## Thinking Path > - Paperclip keeps agent work in project workspaces. > - A later user request must continue from the saved repository state. > - A successful reply alone cannot prove file or Git history preservation. > - The repository journey in #18652 passed against QA staging with independent byte and commit checks. > - Its separate completion journey remains unpassed and must stay open. > - This PR delivers only repository continuation and reuses the merged shared support from #18728. ## Linked Issues or Issue Description Refs: #18652. Builds on merged #18728. This is the repository-only delivery from #18652. The original completion implementation and all failed attempts remain on that open PR. ## What Changed - Add `repository-continuation` with the exact measured source fixture, two native turns, and eight required checks. - Start from measured source `ac4efb7c6f4782ea806661683c656fd2b4cc3189`. Keep the repository flow and Git/byte oracle unchanged. Clarify the two file-content instructions to require the word first or verified followed by exactly one LF newline; retain the exact expected bytes and the original random-reference contract. - Document the two-turn proof boundary: final saved hidden Git metadata is not independently read. Keep all eight success assertions. - Use the original-deadline and routine failure-reporting repairs from merged #18728. Integrate its actual master squash `e5713d95c6d9a258882649789ea5eb071cc17dc0` through one normal merge. - Use a fixed public read-only repository commit. Verify downloaded Git objects, parent commits, changed paths, saved workspace identity, and exact source bytes across both turns. Reopen the registered outputs through the browser. - Keep the shared artifact reader behavior unchanged and narrow its type to the API capabilities it uses. - Add only this case to the catalog, feature association, dispatcher, source hash, types, run/check contract, strict catalog expectation, and journey documentation. ## Verification - Current head `86808b7ec17e1727bd55a3fd46bc7e0409ca0e31` passed fresh [QA staging run 41589522430](https://github.com/paperclipai/paperclip-cloud/actions/runs/41589522430) and its [full report](https://pages.paperclip.ing/evals-qa-smoke-41589522430-1/). All eight checks and five phases passed. Both native turns succeeded and committed in the same workspace. The independent same-buffer Git audit verified that the second commit has the first commit as its parent. The saved original files and browser downloads matched their expected bytes. This does not add a final hidden `.git`/HEAD assertion. - The fresh run used OpenCode with `openrouter/deepseek/deepseek-v4.1-flash-1017` on QA staging/Daytona. It used Core `09162fe893b464dd2ccdafd14bd3ef1611202a86`, Cloud `cloud-41570439002-1`, and environment configuration version 9. Actual checkouts matched public source `86808b7ec17e1727bd55a3fd46bc7e0409ca0e31`, adapter `7367cf6fd0ee1cf52c2651a8a6671e087728a9c1`, and protected workflow bootstrap `a9ad6877de62fe3f65aacadc8a9dfba7d90729cf`. The bootstrap matched the accepted 117-file QA contract. - Normal cleanup completed. The original manifest is complete, evidence cleanup is complete, no operations remain active, and the owned agent is terminal. The coordinator verified ACTIVE TestOps case 685 / result 3695 and closed [launch 1921](https://paperclip.testops.cloud/launch/1921). This records normal cleanup closure; no separate physical provider-stop acknowledgement was collected. - Measured source `ac4efb7c6f4782ea806661683c656fd2b4cc3189` passed [QA staging run 41573331189](https://pages.paperclip.ing/evals-qa-smoke-41573331189-1/) against Core `09162fe893b464dd2ccdafd14bd3ef1611202a86`. TestOps launch 1723 / result 3501 maps to case 685. All eight checks and five phases passed. The two run bindings, independent saved-byte/Git audit, and completed cleanup were verified. - Initial extraction `54103639850d08d4d125ebe0493d8d3d49b58211` preserved the measured case files. The current head also clarifies the two LF instructions, documents the final Git-metadata limit, and inherits the reviewed helper repairs. This changed the fixture conditions. The current head now has its own live pass, listed below. The original ac4 result remains a pass under its original conditions; it was not used to qualify this head. - The emitted-prompt control failed on the old quoted backslash+n instructions, while 14 sibling controls passed. All 47 focused repository/artifact controls then passed after the two-sentence repair. Local: 891 related support tests, runner E2E typecheck, recursive typecheck, build, maps, token gates, and `git diff --check` passed. The inherited routine source has 62 causal read/cleanup controls and 26 unchanged real component controls. - The initial repository head's normal full CI run 41578686494 passed. Its 4/5 review and all original findings remain recorded. The later stacked head `037b979477161881a4877230500e6f3de8faea5c` also passed full CI run 41581342503 and a fresh 5/5 review. Current integration head `86808b7ec17e1727bd55a3fd46bc7e0409ca0e31` passed normal full [CI run 41584981651](https://github.com/paperclipai/paperclip/actions/runs/41584981651), attempt 1. Its fresh exact-head Greptile review is 5/5, with zero unresolved threads and no open findings. The broad local suite was not repeated. The earlier full local run recorded in #18643 had four failures and did not reach 164 serialized groups; this PR does not claim an all-local pass. - Earlier repository failures remain recorded: provider-pack transfer timed out in one attempt; a later attempt could not read saved workspace files. The later pass does not regrade those failures. The product repairs are separate from this unchanged case delivery. - The fixed actual #18728 squash is now integrated. The PR targets master. Its aggregate diff has exactly 14 repository-only paths, each identical to `037b979477161881a4877230500e6f3de8faea5c`; all other master changes are preserved. No later master head was chased. The current CI, review, and fresh staging qualification all passed for this integration head. ## Risks - Git bundles and workspace bytes are private evidence. Public reports retain closed checks; the fixture never pushes to the public source repository. - The second committed output must retain the first commit as its parent, the same workspace binding, and original saved file bytes. Final hidden `.git`/HEAD after the second save is not independently exposed by the supported remote workspace API; this test does not claim third-turn Git metadata durability. - Only JSON metadata observations retry transient transport failures. Binary attachment and browser downloads remain single attempts. Transport failures and digest mismatches remain visible failures. - The existing artifact reader is shared. Its runtime code is unchanged and its regression coverage remains included. - Daily source pins and plan selection stay with the coordinator. This PR does not register or bypass the unpassed completion case. ## Model Used OpenAI GPT-6 through Codex, with tool use and code execution. The exact deployment ID and context window are not exposed by this session. ## 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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
292 lines
22 KiB
TypeScript
292 lines
22 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" | "file-input-answer" | "routine-scheduled-run" | "repository-continuation">;
|
|
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;
|
|
}
|
|
|
|
/** 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");
|
|
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 =>
|
|
state.currentIssue.status === "done" && state.taskRuns.length === count && state.taskRuns.every(run => !active(run))
|
|
&& state.interactions.every(row => row.status !== "pending"), "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 =>
|
|
state.taskRuns.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");
|
|
}
|