mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Native runs report their outcome through paperclip_finish. > - The server previously turned incomplete reports into human approval requests. > - Those requests could block a later successful run, even when no person had requested review. > - This pull request creates review cards only for explicit attention requests and withdraws proven old fallback cards. > - Agents receive useful completion feedback, while explicit approval gates and task state protections remain in force. ## Linked Issues or Issue Description Related: #13266 removed reviews caused by policy upgrades. This change removes the separate completion fallback. **What happened?** An agent reported needs_review while waiting for checks without requesting a human decision. Paperclip created a generic Native completion review. A later successful report could not complete the task because that old card remained pending. **Expected behavior** Ordinary low-risk work completes after a valid done report, a successful run, and workspace finalization. Incomplete work stays with the agent. Explicit approval requests remain visible and must be resolved. **Steps to reproduce** 1. Complete a native run with needs_review and no attention requests. 2. Continue the task and submit a successful done report. 3. Observe that the old implementation leaves the task in review behind a generic confirmation card. ## What Changed - Require explicit attention requests to create native review cards. Route each request independently and preserve pending or declined decisions. - Withdraw only pending system cards with matching old decision, assessment, effect, contract, and prompt provenance. Preserve history and explicit or answered requests. - Reassess an affected current result without overwriting later task edits, runs, contracts, or workspace failures. - Return pending approval links and required actions through the completion tool. Reject contradictory done reports and empty review requests before accepting a result. - Allow one corrective continuation for incomplete results, then expose a recovery action. - Update status fixtures, database regressions, runner tests, and the completion contract documentation. ## Verification - `pnpm -r typecheck` passed after merging current master. - `pnpm build` passed after merging current master. - The combined branch passed 89 completion and Agent Chat tests. Other targeted tests passed: 170 external-chat and reconciliation tests; 50 runner-resume and control-plane tests; 13 arbiter tests; 7 chat delivery tests; 21 runner completion and runtime-context tests. - The full local test attempt exposed old review fixtures and a missing fake-provider binary. The fixtures are fixed and the helper is built. All affected suites pass in fresh reruns. The timing-sensitive Discord test also passed on rerun. - All latest-head CI checks passed, including build, typecheck, general and serialized tests, runner verification, browser tests, and canary dry run. Greptile is 5/5 with zero unresolved comments. ## Risks - Cleanup changes existing pending cards. It requires exact system provenance and only applies to low-risk agent-claim contracts. It does not delete history or dismiss explicit requests. - Status still commits after the turn and workspace finalization. Completion feedback reports current constraints and does not claim an early status commit. - Incomplete reports now request a bounded corrective run instead of an automatic approval. Repeated failures expose recovery. ## Model Used OpenAI Codex, GPT-6, with repository inspection, code execution, and test tools. The exact deployment identifier and context window size are not exposed in this 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 <noreply@paperclip.ing>
603 lines
17 KiB
TypeScript
603 lines
17 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { NativeEvidenceAssessment } from "./evidence-classifier.js";
|
|
import { arbitrateNativeStatus } from "./status-arbiter.js";
|
|
|
|
function assessment(
|
|
overrides: Partial<NativeEvidenceAssessment> = {},
|
|
): NativeEvidenceAssessment {
|
|
return {
|
|
objectiveClaimSatisfied: true,
|
|
objectiveSatisfied: true,
|
|
allCriteriaSatisfied: true,
|
|
verificationPassed: true,
|
|
hasFailedVerification: false,
|
|
hasBlockingRemainingWork: false,
|
|
reportedDisposition: "done",
|
|
summary: "Complete",
|
|
contractRevisionMatches: true,
|
|
criterionAssessments: [],
|
|
verificationAssessments: [],
|
|
verificationCaveats: [],
|
|
acceptedEvidenceRefs: ["event:2"],
|
|
missingRequirements: [],
|
|
rejectedEvidence: [],
|
|
unverifiableEvidence: [],
|
|
blocker: null,
|
|
continuation: null,
|
|
attentionRequests: [],
|
|
ignoredAttentionRequests: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function arbitrate(
|
|
overrides: Partial<Parameters<typeof arbitrateNativeStatus>[0]> = {},
|
|
) {
|
|
return arbitrateNativeStatus({
|
|
assessment: assessment(),
|
|
terminalState: "succeeded",
|
|
workspaceFinalizeStatus: "succeeded",
|
|
agentId: "agent",
|
|
priorIssueStatus: "in_progress",
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
describe("native status authority", () => {
|
|
it("treats only the authorized Board response_wake as passive and preserves governance", () => {
|
|
const passive = assessment({
|
|
reportedDisposition: "yielded",
|
|
continuation: {
|
|
kind: "response_wake",
|
|
summary: "Wait for the next request",
|
|
idempotencyKey: "board-wait",
|
|
},
|
|
});
|
|
expect(
|
|
arbitrate({ assessment: passive, boardResponseWaitAuthorized: true }),
|
|
).toMatchObject({
|
|
reasonCode: "board_response_waiting",
|
|
toStatus: "in_progress",
|
|
effects: [],
|
|
});
|
|
expect(arbitrate({ assessment: passive })).toMatchObject({
|
|
reasonCode: "live_continuation_registered",
|
|
effects: [expect.objectContaining({ kind: "enqueue_continuation" })],
|
|
});
|
|
expect(
|
|
arbitrate({ assessment: passive, boardResponseWaitOrigin: true }),
|
|
).toMatchObject({
|
|
reasonCode: "board_response_wait_superseded",
|
|
statusAction: "preserve",
|
|
effects: [],
|
|
});
|
|
expect(
|
|
arbitrate({
|
|
assessment: passive,
|
|
boardResponseWaitAuthorized: true,
|
|
governanceGate: { kind: "approval", id: "approval" },
|
|
}),
|
|
).toMatchObject({
|
|
reasonCode: "governed_response_waiting",
|
|
toStatus: "in_review",
|
|
effects: [expect.objectContaining({ kind: "create_interaction" })],
|
|
});
|
|
for (const kind of ["same_agent", "retry", "monitor"] as const) {
|
|
expect(
|
|
arbitrate({
|
|
assessment: {
|
|
...passive,
|
|
continuation: { ...passive.continuation!, kind },
|
|
},
|
|
boardResponseWaitAuthorized: true,
|
|
}),
|
|
).toMatchObject({
|
|
reasonCode: "live_continuation_registered",
|
|
effects: [expect.objectContaining({ continuationKind: kind })],
|
|
});
|
|
}
|
|
});
|
|
it("marks done only from successful finalization and complete durable evidence", () => {
|
|
expect(arbitrate()).toEqual(
|
|
expect.objectContaining({
|
|
statusAction: "done",
|
|
toStatus: "done",
|
|
reasonCode: "completion_contract_satisfied",
|
|
effects: [{ kind: "release_checkout" }],
|
|
}),
|
|
);
|
|
expect(arbitrate({ completionClaimPolicyAccepted: true })).toEqual(
|
|
expect.objectContaining({
|
|
statusAction: "done",
|
|
reasonCode: "completion_claim_policy_accepted",
|
|
}),
|
|
);
|
|
expect(
|
|
arbitrate({
|
|
assessment: assessment({
|
|
verificationPassed: false,
|
|
missingRequirements: ["test"],
|
|
}),
|
|
}),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
statusAction: "in_progress",
|
|
toStatus: "in_progress",
|
|
reasonCode: "completion_evidence_incomplete",
|
|
effects: [expect.objectContaining({ kind: "enqueue_continuation" })],
|
|
}),
|
|
);
|
|
const claimOnly = assessment({
|
|
objectiveSatisfied: false,
|
|
allCriteriaSatisfied: false,
|
|
verificationPassed: false,
|
|
criterionAssessments: [
|
|
{
|
|
criterionId: "objective",
|
|
claimStatus: "satisfied",
|
|
outcome: "missing",
|
|
evidenceRefs: [],
|
|
reasonCode: "criterion_evidence_missing",
|
|
},
|
|
],
|
|
verificationAssessments: [
|
|
{
|
|
commandOrCheck: "Answered the question",
|
|
claimStatus: "passed",
|
|
outcome: "unverifiable",
|
|
evidenceRef: null,
|
|
reasonCode: "verification_has_no_durable_reference",
|
|
reportedReasonCode: null,
|
|
detail: null,
|
|
},
|
|
],
|
|
acceptedEvidenceRefs: [],
|
|
missingRequirements: ["objective"],
|
|
});
|
|
expect(arbitrate({ assessment: claimOnly })).toEqual(
|
|
expect.objectContaining({
|
|
toStatus: "in_progress",
|
|
reasonCode: "completion_evidence_incomplete",
|
|
}),
|
|
);
|
|
expect(
|
|
arbitrate({ assessment: claimOnly, completionClaimPolicyAccepted: true }),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
toStatus: "done",
|
|
reasonCode: "completion_claim_policy_accepted",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("creates explicit liveness paths for review, continuation, cancellation, and governance", () => {
|
|
expect(
|
|
arbitrate({
|
|
assessment: assessment({ reportedDisposition: "needs_review" }),
|
|
}),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
toStatus: "in_progress",
|
|
effects: [expect.objectContaining({ kind: "enqueue_continuation" })],
|
|
}),
|
|
);
|
|
expect(
|
|
arbitrate({
|
|
assessment: assessment({
|
|
reportedDisposition: "yielded",
|
|
continuation: {
|
|
kind: "retry",
|
|
summary: "Retry the task",
|
|
idempotencyKey: "retry-task",
|
|
},
|
|
}),
|
|
}),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
toStatus: "in_progress",
|
|
effects: [
|
|
expect.objectContaining({
|
|
kind: "enqueue_continuation",
|
|
continuationKind: "retry",
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
const externalChatWait = assessment({
|
|
reportedDisposition: "yielded",
|
|
continuation: {
|
|
kind: "response_wake",
|
|
summary: "Wait for the next authorized provider message",
|
|
idempotencyKey: "external-chat-wait",
|
|
},
|
|
});
|
|
expect(
|
|
arbitrate({
|
|
assessment: externalChatWait,
|
|
externalChatResponseWaitAuthorization: "authorized",
|
|
}),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
statusAction: "in_progress",
|
|
toStatus: "in_progress",
|
|
reasonCode: "external_chat_response_waiting",
|
|
effects: [],
|
|
}),
|
|
);
|
|
expect(
|
|
arbitrate({
|
|
assessment: externalChatWait,
|
|
externalChatResponseWaitAuthorization: "revoked",
|
|
}),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
statusAction: "preserve",
|
|
toStatus: "in_progress",
|
|
reasonCode: "external_chat_response_wait_authorization_lost",
|
|
effects: [],
|
|
}),
|
|
);
|
|
expect(
|
|
arbitrate({
|
|
assessment: externalChatWait,
|
|
externalChatResponseWaitAuthorization: "not_applicable",
|
|
}),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
reasonCode: "live_continuation_registered",
|
|
effects: [
|
|
expect.objectContaining({
|
|
kind: "enqueue_continuation",
|
|
continuationKind: "response_wake",
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
expect(arbitrate({ terminalState: "cancelled" })).toEqual(
|
|
expect.objectContaining({
|
|
toStatus: "in_progress",
|
|
effects: [expect.objectContaining({ kind: "release_run_resources" })],
|
|
}),
|
|
);
|
|
expect(
|
|
arbitrate({ governanceGate: { kind: "interaction", id: "interaction" } }),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
toStatus: "in_review",
|
|
reasonCode: "governed_gate_pending",
|
|
effects: [
|
|
{
|
|
kind: "create_interaction",
|
|
gate: { kind: "interaction", id: "interaction" },
|
|
},
|
|
{
|
|
kind: "notify_owner",
|
|
agentId: "agent",
|
|
reason: "governed_gate_pending",
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(
|
|
arbitrate({
|
|
governanceGate: { kind: "interaction", id: "interaction" },
|
|
assessment: assessment({
|
|
reportedDisposition: "yielded",
|
|
continuation: {
|
|
kind: "response_wake",
|
|
summary: "Resume from the response",
|
|
idempotencyKey: "interaction-response:interaction",
|
|
},
|
|
}),
|
|
}),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
toStatus: "in_review",
|
|
reasonCode: "governed_response_waiting",
|
|
effects: [
|
|
{
|
|
kind: "create_interaction",
|
|
gate: { kind: "interaction", id: "interaction" },
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("accepts low-risk completion claims with unrun verification caveats", () => {
|
|
const withCaveat = assessment({
|
|
verificationPassed: false,
|
|
criterionAssessments: [
|
|
{
|
|
criterionId: "objective",
|
|
claimStatus: "satisfied",
|
|
outcome: "missing",
|
|
evidenceRefs: [],
|
|
reasonCode: "criterion_evidence_missing",
|
|
},
|
|
],
|
|
verificationAssessments: [
|
|
{
|
|
commandOrCheck: "Run npm test",
|
|
claimStatus: "not_run",
|
|
outcome: "missing",
|
|
evidenceRef: null,
|
|
reasonCode: "verification_not_run",
|
|
reportedReasonCode: "tool_unavailable",
|
|
detail: "Node and npm are unavailable in this environment.",
|
|
},
|
|
],
|
|
verificationCaveats: [
|
|
{
|
|
commandOrCheck: "Run npm test",
|
|
reasonCode: "tool_unavailable",
|
|
detail: "Node and npm are unavailable in this environment.",
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(
|
|
arbitrate({
|
|
assessment: withCaveat,
|
|
completionClaimPolicyAccepted: true,
|
|
}),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
toStatus: "done",
|
|
reasonCode: "completion_claim_policy_accepted",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("keeps failed verification with the agent and routes explicit attention to its owner", () => {
|
|
const failed = assessment({
|
|
verificationPassed: false,
|
|
hasFailedVerification: true,
|
|
verificationAssessments: [
|
|
{
|
|
commandOrCheck: "Run npm test",
|
|
claimStatus: "failed",
|
|
outcome: "rejected",
|
|
evidenceRef: null,
|
|
reasonCode: "verification_reported_failed",
|
|
reportedReasonCode: null,
|
|
detail: "One test failed.",
|
|
},
|
|
],
|
|
});
|
|
expect(
|
|
arbitrate({
|
|
assessment: failed,
|
|
completionClaimPolicyAccepted: true,
|
|
reviewOwnerUserId: "user-1",
|
|
}),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
toStatus: "in_progress",
|
|
reasonCode: "completion_evidence_incomplete",
|
|
effects: [
|
|
expect.objectContaining({
|
|
kind: "enqueue_continuation",
|
|
agentId: "agent",
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
|
|
const withAttention = assessment({
|
|
attentionRequests: [
|
|
{
|
|
kind: "approval",
|
|
summary: "Approve publication",
|
|
ownerClass: "human",
|
|
targetAgentId: null,
|
|
sourceIndex: 0,
|
|
sourceKind: "approval",
|
|
legacy: false,
|
|
},
|
|
],
|
|
});
|
|
expect(
|
|
arbitrate({
|
|
assessment: withAttention,
|
|
completionClaimPolicyAccepted: true,
|
|
}),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
toStatus: "in_review",
|
|
reasonCode: "actionable_attention_pending",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("blocks only for a task-wide blocker with a named owner and action", () => {
|
|
expect(
|
|
arbitrate({
|
|
assessment: assessment({
|
|
reportedDisposition: "blocked",
|
|
blocker: {
|
|
boardOwned: true,
|
|
scope: "task_wide",
|
|
unblockAction: "Approve access",
|
|
},
|
|
}),
|
|
}),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
toStatus: "blocked",
|
|
unblockDescriptor: { owner: "board", action: "Approve access" },
|
|
effects: [
|
|
{ kind: "bind_blocker", owner: "board", action: "Approve access" },
|
|
{
|
|
kind: "notify_owner",
|
|
agentId: "agent",
|
|
reason: "task_wide_blocker_bound",
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("waits for an explicit unblock instead of inventing another productive track", () => {
|
|
expect(
|
|
arbitrate({
|
|
assessment: assessment({
|
|
reportedDisposition: "blocked",
|
|
objectiveClaimSatisfied: false,
|
|
objectiveSatisfied: false,
|
|
allCriteriaSatisfied: false,
|
|
verificationPassed: false,
|
|
hasBlockingRemainingWork: true,
|
|
blocker: {
|
|
boardOwned: false,
|
|
scope: "current_track",
|
|
unblockAction:
|
|
"Grant access to the current Board comment attachment, then explicitly retry.",
|
|
},
|
|
}),
|
|
}),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
statusAction: "blocked",
|
|
toStatus: "blocked",
|
|
policyVersion: "phase6-v5",
|
|
reasonCode: "current_track_blocker_waiting",
|
|
unblockDescriptor: {
|
|
owner: "board",
|
|
action:
|
|
"Grant access to the current Board comment attachment, then explicitly retry.",
|
|
},
|
|
effects: [
|
|
{
|
|
kind: "bind_blocker",
|
|
owner: "board",
|
|
action:
|
|
"Grant access to the current Board comment attachment, then explicitly retry.",
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("blocks a current-track result when a durable dependency already gates the issue", () => {
|
|
expect(
|
|
arbitrate({
|
|
assessment: assessment({
|
|
reportedDisposition: "blocked",
|
|
blocker: {
|
|
boardOwned: false,
|
|
scope: "current_track",
|
|
unblockAction: "Wait for child task DOT-52",
|
|
},
|
|
}),
|
|
hasUnresolvedIssueBlockers: true,
|
|
}),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
statusAction: "blocked",
|
|
toStatus: "blocked",
|
|
reasonCode: "durable_dependency_blocker_bound",
|
|
unblockDescriptor: {
|
|
owner: { agentId: "agent" },
|
|
action: "Wait for child task DOT-52",
|
|
},
|
|
effects: [],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("lets a durable dependency override an optimistic done result", () => {
|
|
expect(
|
|
arbitrate({
|
|
assessment: assessment({ reportedDisposition: "done", blocker: null }),
|
|
completionClaimPolicyAccepted: true,
|
|
hasUnresolvedIssueBlockers: true,
|
|
}),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
statusAction: "blocked",
|
|
toStatus: "blocked",
|
|
reasonCode: "durable_dependency_blocker_bound",
|
|
effects: [],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not create a duplicate review after this run's review was already accepted", () => {
|
|
expect(
|
|
arbitrate({
|
|
assessment: assessment({
|
|
reportedDisposition: "needs_review",
|
|
attentionRequests: [
|
|
{
|
|
kind: "review",
|
|
summary: "Accept the plan",
|
|
ownerClass: "human",
|
|
targetAgentId: null,
|
|
sourceIndex: 0,
|
|
sourceKind: "review",
|
|
legacy: false,
|
|
},
|
|
],
|
|
}),
|
|
governanceResolvedForRun: true,
|
|
}),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
statusAction: "in_review",
|
|
reasonCode: "governance_response_continuation_queued",
|
|
effects: [],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not create a duplicate review after this run's planning confirmation was already accepted", () => {
|
|
expect(
|
|
arbitrate({
|
|
assessment: assessment({
|
|
reportedDisposition: "blocked",
|
|
attentionRequests: [
|
|
{
|
|
kind: "approval",
|
|
summary: "Accept the pinned plan revision.",
|
|
ownerClass: "human",
|
|
targetAgentId: null,
|
|
sourceIndex: 0,
|
|
sourceKind: "approval",
|
|
legacy: false,
|
|
},
|
|
],
|
|
}),
|
|
governanceResolvedForRun: true,
|
|
}),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
statusAction: "in_review",
|
|
reasonCode: "governance_response_continuation_queued",
|
|
effects: [],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("preserves authoritative terminal statuses", () => {
|
|
expect(arbitrate({ priorIssueStatus: "done" })).toEqual(
|
|
expect.objectContaining({
|
|
toStatus: "done",
|
|
reasonCode: "terminal_status_preserved",
|
|
effects: [],
|
|
}),
|
|
);
|
|
});
|
|
it("routes each explicit request to its own reviewer", () => {
|
|
const decision = arbitrate({ assessment: assessment({ attentionRequests: [
|
|
{ kind: "approval", summary: "Approve release", ownerClass: "human", targetAgentId: null, sourceIndex: 0, sourceKind: "approval", legacy: false },
|
|
{ kind: "review", summary: "Review code", ownerClass: "agent", targetAgentId: "review-agent", sourceIndex: 1, sourceKind: "review", legacy: false },
|
|
] }), reviewOwnerUserId: "release-owner" });
|
|
expect(decision.effects).toEqual([
|
|
expect.objectContaining({ kind: "bind_reviewer", requestKey: "attention-0", prompt: "Approve release", ownerUserId: "release-owner", ownerAgentId: null }),
|
|
expect.objectContaining({ kind: "bind_reviewer", requestKey: "attention-1", prompt: "Review code", ownerUserId: null, ownerAgentId: "review-agent" }),
|
|
]);
|
|
});
|
|
|
|
});
|