Merge the tested repository candidate into qualification reporting

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* codex/dependable-repository-conversion:
  fix: validate actual task roots and unfinished response waits
  test: qualify conversion prerequisites before runner selection
This commit is contained in:
DottaandPaperclip committed 2026-10-21 08:24:30 -05:00
commit 9a8c50ca5d
10 files changed
+165 -19

No files matched your search

@@ -122,3 +122,24 @@ the recorded Codex and Claude models. All twelve new cells declare development
API-key authentication and per-turn lifecycle differences. None counts as staging
subscription, warm lifecycle, Slack, or the complete 24-cell milestone. Fresh
provider measurement remains required; catalog registration is not acceptance.
The fifth development attempt uses the recorded Codex model. It has no monitor,
but selects a response wake and then reaches productive-continuation recovery.
Its next admission still fails at Git setup. The first plain-root regression had
used a `task_directory` strategy that the runtime does not persist. The production
runtime records `project_primary`, task ownership, and a `task_session` source.
A corrected real-database reproduction fails with that recorded representation.
The repair now checks those current ownership/source fields and absence of a root
repository or configured project source. Shared and isolated task directories are
covered; configured, agent, worktree, and adapter-managed roots stay strict. The
second Git helper caller was inspected, but is not established as this attempt's
cause. The fifth failure remains retained, including the distinct response-wake
selection. Live same-agent acceptance is still open.
That fifth attempt also exposes an independent completion-validation gap: a
provider can report an unsatisfied objective, leave remainingWork empty, and yield
to response_wake without any pending response. The existing gate checked only
blocking remainingWork entries. A real-database reproduction accepts that invalid
report before repair. The same gate now also checks an unfinished objective.
Current questions, approvals, pause holds, conversations, and authorized external
response waits retain their earlier checks. No accepted result is rewritten.
@@ -334,7 +334,25 @@ const support = await getEmbeddedPostgresTestSupport();
expect(acquisition.materialize).not.toHaveBeenCalled();
});
it.each(["sponsored", "sponsored_git_residue", "local_board", "local_board_jwt", "routine"] as const)("prepares public Git from an idle workspace for a trusted %s task and reuses verified bytes after trust downgrade", async principal => {
it.each([
{ name: "missing task owner", patch: { sourceIssueId: null } },
{ name: "configured directory source", patch: { metadata: { source: "project_primary" } } },
{ name: "agent directory source", patch: { metadata: { source: "agent_home" } } },
{ name: "worktree strategy", patch: { strategyType: "git_worktree" } },
{ name: "adapter-managed mode", patch: { mode: "adapter_managed" } },
{ name: "configured root repository", patch: { repoUrl: "https://github.com/public/anchor" } },
])("keeps strict Git validation for $name", async ({ patch }) => {
const f = await fixture();
await f.request({ kind: "url", url: "https://github.com/public/example" });
await db.update(executionWorkspaces).set({ strategyType: "project_primary", sourceIssueId: f.issueId,
metadata: { source: "task_session" }, ...patch }).where(eq(executionWorkspaces.id, f.workspace.id));
await mkdir(path.join(f.admission.cwd, ".git", "info"), { recursive: true });
await writeFile(path.join(f.admission.cwd, ".git", "info", "exclude"), "/.paperclip-repositories/\n");
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow("not a git repository");
expect(acquisition.materialize).not.toHaveBeenCalled();
});
it.each(["sponsored", "sponsored_git_residue", "sponsored_shared_git_residue", "local_board", "local_board_jwt", "routine"] as const)("prepares public Git from an idle workspace for a trusted %s task and reuses verified bytes after trust downgrade", async principal => {
const f = await fixture();
await db.update(executionWorkspaces).set({ status: "idle" }).where(eq(executionWorkspaces.id, f.workspace.id));
const localBoard = principal === "local_board" || principal === "local_board_jwt";
@@ -366,7 +384,9 @@ const support = await getEmbeddedPostgresTestSupport();
await exec("git", ["-C", cwd, "remote", "set-url", "origin", input.repoUrl]);
return result;
});
if (principal === "sponsored_git_residue") {
if (principal === "sponsored_git_residue" || principal === "sponsored_shared_git_residue") {
await db.update(executionWorkspaces).set({ strategyType: "project_primary", sourceIssueId: f.issueId,
mode: principal === "sponsored_shared_git_residue" ? "shared_workspace" : "isolated_workspace", metadata: { source: "task_session" } }).where(eq(executionWorkspaces.id, f.workspace.id));
await mkdir(path.join(f.admission.cwd, ".git", "info"), { recursive: true });
await writeFile(path.join(f.admission.cwd, ".git", "info", "exclude"), "/.paperclip-repositories/\n");
}
@@ -362,7 +362,9 @@ export function executionWorkspaceRepositoryService(db: Db) {
await fs.rename(ignoreTemporary, ignorePath);
} finally { await fs.rm(ignoreTemporary, { force: true }); }
await measureRunPhase("workspace.repository.ignore_rules", () => ensureManagedRepositoriesIgnored(root, {
plainTaskDirectory: workspace.strategyType === "task_directory" && !workspace.repoUrl && !workspace.projectWorkspaceId,
plainTaskDirectory: workspace.strategyType === "project_primary" && workspace.metadata?.source === "task_session"
&& workspace.sourceIssueId === input.issueId && ["shared_workspace", "isolated_workspace"].includes(workspace.mode)
&& !workspace.repoUrl && !workspace.projectId && !workspace.projectWorkspaceId,
}));
}
return stagingParent;
@@ -212,6 +212,29 @@ describe("native final-response feedback", () => {
const feedback = await nativeCompletionFeedback(db, current.runId, done);
expect(feedback).not.toContain("#document-"); expect(feedback).not.toContain("foreign.example");
});
it("requires a real response wait when an unfinished objective omits remaining work", async () => {
const value = await fixture();
const report: PrpStructuredRunResult = { ...done, reportedWorkDisposition: "yielded",
completionClaim: { ...done.completionClaim, objectiveSatisfied: false, remainingWork: [],
criteria: [{ criterionId: "objective", status: "not_satisfied", evidenceRefs: [] }] },
continuation: { kind: "response_wake", summary: "Repository preparation needs the next admission", idempotencyKey: "repository-admission" },
};
await expect(nativeCompletionFeedback(db, value.runId, report)).rejects.toThrow("without a pending wait condition");
const [workspace] = await db.insert(executionWorkspaces).values({ companyId: value.companyId, sourceIssueId: value.issueId,
mode: "shared_workspace", strategyType: "project_primary", metadata: { source: "task_session" }, name: "Task files" }).returning();
await db.insert(executionWorkspaceRepositories).values({ companyId: value.companyId, executionWorkspaceId: workspace.id,
requestedByIssueId: value.issueId, repositoryIdentity: "public/example", repoUrl: "https://github.com/public/example",
relativePath: ".paperclip-repositories/example", requestKey: "prepare" });
await db.update(issues).set({ executionWorkspaceId: workspace.id }).where(eq(issues.id, value.issueId));
await expect(nativeCompletionFeedback(db, value.runId, report)).rejects.toThrow("omit continuation (or use null)");
const [hold] = await db.insert(issueTreeHolds).values({ companyId: value.companyId, rootIssueId: value.issueId, mode: "pause" }).returning();
await expect(nativeCompletionFeedback(db, value.runId, report)).resolves.toContain("task is paused");
await db.update(issueTreeHolds).set({ status: "released" }).where(eq(issueTreeHolds.id, hold.id));
const [question] = await db.insert(issueThreadInteractions).values({ companyId: value.companyId, issueId: value.issueId,
sourceRunId: value.runId, createdByAgentId: value.agentId, kind: "ask_user_questions", status: "pending", continuationPolicy: "wake_assignee",
payload: { version: 1, questions: [{ id: "branch", prompt: "Which private branch?", selectionMode: "single", required: true, allowOther: true, options: [] }] } }).returning();
await expect(nativeCompletionFeedback(db, value.runId, report)).resolves.toContain(`Pending request: ${question.id}`);
});
it("corrects a repository-admission blocker before accepting the result", async () => {
const value = await fixture();
const [workspace] = await db.insert(executionWorkspaces).values({ companyId: value.companyId,
@@ -27,6 +27,8 @@ import {
type PrpStructuredRunResult,
} from "../../vendor/paperclip-runner/index.js";
const repositoryYieldCorrection = 'This task has a saved repository preparation for its next admission. This is routine setup, not a blocker. Call paperclip_finish with reportedWorkDisposition="yielded" and omit continuation (or use null); Paperclip will select the saved workspace continuation. Preserve the completed work and explain what remains. Do not repeat setup, set a timer, or ask the user to restart.';
function record(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown> : {};
@@ -250,7 +252,7 @@ export async function nativeCompletionFeedback(
const repository = await pendingTaskRepository(db, { companyId: run.companyId, issueId: issue.id, workspaceId: issue.executionWorkspaceId, repositoryIds });
if (repository &&
!(await issueTreeControlService(db).getActivePauseHoldGate(run.companyId, issue.id))) {
throw new Error('This task has a saved repository preparation for its next admission. This is routine setup, not a blocker. Call paperclip_finish with reportedWorkDisposition="yielded" and omit continuation (or use null); Paperclip will select the saved workspace continuation. Preserve the completed work and explain what remains. Do not repeat setup, set a timer, or ask the user to restart.');
throw new Error(repositoryYieldCorrection);
}
}
if (result.reportedWorkDisposition === "yielded" && result.continuation?.kind === "monitor") {
@@ -260,7 +262,7 @@ export async function nativeCompletionFeedback(
!isConversation(issue) &&
result.reportedWorkDisposition === "yielded" &&
result.continuation?.kind === "response_wake" &&
result.completionClaim.remainingWork.some((entry) => entry.blocksCompletion) &&
(!result.completionClaim.objectiveSatisfied || result.completionClaim.remainingWork.some((entry) => entry.blocksCompletion)) &&
signals.actionableAttentionRequests.length === 0
) {
const pause = await issueTreeControlService(db).getActivePauseHoldGate(
@@ -275,8 +277,10 @@ export async function nativeCompletionFeedback(
});
// Conversation turns and revoked chat authority retain their own lifecycle.
if (chatWait === "not_applicable") {
const repository = await pendingTaskRepository(db, { companyId: run.companyId, issueId: issue.id, workspaceId: issue.executionWorkspaceId });
if (repository) throw new Error(repositoryYieldCorrection);
throw new Error(
"The response_wake report includes blocking remaining work without a pending wait condition. Answering a user comment does not pause the task. Continue the authorized work, register a same_agent continuation, or report the concrete blocker or required question. Do not repeat completed work.",
"The response_wake report includes an unfinished objective or blocking remaining work without a pending wait condition. Answering a user comment does not pause the task. Continue the authorized work, or report the concrete blocker or required question. Do not repeat completed work.",
);
}
}
+5
View File
@@ -66,6 +66,11 @@ hard stops. The conversion journey creates a real legacy agent and selects
Paperclip Runner through the public agent update API. It checks retained
configuration, encrypted secret references, environment, instructions, and
corresponding permission policy before submitting work through the browser.
The disposable legacy profile starts without optional CLI-only extra arguments;
the journey does not remove settings from an existing user agent. For either
provider, Daytona's public environment probe must save platform qualification
before the same agent selects Runner. Probe leases and uncertain probe accounting
remain included in attempt evidence and cleanup.
The observer seeds a random reference in the fixture-owned Git checkout before
task creation. It checks the downloaded JSON against the original file bytes,
@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from "vitest";
import { runnerSuites } from "./catalog.js";
import { convertRepositoryProofAgent } from "./repository-conversion-setup.js";
function fixtures(driver = "sandbox"): Parameters<typeof convertRepositoryProofAgent>[1] {
return { agent: { id: "agent", name: "Fixture Agent", companyId: "company" }, company: { id: "company", name: "Fixture Company" }, environment: { id: "environment", driver,
config: { image: `image@sha256:${"a".repeat(64)}` } } };
}
describe("real legacy conversion prerequisites", () => {
it("constructs compatible legacy settings before recording a retention baseline", () => {
const suite = runnerSuites.find(suite => suite.id === "legacy-conversion")!;
for (const profile of suite.profiles) {
const agent = profile.buildAgent({ environmentId: "environment", environmentFixtureId: "local", workspacePath: "/owned/workspace",
executionId: "fixture", secretRefs: { OPENAI_API_KEY: { type: "secret_ref", secretId: "codex-reference", version: "latest" },
ANTHROPIC_API_KEY: { type: "secret_ref", secretId: "claude-reference", version: "latest" } } });
expect(agent.runner).toBe("legacy");
expect(agent.adapterType).toBe(profile.provider === "codex" ? "codex_local" : "claude_local");
expect(agent.adapterConfig).toMatchObject({ model: profile.model, timeoutSec: 360, cwd: "/owned/workspace" });
expect(agent.adapterConfig).not.toHaveProperty("extraArgs");
expect((agent.adapterConfig as Record<string, any>).env).toMatchObject(profile.provider === "codex"
? { OPENAI_API_KEY: { type: "secret_ref", secretId: "codex-reference", version: "latest" } }
: { ANTHROPIC_API_KEY: { type: "secret_ref", secretId: "claude-reference", version: "latest" } });
}
});
it("qualifies the sandbox before selecting Runner for either provider", async () => {
const fixture = fixtures(); let qualified = false;
const api = { post: vi.fn(async () => { qualified = true; return { ok: true, details: { platformQualificationRecorded: true, leaseId: "lease" } }; }),
patch: vi.fn(async () => { if (!qualified) throw new Error("agent_runner_unavailable"); return {}; }) };
await expect(convertRepositoryProofAgent(api as any, fixture)).resolves.toBeUndefined();
expect(api.post).toHaveBeenCalledWith("/api/environments/environment/probe?companyId=company", {}, { timeout: 330_000 });
expect(fixture.setupProbe).toMatchObject({ leaseId: "lease" });
expect(api.patch).toHaveBeenCalledExactlyOnceWith("/api/agents/agent", { runner: "paperclip" });
});
it("does not attempt conversion when platform qualification is absent", async () => {
const fixture = fixtures(); const api = { post: vi.fn(async () => ({ ok: true, details: { leaseId: "lease" } })), patch: vi.fn() };
await expect(convertRepositoryProofAgent(api as any, fixture)).rejects.toThrow("qualification");
expect(api.patch).not.toHaveBeenCalled();
expect(fixture.setupProbe?.leaseId).toBe("lease");
});
it("retains uncertain probe accounting when the response is lost", async () => {
const fixture = fixtures(); const api = { post: vi.fn(async () => { throw new Error("connection lost"); }), patch: vi.fn() };
await expect(convertRepositoryProofAgent(api as any, fixture)).rejects.toThrow("connection lost");
expect(api.patch).not.toHaveBeenCalled();
expect(fixture.setupLeaseAccountingIncomplete).toBe(true);
});
it.each([{ image: "image:latest" }, { image: `image@sha256:${"a".repeat(64)}`, snapshot: "snapshot" }])("refuses an unqualified image before creating a sandbox", async config => {
const fixture = fixtures(); fixture.environment.config = config;
const api = { post: vi.fn(), patch: vi.fn() };
await expect(convertRepositoryProofAgent(api as any, fixture)).rejects.toThrow("immutable");
expect(api.post).not.toHaveBeenCalled(); expect(api.patch).not.toHaveBeenCalled();
});
it("converts a local agent without creating a remote sandbox", async () => {
const api = { post: vi.fn(), patch: vi.fn(async () => ({})) };
await convertRepositoryProofAgent(api as any, fixtures("local"));
expect(api.post).not.toHaveBeenCalled(); expect(api.patch).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,20 @@
import type { RunnerApi } from "./api.js";
import type { LiveFixtureValues } from "./live-fixtures.js";
export async function convertRepositoryProofAgent(api: Pick<RunnerApi, "post" | "patch">, fixtures: Pick<LiveFixtureValues, "environment" | "agent" | "company" | "setupProbe" | "setupLeaseAccountingIncomplete">) {
if (fixtures.environment.driver === "sandbox") {
if (!/^.+@sha256:[a-f0-9]{64}$/i.test(String(fixtures.environment.config?.image ?? "")) || fixtures.environment.config?.snapshot)
throw new Error("Legacy conversion requires the immutable fixture image");
const startedAt = Date.now(), previousAccounting = fixtures.setupLeaseAccountingIncomplete;
fixtures.setupLeaseAccountingIncomplete = true;
const probe = await api.post<{ ok: boolean; details?: { platformQualificationRecorded?: boolean; leaseId?: string } }>(
`/api/environments/${fixtures.environment.id}/probe?companyId=${encodeURIComponent(fixtures.company.id)}`, {}, { timeout: 330_000 });
if (probe.details?.leaseId) {
fixtures.setupProbe = { leaseId: probe.details.leaseId, durationMs: Date.now() - startedAt };
fixtures.setupLeaseAccountingIncomplete = previousAccounting;
}
if (!probe.ok || !probe.details?.platformQualificationRecorded || !probe.details.leaseId)
throw new Error("Legacy conversion platform qualification was not recorded");
}
await api.patch(`/api/agents/${fixtures.agent.id}`, { runner: "paperclip" });
}
@@ -6,6 +6,7 @@ import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { pollUntil, type RunnerApi } from "./api.js";
import { createTaskThroughUi } from "./user-actions.js";
import { convertRepositoryProofAgent } from "./repository-conversion-setup.js";
import { readRegisteredArtifacts } from "./registered-artifact.js";
import { gradeRepositoryProof, repositoryConfigurationDigest, repositoryProofFiles, type RepositoryProofObservation } from "./repository-verification.js";
import type { LiveFixtureValues } from "./live-fixtures.js";
@@ -85,7 +86,7 @@ export async function runRepositoryVerificationFlow(input: {
observation.originalAdapter = before.adapterType;
observation.configurationBefore = repositoryConfigurationDigest(before);
observation.instructionsBefore = await instructionsDigest();
if (conversion) await api.patch(`/api/agents/${fixtures.agent.id}`, { runner: "paperclip" });
if (conversion) await convertRepositoryProofAgent(api, fixtures);
const after = await api.get<Row>(`/api/agents/${fixtures.agent.id}`);
observation.finalAdapter = after.adapterType;
observation.configurationAfter = repositoryConfigurationDigest(after);
@@ -95,15 +96,6 @@ export async function runRepositoryVerificationFlow(input: {
if (after.defaultEnvironmentId !== fixtures.environment.id || observation.configurationBefore !== observation.configurationAfter ||
observation.instructionsBefore !== observation.instructionsAfter || !observation.permissionsPreserved || after.adapterType !== "paperclip_runner")
throw new Error("Production runner selection did not retain the configured agent context");
if (conversion && execution.environment.id === "daytona" && execution.profile.provider === "codex") {
if (!/^.+@sha256:[a-f0-9]{64}$/i.test(String(fixtures.environment.config?.image ?? "")) || fixtures.environment.config?.snapshot)
throw new Error("Converted Codex requires the immutable fixture image");
const startedAt = Date.now();
const probe = await api.post<{ ok: boolean; details?: { platformQualificationRecorded?: boolean; leaseId?: string } }>(
`/api/environments/${fixtures.environment.id}/probe?companyId=${encodeURIComponent(fixtures.company.id)}`, {}, { timeout: 330_000 });
if (!probe.ok || !probe.details?.platformQualificationRecorded || !probe.details.leaseId) throw new Error("Converted Codex platform qualification was not recorded");
fixtures.setupProbe = { leaseId: probe.details.leaseId, durationMs: Date.now() - startedAt };
}
await save("configured");
const created = await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name,
projectName: fixtures.project.name, title: execution.task.buildTitle(nonce), prompt: execution.task.buildPrompt(nonce), workMode: "standard", requireExplicitTitle: true });
+4 -3
View File
@@ -9,8 +9,9 @@ export function repositoryProofFiles(nonce: string) {
export function repositoryProofProfile(native: RunnerProfileFixture, legacy?: RunnerProfileFixture): RunnerProfileFixture {
return { ...native, buildAgent(input) {
const { instructionsBundle: _manual, ...agent } = (legacy ?? native).buildAgent(input);
return { ...agent, ...(legacy ? { runner: "legacy" } : {}), budgetMonthlyCents: 1000,
adapterConfig: { ...(agent.adapterConfig as Record<string, unknown>), model: native.model } };
const config = { ...(agent.adapterConfig as Record<string, unknown>), model: native.model };
if (legacy) delete (config as Record<string, unknown>).extraArgs;
return { ...agent, ...(legacy ? { runner: "legacy" } : {}), budgetMonthlyCents: 1000, adapterConfig: config };
} };
}
@@ -95,7 +96,7 @@ export function repositoryConfigurationDigest(agent: Record<string, any>) {
export function repositoryProofDigest() {
const hash = createHash("sha256");
for (const file of ["repository-verification.ts", "repository-verification-flow.ts", "registered-artifact.ts", "live-fixtures.ts", "runner.spec.ts", "catalog.ts", "stock-harness.ts"])
for (const file of ["repository-verification.ts", "repository-verification-flow.ts", "repository-conversion-setup.ts", "registered-artifact.ts", "live-fixtures.ts", "runner.spec.ts", "catalog.ts", "stock-harness.ts"])
hash.update(file).update(readFileSync(new URL(file, import.meta.url)));
return hash.digest("hex");
}