mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 07:12:00 +01:00
fix(native): keep the agent's own unanswered question open after a turn ends without a report (#20934)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - An agent can ask the person a question and wait for the answer. Native task status comes from the server's status arbiter, not from model prose > - When a native turn ends without `paperclip_finish`, the Runner sends a default report: `done`, objective satisfied, with the agent's final message as the only evidence > - A later comment from the person moves an open question into history (#15392), so the question no longer gates completion > - Together these close a task as `done` while the agent is still waiting for its own answer and has produced nothing. No Retry appears and nothing flags it > - This pull request keeps such a task `in_review` on the agent's own unanswered question when the only completion evidence is the agent's final message > - The benefit is that a "waiting for your answer" turn can no longer become a silent false completion ## Linked Issues or Issue Description Refs #15392 **What happened?** Restart-chaos wave on master7e8901aa60, OpenCode task t06 (W2Q), no fault injected. Run fec9530f asks a question (interaction c2d3b3f2); the task goes `in_review`. A board comment says "include NOTE-238ef87a in the brief; do not write the brief yet; I will answer shortly". Run f6cbefa8 replies "I will not write the brief until the board's answer arrives. Ending turn to await the codename answer." and ends its turn without `paperclip_finish`. The Runner's default report (`provider:opencode:agent-message` as the only evidence, `reportedWorkDisposition: done`) is accepted, and the low-risk claim policy sets the task `done`. The question is still unanswered and the `brief` document does not exist. **What did you expect to happen?** The task keeps waiting for the answer. The answer continues the task, and the agent writes the brief. **Steps to reproduce** 1. An agent asks a question on its task. 2. The person posts a comment before answering. 3. The agent ends its turn without a completion report, saying it waits for the answer. 4. The task is `done`; the question stays pending. ## What Changed - New `server/src/services/native-runtime/own-question-wait.ts`: - `reportsOnlyProviderMessage(result)`: true when every evidence reference in the report is `provider:<provider>:agent-message` and the report has no artifact and no verification. This is the shape of the Runner's default report for a turn that ended without `paperclip_finish`. - `pendingOwnQuestionId(db, …)`: the agent's own pending, undismissed ordinary question (`ask_user_questions`, not a governed tool, secret or connection request) on the task. - `finalizeNativeRun` (`native-run-finalizer.ts`): when the proposed decision is `done`, the task is not an Agent Chat conversation, the report cites only the agent's final message, and the agent's own question is still pending, the decision becomes `in_review` with reason `own_question_unanswered` and releases the checkout. The question stays pending, so the person's answer continues the task through the existing answer path. - Dismissal (`POST /issues/:id/interactions/:interactionId/dismiss`, `dismissInteraction`): new `enqueueOwnQuestionDismissalWake` runs inside the dismissal transaction, only when the card moves from pending to dismissed. When the task is `in_review` on an applied `own_question_unanswered` decision of the assignee's run, the dismissed card is the assignee's own question, and no other pending, undismissed question of that agent remains, it saves a durable wake intent (`issue_question_dismissed`, idempotency key per question) in the existing native status wake outbox (`enqueueWake`, now exported). The outbox dispatcher delivers and retries it, so the agent continues without the answer. Dismissal of any other request keeps the no-wake rule. - `assignmentDenial` (`agent-interaction-delivery.ts`): an `issue_question_dismissed` wake counts as a person's wake when its `dismissedInteractionId` names the recipient's own question on that task with a `dismissedByUserId`, so a recipient that refuses agent assignments still receives it. - `doc/execution-semantics.md` describes the exception to the historical-question rule. ## Verification - New case in `server/src/services/native-runtime/historical-task-questions.test.ts` ("keeps the agent's own unanswered question open after an unreported turn"), run through the real `PaperclipControlPlanePort.completeRun` and `finalizeNativeRun` with a low-risk `agent_claim_policy` contract, as on staging: - Red on master (finalizer hunk removed, test kept): `unreported` fails, `expected 'done' to be 'in_review'`. - Green: `unreported` rests `in_review` with `own_question_unanswered` and the question still pending. `unreported-dismissed`, `unreported-answered` and `verified` (work-product evidence) still rest `done`, so the #15392 behavior is unchanged. - Dismissal after finalization (same test): the release check is null while a second own question is open and returns the agent once both are dismissed. A repeated dismissal saves no second intent; the saved intent is a queued `issue_question_dismissed` outbox row. Delivery test `delivers a dismissal wake only for a person's dismissal of the recipient's question` (30 passed). Route test `saves the dismissal wake intent in the dismissal transaction only when the card is dismissed` (147 passed). - `cd server && pnpm exec vitest run src/services/native-runtime/historical-task-questions.test.ts src/services/native-runtime/native-run-finalizer`: all passed. - `cd server && pnpm exec vitest run src/services/native-runtime/paperclip-control-plane-port.test.ts src/services/native-runtime/status-arbiter.test.ts`: 119 passed. - `cd server && pnpm exec tsc --noEmit -p tsconfig.json`: passes. - Chaos, Daytona OpenCode, seed 6201, W2Q K0, 4 tasks (wave w-20261111T211421-s6201, run from master7e8901aa60plus this change because the pinned runner image must match runner sources): no false completion. t02 and t04 PASS (in_review -> in_progress -> done after the answer). t01 failed `R3:retry_needed` through the blocked path fixed separately in #20931 (not in this build). t03 timed out because its rewake OpenCode turn stayed active for 20 minutes without ending (provider turn never finished; this change acts only at finalization, which never ran). The unreported-done path did not occur in this sample, so the red/green proof for this change is the integration test. Before: wave w-20261111T200718-s6201 t06 closed done with the question unanswered. ## Risks - Low. Only a `done` decision whose report cites nothing but the agent's final message changes, and only while the same agent's own ordinary question is pending and undismissed. Explicit completion reports with their own evidence keep the current rule. A person can still dismiss the question to release it. - An agent that really finished without `paperclip_finish` while its own old question is open now rests `in_review` instead of `done`. The open question is visible on the task; answering or dismissing it settles the task. - No schema change. A result saved by an older release is finalized by the same rule because it reads only the saved report and interaction rows. - Related observation (not changed here): in 5 Claude/OpenCode W2/W2C cells the agent wrote "the task remains in_progress as instructed" yet the task became `done` (Claude t06, wave s6101). The run's `paperclip_finish` input itself sent `reportedWorkDisposition: "done"`, `objectiveSatisfied: true`, with the deferred phase listed as `blocksCompletion: false`. The server did not map a yielded disposition to done. That is model behavior on a contradictory report, a different root from this change. ## 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
65ebd96bcf
commit
9c56e5465c
10 files changed
+236
-3
No files matched your search
@@ -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:<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.
|
||||
|
||||
@@ -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<string | null> => 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<void>) => {
|
||||
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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown>, 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));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -5677,6 +5677,7 @@ export function issueThreadInteractionService(
|
||||
issue: { id: string; companyId: string },
|
||||
interactionId: string,
|
||||
actor: InteractionActor,
|
||||
afterDismissed?: (tx: Db) => Promise<void>,
|
||||
) => {
|
||||
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!);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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<string | null> = [];
|
||||
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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown>)[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<Db, "select">, input: {
|
||||
companyId: string; issueId: string; agentId: string;
|
||||
}): Promise<string | null> {
|
||||
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<Db, "select">, 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<string | null> {
|
||||
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 },
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user