Files
PaperClipAI/server/src/services/native-runtime/status-arbiter.test.ts
T
DottaandPaperclip 1778075155 fix(server): continue unfinished tasks after status replies (#14626)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Native runs report their task outcome through a structured finish
result.
> - A Board comment can permit a passive wait for the next response.
> - That exception accepted reports that also admitted blocking
unfinished work.
> - The task then stayed In Progress without a runner, and recovery
treated the wait as healthy.
> - This pull request rejects that contradiction and uses the existing
bounded continuation path.
> - Real wait conditions and protection against obsolete requests remain
in force.

## Linked Issues or Issue Description

**What happened?**

An agent answered a status inquiry with `yielded` and `response_wake`.
The same result listed blocking remaining work. The server accepted an
indefinite wait without a question, approval, dependency, or pause. No
further run was queued.

**Expected behavior**

Unfinished ordinary tasks must continue or have a recorded reason to
wait. A status reply alone must not suspend the work.

**Steps to reproduce**

1. Add a Board status inquiry to an unfinished assigned task.
2. Submit a successful native result with `yielded`, `response_wake`,
and `remainingWork[].blocksCompletion: true`.
3. Leave the task without any real wait condition.
4. Observe that the old policy preserves In Progress with no
continuation and suppresses recovery.

**Paperclip version or commit**

Reproduced in the native status policy at `da887ea3e`. The branch is
based on current master.

**Deployment mode**

Authenticated server with the native Paperclip Runner.

Related work: Refs #13338 (native response waits and recovery). Refs
#12071 (separate legacy recovery and retry-state work). This change
fixes the native unfinished-response-wait exception.

## What Changed

- Reject contradictory finish reports while the provider can still
correct them.
- Route accepted unfinished response waits through the existing
one-follow-up continuation budget. Repeated incomplete results create a
visible recovery action.
- Preserve questions, approvals, dependencies, pauses, conversation
lifecycles, and superseded Board requests.
- Recheck the current Board source in the decision transaction before
queuing repair.
- Let normal recovery reconsider old committed waits that report
blocking work and still have a current source.
- Add policy and database regression tests. Document the rule.

## Verification

- `pnpm exec vitest run
server/src/services/native-runtime/status-arbiter.test.ts
server/src/__tests__/heartbeat-process-recovery.test.ts
--no-file-parallelism`: 346 tests passed, no skips.
- `pnpm -r typecheck`: passed.
- `pnpm build`: passed.
- `git diff --check origin/master...HEAD`: passed.
- Final head `8f0824d9bb4d631f2347f8afffe5fff6d574cb69`: all CI checks
green (54 passed, 2 intentionally skipped), including all general tests,
serialized server suites, runner tests, eight E2E shards, and the canary
dry run.
- Greptile: 5/5 on the final head, with no unresolved review threads.
- The broad local `pnpm test:run` encountered an unrelated timing
failure in `workspace-runtime.test.ts` (waiting for managed process-tree
listeners). That test passed on an isolated rerun. The duplicate broad
run was stopped; the complete CI suite passed on the final commit.
- The transaction-race regression reproduced an obsolete continuation
before the fix. All 12 source-change cases now pass, covering passive
waits, corrective continuations, and exhausted-repair decisions.

## Risks

- Agents that previously parked unfinished ordinary tasks must now
continue or record an actual wait condition.
- Old contradictory waits become eligible for normal recovery. Existing
ownership, budget, pause, and supersession checks still apply.
- The rule uses the structured blocking-work flag. It does not infer
omitted work from prose.
- No schema, dependency, or UI change.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, repository inspection, code
editing, and test execution. The session does not expose a more specific
deployment identifier or 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 `#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>
2026-09-29 16:51:50 -05:00

654 lines
21 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("a reviewer finishes its decision without completing rejected or still-reviewed work", () => {
for (const priorIssueStatus of ["in_progress", "in_review"] as const) {
const decision = arbitrate({ priorIssueStatus, nativeReviewOutcome: "resolved" });
expect(decision).toMatchObject({ statusAction: "preserve", toStatus: priorIssueStatus });
expect(decision.effects).not.toContainEqual(expect.objectContaining({ kind: "enqueue_continuation" }));
}
});
it("routes an unfinished reviewer action to bounded recovery instead of retrying the worker task", () => {
const decision = arbitrate({ priorIssueStatus: "in_review", nativeReviewOutcome: "pending" });
expect(decision).toMatchObject({ statusAction: "preserve", toStatus: "in_review" });
expect(decision.effects).toContainEqual(expect.objectContaining({
kind: "record_recovery", cause: "native_review_unresolved", agentId: "agent",
}));
expect(decision.effects.some((effect) => ["enqueue_continuation", "schedule_retry"].includes(effect.kind))).toBe(false);
});
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("repairs unfinished response waits once using the server continuation budget", () => {
const unfinished = assessment({
reportedDisposition: "yielded", hasBlockingRemainingWork: true,
continuation: { kind: "response_wake", summary: "Answered status; await next message", idempotencyKey: "model-key" },
});
for (const boardResponseWaitAuthorized of [true, false]) {
expect(arbitrate({ assessment: unfinished, boardResponseWaitAuthorized })).toMatchObject({
reasonCode: "completion_evidence_incomplete",
toStatus: "in_progress",
effects: [{ kind: "enqueue_continuation", continuationKind: "same_agent", idempotencyKey: "native-completion-incomplete" }],
});
expect(arbitrate({ assessment: unfinished, boardResponseWaitAuthorized, allowIncompleteContinuation: false })).toMatchObject({
reasonCode: "prior_status_preserved_no_live_path",
effects: [{ kind: "record_finalization_error", cause: "completion_evidence_incomplete" }],
});
}
expect(arbitrate({ assessment: unfinished, boardResponseWaitOrigin: true, boardResponseWaitAuthorized: true }))
.toMatchObject({ reasonCode: "completion_evidence_incomplete" });
expect(arbitrate({ assessment: unfinished, boardResponseWaitOrigin: true, boardResponseWaitAuthorized: false }))
.toMatchObject({ reasonCode: "board_response_wait_superseded", effects: [] });
expect(arbitrate({ assessment: unfinished, hasActivePauseHold: true }))
.toMatchObject({ reasonCode: "response_wait_pause_preserved", effects: [] });
expect(arbitrate({ assessment: unfinished, hasUnresolvedIssueBlockers: true }))
.toMatchObject({ toStatus: "blocked", reasonCode: "durable_dependency_blocker_bound", effects: [] });
expect(arbitrate({ assessment: unfinished, governanceGate: { kind: "interaction", id: "question" } }))
.toMatchObject({ reasonCode: "governed_response_waiting", effects: [{ kind: "create_interaction" }] });
expect(arbitrate({ assessment: unfinished, externalChatResponseWaitAuthorization: "authorized" }))
.toMatchObject({ reasonCode: "external_chat_response_waiting", effects: [] });
expect(arbitrate({ assessment: unfinished, externalChatResponseWaitAuthorization: "revoked" }))
.toMatchObject({ reasonCode: "external_chat_response_wait_authorization_lost", effects: [] });
expect(arbitrate({ assessment: unfinished, isConversation: true, boardResponseWaitAuthorized: true }))
.toMatchObject({ reasonCode: "board_response_waiting", effects: [] });
});
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-v7",
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" }),
]);
});
});