fix(native): link a person-owned agent blocker to the agent's open question whatever owner kind the model reports (#20969)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - #20931 lets a person's answer continue a task that its agent blocked
while it waited for that answer
> - That link is recorded only when the blocker's unblock descriptor is
bound to the board
> - Codex reports such a wait as `paperclip_block` with `owner: {"kind":
"user", "name": "Board"}` and scope `task_wide`. The evidence classifier
treats only `kind: "board"` (not a PRP owner kind) as board-owned, so
the arbiter binds this person-owned blocker to the reporting agent, and
no link is recorded
> - So every Codex W2Q task still loses the answer
(`issue_not_in_progress`) and needs Retry
> - This pull request classifies a `user` owner as a person
(board-bound) and records the link from the reported owner kind, not
from the spelling of the owner
> - The benefit is that the person's answer reaches the agent whatever
owner shape the model uses

## Linked Issues or Issue Description

Refs #20931

**What happened?**
Restart-chaos wave w-20261111T230717-s5201 on master 9c56e546 (Daytona
warm, Codex, no fault): all 3 W2Q tasks (t01, t05, t09) failed. Each
continuation after the answer (runs 8864fa72, 814b2cd0, 757c1c8f) was
cancelled with `issue_not_in_progress (current status: blocked)`. Each
agent blocked with `owner: {"kind": "user", "name": "Board"}`, scope
`task_wide` (reason codes `missing_required_answer`,
`awaiting_codename_answer`). The decision was `task_wide_blocker_bound`
with the unblock owner `{agentId}`, so `questionsAwaitedByAgentBlock`
recorded no `awaitingInteractionIds`.

**What did you expect to happen?**
The answer continues the task, as it does for a board-bound blocker.

**Steps to reproduce**
1. A Codex agent asks a question; the person comments before answering.
2. The agent calls `paperclip_block` with a `user` owner and scope
`task_wide`.
3. The person answers. The continuation is cancelled with
`issue_not_in_progress`.

## What Changed

- `evidence-classifier.ts`: new `nativeBlockerOwnerKind` and an optional
`ownerKind` on the blocker assessment (`agent`, `user`, `system`,
`external`, legacy `board`, or `unspecified`). A `user` owner is now
board-owned, like `board`: a person resolves it, so it is not bound to
the reporting agent.
- `status-arbiter.ts`: both agent-reported blocker decisions
(`task_wide_blocker_bound`, `current_track_blocker_waiting`) carry
`blockerOwnerKind`.
- `blocked-question-answer.ts` and the status committer:
`questionsAwaitedByAgentBlock` records the agent's own pending,
undismissed questions when the reported owner is one a person resolves:
`user`, `external`, legacy `board`, or no recognized kind. `agent` and
`system` owners and dependency blocks
(`durable_dependency_blocker_bound`) stay excluded. A decision without
`blockerOwnerKind` keeps the previous board-bound rule. All other guards
are unchanged: asked by this agent, answered by a person, decision still
current, `statusVersion` match, guarded checkout.
- `doc/execution-semantics.md` describes the rule.

## Verification

- `server/src/__tests__/heartbeat-blocked-question-answer.test.ts` now
builds the block decision with the real `classifyNativeEvidence` and
`arbitrateNativeStatus` from the exact Codex report shape, commits it
with `commitNativeStatusDecision`, and delivers the answer through
`heartbeat.wakeup`.
- Red on master (product hunks removed, test kept): `user` task-wide
fails because the unblock owner is `{ agentId }` instead of `board`;
`external` task-wide fails with `status: cancelled, errorCode:
issue_not_in_progress`.
- Green: 10/10. `user` task-wide, `external` task-wide and `user`
current-track are delivered. `agent` and `system` owners still cancel
with `issue_not_in_progress`, as do the earlier negative cases.
- `cd server && pnpm exec vitest run
src/services/native-runtime/status-arbiter.test.ts
src/services/native-runtime/evidence-classifier.test.ts
src/services/native-runtime/historical-task-questions.test.ts
src/modules/run-dispatch`: 257 passed. `recovery-observability`,
`heartbeat-issue-liveness-escalation`, `native-completion-feedback`,
`paperclip-control-plane-port`: all passed.
- `cd server && pnpm exec tsc --noEmit -p tsconfig.json`: passes.
- Chaos: see the comment below.

## Risks

- A task-wide blocker that names a person (`user`) no longer binds its
unblock descriptor to the reporting agent and no longer wakes that agent
about its own blocker. The board owns it, as for a current-track
blocker.
- An `external` owner can now link to the agent's open question. If that
blocker was about something else, the answer resumes the agent and it
reports the blocker again.
- Upgrade: `ownerKind` and `blockerOwnerKind` are optional. A decision
built by an older release keeps the previous rule. No schema change.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), with tool use and code execution
in an agent harness.

## 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-11 18:50:00 -06:00
1 parent dfd2a6efca
commit 5cd5adfccc
6 files changed
+74 -13

No files matched your search

+6 -3
View File
@@ -4144,8 +4144,10 @@ does not turn the admitted response into an attempt to check out the task.
A person's answer to the agent's own question also continues a task that the same
agent blocked while it waited for that answer. The agent can report such a wait as
a blocker, for example after a later comment from the person moved the question
into history. When the status committer applies an agent-reported, board-owned
blocker (`current_track_blocker_waiting` or `task_wide_blocker_bound`), it records
into history. When the status committer applies an agent-reported blocker
(`current_track_blocker_waiting` or `task_wide_blocker_bound`) that a person
resolves (reported owner kind `user`, `external`, a legacy `board` owner, or no
recognized kind; an `agent` or `system` owner and dependency blocks are excluded), it records
in the same transaction the agent's own pending, undismissed ordinary questions on
the task as `awaitingInteractionIds` in the decision. Dispatch accepts the Blocked
task only when the answered interaction is in that list, was created by the
@@ -4154,7 +4156,8 @@ assignee and resolved by a person, the decision is still the task's
`projectedStatusVersion`. The checkout repeats the decision and version comparison
in its guarded update, so a person who re-blocks the task in between keeps the new
block. The continuation then checks the task out to `in_progress` and clears its
blocker fields. If the blocker was about something else, the agent receives the
blocker fields. A task-wide blocker whose reported owner kind is `user` binds
its unblock descriptor to the board, not to the reporting agent. If the blocker was about something else, the agent receives the
answer and reports its blocker again. A task blocked by a person, blocked before
the question existed, blocked by a decision saved before this rule, reassigned,
done or cancelled still cancels the answer's continuation, and Retry remains.
@@ -20,7 +20,8 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } fro
import { heartbeatService } from "../services/heartbeat.ts";
import { issueService } from "../services/issues.ts";
import { commitNativeStatusDecision } from "../services/native-runtime/status-decision-committer.js";
import { NATIVE_STATUS_ARBITER_POLICY_VERSION } from "../services/native-runtime/status-arbiter.js";
import { arbitrateNativeStatus, NATIVE_STATUS_ARBITER_POLICY_VERSION, type NativeStatusDecision } from "../services/native-runtime/status-arbiter.js";
import { classifyNativeEvidence } from "../services/native-runtime/evidence-classifier.js";
const mockAdapterExecute = vi.hoisted(() => vi.fn(async () => ({
exitCode: 0, signal: null, timedOut: false, errorMessage: null,
@@ -81,7 +82,8 @@ describeEmbeddedPostgres("an answer to the agent's own question continues the ta
return { companyId, agentId, issueId, question: question! };
}
async function agentReportsBlocker(input: { companyId: string; agentId: string; issueId: string }): Promise<void> {
async function agentReportsBlocker(input: { companyId: string; agentId: string; issueId: string;
blocker?: Record<string, unknown> }): Promise<void> {
const runId = randomUUID(), contractId = randomUUID(), resultId = randomUUID(), assessmentId = randomUUID();
await db.insert(completionContracts).values({ id: contractId, companyId: input.companyId, issueId: input.issueId,
revision: 1, schemaVersion: "paperclip.completion-contract.v1", policyVersion: "test", risk: "standard",
@@ -100,14 +102,25 @@ describeEmbeddedPostgres("an answer to the agent's own question continues the ta
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION, assessmentJson: {}, inputDigest: assessmentId });
await db.insert(nativeRunFinalizations).values({ runId, companyId: input.companyId, issueId: input.issueId,
phase: "arbitrating", resultId, assessmentId });
const decision: NativeStatusDecision = input.blocker ? arbitrateNativeStatus({
assessment: await classifyNativeEvidence({ db, companyId: input.companyId, issueId: input.issueId, runId,
contract: { revision: "1", objective: "Write the brief", criteria: [{ id: "objective", requirement: "Write the brief" }] },
result: { schema: "paperclip.run_result.v1", reportedWorkDisposition: "blocked",
summary: "Waiting for the codename answer.", blocker: input.blocker,
completionClaim: { contractRevision: "1", objectiveSatisfied: false,
criteria: [{ criterionId: "objective", status: "not_satisfied", evidenceRefs: [] }],
remainingWork: [{ description: "Write the brief after the answer.", blocksCompletion: true }] },
evidence: [], verification: [], attentionRequests: [], artifacts: [] } }),
terminalState: "succeeded", workspaceFinalizeStatus: "succeeded", agentId: input.agentId,
priorIssueStatus: snapshot!.status as "in_review",
}) : { policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION, statusAction: "blocked" as const, toStatus: "blocked" as const,
reasonCode: "current_track_blocker_waiting",
unblockDescriptor: { owner: "board" as const, action: "Answer the pending codename question." },
effects: [{ kind: "release_checkout" as const }] };
await commitNativeStatusDecision({
db, companyId: input.companyId, issueId: input.issueId, runId, assessmentId,
priorStatus: snapshot!.status, priorStatusVersion: Number(snapshot!.statusVersion),
priorDecisionId: snapshot!.lastStatusDecisionId,
decision: { policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION, statusAction: "blocked", toStatus: "blocked",
reasonCode: "current_track_blocker_waiting",
unblockDescriptor: { owner: "board", action: "Answer the pending codename question." },
effects: [{ kind: "release_checkout" }] },
priorDecisionId: snapshot!.lastStatusDecisionId, decision,
});
await db.update(heartbeatRuns).set({ status: "succeeded", finishedAt: new Date() }).where(eq(heartbeatRuns.id, runId));
}
@@ -148,6 +161,33 @@ describeEmbeddedPostgres("an answer to the agent's own question continues the ta
expect(issue).toMatchObject({ blockedTransitionAt: null, unblockDescriptor: null });
}, 60_000);
it.each([
["user", { kind: "user", name: "Board" }, "task_wide"],
["external", { kind: "external", name: "Requester" }, "task_wide"],
["user current track", { kind: "user", name: "Board" }, "current_track"],
])("delivers the answer for the agent's own blocker owned by a person (%s)", async (_label, owner, scope) => {
mockAdapterExecute.mockClear();
const { agentId, issueId, question, companyId } = await seed();
await agentReportsBlocker({ companyId, agentId, issueId, blocker: { owner, scope,
reasonCode: "awaiting_codename_answer", unblockAction: `Answer the existing codename question (${question.id}).` } });
const [blocked] = await db.select().from(issues).where(eq(issues.id, issueId));
expect(blocked).toMatchObject({ status: "blocked", unblockDescriptor: { owner: scope === "task_wide" && owner.kind === "external" ? { agentId } : "board" } });
await answer(question);
const run = await deliverAnswer({ agentId, issueId, questionId: question.id });
expect({ status: run?.status, errorCode: run?.errorCode ?? null }).toEqual({ status: "succeeded", errorCode: null });
}, 60_000);
it.each([["agent", { kind: "agent", name: "Writer" }], ["system", { kind: "system", name: "Paperclip" }]])(
"keeps cancelling the answer for a blocker the agent or the system owns (%s)", async (_label, owner) => {
mockAdapterExecute.mockClear();
const { agentId, issueId, question, companyId } = await seed();
await agentReportsBlocker({ companyId, agentId, issueId, blocker: { owner, scope: "task_wide",
reasonCode: "awaiting_input", unblockAction: "Resolve the blocker." } });
await answer(question);
const run = await deliverAnswer({ agentId, issueId, questionId: question.id });
expect(run).toMatchObject({ status: "cancelled", errorCode: "issue_not_in_progress" });
}, 60_000);
it("keeps cancelling the answer when a person blocked the task after the agent", async () => {
mockAdapterExecute.mockClear();
const { agentId, issueId, question, companyId } = await seed();
@@ -4,14 +4,18 @@ import { queuedInteractionId } from "./queued-interaction-response.js";
import { ordinaryQuestionCondition } from "./issue-question-context.js";
const AGENT_REPORTED_BLOCKER_REASON_CODES = ["current_track_blocker_waiting", "task_wide_blocker_bound"];
const PERSON_RESOLVED_BLOCKER_OWNER_KINDS = ["user", "board", "external", "unspecified"];
export type AgentBlockingQuestionState = { lastStatusDecisionId: string; statusVersion: number };
export async function questionsAwaitedByAgentBlock(db: Pick<Db, "select">, input: {
companyId: string; issueId: string; agentId: string | null | undefined; reasonCode: string | null;
toStatus: string; unblockOwner: unknown;
toStatus: string; unblockOwner: unknown; blockerOwnerKind?: string;
}): Promise<string[]> {
if (!input.agentId || input.toStatus !== "blocked" || input.unblockOwner !== "board"
const personResolves = input.blockerOwnerKind === undefined
? input.unblockOwner === "board"
: PERSON_RESOLVED_BLOCKER_OWNER_KINDS.includes(input.blockerOwnerKind);
if (!input.agentId || input.toStatus !== "blocked" || !personResolves
|| !AGENT_REPORTED_BLOCKER_REASON_CODES.includes(input.reasonCode ?? "")) return [];
const rows = await db.select({ id: issueThreadInteractions.id }).from(issueThreadInteractions).where(and(
eq(issueThreadInteractions.companyId, input.companyId),
@@ -27,6 +27,14 @@ export interface NativeEvidenceRefAssessment {
durableRecordId: string | null;
}
export type NativeBlockerOwnerKind = "agent" | "user" | "system" | "external" | "board" | "unspecified";
export function nativeBlockerOwnerKind(owner: Record<string, unknown>): NativeBlockerOwnerKind {
if (owner.owner === "board") return "board";
return ["agent", "user", "system", "external", "board"].includes(String(owner.kind))
? owner.kind as NativeBlockerOwnerKind : "unspecified";
}
export interface NativeEvidenceAssessment {
objectiveClaimSatisfied: boolean;
objectiveSatisfied: boolean;
@@ -65,6 +73,7 @@ export interface NativeEvidenceAssessment {
blocker: {
unblockAction: string;
boardOwned: boolean;
ownerKind?: NativeBlockerOwnerKind;
scope: "current_track" | "task_wide";
} | null;
continuation: {
@@ -417,7 +426,8 @@ export async function classifyNativeEvidence(input: {
blocker: reported === "blocked" && typeof blocker.unblockAction === "string"
? {
unblockAction: blocker.unblockAction,
boardOwned: blockerOwner.kind === "board" || blockerOwner.owner === "board",
boardOwned: ["board", "user"].includes(String(blockerOwner.kind)) || blockerOwner.owner === "board",
ownerKind: nativeBlockerOwnerKind(blockerOwner),
scope: blocker.scope === "task_wide" ? "task_wide" : "current_track",
}
: null,
@@ -96,6 +96,7 @@ export interface NativeStatusDecision {
action: string;
} | null;
effects: NativeStatusEffect[];
blockerOwnerKind?: string;
}
/**
@@ -619,6 +620,7 @@ export function arbitrateNativeStatus(input: {
statusAction: "blocked",
toStatus: "blocked",
reasonCode: "task_wide_blocker_bound",
blockerOwnerKind: input.assessment.blocker.ownerKind,
unblockDescriptor: {
owner,
action: input.assessment.blocker.unblockAction,
@@ -642,6 +644,7 @@ export function arbitrateNativeStatus(input: {
statusAction: "blocked",
toStatus: "blocked",
reasonCode: "current_track_blocker_waiting",
blockerOwnerKind: input.assessment.blocker.ownerKind,
// A provider's current_track label is not evidence that another
// authorized, productive track exists. Bind the actual unblock request
// without waking the same agent to repeat the blocked work or old title.
@@ -2173,6 +2173,7 @@ export async function commitNativeStatusDecision(input: {
const awaitingInteractionIds = await questionsAwaitedByAgentBlock(tx as unknown as Db, {
companyId: input.companyId, issueId: input.issueId, agentId: originRun?.agentId, reasonCode,
toStatus: input.decision.toStatus, unblockOwner: input.decision.unblockDescriptor?.owner,
blockerOwnerKind: input.decision.blockerOwnerKind,
});
const decisionJson = {
statusAction: input.decision.statusAction,