From ac4f13d205ce7783856a673497b2c01be7781ce7 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 30 Oct 2026 08:26:14 -0500 Subject: [PATCH] fix(e2e): report committed child blockers promptly (#18306) ## Thinking Path > - Paperclip manages AI agents and their work. > - Product E2E checks the ordinary onboarding conversation and its delivered work. > - A native run can finish successfully while its task remains blocked for a human. > - The onboarding journey kept waiting for such a child until the campaign deadline. > - This pull request reports the committed child blocker when no owned work remains pending. > - The journey still requires the same independent checks before it can pass. ## Linked Issues or Issue Description **What happened?** An approved child can stop with a committed current-track blocker owned by the board. Its native run succeeds, but the child task stays blocked. The onboarding journey reports a deadline failure instead of the observed child blocker. **Expected behavior** Report `onboarding_child_task_blocked` when the latest child run commits that blocker at the child's current status version. Keep waiting for parent dependencies and pending native or workspace recovery. **Steps to reproduce** Use the existing onboarding fixture with an approved blocked child. Give its latest native run a succeeded status, committed finalization, blocked work disposition, and the matching task status version. The old journey waits until its deadline. The new journey fails at that settled blocker without downloading or accepting any delivery. Related public context: #18298. Public issue and PR searches found no duplicate blocked-child harness fix. This change reports the failure boundary; it does not repair artifact delivery. ## What Changed - Check the owned child's board blocker after the existing run and recovery checks. - Require a unique latest producer, native committed results, and the current task status version. - Preserve parent dependency waits and all artifact, document, download, link, scope, and budget checks. - Add a behavioral regression and controls for active work, native recovery, workspace replacement, stale versions, newer producers, and agent-owned blockers. - Document the distinct failure code and the existing control-plane writers for its metadata. ## Verification - The new behavioral regression failed on the old code with `onboarding_first_task_deadline`; 147 controls passed. - The current head passed all 148 onboarding tests, including the valid agent-owner object control. - The initial implementation passed 305 tests across all 10 Product E2E files. The later update changes only writer links and the owner control; its 148 focused tests and harness typecheck also pass. - `pnpm test:e2e:runner:typecheck` passed after building the isolated checkout's workspace declarations. - `git diff --check` passed. - All required CI checks passed on `1b3bd04d193fc98d0d9f2eb3b826a4ef4bdb6dea`, including full tests and build. They were not repeated locally. Greptile reviewed that exact head at 5/5. - The full Cloud journey remains unverified by these source and CI checks. This change does not claim that the underlying artifact delivery issue is fixed. ## Risks The early failure depends on the public task and run projections. Missing, stale, or ambiguous producer metadata keeps the existing bounded wait. A pending recovery or newer producer prevents the old blocker from ending the journey. No success requirement changes. ## Model Used OpenAI Codex, exact model ID `gpt-6.1-sol`, reasoning effort `ultra`. The model used source inspection, code editing, and local test tools. The runtime did not expose its context window size. Root supplied the model and effort metadata for this agent 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 --- tests/runner-e2e/PRODUCT-JOURNEYS.md | 9 +++ .../onboarding-first-task.test.ts | 68 +++++++++++++++++++ .../product-journeys/onboarding-first-task.ts | 16 ++++- 3 files changed, 92 insertions(+), 1 deletion(-) diff --git a/tests/runner-e2e/PRODUCT-JOURNEYS.md b/tests/runner-e2e/PRODUCT-JOURNEYS.md index ed5f8c2cc83..117e358c263 100644 --- a/tests/runner-e2e/PRODUCT-JOURNEYS.md +++ b/tests/runner-e2e/PRODUCT-JOURNEYS.md @@ -121,6 +121,15 @@ board-owned recovery, and exhausted attempts still fail. The producing turn must settle before proposal acceptance, and the recovered child must actually succeed before the existing document, artifact, download, and parent-link checks can pass. +An approved child that commits a board-owned current-track blocker fails with +`onboarding_child_task_blocked` once all owned work has settled. The latest child +run must prove the blocked decision at the child's current status version. +Parent dependency waits, pending recovery, and stale child decisions keep waiting +within the original deadline. This failure does not satisfy any delivery check. +The [semantic control-plane port](../../server/src/services/native-runtime/paperclip-control-plane-port.ts) +writes `prpReportedWorkDisposition`. [Native finalization](../../server/src/services/native-runtime/native-run-finalizer.ts) +commits the blocked decision and `statusVersionAfter` into the run's `resultJson`. + The attached `ask-question` recipe requires a prepared native general agent and an explicit execution environment. Legacy execution is unsupported until its saved model/provider identity can be verified. A registered diff --git a/tests/runner-e2e/product-journeys/onboarding-first-task.test.ts b/tests/runner-e2e/product-journeys/onboarding-first-task.test.ts index 8d54258e250..15b9f30007e 100644 --- a/tests/runner-e2e/product-journeys/onboarding-first-task.test.ts +++ b/tests/runner-e2e/product-journeys/onboarding-first-task.test.ts @@ -269,6 +269,61 @@ describe("Cloud canonical onboarding first task", () => { await expect(onboardingFirstTask(f.input)).rejects.toMatchObject({ code: "onboarding_first_task_deadline" }); expect(f.events).not.toContain("visible:download"); }); + it("fails at a committed board-owned child blocker instead of the campaign deadline", async () => { + let now = Date.now(); + vi.spyOn(Date, "now").mockImplementation(() => now); + vi.mocked(connectionDelay).mockImplementation(async ms => { now += ms; }); + const f = fixture({ mutate(phase, state) { + if (phase === "accepted") withBoardBlockedChild(state); + } }); + f.input.deadlineAt = now + 2000; + await expect(onboardingFirstTask(f.input)).rejects.toMatchObject({ outcome: "failed", code: "onboarding_child_task_blocked" }); + expect(connectionDelay).not.toHaveBeenCalled(); + expect(f.events).not.toContain("visible:download"); + }); + it("continues waiting for a parent dependency while the child has completed", async () => { + let waiting = true; + vi.mocked(connectionDelay).mockImplementation(async () => { waiting = false; }); + const f = fixture({ mutate(phase, state) { + if (phase !== "accepted" || !waiting) return; + Object.assign(state.tasks[0]!, { status: "blocked", unblockDescriptor: { owner: "board", action: "Wait for the child." } }); + state.runs.at(-1)!.resultJson = { finalizationReasonCode: "durable_dependency_blocker_bound", issueStatusAfter: "blocked" }; + } }); + await expect(onboardingFirstTask(f.input)).resolves.toMatchObject({ artifact: { originatingRunId: "child-run" } }); + expect(connectionDelay).toHaveBeenCalledOnce(); + }); + it.each(["active", "native-recovery", "workspace-replacement"])("waits past an old child blocker while %s remains pending", async mode => { + let waiting = true; + const now = Date.now(); + vi.mocked(connectionDelay).mockImplementation(async () => { waiting = false; }); + const f = fixture({ mutate(phase, state) { + if (phase !== "accepted") return; + if (mode === "workspace-replacement") { + state.runs.push(workspaceDeferral()); + if (!waiting) linkWorkspaceRetry(state.runs.find(run => run.id === "child-run")!); + } + if (!waiting) return; + withBoardBlockedChild(state); + if (mode === "active") Object.assign(state.runs[1]!, { status: "running", finishedAt: null }); + if (mode === "native-recovery") withPendingNativeRecovery(state.runs[1]!, now - 1000, now + 30_000); + } }); + await expect(onboardingFirstTask(f.input)).resolves.toMatchObject({ artifact: { originatingRunId: "child-run" } }); + expect(connectionDelay).toHaveBeenCalledOnce(); + }); + it.each(["stale-version", "newer-producer", "agent-owner"])("does not fail from a nonauthoritative child blocker: %s", async mode => { + let waiting = true; + vi.mocked(connectionDelay).mockImplementation(async () => { waiting = false; }); + const f = fixture({ mutate(phase, state) { + if (phase !== "accepted" || !waiting) return; + const run = withBoardBlockedChild(state); + if (mode === "stale-version") state.tasks[1]!.statusVersion++; + if (mode === "newer-producer") state.runs.push({ ...run, id: "newer-child-run", createdAt: "2026-01-01T00:00:02Z", + finishedAt: "2026-01-01T00:00:03Z", resultJson: { finalizationPhase: "committed", prpReportedWorkDisposition: "completed" } }); + if (mode === "agent-owner") state.tasks[1]!.unblockDescriptor.owner = { agentId: "other-agent" }; + } }); + await expect(onboardingFirstTask(f.input)).resolves.toMatchObject({ artifact: { originatingRunId: "child-run" } }); + expect(connectionDelay).toHaveBeenCalledOnce(); + }); it("retains an ordinary blocked unstarted wake without mistaking it for paid failure", async () => { const f = fixture({ mutate(phase, state) { if (phase === "accepted") state.runs.push({ ...state.runs[0]!, id: "obsolete-wake", status: "cancelled", errorCode: "issue_dependencies_blocked", startedAt: null }); @@ -566,3 +621,16 @@ function withPendingNativeRecovery(run: Row, failedAt: number, retryAt: number, attempt: 1, maxAttempts: 3, recoveryOwner: "agent", successorRunId: null, } }); } + +function withBoardBlockedChild(state: Parameters>[1]): Row { + Object.assign(state.tasks[0]!, { status: "blocked" }); + Object.assign(state.tasks[1]!, { status: "blocked", statusVersion: 2, + unblockDescriptor: { owner: "board", action: "Provide an accessible delivery artifact." } }); + const run = state.runs.find(run => run.id === "child-run")!; + Object.assign(run, { createdAt: "2026-01-01T00:00:00Z", nativePhase: "committed", resultJson: { + finalizationPhase: "committed", prpReportedWorkDisposition: "blocked", + finalizationReasonCode: "current_track_blocker_waiting", authoritativeDecision: "blocked", + issueStatusAfter: "blocked", statusVersionAfter: 2, + } }); + return run; +} diff --git a/tests/runner-e2e/product-journeys/onboarding-first-task.ts b/tests/runner-e2e/product-journeys/onboarding-first-task.ts index 4c5d72e946f..f6cf025e385 100644 --- a/tests/runner-e2e/product-journeys/onboarding-first-task.ts +++ b/tests/runner-e2e/product-journeys/onboarding-first-task.ts @@ -243,8 +243,22 @@ export async function onboardingFirstTask(input: OnboardingFirstTaskInput): Prom if (childId && child?.id !== childId) fail("onboarding_approved_child_mismatch"); const workspacePending = assertRuns(snapshot, [parent.id, ...(childId ? [childId] : [])]); if (snapshot.attachments.some(row => row.issueId !== childId)) fail("onboarding_artifact_created_by_parent"); + const pending = workspacePending || [...seen.values()].some(isPendingRun); + if (child?.status === "blocked" && child.unblockDescriptor?.owner === "board" && !pending) { + const childRuns = [...seen.values()].filter(run => runIssueId(run) === child.id) + .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)); + const latest = childRuns[0]; + const createdAt = latest && typeof latest.createdAt === "string" ? Date.parse(latest.createdAt) : NaN; + const result = latest?.resultJson; + if (Number.isFinite(createdAt) && childRuns.slice(1).every(run => Date.parse(run.createdAt) < createdAt) && + latest?.runtimeMode === "native" && latest.nativeIssueId === child.id && latest.contextSnapshot?.issueId === child.id && + latest.status === "succeeded" && latest.nativePhase === "committed" && result?.finalizationPhase === "committed" && + result.prpReportedWorkDisposition === "blocked" && result.finalizationReasonCode === "current_track_blocker_waiting" && + result.authoritativeDecision === "blocked" && result.issueStatusAfter === "blocked" && + Number.isSafeInteger(child.statusVersion) && result.statusVersionAfter === child.statusVersion) fail("onboarding_child_task_blocked"); + } const settled = child && currentParent.status === "done" && firstTaskCompletionSettled(snapshot.tasks, [parent.id], parent.id) && - !workspacePending && ![...seen.values()].some(isPendingRun); + !pending; if (settled) { const artifacts = snapshot.attachments.filter(row => row.issueId === child.id && row.originalFilename === "connection-proof.json"); if (artifacts.length !== 1) fail("onboarding_child_artifact_missing");