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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c1edb9ec02
commit
722303e4a2
8 files changed
+273
-2
No files matched your search
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in new issue
Block a user