Files
PaperClipAI/server/src/services/queued-interaction-response.ts
T
DottaandPaperclip 3cc1032f6a fix: deliver saved answers to dependency-blocked tasks (#20363)
## Thinking Path

> - Paperclip lets people direct agent work through tasks and structured
answers.
> - Dependencies stop task execution until prerequisite work finishes.
> - A person must still be able to answer or redirect the assignee of a
blocked task.
> - Ordinary comments already get a bounded answer/triage turn. A saved
question response has no comment ID, so the same gates reject it.
> - Later dispatch also requires an active task and tries to check it
out. That conflicts with bounded interaction on a blocked task.
> - This pull request verifies the stored human response at each gate
and keeps the turn in bounded interaction mode.
> - The assignee can receive the answer without starting dependency work
or removing its blockers.

## Linked Issues or Issue Description

Refs #19923. That change covers dependency waits and comment retries.
This change covers stored interaction responses that have no comment ID.

**What happened?**

A parent task waits on a parked child. The assignee asked a setup
question. After a person answers, the response stays queued. Sending
that saved response is refused as `issue_dependencies_blocked`. Ordinary
new messages can reach the same assignee.

Passing admission alone is insufficient. The dispatch status check
rejects a blocked task, and automatic checkout then tries to start work
whose prerequisites are unfinished.

**Expected behavior**

Deliver the stored human answer or confirmation to its requesting
assignee as a bounded answer/triage turn. Keep the task blocked and keep
its dependency relationships. Preserve current-owner, pause, approval,
budget, recovery, and execution gates.

**Steps to reproduce**

1. Assign a parent task and make a backlog child block it.
2. Save a question on the parent and answer it as a person.
3. Dispatch its resolved-interaction wake, or send the saved answer from
the task queue.
4. Observe that the answer does not reach the provider while an ordinary
comment can run in answer/triage mode.

**Paperclip version or commit**

Reproduced on master at `653ab8b18f76`. This branch is rebased onto
master at `0e3fdebea8`.

## What Changed

- Verify the stored response by company, task, requesting agent, status,
kind, human resolver, and resolution time. A context flag alone is
insufficient.
- Allow a verified response through dependency admission and claim in
the existing bounded interaction mode.
- Recheck that response during scheduled retry promotion and final
provider dispatch.
- Keep bounded responses from checking out the blocked task or setting
it to `in_progress`, including when the last blocker finishes during
preparation.
- Preserve continuation classification through workspace and AI-account
resource waits, so the final response and owner checks still run.
- Correct the task-case API summary so the existing `case for task`
discovery regression passes on the first search page. This fixes a
pre-existing failure exposed by full CI, without changing search ranking
or assertions.
- Ignore obsolete Retry notices after newer conversation work, without
acknowledging saved input or changing execution authority.
- Document the response contract. Add real PostgreSQL and
assembled-heartbeat regressions.

## Verification

- Final commit `ba4b337cf1bb0236b7c0a742665b222ee894ff51`: [required CI
passed, attempt
2](https://github.com/paperclipai/paperclip/actions/runs/42706363120/attempts/2),
including typecheck, production build, server/chat tests, Runner checks,
and all 8 browser shards. PR status: 59 passing checks and 3 normal
skips.
- Red: answered questions and accepted/rejected confirmations return no
admitted run before the fix. All three new positive cases fail at the
dependency gate. A resource-wait retry also reproduces as refused before
its gate is fixed.
- Green: at delivery revision `9375a603e4`, focused heartbeat
integration and classification passed 14 tests; retry scheduling and
dispatch safety passed 241 tests. These use real PostgreSQL and
controlled adapter and resource-availability boundaries. The wider
initial dependency, native-continuation, and queue suites also passed
before the final refinements.
- Negative cases keep pending, missing, wrong-task, and agent-resolved
responses blocked. Changing the response proof immediately before
dispatch cancels the run without calling the provider. Policy controls
retain the current assignee, task status, and pending approval checks.
- Positive cases execute once, leave the parent blocked, preserve the
dependency edge, and leave the child in backlog with no child execution.
The same saved response survives a resource wait; removing its human
proof refuses promotion.
- The resource-wait provider-boundary and blocker-completion regressions
also failed before their fixes. All 14 focused integration and
classifier cases passed at delivery revision `9375a603e4`, including a
changed response during retry preparation.
- Red: a retained Retry for an older run makes a newer successful
conversation display as failed. Green: all 78 database-backed query and
interruption-presentation tests pass, including a later real failure and
independent queued input controls.
- Server layered typecheck and module-boundary checks passed at
`11992aad0c7fbaf13e7548d71ee633560bd8bb6f`, before the one-line API
summary correction. That CI passed build, typecheck and every browser
shard, but exposed the pre-existing case-search assertion and a
report-assets test timeout. Final-head CI passed as linked above.
- Fresh Greptile review is 5/5 on final commit
`ba4b337cf1bb0236b7c0a742665b222ee894ff51`, with no unresolved threads.
- The existing API discovery suite reproduced its first-page assertion
before the summary correction; all 3 cases pass afterward. The timed-out
report-assets privacy test passed locally in isolation and the full
support job passed on the final-head rerun. Runner shutdowns interrupted
the first attempt; affected jobs were rerun once without code changes. A
separate onboarding timeout also passed on that rerun.
- `pnpm verify --explain` requests broad CI because the execution
contract is a global input. The final CI run supplied that coverage.
- Separately recovered the reported staging conversation through an
ordinary message and the existing steering control. The agent
acknowledged the message and the saved-answer queue drained. The fresh
Notebook action subsequently succeeded. This was on the previously
deployed revision; it does not claim this code is deployed.

## Risks

- This changes admission and dispatch. Each gate checks the persisted
response; no caller-supplied marker grants the exception.
- The bounded turn does not check out the task, resolve a dependency, or
approve another action. Existing pause, ownership, budget, approval, and
stopped-execution controls still apply.
- Each additional lookup is one primary-key read and runs only for a
response on a dependency-blocked path. There is no new polling worker.
- No migration is required. Older queued contexts get the exception only
when their stored human response still verifies. No prior run or action
is replayed by this change.
- This PR does not roll out the code to staging.

## Model Used

OpenAI Codex, GPT-6 family, with repository tools, code execution, and
browser inspection. The runtime does not expose the exact model ID,
context-window limit, or reasoning-mode label; those values are not
asserted.

## 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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-09 16:27:34 -06:00

139 lines
7.5 KiB
TypeScript

import { and, eq, inArray, sql } from "drizzle-orm";
import { agentWakeupRequests, issueThreadInteractions, type Db } from "@paperclipai/db";
import type { IssueComment, IssueQueuedCommentEntry } from "@paperclipai/shared";
function record(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
/** Interaction receipts keep their typed payload; they never become editable comments. */
export function queuedInteractionId(payload: unknown): string | null {
const p = record(payload);
return p.mutation === "interaction" && typeof p.interactionId === "string"
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(p.interactionId)
&& ["accepted", "answered", "rejected"].includes(String(p.interactionStatus))
&& p.externalChatContinuation !== true
? p.interactionId : null;
}
/**
* One readable line for a queued response. The steering body also carries the
* typed JSON receipt for the agent; people only ever see this line.
*/
export function queuedInteractionSummary(interaction: {
kind: string; status: string; title?: string | null; payload?: unknown;
}): string {
const p = record(interaction.payload);
if (interaction.kind === "connection_intent") {
const service = typeof p.serviceName === "string" && p.serviceName.trim() ? p.serviceName.trim() : null;
const subject = service ? `${service} connection` : interaction.title ?? "Connection request";
return interaction.status === "rejected" ? `Declined: ${subject}` : `Connected: ${subject}`;
}
const label = interaction.status === "accepted" ? "Accepted" : interaction.status === "rejected" ? "Rejected" : "Answered";
const subject = interaction.title ?? (typeof p.prompt === "string" ? p.prompt : null) ?? "Questions";
return `${label}: ${subject}`;
}
/**
* A run that started with this exact response in its context already received
* it. The run must carry the resolved status: the card-creation wake carries
* the same interaction id while the card is still pending, so it never counts.
* Any other wake that still carries the same receipt (for example a recovery
* continuation that raced the response's own wake) is redundant: it is not
* pending input, so it must not hold the composer queue or the issue.
* A cancelled or failed start does not count; that response is still owed.
*/
// Table-qualified on purpose: inside a select list Drizzle renders columns
// unqualified, which would bind them to the subquery's own run row.
function deliveredInteractionRunExists(companyId: string, issueId: string) {
return sql<boolean>`EXISTS (SELECT 1 FROM heartbeat_runs delivered_run
WHERE delivered_run.company_id = ${companyId}
AND delivered_run.context_snapshot->>'issueId' = ${issueId}
AND delivered_run.context_snapshot->>'interactionId' = issue_thread_interactions.id::text
AND delivered_run.context_snapshot->>'interactionStatus' = issue_thread_interactions.status
AND delivered_run.started_at IS NOT NULL
AND delivered_run.status IN ('running', 'succeeded'))`;
}
/**
* The explicit steering message for a resolved interaction. It preserves the
* question labels, selected options, custom answers, approval target and exact
* revision.
*/
export function queuedInteractionSteeringBody(interaction: {
id: string; kind: string; status: string; title?: string | null; payload?: unknown; result?: unknown;
}): string {
return `${queuedInteractionSummary(interaction)}\n\n${JSON.stringify({
interactionId: interaction.id, kind: interaction.kind, status: interaction.status,
payload: interaction.payload, result: record(interaction.result),
}, null, 2)}`;
}
export type QueuedInteractionResponse = NonNullable<Awaited<ReturnType<typeof readQueuedInteractionResponse>>>;
/** A stored human answer can reach its assignee while dependency work stays blocked. */
export async function isVerifiedDependencyInteractionResponse(db: Pick<Db, "select">, input: {
companyId: string; issueId: string; agentId: string; context: unknown;
}): Promise<boolean> {
const id = queuedInteractionId(input.context);
if (!id) return false;
const context = record(input.context);
const [interaction] = await db.select({ status: issueThreadInteractions.status, kind: issueThreadInteractions.kind,
resolvedByUserId: issueThreadInteractions.resolvedByUserId, resolvedAt: issueThreadInteractions.resolvedAt })
.from(issueThreadInteractions).where(and(
eq(issueThreadInteractions.id, id), eq(issueThreadInteractions.companyId, input.companyId),
eq(issueThreadInteractions.issueId, input.issueId), eq(issueThreadInteractions.createdByAgentId, input.agentId),
)).limit(1);
return Boolean(interaction?.resolvedByUserId && interaction.resolvedAt
&& interaction.status === context.interactionStatus
&& (context.interactionKind === undefined || interaction.kind === context.interactionKind));
}
export async function readQueuedInteractionResponse(db: Db, companyId: string, issueId: string, payload: unknown) {
const id = queuedInteractionId(payload);
if (!id) return null;
const [row] = await db.select({
interaction: issueThreadInteractions,
delivered: deliveredInteractionRunExists(companyId, issueId),
}).from(issueThreadInteractions).where(and(
eq(issueThreadInteractions.id, id), eq(issueThreadInteractions.companyId, companyId),
eq(issueThreadInteractions.issueId, issueId),
inArray(issueThreadInteractions.status, ["accepted", "answered", "rejected"]),
));
const interaction = row?.interaction;
if (!interaction || interaction.status !== record(payload).interactionStatus || row.delivered) return null;
const heading = queuedInteractionSummary(interaction);
const body = queuedInteractionSteeringBody(interaction);
const comment: IssueComment = {
id: interaction.id, companyId, issueId, authorType: interaction.resolvedByUserId ? "user" : "agent",
authorUserId: interaction.resolvedByUserId, authorAgentId: interaction.resolvedByAgentId,
body, presentation: null, metadata: null,
createdAt: interaction.resolvedAt ?? interaction.updatedAt, updatedAt: interaction.updatedAt,
};
const source: NonNullable<IssueQueuedCommentEntry["source"]> = {
kind: "interaction", interactionId: interaction.id, interactionKind: interaction.kind,
requiresFreshSession: record(record(payload)._paperclipWakeContext).forceFreshSession === true,
summary: heading,
};
return { comment, source };
}
/** A saved answer is a live continuation, even though its card is no longer pending. */
export async function hasQueuedInteractionResponse(db: Db, companyId: string, issueId: string, agentId?: string | null) {
if (!agentId) return false;
const [row] = await db.select({ id: agentWakeupRequests.id }).from(agentWakeupRequests)
.innerJoin(issueThreadInteractions, and(
eq(issueThreadInteractions.companyId, agentWakeupRequests.companyId),
sql`${issueThreadInteractions.id}::text = ${agentWakeupRequests.payload}->>'interactionId'`,
eq(issueThreadInteractions.issueId, issueId),
inArray(issueThreadInteractions.status, ["accepted", "answered", "rejected"]),
)).where(and(
eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.agentId, agentId),
inArray(agentWakeupRequests.status, ["deferred_issue_execution", "queued"]),
sql`${agentWakeupRequests.payload}->>'issueId' = ${issueId}`,
sql`${agentWakeupRequests.payload}->>'mutation' = 'interaction'`,
sql`NOT ${deliveredInteractionRunExists(companyId, issueId)}`,
)).limit(1);
return Boolean(row);
}