mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 07:12:00 +01:00
## Thinking Path > - Paperclip lets people delegate work to agents and approve proposed work. > - The onboarding first task asks for scope, proposes a task, and waits for a human decision. > - An answer to its opening question supplies scope. It does not approve a proposal. > - Task creation already checks the saved proposal decision. > - Native completion used the general completion policy and could mark the onboarding parent Done without that decision. > - This PR checks the trusted decision before completion feedback and again when the native status is committed. > - The first task stays in review until a saved human decision permits closure. Ordinary completion and acknowledged refusal keep their current behavior. > - The Product E2E guard also retains closed scalar evidence so future failures show which boundary condition was observed. ## Linked Issues or Issue Description Refs: #17914. Profile compatibility follow-up to #17922. **What happened?** A native onboarding run can submit a valid low-risk Done result after only the opening answer. The native finalizer then marks the first task Done even though no human has accepted or rejected a proposal. **Expected behavior** The opening answer must not permit completion. A trusted saved proposal decision must permit it. A saved rejection permits acknowledgment and closure, but does not authorize new work. A later pending proposal must keep the task in review. **Steps to reproduce** Create a server-owned onboarding first task with an answered opening question, no proposal decision, and a valid low-risk completion contract. Pass a Done result through native completion feedback, the control-plane port, and the finalizer. Before this fix, the persisted status is Done. The regression expects In review. Ordinary completion and acknowledged rejection pass as controls. ## What Changed - Share the trusted latest-proposal and human-response checks with completion admission. Keep task creation acceptance-only. - Refuse premature native completion feedback. Keep an already saved premature result in review during finalization. - Check the decision again under the existing task and run locks before committing Done. - Cover accepted decisions, saved refusals, forged context, pending or changed proposals, and a real database race. - Retain a closed scalar receipt when the existing E2E pre-acceptance guard fails. Validate and display it without task content, IDs, filenames, or raw rows. Preserve the original failure, blocked dependents, and historical reports. - Include the evidence module in the journey definition hash. Bind the receipt to its exact failure code and check its closed keys against the full type. - Repair base-branch profile drift found by the full harness check: include the active Copilot v66 fixture in image hashing and update stale Pi version/hash expectations from #17922. Keep all older image inputs and strict hash checks. - Update the onboarding feature and agent access maps and E2E evidence documentation. ## Verification - Semantic regression before implementation: one failure and two passing controls on PostgreSQL 18. The failed assertion observed Done instead of In review. - The local database uses an owned disposable container with an immutable image digest. Actual migrations, feedback, control-plane port, finalizer, and status commit run against PostgreSQL. A temporary test adapter replaces only the embedded database transport because this host has exhausted its PostgreSQL semaphores. - Native control-plane suite: 55 tests passed, including 17 new controls. Existing task-creation admission: 25 passed. Existing completion feedback: 19 passed. - Credential-free E2E support: 206 Vitest suites passed, 4,407 tests passed, and two skipped. All 137 Node support checks passed. Harness typecheck passed. - The failure-receipt regression had 11 failed assertions before capture was implemented. Two failure-code binding assertions fail without their gate. Eight placement/binding cases first validate the receipt-free control, then reject the misplaced receipt. - The untouched base reproduced the three profile compatibility failures. The narrow repair passed all 83 affected tests before the full harness run. - Private workflow preparation and credential-free onboarding preview: nine compatibility checks passed. Ownership, recovery, evidence, shared-contract and workflow compatibility: 77 passed. The actual provider preview also confirms immediate success deletion, earlier declared failure eligibility, limits, and zero retries without credential access. Map checks and git diff checks passed. - Required repository typecheck passed for the product fix. The combined branch build passed. The local full repository suite was stopped after repeated PostgreSQL startup failures; a separate initdb probe confirmed host SysV semaphore exhaustion. No shared IPC resources were changed. [Hosted CI](https://github.com/paperclipai/paperclip/actions/runs/40591020873) passed the full database suites, typecheck, build, runner checks, serialized suites, and browser shards on final head `1ac4c23605a03bfd1f1f8a017b14ce29fda85f6b` (55 success, two expected skips, one advisory neutral). - Independent source review approved the product guard, atomic race regression, evidence contract, and maps with no actionable findings. Both automated reviews are 5/5 on final head `1ac4c23605a03bfd1f1f8a017b14ce29fda85f6b`; prior findings are fixed and resolved. Final CI is green and the PR is conflict-free. - No Cloud workspace, provider call, release publication, or deployment is part of this verification. These tests do not identify a historical Cloud failure or qualify live onboarding. - Local dependency installation uses the existing CI-generated lockfile from #17811. No lockfile change is included. ## Risks - Existing onboarding runs without a trusted saved proposal decision remain in review even when the agent reports success. This is the intended approval boundary. - Decision changes can race completion. The status commit checks the decision under its existing locks and uses the existing status-race recovery path. - A saved refusal permits closure only. Creation admission still rejects it. - Evidence readers reject malformed receipts and unrelated failure codes. The version-1 key registry is checked against every receipt field at compile time. Existing attempts do not gain evidence after the fact. ## Model Used OpenAI GPT-6.1 Sol (`gpt-6.1-sol`), ultra reasoning, through Codex. Used repository inspection, code execution, and collaborating coding agents. The runtime did not report a context-window size. ## 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 references) - [x] My branch name describes the change and contains no internal ticket 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 <noreply@paperclip.ing>
106 lines
8.1 KiB
TypeScript
106 lines
8.1 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { validateRetainedRunnerResult } from "../result-validation.js";
|
|
import { renderCaseOutcome } from "../case-outcome.js";
|
|
import type { RunnerE2EResult } from "../types.js";
|
|
import type { OnboardingPreAcceptanceEvidence } from "./onboarding-pre-acceptance.js";
|
|
|
|
function result(): RunnerE2EResult {
|
|
return { schema: "paperclip.runner-e2e.result/v2", suiteId: "product-journeys", suiteDefinitionHash: "test",
|
|
executionId: "product-journeys.qa.runner-codex.local.cloud-onboarding", profileId: "runner-codex", environmentId: "local", caseId: "cloud-onboarding",
|
|
provider: "codex", model: "synthetic-model", runtimeMode: "native", status: "failed", error: "onboarding_work_before_acceptance", cleanup: "passed", attempt: 1,
|
|
startedAt: "2026-01-01T00:00:00Z", finishedAt: "2026-01-01T00:00:01Z", durationMs: 1000,
|
|
productJourney: { outcome: "failed", startingState: "onboarding", phases: [{ phase: "journey", outcome: "failed", code: "onboarding_work_before_acceptance" }],
|
|
checks: [], privacy: "private", resourceDisposition: "retained" } };
|
|
}
|
|
function preAcceptanceReceipt(): OnboardingPreAcceptanceEvidence {
|
|
return { version: 1, checkpoint: "awaiting-proposal", violations: ["attachment_present"],
|
|
taskCount: 1, attachmentCount: 1, documentCount: 0, nonPlanningDocumentCount: 0,
|
|
firstTaskMatchesCanonicalParent: true, firstTaskStatus: "in_review", canonicalParentStatus: "in_review" };
|
|
}
|
|
function passedResult(): RunnerE2EResult {
|
|
const retained = result();
|
|
retained.status = "passed";
|
|
delete retained.error;
|
|
retained.runIds = ["run-1"];
|
|
Object.assign(retained.productJourney!, { outcome: "passed", campaignId: "campaign", attemptId: "attempt",
|
|
phases: [{ phase: "journey", outcome: "passed" }], resourceDisposition: "released",
|
|
checks: ["fresh-workspace", "ui-connection-and-native-agent", "first-task-complete", "downloadable-artifact-verified", "native-trace-verified"].map(id => ({ id, passed: true })),
|
|
deployment: { kind: "cloud", targetId: "qa", origin: "https://qa.example.test", commit: "a".repeat(40), cloudRelease: "release", runtimeImage: "image", configurationHash: "config" },
|
|
operations: { campaignId: "campaign", attemptId: "attempt", modelCostUsd: null, setupCostUsd: null, requestCount: 1,
|
|
feedback: { collection: "complete", complaints: 0, suggestions: 0 }, traceIds: ["trace-1"] } });
|
|
return retained;
|
|
}
|
|
describe("onboarding pre-acceptance retained evidence", () => {
|
|
it("accepts historical results without pre-acceptance evidence and preserves optional receipts in reports", () => {
|
|
const retained = result();
|
|
expect(() => validateRetainedRunnerResult(retained)).not.toThrow();
|
|
expect(renderCaseOutcome(retained, true, [])).not.toContain("Onboarding pre-acceptance evidence");
|
|
Object.assign(retained.productJourney!, { onboardingPreAcceptance: preAcceptanceReceipt() });
|
|
const roundTrip = JSON.parse(JSON.stringify(retained));
|
|
expect(() => validateRetainedRunnerResult(roundTrip)).not.toThrow();
|
|
const output = renderCaseOutcome(roundTrip, true, []);
|
|
for (const text of ["Onboarding pre-acceptance evidence", "awaiting-proposal", "attachment_present", "First task status", "Canonical parent status", "in_review", "Attachments", "Non-planning documents", "onboarding_work_before_acceptance"]) expect(output).toContain(text);
|
|
expect(roundTrip.status).toBe("failed");
|
|
expect(roundTrip.productJourney.outcome).toBe("failed");
|
|
});
|
|
it.each([
|
|
["a passed result", passedResult],
|
|
["another case", (): RunnerE2EResult => ({ ...result(), caseId: "task-properties",
|
|
executionId: "product-journeys.qa.browser.local.task-properties", profileId: "browser", provider: null, model: null, runtimeMode: "none" })],
|
|
["another suite", (): RunnerE2EResult => ({ ...result(), suiteId: "first-task", executionId: "first-task.runner-codex.local.cloud-onboarding" })],
|
|
] as const)("rejects a valid receipt on %s after accepting the same result without it", (_label, makeResult) => {
|
|
const retained = makeResult();
|
|
expect(() => validateRetainedRunnerResult(retained)).not.toThrow();
|
|
retained.productJourney!.onboardingPreAcceptance = preAcceptanceReceipt();
|
|
expect(() => validateRetainedRunnerResult(retained)).toThrow("result.productJourney.onboardingPreAcceptance");
|
|
});
|
|
it.each(["passed", "blocked", "unsupported"] as const)("rejects a valid receipt on a %s journey outcome after accepting the same result without it", outcome => {
|
|
const retained = result();
|
|
retained.productJourney!.outcome = outcome;
|
|
expect(() => validateRetainedRunnerResult(retained)).not.toThrow();
|
|
retained.productJourney!.onboardingPreAcceptance = preAcceptanceReceipt();
|
|
expect(() => validateRetainedRunnerResult(retained)).toThrow("result.productJourney.onboardingPreAcceptance");
|
|
});
|
|
it.each(["onboarding_artifact_evidence_missing", undefined])("rejects a valid receipt for an unrelated or absent failure code (%s)", error => {
|
|
const retained = result();
|
|
retained.error = error;
|
|
expect(() => validateRetainedRunnerResult(retained)).not.toThrow();
|
|
retained.productJourney!.onboardingPreAcceptance = preAcceptanceReceipt();
|
|
expect(() => validateRetainedRunnerResult(retained)).toThrow("result.productJourney.onboardingPreAcceptance");
|
|
});
|
|
it.each([
|
|
["raw bodies", (): unknown => ({ ...preAcceptanceReceipt(), body: "private-body-must-not-render" })],
|
|
["task identifiers", (): unknown => ({ ...preAcceptanceReceipt(), taskIds: ["private-task"] })],
|
|
["document keys", (): unknown => ({ ...preAcceptanceReceipt(), documentKeys: ["private-key"] })],
|
|
["unknown version", (): unknown => ({ ...preAcceptanceReceipt(), version: 2 })],
|
|
["unknown checkpoint", (): unknown => ({ ...preAcceptanceReceipt(), checkpoint: "private text" })],
|
|
["unknown violation", (): unknown => ({ ...preAcceptanceReceipt(), violations: ["private text"] })],
|
|
["duplicate violations", (): unknown => ({ ...preAcceptanceReceipt(), violations: ["attachment_present", "attachment_present"] })],
|
|
["empty violations", (): unknown => ({ ...preAcceptanceReceipt(), violations: [] })],
|
|
["inconsistent violations", (): unknown => ({ ...preAcceptanceReceipt(), violations: ["first_task_done"] })],
|
|
["negative counts", (): unknown => ({ ...preAcceptanceReceipt(), attachmentCount: -1 })],
|
|
["fractional counts", (): unknown => ({ ...preAcceptanceReceipt(), taskCount: 1.5 })],
|
|
["unsafe counts", (): unknown => ({ ...preAcceptanceReceipt(), documentCount: Number.MAX_SAFE_INTEGER + 1 })],
|
|
["invalid document counts", (): unknown => ({ ...preAcceptanceReceipt(), nonPlanningDocumentCount: 1 })],
|
|
["raw task status", (): unknown => ({ ...preAcceptanceReceipt(), firstTaskStatus: "private status" })],
|
|
["raw parent status", (): unknown => ({ ...preAcceptanceReceipt(), canonicalParentStatus: "private status" })],
|
|
["nonboolean match", (): unknown => ({ ...preAcceptanceReceipt(), firstTaskMatchesCanonicalParent: "true" })],
|
|
["inconsistent task match", (): unknown => ({ ...preAcceptanceReceipt(), firstTaskMatchesCanonicalParent: false })],
|
|
["inconsistent matched statuses", (): unknown => ({ ...preAcceptanceReceipt(), canonicalParentStatus: "done" })],
|
|
["missing fields", (): unknown => ({ version: 1 })],
|
|
["null", (): unknown => null],
|
|
] as const)("rejects pre-acceptance evidence containing %s", (_label, makeReceipt) => {
|
|
const retained = result();
|
|
Object.assign(retained.productJourney!, { onboardingPreAcceptance: makeReceipt() });
|
|
expect(() => validateRetainedRunnerResult(retained)).toThrow("result.productJourney.onboardingPreAcceptance");
|
|
});
|
|
it("renders a fixed fallback for malformed pre-acceptance evidence without exposing its fields", () => {
|
|
const retained = result();
|
|
Object.assign(retained.productJourney!, { onboardingPreAcceptance: { ...preAcceptanceReceipt(), body: "private-body-must-not-render" } });
|
|
const output = renderCaseOutcome(retained, false, []);
|
|
expect(output).toContain("Onboarding pre-acceptance evidence unavailable: invalid receipt");
|
|
expect(output).not.toContain("private-body-must-not-render");
|
|
expect(output).not.toContain("attachment_present");
|
|
});
|
|
});
|