fix(dispatch): let the answer to the agent's own question continue the task it blocked on

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 committed 2026-11-11 14:38:55 -06:00
1 parent c1edb9ec02
commit 722303e4a2
8 files changed
+273 -2

No files matched your search

+13
View File
@@ -4106,6 +4106,19 @@ An absent, changed, pending, or agent-resolved response grants no such exception
That bounded response remains a continuation after a workspace or AI-account wait, That bounded response remains a continuation after a workspace or AI-account wait,
so final dispatch still verifies it. Finishing a dependency during preparation so final dispatch still verifies it. Finishing a dependency during preparation
does not turn the admitted response into an attempt to check out the task. 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. Dispatch accepts the Blocked task only through durable linkage: the
interaction was created by the assignee on this task and resolved by a person,
and the task's current Blocked status is the applied status decision
(`current_track_blocker_waiting` or `task_wide_blocker_bound`) of a run of the
same agent, recorded after the question was asked and before it was answered,
with no later move into Blocked. The continuation then checks the task out to
`in_progress` and clears its blocker fields. A task blocked by a person, blocked
before the question existed, reassigned, done or cancelled still cancels the
answer's continuation as before.
Task interruption notices ignore Retry receipts for a superseded run. A later Task interruption notices ignore Retry receipts for a superseded run. A later
successful conversation must not inherit the old failure banner. This read-only successful conversation must not inherit the old failure banner. This read-only
projection neither retires Retry authority nor acknowledges independent saved input. projection neither retires Retry authority nor acknowledges independent saved input.
@@ -0,0 +1,180 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
agents,
authUsers,
companies,
companyMemberships,
completionContracts,
createDb,
heartbeatRuns,
issueThreadInteractions,
issues,
nativeRunFinalizations,
nativeRunResults,
workAssessments,
} from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { heartbeatService } from "../services/heartbeat.ts";
import { commitNativeStatusDecision } from "../services/native-runtime/status-decision-committer.js";
import { NATIVE_STATUS_ARBITER_POLICY_VERSION } from "../services/native-runtime/status-arbiter.js";
const mockAdapterExecute = vi.hoisted(() => vi.fn(async () => ({
exitCode: 0, signal: null, timedOut: false, errorMessage: null,
summary: "Wrote the brief with the answer.", provider: "test", model: "test-model",
})));
vi.mock("../adapters/index.ts", async () => {
const actual = await vi.importActual<typeof import("../adapters/index.ts")>("../adapters/index.ts");
return { ...actual, getServerAdapter: vi.fn(() => ({ supportsLocalAgentJwt: false, execute: mockAdapterExecute })) };
});
const support = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = support.supported ? describe : describe.skip;
describeEmbeddedPostgres("an answer to the agent's own question continues the task it blocked on", () => {
let db!: ReturnType<typeof createDb>;
let heartbeat!: ReturnType<typeof heartbeatService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
const userId = "blocked-question-user";
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-blocked-question-answer-");
db = createDb(tempDb.connectionString);
await db.insert(authUsers).values({ id: userId, name: "Board", email: "board@example.test",
createdAt: new Date(), updatedAt: new Date() });
heartbeat = heartbeatService(db);
}, 30_000);
afterAll(async () => {
await heartbeat?.drainActiveRunExecutions();
await db?.$client.end();
await tempDb?.cleanup();
});
async function seed() {
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Question fixture",
issuePrefix: `Q${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false, defaultResponsibleUserId: userId });
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId,
status: "active", membershipRole: "owner" });
await db.insert(agents).values({ id: agentId, companyId, name: "Writer", role: "engineer", status: "active",
adapterType: "codex_local", adapterConfig: {}, permissions: {},
runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } } });
await db.insert(issues).values({ id: issueId, companyId, title: "Write the brief", status: "in_review",
assigneeAgentId: agentId, responsibleUserId: userId });
const askingRunId = randomUUID();
await db.insert(heartbeatRuns).values({ id: askingRunId, companyId, agentId, status: "succeeded",
runtimeMode: "native", nativeIssueId: issueId, invocationSource: "assignment", triggerDetail: "system",
contextSnapshot: { issueId }, finishedAt: new Date() });
const [question] = await db.insert(issueThreadInteractions).values({ companyId, issueId,
createdByAgentId: agentId, sourceRunId: askingRunId, kind: "ask_user_questions", status: "pending",
continuationPolicy: "wake_assignee", title: "Pick a codename",
payload: { version: 1, questions: [{ id: "codename", prompt: "Which codename?", selectionMode: "single",
options: [{ id: "alpha", label: "ALPHA" }, { id: "bravo", label: "BRAVO" }] }] },
}).returning();
return { companyId, agentId, issueId, question: question! };
}
async function agentReportsBlocker(input: { companyId: string; agentId: string; issueId: string }) {
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",
completionAuthority: "server_arbiter", incompleteCriteriaPolicy: "preserve_non_terminal",
contractJson: {}, canonicalSha256: contractId, createdByActorType: "system", createdByActorId: "test" });
await db.insert(heartbeatRuns).values({ id: runId, companyId: input.companyId, agentId: input.agentId,
status: "running", runtimeMode: "native", nativeIssueId: input.issueId, invocationSource: "automation",
triggerDetail: "system", completionContractId: contractId, contextSnapshot: { issueId: input.issueId } });
await db.insert(nativeRunResults).values({ id: resultId, companyId: input.companyId, issueId: input.issueId,
runId, completionContractId: contractId, serverFingerprint: resultId, schemaStatus: "accepted",
resultJson: {}, canonicalSha256: resultId });
const [snapshot] = await db.select().from(issues).where(eq(issues.id, input.issueId));
await db.insert(workAssessments).values({ id: assessmentId, companyId: input.companyId, issueId: input.issueId,
runId, contractId, resultId, triggerKind: "native_result", triggerActorCompanyId: input.companyId,
priorIssueStatus: snapshot!.status, priorStatusVersion: snapshot!.statusVersion,
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 });
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" }] },
});
await db.update(heartbeatRuns).set({ status: "succeeded", finishedAt: new Date() }).where(eq(heartbeatRuns.id, runId));
}
async function answer(question: typeof issueThreadInteractions.$inferSelect) {
await db.update(issueThreadInteractions).set({ status: "answered", resolvedByUserId: userId, resolvedAt: new Date(),
result: { version: 1, answers: [{ questionId: "codename", optionIds: ["bravo"] }] } })
.where(eq(issueThreadInteractions.id, question.id));
}
async function deliverAnswer(input: { agentId: string; issueId: string; questionId: string }) {
const context = { issueId: input.issueId, mutation: "interaction", interactionId: input.questionId,
interactionKind: "ask_user_questions", interactionStatus: "answered",
interactionResolvedAt: new Date().toISOString(), wakeReason: "issue_commented" };
const run = await heartbeat.wakeup(input.agentId, { source: "automation", triggerDetail: "system",
reason: "issue_commented", payload: context, contextSnapshot: context,
requestedByActorType: "user", requestedByActorId: userId });
await heartbeat.drainActiveRunExecutions();
return run ? (await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)))[0]! : null;
}
it("delivers the answer when the agent blocked the task to wait for it", async () => {
mockAdapterExecute.mockClear();
const { agentId, issueId, question, companyId } = await seed();
await agentReportsBlocker({ companyId, agentId, issueId });
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0]!.status).toBe("blocked");
await answer(question);
const run = await deliverAnswer({ agentId, issueId, questionId: question.id });
expect({ status: run?.status, errorCode: run?.errorCode ?? null, error: run?.error ?? null })
.toEqual({ status: "succeeded", errorCode: null, error: null });
expect(run?.startedAt).not.toBeNull();
expect(run?.contextSnapshot).toMatchObject({ interactionId: question.id, interactionStatus: "answered" });
expect(mockAdapterExecute).toHaveBeenCalled();
const [issue] = await db.select().from(issues).where(eq(issues.id, issueId));
expect(issue).toMatchObject({ blockedTransitionAt: null, unblockDescriptor: null });
}, 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();
await agentReportsBlocker({ companyId, agentId, issueId });
await db.update(issues).set({ status: "in_review", blockedTransitionAt: null }).where(eq(issues.id, issueId));
await db.update(issues).set({ status: "blocked", blockedTransitionAt: new Date(Date.now() + 1_000) })
.where(eq(issues.id, issueId));
await answer(question);
const run = await deliverAnswer({ agentId, issueId, questionId: question.id });
expect(run).toMatchObject({ status: "cancelled", errorCode: "issue_not_in_progress" });
expect(mockAdapterExecute).not.toHaveBeenCalled();
}, 60_000);
it("keeps cancelling the answer when the agent blocked before asking", async () => {
mockAdapterExecute.mockClear();
const { agentId, issueId, question, companyId } = await seed();
await agentReportsBlocker({ companyId, agentId, issueId });
await db.update(issueThreadInteractions).set({ createdAt: new Date(Date.now() + 1_000) })
.where(eq(issueThreadInteractions.id, question.id));
await answer(question);
const run = await deliverAnswer({ agentId, issueId, questionId: question.id });
expect(run).toMatchObject({ status: "cancelled", errorCode: "issue_not_in_progress" });
expect(mockAdapterExecute).not.toHaveBeenCalled();
}, 60_000);
it("keeps cancelling the answer once the task is done", async () => {
mockAdapterExecute.mockClear();
const { agentId, issueId, question, companyId } = await seed();
await agentReportsBlocker({ companyId, agentId, issueId });
await db.update(issues).set({ status: "done" }).where(eq(issues.id, issueId));
await answer(question);
const run = await deliverAnswer({ agentId, issueId, questionId: question.id });
if (run) expect(run).toMatchObject({ status: "cancelled" });
expect(mockAdapterExecute).not.toHaveBeenCalled();
}, 60_000);
});
@@ -44,6 +44,7 @@ import { lockRunBudgetRecoveryOwnerInTransaction, recheckRunBudgetReservationInT
import { isCompletedOnboardingHandoffWake } from "../../../services/chat-completion-delivery.js"; import { isCompletedOnboardingHandoffWake } from "../../../services/chat-completion-delivery.js";
import { isUnstartedTaskQuestionAnswer } from "../../../services/unstarted-task-question-answer.js"; import { isUnstartedTaskQuestionAnswer } from "../../../services/unstarted-task-question-answer.js";
import { isVerifiedDependencyInteractionResponse } from "../../../services/queued-interaction-response.js"; import { isVerifiedDependencyInteractionResponse } from "../../../services/queued-interaction-response.js";
import { isAnswerToAgentBlockingQuestion } from "../../../services/blocked-question-answer.js";
import { isHeartbeatWakeOnDemandEnabled } from "../../../services/heartbeat-policy.js"; import { isHeartbeatWakeOnDemandEnabled } from "../../../services/heartbeat-policy.js";
import { notifyChatRunMilestones } from "../../../services/chat-run-milestone-notifications.js"; import { notifyChatRunMilestones } from "../../../services/chat-run-milestone-notifications.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "../../../services/delivery-work-notifications.js"; import { DELIVERY_QUEUES, notifyDeliveryWork } from "../../../services/delivery-work-notifications.js";
@@ -706,6 +707,9 @@ export function createPostgresRunDispatchAdapter(
&& context.dependencyBlockedInteraction === true && context.dependencyBlockedInteraction === true
&& await isVerifiedDependencyInteractionResponse(dbOrTx, { companyId: input.companyId, issueId, && await isVerifiedDependencyInteractionResponse(dbOrTx, { companyId: input.companyId, issueId,
agentId: input.agentId, context })), agentId: input.agentId, context })),
isAgentBlockingQuestionAnswer: Boolean(isResolvedInteractionContinuation && issue?.status === "blocked"
&& await isAnswerToAgentBlockingQuestion(dbOrTx, { companyId: input.companyId, issueId,
agentId: input.agentId, context })),
isUnstartedTaskQuestionAnswer: Boolean(isResolvedInteractionContinuation && issue?.status === "todo" && isUnstartedTaskQuestionAnswer: Boolean(isResolvedInteractionContinuation && issue?.status === "todo" &&
await isUnstartedTaskQuestionAnswer(dbOrTx, { companyId: input.companyId, issueId, interactionId: context.interactionId })), await isUnstartedTaskQuestionAnswer(dbOrTx, { companyId: input.companyId, issueId, interactionId: context.interactionId })),
isConnectionContinuation: (isResolvedInteractionContinuation && context.interactionKind === "connection_intent") isConnectionContinuation: (isResolvedInteractionContinuation && context.interactionKind === "connection_intent")
@@ -267,6 +267,19 @@ describe("decideScheduledRetryGate", () => {
}); });
describe("decideQueuedRunStaleness", () => { 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", () => { it("keeps a verified response bounded to a blocked task and its current assignee", () => {
const response = { ...baseStalenessFacts(), isResolvedInteractionContinuation: true, const response = { ...baseStalenessFacts(), isResolvedInteractionContinuation: true,
issueStatus: "blocked", isDependencyBlockedInteractionResponse: true }; issueStatus: "blocked", isDependencyBlockedInteractionResponse: true };
@@ -149,6 +149,7 @@ export type QueuedRunFacts = {
isUnstartedTaskQuestionAnswer?: boolean; isUnstartedTaskQuestionAnswer?: boolean;
/** The stored human response was verified again for bounded dependency interaction. */ /** The stored human response was verified again for bounded dependency interaction. */
isDependencyBlockedInteractionResponse?: boolean; isDependencyBlockedInteractionResponse?: boolean;
isAgentBlockingQuestionAnswer?: boolean;
/** A connection resolution or tool refresh can resume an agent waiting in review. */ /** A connection resolution or tool refresh can resume an agent waiting in review. */
isConnectionContinuation?: boolean; isConnectionContinuation?: boolean;
isInteractionWake: boolean; isInteractionWake: boolean;
@@ -516,7 +517,8 @@ export function decideQueuedRunStaleness(
if (facts.isResolvedInteractionContinuation || facts.isConnectionContinuation) { if (facts.isResolvedInteractionContinuation || facts.isConnectionContinuation) {
// A todo task that has never started has no parked execution to protect. // A todo task that has never started has no parked execution to protect.
const startsUnstartedTask = facts.issueStatus === "todo" && facts.isUnstartedTaskQuestionAnswer === true; 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({ const earlyStatus = decideIssueStatus({
status: facts.issueStatus, status: facts.issueStatus,
requiresInProgress: facts.issueStatus !== "in_review" && !startsUnstartedTask && !answersBlockedTask, requiresInProgress: facts.issueStatus !== "in_review" && !startsUnstartedTask && !answersBlockedTask,
@@ -0,0 +1,50 @@
import { and, eq, gte, inArray, isNotNull, sql } from "drizzle-orm";
import { heartbeatRuns, issueThreadInteractions, issues, statusDecisions, type Db } from "@paperclipai/db";
import { queuedInteractionId } from "./queued-interaction-response.js";
const AGENT_REPORTED_BLOCKER_REASON_CODES = ["current_track_blocker_waiting", "task_wide_blocker_bound"];
export async function isAnswerToAgentBlockingQuestion(db: Pick<Db, "select">, input: {
companyId: string; issueId: string; agentId: string; context: unknown;
}): Promise<boolean> {
const interactionId = queuedInteractionId(input.context);
if (!interactionId) return false;
const interactionStatus = (input.context as Record<string, unknown>).interactionStatus;
const rows = await db
.select({ id: issueThreadInteractions.id })
.from(issueThreadInteractions)
.innerJoin(issues, and(
eq(issues.id, issueThreadInteractions.issueId),
eq(issues.companyId, issueThreadInteractions.companyId),
))
.innerJoin(statusDecisions, and(
eq(statusDecisions.id, issues.lastStatusDecisionId),
eq(statusDecisions.companyId, issues.companyId),
eq(statusDecisions.issueId, issues.id),
))
.innerJoin(heartbeatRuns, and(
eq(heartbeatRuns.id, statusDecisions.runId),
eq(heartbeatRuns.companyId, statusDecisions.companyId),
))
.where(and(
eq(issueThreadInteractions.id, interactionId),
eq(issueThreadInteractions.companyId, input.companyId),
eq(issueThreadInteractions.issueId, input.issueId),
eq(issueThreadInteractions.createdByAgentId, input.agentId),
eq(issueThreadInteractions.status, String(interactionStatus)),
isNotNull(issueThreadInteractions.resolvedByUserId),
isNotNull(issueThreadInteractions.resolvedAt),
eq(issues.status, "blocked"),
eq(issues.assigneeAgentId, input.agentId),
isNotNull(issues.blockedTransitionAt),
eq(statusDecisions.applicationState, "applied"),
eq(statusDecisions.toStatus, "blocked"),
inArray(statusDecisions.reasonCode, AGENT_REPORTED_BLOCKER_REASON_CODES),
gte(statusDecisions.createdAt, issueThreadInteractions.createdAt),
sql`${statusDecisions.createdAt} <= ${issueThreadInteractions.resolvedAt}`,
sql`${statusDecisions.appliedAt} >= ${issues.blockedTransitionAt}`,
eq(heartbeatRuns.agentId, input.agentId),
))
.limit(1);
return rows.length > 0;
}
+5 -1
View File
@@ -7,6 +7,7 @@ import { isProcessRunAdmissionClosed } from "./heartbeat/run-admission.js";
import type { StopInvocationOptions } from "./heartbeat/run-control.js"; import type { StopInvocationOptions } from "./heartbeat/run-control.js";
import { isContextCompactionRun } from "./context-compaction.js"; import { isContextCompactionRun } from "./context-compaction.js";
import { isUnstartedTaskQuestionAnswer } from "./unstarted-task-question-answer.js"; import { isUnstartedTaskQuestionAnswer } from "./unstarted-task-question-answer.js";
import { isAnswerToAgentBlockingQuestion } from "./blocked-question-answer.js";
import { isVerifiedDependencyInteractionResponse } from "./queued-interaction-response.js"; import { isVerifiedDependencyInteractionResponse } from "./queued-interaction-response.js";
import { registerDeferredReceiptWork } from "./heartbeat/deferred-receipt-work.js"; import { registerDeferredReceiptWork } from "./heartbeat/deferred-receipt-work.js";
import { registerProviderLimitResumeWork, type PrepareChatRetry } from "./native-runtime/provider-limit-resume.js"; import { registerProviderLimitResumeWork, type PrepareChatRetry } from "./native-runtime/provider-limit-resume.js";
@@ -2304,10 +2305,13 @@ export function heartbeatService(
await timePreparation("issue_checkout", async () => { await timePreparation("issue_checkout", async () => {
const startsUnstartedTask = issueStatus === "todo" && const startsUnstartedTask = issueStatus === "todo" &&
await isUnstartedTaskQuestionAnswer(db, { companyId: agent.companyId, issueId, interactionId: context.interactionId }); await isUnstartedTaskQuestionAnswer(db, { companyId: agent.companyId, issueId, interactionId: context.interactionId });
const answersAgentBlockingQuestion = issueStatus === "blocked" &&
await isAnswerToAgentBlockingQuestion(db, { companyId: agent.companyId, issueId, agentId: agent.id, context });
await issuesSvc.checkout( await issuesSvc.checkout(
issueId, issueId,
agent.id, agent.id,
[...resolvedInteractionCheckoutExpectedStatuses(), ...(startsUnstartedTask ? ["todo"] : [])], [...resolvedInteractionCheckoutExpectedStatuses(), ...(startsUnstartedTask ? ["todo"] : []),
...(answersAgentBlockingQuestion ? ["blocked"] : [])],
run.id, run.id,
); );
}); });
+5
View File
@@ -12651,6 +12651,11 @@ export function issueService(db: Db) {
status: "in_progress", status: "in_progress",
startedAt: now, startedAt: now,
updatedAt: now, updatedAt: now,
...(expectedStatuses.includes("blocked") ? {
unblockDescriptor: sql`case when ${issues.status} = 'blocked' then null else ${issues.unblockDescriptor} end`,
blockedTransitionAt: sql`case when ${issues.status} = 'blocked' then null else ${issues.blockedTransitionAt} end`,
blockedOwnerNotifiedAt: sql`case when ${issues.status} = 'blocked' then null else ${issues.blockedOwnerNotifiedAt} end`,
} : {}),
}) })
.where( .where(
and( and(