mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - A native run commits its status decision and its final reply comment
in separate steps
> - Background presentation recovery publishes an accepted reply when
the live path does not record one
> - That worker dropped the run after one refused attempt and only threw
on an error, with no record on the run
> - A task could then show done while the user never saw the answer,
until an unrelated wake reloaded the queue
> - This pull request makes the publication of an accepted ordinary task
reply durable, bounded, and recorded
> - The benefit is that a task cannot close silently without its answer
## Linked Issues or Issue Description
Refs #19130 (verify requested publication at finalization, for
documents), #19183 (deliver messages saved while a native run
finalizes), #19186 (stale owner done). This change does not duplicate
them. It covers the final reply comment.
**What happened?**
A native run accepted a result with disposition `done`. The status
decision set the task to done. The accepted final reply was not
published as a task comment. The run had no publication record (no
`run.presentation.resolved` event and no `presentationDecision`). The
reply was published by background recovery 58 minutes later, after an
unrelated wake reloaded the presentation queue.
**Expected behavior**
The accepted reply is published promptly. A refused or failed attempt is
retried with backoff and never posts twice. If publication fails
permanently, the run and the task show a named failure with an owner.
**Steps to reproduce**
1. Commit an accepted native task result with disposition `done`, with
the final reply not yet visible to presentation recovery.
2. Run the native response presentation worker check once. The attempt
is refused.
3. On `master`, the worker returns `null` and drops the run. Nothing
records the attempt. Nothing retries it until another notification for
that scope.
**Paperclip version or commit**
`master` at 3b17b5ce15.
**Deployment mode**
Cloud staging, native `paperclip_runner` agent.
Root cause notes: the staging evidence shows that the live completion
path did not reach its presentation step for the affected run (no
`lifecycle run succeeded` event). Staging server logs for that window
are not available, so the exact first refusal is not proven. The worker
defect is proven: one refusal makes `check()` return `null`, which
deletes the scheduled entry. One thrown error escapes with only
in-memory backoff and no record. The fix closes that gap for every
refusal or error cause.
## What Changed
- `native-response-presentation-work.ts`: when an accepted ordinary task
reply (`done` or `needs_review`, not an Agent Chat conversation) is
still unpublished after a due attempt, the worker records the attempt
and keeps the run scheduled. A thrown error for this class is recorded
instead of escaping. When the reply is published, an open retry receipt
is set to `resolved`.
- New `native-response-publication-receipt.ts`: stores the receipt in
`heartbeat_runs.result_json.responsePublication` (attempts, last reason,
last error, next attempt). Backoff is 30 s doubling to at most 5 min,
for 6 attempts. Each retry appends a `run.response_publication.retrying`
run event. After the last attempt it records
`native_response_publication_failed` with the run's agent as owner, a
`run.response_publication.failed` run event, and an
`issue.response_publication_failed` task activity.
- `native-response-presentation-query.ts`: the candidate query exposes
the saved retry deadline, so a restarted worker reconstructs the
schedule. It excludes a run whose publication is recorded as failed, so
retries stay bounded.
- An open retry receipt is settled to `resolved` in the same write that
records the reply's `commentId` (background recovery and the live
`recordRunPresentationDecision`). A candidate that disappears for
another reason leaves the receipt unchanged, so the bound cannot reset.
- When the reply comment already exists but its decision was not
recorded, recovery now records it as `reuse` with that `commentId`. It
does not post again and does not count a refusal.
- Under the run lock, an attempt is not counted again when another
worker already saved a future deadline. The worker also honors that
saved deadline after recovery.
- Idempotency is unchanged: the comment is written only while the
presentation decision has no `commentId`, under the existing coordinator
lock. The receipt write never overwrites a run that already has a
`commentId`.
- `doc/execution-semantics.md`: documents the contract.
## Verification
- `cd server && npx vitest run
src/services/native-runtime/native-response-publication-retry.test.ts`
- Red on `master` (3 failed): after one refusal `check()` returned
`null` (dropped); one injected transaction error escaped with no record;
the refusal loop never stopped (more than 10 attempts, no failure
recorded).
- Green with this change (5 passed, including an already posted reply
and two concurrent workers): one refusal keeps the run scheduled and
recorded, a reloaded worker keeps the same deadline, the reply posts
exactly once after it becomes visible, and repeated checks do not
double-post; one injected error is recorded and retried; permanent
refusal stops after bounded attempts with the named, owned failure on
the run event and task activity.
- Related suites pass: `agent-chat-response-recovery.test.ts`,
`presentation-ownership-read.test.ts`,
`idle-native-finalization.test.ts`, `chat-monitor-continuation.test.ts`,
`native-preserved-monitor-wait.test.ts`,
`paperclip-control-plane-port.test.ts`,
`chat-channels.integration.test.ts`,
`photon/channel.integration.test.ts` (2047 of 2049 passed in one
combined run). One local failure needs a bundled Slack skill file that
is missing in this worktree. The other ("materializes direct
external-chat finals once") failed once under load and then passed three
times in isolation and in the combined set with this change.
- Not run: a live staging run. The change has not been deployed.
## Risks
- Behavior change: an ordinary task reply that recovery keeps refusing
now gets a recorded failure and a task activity after about 12 minutes.
Before, it was dropped silently. A persistent integrity decline (for
example a digest mismatch) now becomes visible this way. This is
intended.
- The status decision still commits before the reply. This PR does not
hold `done` on the comment. It makes a missing reply visible with an
owner.
- Agent Chat conversations and yielded chat responses keep their
existing behavior and their completion outbox owner.
- No schema migration. The receipt lives in the existing `result_json`
column.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code
execution in Claude Code.
## 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
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
104 lines
8.9 KiB
TypeScript
104 lines
8.9 KiB
TypeScript
import { QueryBuilder } from "drizzle-orm/pg-core";
|
|
import { and, asc, eq, exists, gt, inArray, isNotNull, isNull, notExists, or, sql } from "drizzle-orm";
|
|
import { chatActions, heartbeatRunEvents, heartbeatRuns, issues, nativeRunFinalizations, nativeRunResults, statusDecisions, statusDecisionEffects } from "@paperclipai/db";
|
|
import type { NativeResponsePresentationScope } from "../native-response-presentation-notifications.js";
|
|
|
|
export function nativeResponsePresentationCandidates(scope?: NativeResponsePresentationScope, after?: string, runId?: string) {
|
|
const db = new QueryBuilder();
|
|
return db.select({ id: heartbeatRuns.id, companyId: heartbeatRuns.companyId,
|
|
issueId: nativeRunFinalizations.issueId, agentId: heartbeatRuns.agentId, nextAttemptAt: nativeRunFinalizations.nextAttemptAt,
|
|
endpointId: chatActions.endpointId, principalId: chatActions.principalId,
|
|
// Durable publication retry schedule for an ordinary final task reply.
|
|
publicationNextAttemptAt: sql<string | null>`${heartbeatRuns.resultJson} #>> '{responsePublication,nextAttemptAt}'`.as("publication_next_attempt_at"),
|
|
// Agent Chat conversations keep their completion outbox as the bounded owner.
|
|
taskReply: sql<boolean>`coalesce(${nativeRunResults.resultJson} #>> '{result,reportedWorkDisposition}' in ('done', 'needs_review')
|
|
and (coalesce(${issues.conversationAgentId}::text, '') = '' or coalesce(${issues.conversationUserId}, '') = ''), false)`.as("task_reply"),
|
|
}).from(nativeRunFinalizations)
|
|
.innerJoin(heartbeatRuns, and(eq(heartbeatRuns.id, nativeRunFinalizations.runId),
|
|
eq(heartbeatRuns.companyId, nativeRunFinalizations.companyId), eq(heartbeatRuns.nativeIssueId, nativeRunFinalizations.issueId)))
|
|
.leftJoin(chatActions, and(eq(chatActions.id, heartbeatRuns.wakeupRequestId),
|
|
eq(chatActions.companyId, heartbeatRuns.companyId)))
|
|
.innerJoin(issues, and(eq(issues.id, nativeRunFinalizations.issueId), eq(issues.companyId, nativeRunFinalizations.companyId)))
|
|
.innerJoin(nativeRunResults, and(eq(nativeRunResults.id, nativeRunFinalizations.resultId),
|
|
eq(nativeRunResults.companyId, nativeRunFinalizations.companyId), eq(nativeRunResults.issueId, nativeRunFinalizations.issueId),
|
|
eq(nativeRunResults.runId, heartbeatRuns.id), eq(nativeRunResults.completionContractId, heartbeatRuns.completionContractId)))
|
|
.innerJoin(statusDecisions, and(eq(statusDecisions.id, nativeRunFinalizations.decisionId),
|
|
eq(statusDecisions.companyId, nativeRunFinalizations.companyId), eq(statusDecisions.issueId, nativeRunFinalizations.issueId),
|
|
eq(statusDecisions.runId, heartbeatRuns.id), eq(statusDecisions.assessmentId, nativeRunFinalizations.assessmentId)))
|
|
.where(and(
|
|
eq(heartbeatRuns.runtimeMode, "native"), inArray(heartbeatRuns.status, ["succeeded", "failed", "timed_out"]),
|
|
isNotNull(heartbeatRuns.finishedAt), eq(nativeRunFinalizations.phase, "committed"), isNull(nativeRunFinalizations.leaseOwner),
|
|
eq(nativeRunResults.schemaStatus, "accepted"),
|
|
sql`jsonb_typeof(${heartbeatRuns.resultJson} #> '{presentationDecision,commentId}') is distinct from 'string'`,
|
|
sql`coalesce(${heartbeatRuns.resultJson} #> '{presentationDecision,commentAction}', '"create"'::jsonb) = '"create"'::jsonb`,
|
|
// A reply whose bounded publication retries are exhausted stays visible
|
|
// as a named failure instead of being retried forever.
|
|
sql`${heartbeatRuns.resultJson} #>> '{responsePublication,state}' is distinct from 'failed'`,
|
|
sql`${heartbeatRuns.contextSnapshot}->'skipIssueComment' is distinct from 'true'::jsonb`,
|
|
sql`coalesce(${statusDecisions.decisionJson}->'externalChatReviewPresentation', 'null'::jsonb) in ('null'::jsonb, 'false'::jsonb, '0'::jsonb, '""'::jsonb)`,
|
|
sql`${nativeRunResults.resultJson} #>> '{result,schema}' = 'paperclip.run_result.v1'`,
|
|
sql`${nativeRunResults.resultJson} #>> '{terminal,runTerminalState}' = 'succeeded'`,
|
|
sql`${nativeRunResults.resultJson} #>> '{terminal,turnTerminalState}' = 'completed'`,
|
|
or(
|
|
and(eq(heartbeatRuns.status, "succeeded"),
|
|
or(sql`(coalesce(${issues.conversationAgentId}::text, '') = '' or coalesce(${issues.conversationUserId}, '') = '')`,
|
|
and(eq(statusDecisions.reasonCode, "conversation_turn_finished"),
|
|
eq(issues.conversationAgentId, heartbeatRuns.agentId), eq(issues.conversationUserId, heartbeatRuns.responsibleUserId),
|
|
eq(issues.status, "in_progress"), eq(issues.conversationState, "active"),
|
|
or(isNull(issues.executionRunId), eq(issues.executionRunId, heartbeatRuns.id)),
|
|
sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${issues.id}::text`,
|
|
sql`jsonb_typeof(${heartbeatRuns.contextSnapshot}->'conversationSessionGeneration') = 'number'`,
|
|
sql`${heartbeatRuns.contextSnapshot}->>'conversationSessionGeneration' = ${issues.conversationSessionGeneration}::text`,
|
|
exists(db.select({ id: heartbeatRunEvents.id }).from(heartbeatRunEvents).where(and(
|
|
eq(heartbeatRunEvents.companyId, heartbeatRuns.companyId), eq(heartbeatRunEvents.runId, heartbeatRuns.id),
|
|
eq(heartbeatRunEvents.agentId, heartbeatRuns.agentId), eq(heartbeatRunEvents.eventType, "item.completed"),
|
|
sql`${heartbeatRunEvents.payload} #>> '{prpEvent,runId}' = ${heartbeatRuns.id}::text`,
|
|
sql`${heartbeatRunEvents.payload} #>> '{prpEvent,normalizedSessionId}' = ${heartbeatRuns.nativeSessionId}::text`,
|
|
sql`${heartbeatRunEvents.payload} #>> '{prpEvent,turnId}' = ${nativeRunResults.turnId}`,
|
|
sql`${heartbeatRunEvents.payload} #>> '{prpEvent,payload,kind}' = 'agentMessage'`,
|
|
sql`${heartbeatRunEvents.payload} #>> '{prpEvent,payload,channel}' = 'final'`,
|
|
sql`${heartbeatRunEvents.payload} #>> '{prpEvent,payload,text}' ~ '[^[:space:]]'`,
|
|
))))),
|
|
or(
|
|
and(sql`${nativeRunResults.resultJson} #>> '{result,reportedWorkDisposition}' = 'done'`,
|
|
sql`${nativeRunResults.resultJson} #>> '{terminal,reportedWorkDisposition}' = 'done'`),
|
|
and(sql`(coalesce(${issues.conversationAgentId}::text, '') = '' or coalesce(${issues.conversationUserId}, '') = '')`,
|
|
sql`${nativeRunResults.resultJson} #>> '{result,reportedWorkDisposition}' = 'needs_review'`,
|
|
sql`${nativeRunResults.resultJson} #>> '{terminal,reportedWorkDisposition}' = 'needs_review'`),
|
|
)),
|
|
and(sql`${nativeRunResults.resultJson} #>> '{result,reportedWorkDisposition}' = 'yielded'`,
|
|
sql`${nativeRunResults.resultJson} #>> '{terminal,reportedWorkDisposition}' = 'yielded'`,
|
|
sql`jsonb_typeof(${nativeRunResults.resultJson} #> '{result,summary}') = 'string'
|
|
and ${nativeRunResults.resultJson} #>> '{result,summary}' ~ '[^[:space:]]'`,
|
|
sql`jsonb_typeof(${nativeRunResults.resultJson} #> '{result,continuation,idempotencyKey}') = 'string'
|
|
and ${nativeRunResults.resultJson} #>> '{result,continuation,idempotencyKey}' ~ '[^[:space:]]'`,
|
|
or(
|
|
and(sql`${nativeRunResults.resultJson} #>> '{result,continuation,kind}' = 'response_wake'`,
|
|
sql`${nativeRunResults.resultJson} #> '{result,attentionRequests}' = '[]'::jsonb`,
|
|
sql`jsonb_typeof(${nativeRunResults.resultJson} #> '{result,continuation,summary}') = 'string'
|
|
and ${nativeRunResults.resultJson} #>> '{result,continuation,summary}' ~ '[^[:space:]]'`),
|
|
and(eq(heartbeatRuns.status, "succeeded"), or(
|
|
and(sql`${nativeRunResults.resultJson} #>> '{result,continuation,kind}' = 'same_agent'`,
|
|
eq(statusDecisions.reasonCode, "live_continuation_registered")),
|
|
and(sql`${nativeRunResults.resultJson} #>> '{result,continuation,kind}' = 'monitor'`,
|
|
inArray(statusDecisions.reasonCode, ["scheduled_monitor_waiting", "scheduled_monitor_dispatched", "scheduled_monitor_preserved"])),
|
|
)),
|
|
)),
|
|
),
|
|
notExists(db.select({ id: statusDecisionEffects.id }).from(statusDecisionEffects).where(and(
|
|
eq(statusDecisionEffects.companyId, nativeRunFinalizations.companyId),
|
|
eq(statusDecisionEffects.issueId, nativeRunFinalizations.issueId),
|
|
eq(statusDecisionEffects.decisionId, nativeRunFinalizations.decisionId),
|
|
inArray(statusDecisionEffects.deliveryState, ["pending", "failed"]),
|
|
))),
|
|
scope ? and(eq(heartbeatRuns.companyId, scope.companyId), scope.issueId ? eq(nativeRunFinalizations.issueId, scope.issueId) : undefined) : undefined,
|
|
scope?.runId ? eq(heartbeatRuns.id, scope.runId) : undefined,
|
|
scope?.agentId ? eq(heartbeatRuns.agentId, scope.agentId) : undefined,
|
|
after ? gt(heartbeatRuns.id, after) : undefined, runId ? eq(heartbeatRuns.id, runId) : undefined,
|
|
)).orderBy(asc(heartbeatRuns.id)).limit(100);
|
|
}
|
|
|
|
export function nativeResponsePresentationPendingQuery() {
|
|
return nativeResponsePresentationCandidates();
|
|
}
|