diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 636028ec192..81d44e4fd8e 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -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. diff --git a/server/src/__tests__/heartbeat-blocked-question-answer.test.ts b/server/src/__tests__/heartbeat-blocked-question-answer.test.ts index 33aa15a8325..995787b76d7 100644 --- a/server/src/__tests__/heartbeat-blocked-question-answer.test.ts +++ b/server/src/__tests__/heartbeat-blocked-question-answer.test.ts @@ -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 { + async function agentReportsBlocker(input: { companyId: string; agentId: string; issueId: string; + blocker?: Record }): Promise { 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(); diff --git a/server/src/services/blocked-question-answer.ts b/server/src/services/blocked-question-answer.ts index 38355ea86eb..da298f9260d 100644 --- a/server/src/services/blocked-question-answer.ts +++ b/server/src/services/blocked-question-answer.ts @@ -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, input: { companyId: string; issueId: string; agentId: string | null | undefined; reasonCode: string | null; - toStatus: string; unblockOwner: unknown; + toStatus: string; unblockOwner: unknown; blockerOwnerKind?: string; }): Promise { - 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), diff --git a/server/src/services/native-runtime/evidence-classifier.ts b/server/src/services/native-runtime/evidence-classifier.ts index 66182c1c9f8..1949bb86402 100644 --- a/server/src/services/native-runtime/evidence-classifier.ts +++ b/server/src/services/native-runtime/evidence-classifier.ts @@ -27,6 +27,14 @@ export interface NativeEvidenceRefAssessment { durableRecordId: string | null; } +export type NativeBlockerOwnerKind = "agent" | "user" | "system" | "external" | "board" | "unspecified"; + +export function nativeBlockerOwnerKind(owner: Record): 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, diff --git a/server/src/services/native-runtime/status-arbiter.ts b/server/src/services/native-runtime/status-arbiter.ts index f3b6fd85f3d..7fd22e7506a 100644 --- a/server/src/services/native-runtime/status-arbiter.ts +++ b/server/src/services/native-runtime/status-arbiter.ts @@ -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. diff --git a/server/src/services/native-runtime/status-decision-committer.ts b/server/src/services/native-runtime/status-decision-committer.ts index f7ab8b98188..999a52c1d13 100644 --- a/server/src/services/native-runtime/status-decision-committer.ts +++ b/server/src/services/native-runtime/status-decision-committer.ts @@ -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,