mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 10:13:11 +02:00
fix: validate actual task roots and unfinished response waits
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user