fix: validate actual task roots and unfinished response waits

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-21 08:21:54 -05:00
1 parent ed5452863c
commit 91c026f169
5 files changed
+78 -6

No files matched your search

@@ -111,3 +111,26 @@ Configured checkouts keep strict validation, real Git roots still receive the
exclusion, linked metadata stays rejected, and missing Git stays an error. The
repair's Git and authorization cohort passes locally. Fresh provider
remeasurement and the monitor-fallback defect remain open.
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.",
);
}
}