mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:56:58 +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>
147 lines
12 KiB
TypeScript
147 lines
12 KiB
TypeScript
import { createObservedReader } from "./observation-read.js";
|
|
import { createHash } from "node:crypto";
|
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { expect } from "@playwright/test";
|
|
import type { WorkspaceFileContent } from "../../../packages/shared/src/types/workspace-file-resource.js";
|
|
import { readRegisteredArtifacts } from "../registered-artifact.js";
|
|
import { createTaskThroughUi, submitTaskReply } from "../user-actions.js";
|
|
import { fixtureReplyStoredOnce, validateAgentTaskState, waitFor } from "./agent-flows.js";
|
|
import { repositoryContinuationSource, repositoryContinuationStory } from "./repository-continuation-contract.js";
|
|
import { inspectRepositoryBundle, repositoryCommitMatches, repositoryWorkspaceBindingMatches, repositoryWorkspaceFileMatches } from "./repository-continuation-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>;
|
|
const id = "repository-continuation";
|
|
|
|
/** Attached and managed targets share the browser story and byte/Git oracle. */
|
|
export async function runRepositoryContinuation(context: JourneyContext, session: TargetSession, campaignId: string,
|
|
evidence: JourneyEvidence, options: { deadlineAt: number; directory: string }) {
|
|
const { api, page, company, agent } = context;
|
|
const observations = createObservedReader(api, options.deadlineAt);
|
|
const readObserved = observations.get;
|
|
try {
|
|
if (!agent || agent.runtimeMode !== "native" || !context.environmentId)
|
|
throw new JourneyError("unsupported", "native_agent_and_environment_required");
|
|
const story = repositoryContinuationStory(campaignId), { files } = story, source = repositoryContinuationSource;
|
|
await mkdir(options.directory, { mode: 0o700, recursive: true });
|
|
const retain = (filename: string, value: unknown) => writeFile(path.join(options.directory, filename), JSON.stringify(value, null, 2), { mode: 0o600 });
|
|
const check = (name: string, passed: boolean) => {
|
|
evidence.checks.push({ id: name, passed });
|
|
if (!passed) throw new JourneyError("failed", name.replaceAll("-", "_"));
|
|
};
|
|
const projectName = `Journey QA repository ${campaignId}`;
|
|
await session.own({ kind: "project", name: projectName, journeyId: id });
|
|
const project = await api.post<Row>(`/api/companies/${company.id}/projects`, {
|
|
name: projectName, description: "Disposable read-only public repository continuation fixture.",
|
|
executionWorkspacePolicy: { enabled: true, defaultMode: "shared_workspace", sharedWorkspaceConcurrency: "serialize",
|
|
allowIssueOverride: false, environmentId: context.environmentId, workspaceStrategy: { type: "project_primary" } },
|
|
workspace: { name: "Primary", sourceType: "git_repo", repoUrl: source.url, repoRef: source.commit, isPrimary: true },
|
|
});
|
|
await session.own({ kind: "project", name: projectName, id: project.id, journeyId: id });
|
|
const configured = await readObserved<Row>(`/api/projects/${project.id}`);
|
|
const projectWorkspaces = configured.workspaces?.filter((workspace: Row) => workspace.repoUrl === source.url && workspace.repoRef === source.commit && workspace.isPrimary);
|
|
if (configured.companyId !== company.id || !Array.isArray(projectWorkspaces) || projectWorkspaces.length !== 1)
|
|
throw new JourneyError("failed", "repository_project_source_mismatch");
|
|
const projectWorkspaceId = projectWorkspaces[0]!.id;
|
|
const title = `Journey QA repository continuation ${campaignId}`;
|
|
const created = await createTaskThroughUi({ page, origin: api.origin, issuePrefix: company.issuePrefix,
|
|
agentName: agent.name, projectName, title, prompt: story.first, workMode: "standard", requireExplicitTitle: true,
|
|
beforeDispatch: () => session.own({ kind: "issue", name: title, journeyId: id }) });
|
|
await session.own({ kind: "issue", name: title, id: created.issueId, journeyId: id });
|
|
const route = `/api/issues/${created.issueId}`;
|
|
const read = async () => {
|
|
const [currentIssue, summaries, comments, interactions] = await Promise.all([
|
|
readObserved<Row & { id: string; status: string }>(route), readObserved<Row[]>(`${route}/runs`),
|
|
readObserved<Row[]>(`${route}/comments?order=asc`), readObserved<Array<Row & { id: string; status: string }>>(`${route}/interactions`),
|
|
]);
|
|
if (!Array.isArray(summaries) || summaries.length > 2) throw new JourneyError("failed", "repository_run_limit_exceeded");
|
|
const taskRuns = (await Promise.all(summaries.map(summary => readObserved<Row & { status: string }>(`/api/heartbeat-runs/${summary.runId}`))))
|
|
.sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)) || String(a.id).localeCompare(String(b.id)));
|
|
const state = { currentIssue, taskRuns, comments, interactions, attachments: [] };
|
|
await retain("repository-latest-task.json", state);
|
|
validateAgentTaskState(context, created.issueId, state, 2);
|
|
if (currentIssue.projectId !== project.id) throw new JourneyError("failed", "repository_project_binding_mismatch");
|
|
return state;
|
|
};
|
|
const settled = (count: number) => waitFor(options.deadlineAt, read, state => state.currentIssue.status === "done"
|
|
&& state.taskRuns.length === count && state.taskRuns.every(run => run.status === "succeeded"
|
|
&& run.nativePhase === "committed" && run.resultJson?.finalizationPhase === "committed")
|
|
&& state.interactions.every(row => row.status !== "pending"), "repository_task_not_completed");
|
|
// Use the attached target's origin for the common registration/download oracle.
|
|
const artifactApi = { get: <T>(route: string) => readObserved<T>(route), request: { get: (route: string) => api.response(route) } };
|
|
const bundle = async (name: string, runId: string) => {
|
|
const [artifact] = await readRegisteredArtifacts(artifactApi, created.issueId, runId, [name]);
|
|
const bytes = await api.bytes(`/api/attachments/${artifact!.attachmentId}/content?download=1`);
|
|
if (createHash("sha256").update(bytes).digest("hex") !== artifact!.sha256) throw new JourneyError("failed", "repository_bundle_download_changed");
|
|
await writeFile(path.join(options.directory, name), bytes, { mode: 0o600, flag: "wx" });
|
|
return { artifact: artifact!, bytes };
|
|
};
|
|
// Task-chat layouts keep generic output controls in Artifacts. Use the
|
|
// production download link for this exact attachment, not a coincidental
|
|
// URL in a model reply or an API-only readback.
|
|
const browserDownload = async (artifact: { attachmentId: string; sha256: string }, phase: string) => {
|
|
await openDocumentAuthoring(context);
|
|
const link = page.getByRole("link", { name: /^Download/ }).and(
|
|
page.locator(`a[href*="/api/attachments/${artifact.attachmentId}/content"]`)).first();
|
|
await expect(link).toBeVisible();
|
|
const [download] = await Promise.all([page.waitForEvent("download", { timeout: Math.max(1, Math.min(30_000, options.deadlineAt - Date.now())) }), link.click()]);
|
|
const filename = path.join(options.directory, `browser-${phase}`);
|
|
await download.saveAs(filename);
|
|
const bytes = await readFile(filename);
|
|
if (createHash("sha256").update(bytes).digest("hex") !== artifact.sha256) throw new JourneyError("failed", "repository_browser_download_changed");
|
|
};
|
|
const file = (name: string) => readObserved<WorkspaceFileContent>(`${route}/file-resources/content?workspace=execution&path=${encodeURIComponent(name)}`);
|
|
const first = await settled(1), firstRun = first.taskRuns[0]!;
|
|
const initialView = await readObserved<Row>(`${route}/workspace`), workspaceId = initialView.workspace?.id;
|
|
const binding = (issue: Row, view: Row, runs: Row[]) => repositoryWorkspaceBindingMatches({ companyId: company.id,
|
|
issueId: created.issueId, projectId: project.id, projectWorkspaceId, workspaceId, repoUrl: source.url, issue, view, runs });
|
|
check("repository-configured-source-bound", binding(first.currentIssue, initialView, first.taskRuns));
|
|
const [referenceArtifact] = await readRegisteredArtifacts(artifactApi, created.issueId, firstRun.id, [files.context]);
|
|
const original = referenceArtifact!.content;
|
|
if (!/^[a-f0-9]{64}\n$/.test(original)) throw new JourneyError("failed", "repository_original_reference_invalid");
|
|
const { artifact: firstBundle, bytes: originalBytes } = await bundle(files.firstBundle, firstRun.id);
|
|
const initialGit = await inspectRepositoryBundle(originalBytes, { sourceCommit: source.commit, files: [files.context, files.first, "README"] });
|
|
check("repository-first-commit-verified", repositoryCommitMatches(initialGit, { parent: source.commit, sourceReadme: source.readme,
|
|
files: { [files.context]: original, [files.first]: "first\n", README: source.readme }, added: [files.context, files.first] }));
|
|
check("repository-original-files-saved", repositoryWorkspaceFileMatches(await file(files.context), workspaceId, files.context, original)
|
|
&& repositoryWorkspaceFileMatches(await file(files.first), workspaceId, files.first, "first\n")
|
|
&& repositoryWorkspaceFileMatches(await file("README"), workspaceId, "README", source.readme));
|
|
await retain("repository-first-observation.json", { source, projectId: project.id, workspaceId, issueId: created.issueId,
|
|
runId: firstRun.id, referenceArtifact, initialGit, bundle: { attachmentId: firstBundle.attachmentId, sha256: firstBundle.sha256 } });
|
|
const url = `${api.origin}/${company.issuePrefix}/issues/${first.currentIssue.identifier ?? created.issueId}`;
|
|
await page.goto(url, { waitUntil: "domcontentloaded" });
|
|
await page.reload();
|
|
await browserDownload(firstBundle, "first-bundle");
|
|
// The original random reference and commit are not supplied by this follow-up.
|
|
await submitTaskReply(page, story.followup);
|
|
const withReply = await waitFor(Math.min(options.deadlineAt, Date.now() + 30_000), read,
|
|
state => state.comments.some(comment => comment.authorUserId === context.user.id && !first.comments.some(old => old.id === comment.id)),
|
|
"repository_followup_not_stored");
|
|
check("repository-followup-stored-once", fixtureReplyStoredOnce(first.comments, withReply.comments, context.user.id, story.followup));
|
|
const final = await settled(2), lastRun = final.taskRuns[1]!;
|
|
const finalView = await readObserved<Row>(`${route}/workspace`);
|
|
check("repository-same-workspace-retained", binding(final.currentIssue, finalView, final.taskRuns)
|
|
&& Date.parse(lastRun.startedAt) >= Date.parse(firstRun.finishedAt));
|
|
const { artifact: finalBundle, bytes: finalBytes } = await bundle(files.finalBundle, lastRun.id);
|
|
const finalGit = await inspectRepositoryBundle(finalBytes,
|
|
{ sourceCommit: source.commit, files: [files.context, files.first, files.followup, "README"] });
|
|
check("repository-second-commit-verified", repositoryCommitMatches(finalGit, { parent: initialGit.head, sourceReadme: source.readme,
|
|
files: { [files.context]: original, [files.first]: "first\n", [files.followup]: "verified\n", README: source.readme }, added: [files.followup] }));
|
|
await page.goto(url, { waitUntil: "domcontentloaded" });
|
|
await page.reload();
|
|
for (const [index, artifact] of [referenceArtifact!, firstBundle, finalBundle].entries()) await browserDownload(artifact, `reopened-${index}`);
|
|
const [retained, retainedBundle] = await readRegisteredArtifacts(artifactApi, created.issueId, firstRun.id, [files.context, files.firstBundle]);
|
|
check("repository-original-and-result-reopened", retained!.attachmentId === referenceArtifact!.attachmentId && retained!.content === original
|
|
&& retainedBundle!.attachmentId === firstBundle.attachmentId && retainedBundle!.sha256 === firstBundle.sha256
|
|
&& repositoryWorkspaceFileMatches(await file(files.context), workspaceId, files.context, original)
|
|
&& repositoryWorkspaceFileMatches(await file(files.first), workspaceId, files.first, "first\n")
|
|
&& repositoryWorkspaceFileMatches(await file(files.followup), workspaceId, files.followup, "verified\n")
|
|
&& repositoryWorkspaceFileMatches(await file("README"), workspaceId, "README", source.readme));
|
|
check("configured-model-and-environment", true); // Both actual native runs passed validateAgentTaskState.
|
|
await retain("repository-continuation-checks.json", { source, projectId: project.id, workspaceId, issueId: created.issueId,
|
|
initialGit, finalGit, referenceArtifact, firstBundle, finalBundle, runIds: final.taskRuns.map(run => run.id) });
|
|
} finally { await observations.preserve(options.directory); }
|
|
}
|