diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index f9909012f43..765eab626a2 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -204,6 +204,21 @@ prevents the current work. New current questions still define a waiting path. Approvals, governed tool/credential/connection requests, and configured review stages retain their gates; a later message does not grant approval. +One exception keeps the asking agent's own wait honest. When a native turn ends +without a completion report, the Runner's default report claims completion and +cites only the agent's final message (`provider::agent-message`), with +no artifact or verification. If the same agent still has its own pending, +undismissed ordinary question on the task, native status finalization does not +close the task. The decision is `in_review` with reason +`own_question_unanswered`, the checkout is released, and the question stays +pending. The person's answer continues the task as usual. If the person dismisses +the question instead, and no other pending, undismissed question of that agent +remains, the dismissal transaction saves a durable wake intent for the assignee +(`issue_question_dismissed`, one idempotency key per question) in the native status +wake outbox, so it continues without the answer and a failed delivery is retried. A completion report with its own evidence, a +dismissed question, and an answered question keep the historical-question rule +above. + A permission denial is not, by itself, a blocker. If an instructed step is denied at an authorization boundary but the issue's own deliverable is complete, the right disposition is `done`, not `blocked` (see the review-delegation rules in §6). This requirement is prospective-only on rollout: it applies to transitions into `blocked` made after the feature ships, gated on the blocked-transition timestamp against the rollout marker, not on issue `createdAt`. Issues already blocked at upgrade time are untouched — no backfilled notifications, no retroactive validation, no `needs_attention` storm on deploy. Triage of pre-existing prose-blocked issues is a one-time opt-in digest, not a default. diff --git a/server/src/__tests__/issue-thread-interaction-routes.test.ts b/server/src/__tests__/issue-thread-interaction-routes.test.ts index 835ad0e98e4..f36beac95c1 100644 --- a/server/src/__tests__/issue-thread-interaction-routes.test.ts +++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts @@ -58,6 +58,10 @@ const mockHeartbeatService = vi.hoisted(() => ({ wakeup: vi.fn(async (_agentId: string, _options: { payload?: unknown; contextSnapshot?: unknown }) => undefined), cancelRun: vi.fn(async () => null), })); +const mockEnqueueOwnQuestionDismissalWake = vi.hoisted(() => vi.fn(async (): Promise => null)); +vi.mock("../services/native-runtime/own-question-wait.js", () => ({ + enqueueOwnQuestionDismissalWake: mockEnqueueOwnQuestionDismissalWake, +})); const mockRequestNativeQuestionRunCancellation = vi.hoisted(() => vi.fn(async () => null as string | null) ); @@ -797,7 +801,7 @@ describe("issue thread interaction routes", () => { const result = await request(await createApp()).post(path).send({}); expect(result.status).toBe(200); expect(result.body).toMatchObject({ status: "pending", dismissedAt: "2026-10-22T12:00:00Z" }); - expect(mockInteractionService.dismissInteraction).toHaveBeenCalledWith(expect.objectContaining({ companyId: "company-1" }), "request-1", { userId: "local-board" }); + expect(mockInteractionService.dismissInteraction).toHaveBeenCalledWith(expect.objectContaining({ companyId: "company-1" }), "request-1", { userId: "local-board" }, expect.any(Function)); expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ action: "issue.thread_interaction_dismissed" })); expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); mockInteractionService.dismissInteraction.mockClear(); @@ -805,6 +809,24 @@ describe("issue thread interaction routes", () => { expect(mockInteractionService.dismissInteraction).not.toHaveBeenCalled(); }); + it("saves the dismissal wake intent in the dismissal transaction only when the card is dismissed", async () => { + const dismissed = { id: "request-1", kind: "ask_user_questions", status: "pending", dismissedAt: "2026-10-22T12:00:00Z" }; + mockInteractionService.dismissInteraction.mockResolvedValue(dismissed); + const path = "/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/request-1/dismiss"; + expect((await request(await createApp()).post(path).send({})).status).toBe(200); + expect(mockEnqueueOwnQuestionDismissalWake).not.toHaveBeenCalled(); + const tx = { transaction: "dismissal" }; + mockInteractionService.dismissInteraction.mockImplementationOnce( + async (_issue: unknown, _id: string, _actor: unknown, afterDismissed?: (tx: unknown) => Promise) => { + await afterDismissed?.(tx); + return dismissed; + }); + expect((await request(await createApp()).post(path).send({})).status).toBe(200); + expect(mockEnqueueOwnQuestionDismissalWake).toHaveBeenCalledWith(tx, + { companyId: "company-1", issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", interactionId: "request-1" }); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); + }); + it("hides private task requests from same-company members without task access before dismissal", async () => { mockAccessDecide.mockResolvedValue({ allowed: false, action: "issue:read", reason: "deny_issue_private", explanation: "Task access was revoked.", diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index e23a85c2e88..8b46fc718b2 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -47,6 +47,7 @@ import { createIssueReadTiming } from "../services/issue-read-timing.js"; import { createInboxMineTiming, type InboxMinePhaseDurations } from "../services/inbox-mine-timing.js"; import { isNativeWorkspaceExportRepairCause } from "@paperclipai/shared"; import { resumeNativeWorkspaceAfterOwnerStop } from "../services/native-runtime/native-workspace-owner-recovery.js"; +import { enqueueOwnQuestionDismissalWake } from "../services/native-runtime/own-question-wait.js"; import { confirmNativeControllerStopped, NATIVE_CONTROLLER_HOST_UNVERIFIED } from "../services/native-runtime/native-controller-host-recovery.js"; import { confirmOwnerGone, OWNER_UNPROVABLE_CAUSE } from "../services/recovery/owner-gone-attestation.js"; import { OWNABLE_KINDS } from "../services/recovery/ownership-registry.js"; @@ -19115,6 +19116,11 @@ export function issueRoutes( const interaction = await issueThreadInteractionService(db).dismissInteraction( issue, req.params.interactionId as string, { userId: actor.actorType === "user" ? actor.actorId : null }, + async (tx) => { + await enqueueOwnQuestionDismissalWake(tx, { + companyId: issue.companyId, issueId: issue.id, interactionId: req.params.interactionId as string, + }); + }, ); await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, diff --git a/server/src/services/agent-interaction-delivery.test.ts b/server/src/services/agent-interaction-delivery.test.ts index f291bfcb21a..8b21607ec34 100644 --- a/server/src/services/agent-interaction-delivery.test.ts +++ b/server/src/services/agent-interaction-delivery.test.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { eq, sql } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { activityLog, agents, agentWakeupRequests, companies, createDb, heartbeatRunEvents, heartbeatRuns, issueComments, issues, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, type Db } from "@paperclipai/db"; +import { activityLog, agents, agentWakeupRequests, companies, createDb, heartbeatRunEvents, heartbeatRuns, issueComments, issueThreadInteractions, issues, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, type Db } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js"; import { filterRunMessageInteractionDelivery, hasAutomaticInteractionDeliveryHold, preparedRunInteractionDeliveryDenied, recheckQueuedCommentInteractionDelivery, recheckQueuedRunInteractions, withInteractionDeliveryOrigin } from "./agent-interaction-delivery.js"; @@ -233,6 +233,21 @@ describe.skipIf(!support.supported)("pending agent interaction delivery", () => .toMatchObject({ actorAgentId: f.sender, operation: permission === "canMentionAgents" ? "mention" : "comment", permission }); }); + it.each(["dismissed", "pending", "other_agent"])("delivers a dismissal wake only for a person's dismissal of the recipient's question (%s)", async mode => { + const f = await seed(); + const [question] = await db.insert(issueThreadInteractions).values({ companyId: f.companyId, issueId: f.issueId, + createdByAgentId: mode === "other_agent" ? f.sender : f.recipient, kind: "ask_user_questions", status: "pending", + title: "Codename", payload: { version: 1, questions: [] }, + ...(mode === "pending" ? {} : { dismissedAt: new Date(), dismissedByUserId: "human" }) }).returning(); + const receipt = await wake(f, { actorType: "system", actorId: "native-status-committer" }); + await db.update(agentWakeupRequests).set({ reason: "issue_question_dismissed", + payload: { ...receipt.payload as Record, dismissedInteractionId: question!.id } }) + .where(eq(agentWakeupRequests.id, receipt.id)); + await restriction(f.recipient, { canReceiveAssignmentsFromAgents: false }); + const result = await recheckQueuedRunInteractions(db, await readRun(f.run.id)); + expect(result.denied).toBe(mode !== "dismissed"); + }); + it("recovers an older automatic retry's initiating agent from its predecessor", async () => { const f = await seed(); await wake(f); await db.update(heartbeatRuns).set({ status: "failed", errorCode: "controller_lease_lost" }).where(eq(heartbeatRuns.id, f.run.id)); diff --git a/server/src/services/agent-interaction-delivery.ts b/server/src/services/agent-interaction-delivery.ts index 2572346d844..2057c7bbb7f 100644 --- a/server/src/services/agent-interaction-delivery.ts +++ b/server/src/services/agent-interaction-delivery.ts @@ -181,6 +181,15 @@ async function assignmentDenial(db: Executor, run: Run, wake: Wake | undefined, eq(caseHandoffs.toAgentId, run.agentId))); if (handoff) { actorType = handoff.actorType; actorId = handoff.actorId; } } + if (wake && actorType === "system" && reason === "issue_question_dismissed" && issueId) { + const dismissedId = string(record(wake.payload).dismissedInteractionId); + const [dismissed] = dismissedId && /^[0-9a-f-]{36}$/i.test(dismissedId) + ? await db.select({ userId: issueThreadInteractions.dismissedByUserId }).from(issueThreadInteractions).where(and( + eq(issueThreadInteractions.companyId, run.companyId), eq(issueThreadInteractions.issueId, issueId), + eq(issueThreadInteractions.id, dismissedId), eq(issueThreadInteractions.createdByAgentId, run.agentId))) + : []; + if (dismissed?.userId) return null; + } if (wake && (actorType === "system" || !actorType) && reason !== "issue_blockers_resolved") { const payload = record(wake.payload), context = record(payload._paperclipWakeContext); const decisionId = string(payload.nativeDecisionId) ?? string(payload.nativeReviewDecisionId) diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index 1e67b712880..dadd829fbd9 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -5677,6 +5677,7 @@ export function issueThreadInteractionService( issue: { id: string; companyId: string }, interactionId: string, actor: InteractionActor, + afterDismissed?: (tx: Db) => Promise, ) => { if (!actor.userId || actor.agentId || actor.runId || actor.systemId) { throw forbidden("Only a human can dismiss a request"); @@ -5712,6 +5713,7 @@ export function issueThreadInteractionService( const [updated] = await tx.update(issueThreadInteractions) .set({ dismissedAt: now, dismissedByUserId: actor.userId, updatedAt: now }) .where(eq(issueThreadInteractions.id, interactionId)).returning(); + await afterDismissed?.(tx as unknown as Db); return hydrateInteraction(updated!); }); }, diff --git a/server/src/services/native-runtime/historical-task-questions.test.ts b/server/src/services/native-runtime/historical-task-questions.test.ts index b541c104194..8be07e309b1 100644 --- a/server/src/services/native-runtime/historical-task-questions.test.ts +++ b/server/src/services/native-runtime/historical-task-questions.test.ts @@ -10,6 +10,7 @@ import { readTaskQuestionContext } from "../issue-question-context.js"; import { issueThreadInteractionService } from "../issue-thread-interactions.js"; import { nativeCompletionFeedback } from "./native-completion-feedback.js"; import { finalizeNativeRun, pendingNativeGovernance } from "./native-run-finalizer.js"; +import { enqueueOwnQuestionDismissalWake, ownQuestionWaitReleasedByDismissal } from "./own-question-wait.js"; import { PaperclipControlPlanePort } from "./paperclip-control-plane-port.js"; import { CONTROL_PLANE_CONFORMANCE_RESULT, CONTROL_PLANE_CONFORMANCE_TERMINAL } from "../../vendor/paperclip-runner/testing.js"; import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js"; @@ -212,6 +213,76 @@ describe("historical task questions", () => { } }); + it.each(["unreported", "unreported-dismissed", "unreported-answered", "verified"])("keeps the agent's own unanswered question open after an unreported turn (%s)", async mode => { + const f = await fixture(); + await message(f); + if (mode === "unreported-dismissed") { + await issueThreadInteractionService(db).dismissInteraction({ id: f.issueId, companyId: f.companyId }, f.question.id, { userId: "board-user" }); + } + if (mode === "unreported-answered") { + await db.update(issueThreadInteractions).set({ status: "answered", resolvedByUserId: "board-user", resolvedAt: new Date(), + result: { version: 1, answers: [{ questionId: "configuration", optionIds: ["configured"] }] } }) + .where(eq(issueThreadInteractions.id, f.question.id)); + } + const contractId = randomUUID(), sessionId = randomUUID(), runnerId = randomUUID(); + const contractSha256 = "own-question-contract"; + await db.insert(completionContracts).values({ id: contractId, companyId: f.companyId, issueId: f.issueId, + revision: 1, schemaVersion: "paperclip.completion-contract.v1", policyVersion: "test", risk: "low", + completionAuthority: "agent_claim_policy", incompleteCriteriaPolicy: "preserve_non_terminal", + contractJson: { revision: "standalone-v1", objective: "Verify configuration", criteria: [{ id: "objective", requirement: "Verify configuration" }] }, + canonicalSha256: contractSha256, createdByActorType: "system", createdByActorId: "test" }); + await db.update(heartbeatRuns).set({ completionContractId: contractId, completionContractSha256: contractSha256, + nativeSessionId: sessionId, runnerInstanceId: runnerId }).where(eq(heartbeatRuns.id, f.runId)); + const [verified] = await db.insert(issueWorkProducts).values({ companyId: f.companyId, issueId: f.issueId, + type: "artifact", provider: "test", title: "Configuration verification", status: "completed", reviewState: "approved" }).returning(); + const workProductRef = `work_product:${verified.id}`; + const providerRef = "provider:opencode:agent-message"; + const result: PrpStructuredRunResult = mode === "verified" + ? { ...CONTROL_PLANE_CONFORMANCE_RESULT, + completionClaim: { ...CONTROL_PLANE_CONFORMANCE_RESULT.completionClaim, + criteria: [{ criterionId: "objective", status: "satisfied", evidenceRefs: [workProductRef] }] }, + evidence: [{ kind: "work_product", ref: workProductRef }], + verification: [{ commandOrCheck: "Configuration verification", status: "passed", artifactRef: workProductRef }] } + : { ...CONTROL_PLANE_CONFORMANCE_RESULT, + summary: "I will not write the configuration until the answer arrives. Ending turn to await the answer.", + completionClaim: { ...CONTROL_PLANE_CONFORMANCE_RESULT.completionClaim, objectiveSatisfied: true, remainingWork: [], + criteria: [{ criterionId: "objective", status: "satisfied", evidenceRefs: [providerRef] }] }, + evidence: [{ ref: providerRef }], verification: [], artifacts: [], attentionRequests: [] }; + const port = new PaperclipControlPlanePort(db, { ...f, sessionId, completionContractId: contractId, + completionContractSha256: contractSha256, sourceInstanceId: runnerId, controlPlaneSourceInstanceId: randomUUID() }); + await port.openRun({ identity: { ...f, sessionId }, backendKind: "mock", sourceInstanceId: runnerId }); + await port.completeRun({ result, terminal: CONTROL_PLANE_CONFORMANCE_TERMINAL, callerResultId: randomUUID() }); + await finalizeNativeRun({ db, runId: f.runId, workspaceFinalizeStatus: "succeeded" }); + const waits = mode === "unreported"; + expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]!.status).toBe(waits ? "in_review" : "done"); + expect(await db.select().from(statusDecisions).where(eq(statusDecisions.issueId, f.issueId))) + .toEqual([expect.objectContaining({ toStatus: waits ? "in_review" : "done", + ...(waits ? { reasonCode: "own_question_unanswered" } : {}) })]); + if (waits) { + expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, f.question.id)))[0]) + .toMatchObject({ status: "pending", resolvedAt: null }); + const scope = { companyId: f.companyId, issueId: f.issueId, interactionId: f.question.id }; + expect(await ownQuestionWaitReleasedByDismissal(db, scope)).toBeNull(); + const [second] = await db.insert(issueThreadInteractions).values({ companyId: f.companyId, issueId: f.issueId, + kind: "ask_user_questions", status: "pending", sourceRunId: f.runId, createdByAgentId: f.agentId, + title: "Second question", payload: f.question.payload }).returning(); + await issueThreadInteractionService(db).dismissInteraction({ id: f.issueId, companyId: f.companyId }, f.question.id, { userId: "board-user" }); + expect(await ownQuestionWaitReleasedByDismissal(db, scope)).toBeNull(); + const wakeIds: Array = []; + const dismissSecond = () => issueThreadInteractionService(db).dismissInteraction({ id: f.issueId, companyId: f.companyId }, + second!.id, { userId: "board-user" }, async tx => { + wakeIds.push(await enqueueOwnQuestionDismissalWake(tx, { ...scope, interactionId: second!.id })); + }); + await dismissSecond(); + await dismissSecond(); + expect(await ownQuestionWaitReleasedByDismissal(db, { ...scope, interactionId: second!.id })).toEqual({ agentId: f.agentId }); + expect(wakeIds).toHaveLength(1); + expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId))) + .toEqual([expect.objectContaining({ id: wakeIds[0], agentId: f.agentId, status: "queued", reason: "issue_question_dismissed", + requestedByActorId: "native-status-committer", idempotencyKey: `issue_question_dismissed:${second!.id}` })]); + } + }); + it("keeps a new input blocker active after the earlier question becomes historical", async () => { const f = await fixture(); await message(f); diff --git a/server/src/services/native-runtime/native-run-finalizer.ts b/server/src/services/native-runtime/native-run-finalizer.ts index 22924cf868e..a2a8c17e0c8 100644 --- a/server/src/services/native-runtime/native-run-finalizer.ts +++ b/server/src/services/native-runtime/native-run-finalizer.ts @@ -16,6 +16,7 @@ import { notifyCancelledWorkspacePreservation } from "../cancelled-workspace-pre import { notifyInstructionCollection } from "../instruction-collection-notifications.js"; import { readRequiredQualityReview } from "../required-quality-review.js"; import { NativeDeliverableEvidenceError, validateNativeDeliverableEvidence } from "./native-deliverable-feedback.js"; +import { OWN_QUESTION_UNANSWERED_REASON, pendingOwnQuestionId, reportsOnlyProviderMessage } from "./own-question-wait.js"; import { conversationTurnPresentsAnswer, pendingNativeGovernance } from "./native-governance.js"; import { notifyChatRunMilestones } from "../chat-run-milestone-notifications.js"; import { onboardingFirstTaskCompletionAllowed } from "../onboarding-first-task-admission.js"; @@ -1709,6 +1710,13 @@ export async function finalizeNativeRun(input: { proposedDecision = supersedeStaleOwnerCompletion(proposedDecision, authoritativeIssue, run.agentId, run.id); if (!reviewContext) proposedDecision = supersedeHandedOffDecision(proposedDecision, authoritativeIssue); } + if (proposedDecision.statusAction === "done" && !isConversation(authoritativeIssue) && + reportsOnlyProviderMessage(result) && await pendingOwnQuestionId(input.db, { + companyId: run.companyId, issueId: authoritativeIssue.id, agentId: run.agentId, + })) { + proposedDecision = { ...proposedDecision, statusAction: "in_review", toStatus: "in_review", + reasonCode: OWN_QUESTION_UNANSWERED_REASON, unblockDescriptor: null, effects: [{ kind: "release_checkout" }] }; + } if (proposedDecision.statusAction === "done") { const continuation = record(run.contextSnapshot?.executionContinuation); const objective = typeof continuation.objective === "string" ? continuation.objective diff --git a/server/src/services/native-runtime/own-question-wait.ts b/server/src/services/native-runtime/own-question-wait.ts new file mode 100644 index 00000000000..96d81cf1091 --- /dev/null +++ b/server/src/services/native-runtime/own-question-wait.ts @@ -0,0 +1,85 @@ +import { and, eq, isNull } from "drizzle-orm"; +import { heartbeatRuns, issueThreadInteractions, issues, statusDecisions, type Db } from "@paperclipai/db"; +import { ordinaryQuestionCondition } from "../issue-question-context.js"; +import { enqueueWake } from "./status-decision-committer.js"; + +const PROVIDER_MESSAGE_REF = /^provider:[a-z0-9_-]+:agent-message$/i; + +const list = (value: unknown): unknown[] => Array.isArray(value) ? value : []; +const field = (value: unknown, key: string): unknown => + value && typeof value === "object" && !Array.isArray(value) ? (value as Record)[key] : undefined; + +export function reportsOnlyProviderMessage(result: unknown): boolean { + const refs = [ + ...list(field(result, "evidence")).map((entry) => field(entry, "ref")), + ...list(field(field(result, "completionClaim"), "criteria")).flatMap((criterion) => list(field(criterion, "evidenceRefs"))), + ]; + return refs.length > 0 && refs.every((ref) => typeof ref === "string" && PROVIDER_MESSAGE_REF.test(ref)) + && list(field(result, "artifacts")).length === 0 && list(field(result, "verification")).length === 0; +} + +export async function pendingOwnQuestionId(db: Pick, input: { + companyId: string; issueId: string; agentId: string; +}): Promise { + const [row] = await db.select({ id: issueThreadInteractions.id }).from(issueThreadInteractions).where(and( + eq(issueThreadInteractions.companyId, input.companyId), + eq(issueThreadInteractions.issueId, input.issueId), + eq(issueThreadInteractions.createdByAgentId, input.agentId), + eq(issueThreadInteractions.status, "pending"), + isNull(issueThreadInteractions.dismissedAt), + ordinaryQuestionCondition(), + )).limit(1); + return row?.id ?? null; +} + +export const OWN_QUESTION_UNANSWERED_REASON = "own_question_unanswered"; + +export async function ownQuestionWaitReleasedByDismissal(db: Pick, input: { + companyId: string; issueId: string; interactionId: string; +}): Promise<{ agentId: string } | null> { + const [row] = await db.select({ agentId: issues.assigneeAgentId }) + .from(issues) + .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), + eq(heartbeatRuns.agentId, issues.assigneeAgentId), + )) + .innerJoin(issueThreadInteractions, and( + eq(issueThreadInteractions.id, input.interactionId), + eq(issueThreadInteractions.companyId, issues.companyId), + eq(issueThreadInteractions.issueId, issues.id), + eq(issueThreadInteractions.createdByAgentId, issues.assigneeAgentId), + )) + .where(and( + eq(issues.id, input.issueId), + eq(issues.companyId, input.companyId), + eq(issues.status, "in_review"), + isNull(issues.assigneeUserId), + eq(statusDecisions.applicationState, "applied"), + eq(statusDecisions.toStatus, "in_review"), + eq(statusDecisions.reasonCode, OWN_QUESTION_UNANSWERED_REASON), + )) + .limit(1); + if (!row?.agentId) return null; + if (await pendingOwnQuestionId(db, { companyId: input.companyId, issueId: input.issueId, agentId: row.agentId })) return null; + return { agentId: row.agentId }; +} + +export async function enqueueOwnQuestionDismissalWake(tx: Db, input: { + companyId: string; issueId: string; interactionId: string; +}): Promise { + const released = await ownQuestionWaitReleasedByDismissal(tx, input); + if (!released) return null; + return enqueueWake({ + tx, companyId: input.companyId, issueId: input.issueId, agentId: released.agentId, + reason: "issue_question_dismissed", + idempotencyKey: `issue_question_dismissed:${input.interactionId}`, + payload: { dismissedInteractionId: input.interactionId }, + contextSnapshot: { dismissedInteractionId: input.interactionId }, + }); +} diff --git a/server/src/services/native-runtime/status-decision-committer.ts b/server/src/services/native-runtime/status-decision-committer.ts index 92964d162a5..52f69c7b5ac 100644 --- a/server/src/services/native-runtime/status-decision-committer.ts +++ b/server/src/services/native-runtime/status-decision-committer.ts @@ -459,7 +459,7 @@ function failAt( throw new Error(`native_status_failpoint:${actual}`); } -async function enqueueWake(input: { +export async function enqueueWake(input: { tx: Db; companyId: string; issueId: string;