diff --git a/doc/plans/2026-10-20-dependable-everyday-work.md b/doc/plans/2026-10-20-dependable-everyday-work.md index 082870f8b13..9ed05b2b8b4 100644 --- a/doc/plans/2026-10-20-dependable-everyday-work.md +++ b/doc/plans/2026-10-20-dependable-everyday-work.md @@ -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. diff --git a/server/src/__tests__/task-workspace-repositories-trust.test.ts b/server/src/__tests__/task-workspace-repositories-trust.test.ts index dc026f81b81..e901e0f4eec 100644 --- a/server/src/__tests__/task-workspace-repositories-trust.test.ts +++ b/server/src/__tests__/task-workspace-repositories-trust.test.ts @@ -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"); } diff --git a/server/src/services/execution-workspace-repositories.ts b/server/src/services/execution-workspace-repositories.ts index 4c2cb2c07ed..2186d5638b0 100644 --- a/server/src/services/execution-workspace-repositories.ts +++ b/server/src/services/execution-workspace-repositories.ts @@ -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; diff --git a/server/src/services/native-runtime/native-completion-feedback.test.ts b/server/src/services/native-runtime/native-completion-feedback.test.ts index 0c77023cadc..0562482cb15 100644 --- a/server/src/services/native-runtime/native-completion-feedback.test.ts +++ b/server/src/services/native-runtime/native-completion-feedback.test.ts @@ -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, diff --git a/server/src/services/native-runtime/native-completion-feedback.ts b/server/src/services/native-runtime/native-completion-feedback.ts index 825ed121e75..0be1b473f26 100644 --- a/server/src/services/native-runtime/native-completion-feedback.ts +++ b/server/src/services/native-runtime/native-completion-feedback.ts @@ -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 { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; @@ -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.", ); } }