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");