mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
fix(dispatch): let the answer to the agent's own question continue the task it blocked on (#20931)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - An agent can ask the person a question (`request_human_input`) and wait for the answer > - A later comment from the person moves an open question into history (#15392). The agent still needs the answer, so it reports a blocker and the task becomes Blocked > - The person then answers. Dispatch cancels the answer's continuation with `issue_not_in_progress`, because only a dependency-blocked task may take an answer while Blocked > - The task rests Blocked with a failed run and a Retry. The answer is lost until a person clicks Retry > - This pull request lets the answer continue the task when the same agent blocked it to wait for that answer, proven from durable records > - The benefit is that the person's answer always reaches the agent that asked, without a Retry ## Linked Issues or Issue Description Refs #15392, Refs #20872 **What happened?** Restart-chaos waves with no fault injected fail every W2Q task on every provider (Claude, OpenCode, Codex) with `R3:retry_needed`. For Claude task t03 on master7e8901aa60: run 5aeb9244 asks a question and the task goes `in_review`. A board comment ("I will answer shortly; do not write the brief yet") wakes run d728886f. That comment makes the question historical, so `paperclip_finish` with a `response_wake` wait is rejected ("without a pending wait condition"). The agent then calls `paperclip_block`; the status decision `current_track_blocker_waiting` sets the task Blocked. The board answers. Continuation run adbda5de is cancelled: "resolved-interaction continuation issue is no longer in_progress (current status: blocked)". The task rests blocked/failed with Retry. **What did you expect to happen?** The answer continues the task. The agent writes the brief with the answer and finishes. **Steps to reproduce** 1. An agent asks a question on its task. 2. The person posts a comment before answering. 3. The agent reports a blocker while it waits for the answer. 4. The person answers the question. 5. The answer's continuation run is cancelled with `issue_not_in_progress`. ## What Changed - Status committer (`status-decision-committer.ts`): when it applies an agent-reported, board-owned blocker (`current_track_blocker_waiting` or `task_wide_blocker_bound`), it records the agent's own pending, undismissed ordinary questions on the task as `awaitingInteractionIds` in the decision JSON, in the same transaction under the issue lock (`questionsAwaitedByAgentBlock`). - New `agentBlockingQuestionState` (`server/src/services/blocked-question-answer.ts`). It reads only server-owned rows. It returns the verified `lastStatusDecisionId` and `statusVersion` when: the answered interaction was created by this agent on this task and resolved by a person; the task is Blocked and still assigned to the agent; the task's `lastStatusDecisionId` is an applied Blocked decision with one of the two reason codes, made by a run of the same agent, whose `awaitingInteractionIds` contains this interaction; and the task's `statusVersion` still equals that decision's `projectedStatusVersion` (nothing changed the status since). - `decideQueuedRunStaleness` (`server/src/modules/run-dispatch/domain/policy.ts`): the Blocked exemption for an answer continuation also accepts this fact (`isAgentBlockingQuestionAnswer`). The postgres adapter loads it under the issue lock. - `heartbeat.ts` resolved-interaction checkout: for that answer, `blocked` is an accepted expected status, and the verified state is passed to `issues.checkout` as `blockedState`. - `issues.checkout`: with `blockedState`, the guarded update accepts a Blocked row only when `lastStatusDecisionId` and `statusVersion` still match, so a person who re-blocks the task in between keeps the new block. When the expected statuses include `blocked`, a checkout from Blocked also clears `unblockDescriptor`, `blockedTransitionAt` and `blockedOwnerNotifiedAt`, as an ordinary status change from Blocked does. - `doc/execution-semantics.md` describes the rule. ## Verification - New integration test `server/src/__tests__/heartbeat-blocked-question-answer.test.ts` (embedded Postgres, real `commitNativeStatusDecision`): the agent's blocker decision makes the task Blocked, the person answers, the answer is delivered through `heartbeat.wakeup` with the same context shape as staging. - Red on master (product hunks removed, test kept): `delivers the answer when the agent blocked the task to wait for it` fails with `status: cancelled, errorCode: issue_not_in_progress`, the same code as the chaos run. - Green: 5/5 pass. The answer run succeeds, the adapter runs, and the blocker fields are cleared. Negative cases still cancel with `issue_not_in_progress`: a person re-blocked the task after the agent, the question did not exist when the agent blocked, and the task is done. A checkout with a stale verified state conflicts and keeps the person's new blocker. - `policy.test.ts`: new case for the fact (continue when set, cancel when unset, reassignment, backlog/done/cancelled). - `cd server && pnpm exec vitest run src/modules/run-dispatch src/__tests__/heartbeat-dependency-scheduling.test.ts`: 208 passed. - `cd server && pnpm exec vitest run src/__tests__/issues-service.test.ts src/__tests__/heartbeat-stale-queue-invalidation.test.ts src/services/explicit-native-continuation.test.ts`: 656 passed. - `cd server && pnpm exec tsc --noEmit -p tsconfig.json`: passes. - Chaos, local, seed 5101, `--workloads W2Q --faults K0 --adapters codex,claude --lifecycle warm`, on722303e4a2(first head): wave w-20261111T204405-s5101, 6/6 PASS (3 Codex, 3 Claude). Every task went in_review -> blocked -> in_progress -> done without Retry. Before: the same local seed on master (w-20261111T190352-s5101) failed 3/3 Codex W2Q cells with `R3:retry_needed`, and Daytona Claude wave w-20261111T200553-s6101 failed 2/3 W2Q cells the same way. ## Risks - Low to medium. Only a resolved interaction continuation on a Blocked task changes behavior, and only when the durable linkage holds. If a board-owned blocker about something else and the agent's own question are open together, the answer resumes the agent; it then reports its blocker again. The blocker report carries no interaction reference, so the server cannot tell them apart; cancelling the answer instead loses it. A person's Blocked status, a block from before the question, dependency blocks, reassignment, done and cancelled keep the old cancel. - State saved by an older release: no schema change and no migration. A Blocked decision saved before this change has no `awaitingInteractionIds`, so its answer keeps the old behavior (cancel, Retry works). - The checkout change only applies when a caller passes `blocked` as an expected status. ## 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:
1 parent
b01959cb15
commit
228b0169be
9 files changed
+327
-2
No files matched your search
@@ -44,6 +44,7 @@ import { lockRunBudgetRecoveryOwnerInTransaction, recheckRunBudgetReservationInT
|
||||
import { isCompletedOnboardingHandoffWake } from "../../../services/chat-completion-delivery.js";
|
||||
import { isUnstartedTaskQuestionAnswer } from "../../../services/unstarted-task-question-answer.js";
|
||||
import { isVerifiedDependencyInteractionResponse } from "../../../services/queued-interaction-response.js";
|
||||
import { agentBlockingQuestionState } from "../../../services/blocked-question-answer.js";
|
||||
import { isHeartbeatWakeOnDemandEnabled } from "../../../services/heartbeat-policy.js";
|
||||
import { notifyChatRunMilestones } from "../../../services/chat-run-milestone-notifications.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "../../../services/delivery-work-notifications.js";
|
||||
@@ -706,6 +707,9 @@ export function createPostgresRunDispatchAdapter(
|
||||
&& context.dependencyBlockedInteraction === true
|
||||
&& await isVerifiedDependencyInteractionResponse(dbOrTx, { companyId: input.companyId, issueId,
|
||||
agentId: input.agentId, context })),
|
||||
isAgentBlockingQuestionAnswer: Boolean(isResolvedInteractionContinuation && issue?.status === "blocked"
|
||||
&& await agentBlockingQuestionState(dbOrTx, { companyId: input.companyId, issueId,
|
||||
agentId: input.agentId, context })),
|
||||
isUnstartedTaskQuestionAnswer: Boolean(isResolvedInteractionContinuation && issue?.status === "todo" &&
|
||||
await isUnstartedTaskQuestionAnswer(dbOrTx, { companyId: input.companyId, issueId, interactionId: context.interactionId })),
|
||||
isConnectionContinuation: (isResolvedInteractionContinuation && context.interactionKind === "connection_intent")
|
||||
|
||||
@@ -267,6 +267,19 @@ describe("decideScheduledRetryGate", () => {
|
||||
});
|
||||
|
||||
describe("decideQueuedRunStaleness", () => {
|
||||
it("continues a task its agent blocked to wait for the answered question", () => {
|
||||
const answer = { ...baseStalenessFacts(), isResolvedInteractionContinuation: true,
|
||||
issueStatus: "blocked", isAgentBlockingQuestionAnswer: true };
|
||||
expect(decideQueuedRunStaleness(answer, NOW)).toEqual({ stale: false });
|
||||
expect(decideQueuedRunStaleness({ ...answer, isAgentBlockingQuestionAnswer: false }, NOW))
|
||||
.toMatchObject({ stale: true, errorCode: "issue_not_in_progress" });
|
||||
expect(decideQueuedRunStaleness({ ...answer, issueAssigneeAgentId: "other-agent" }, NOW))
|
||||
.toMatchObject({ stale: true, errorCode: "issue_assignee_changed" });
|
||||
for (const issueStatus of ["backlog", "done", "cancelled"]) {
|
||||
expect(decideQueuedRunStaleness({ ...answer, issueStatus }, NOW)).toMatchObject({ stale: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a verified response bounded to a blocked task and its current assignee", () => {
|
||||
const response = { ...baseStalenessFacts(), isResolvedInteractionContinuation: true,
|
||||
issueStatus: "blocked", isDependencyBlockedInteractionResponse: true };
|
||||
|
||||
@@ -149,6 +149,7 @@ export type QueuedRunFacts = {
|
||||
isUnstartedTaskQuestionAnswer?: boolean;
|
||||
/** The stored human response was verified again for bounded dependency interaction. */
|
||||
isDependencyBlockedInteractionResponse?: boolean;
|
||||
isAgentBlockingQuestionAnswer?: boolean;
|
||||
/** A connection resolution or tool refresh can resume an agent waiting in review. */
|
||||
isConnectionContinuation?: boolean;
|
||||
isInteractionWake: boolean;
|
||||
@@ -516,7 +517,8 @@ export function decideQueuedRunStaleness(
|
||||
if (facts.isResolvedInteractionContinuation || facts.isConnectionContinuation) {
|
||||
// A todo task that has never started has no parked execution to protect.
|
||||
const startsUnstartedTask = facts.issueStatus === "todo" && facts.isUnstartedTaskQuestionAnswer === true;
|
||||
const answersBlockedTask = facts.issueStatus === "blocked" && facts.isDependencyBlockedInteractionResponse === true;
|
||||
const answersBlockedTask = facts.issueStatus === "blocked" &&
|
||||
(facts.isDependencyBlockedInteractionResponse === true || facts.isAgentBlockingQuestionAnswer === true);
|
||||
const earlyStatus = decideIssueStatus({
|
||||
status: facts.issueStatus,
|
||||
requiresInProgress: facts.issueStatus !== "in_review" && !startsUnstartedTask && !answersBlockedTask,
|
||||
|
||||
Reference in new issue
Block a user