Files
PaperClipAI/server/src/services/work-intent-outbox.ts
T
DottaandClaude Opus 5.5 a9cf4ee731 fix(outbox): deliver a user comment whose wake was lost on a task its agent then closed (#20066)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A user's comment on a task with a live run must reach the agent: it
is steered into the run or delivered in a follow-up run. Per-comment
exactly-once delivery is plan invariant 4
> - A comment commits before its wake is written. A control-plane
restart in between loses the wake, and the reconciler's lost-wake rule
(chaos s9401/t69) saves the missing outbox entry
> - That rule only looked at `todo` and `in_progress` tasks. In the
restart chaos Daytona wave s9501, task t06 got a comment 10 ms before a
K1 kill. The live run then finished, its agent closed the task, and the
comment was never read
> - This pull request extends the rule to a task its agent closed, and
reopens that task for the user's message, as the release drain already
does
> - The benefit is that a message sent while the agent works is never
silently dropped by a restart

## Linked Issues or Issue Description

Refs #19831 (plan, section E: work-intent outbox, invariant 4), and
#19992 (the s9401/t69 and t72 rules this extends). Found by the restart
chaos campaign, wave w-20261108T142906-s9501, task t06:

- The comment was saved at 14:40:17.048.
- The K1 kill came at .058.
- No wake request ever carried the comment, and no run event shows it
steered in.
- The run that started before the comment finished at 14:44:22 and
closed the task.

## What Changed

- `recovery/ownership-invariant-report.ts`
(`checkUserCommentsWithoutWake`): also reports a comment on a `done`
task its agent closed (`taskClosedByAgentSql`), when no wake carries it,
no run started after it, and no run of the task logged it as a steered
`userMessage` item.
- `work-intent-outbox.ts` (`saveUndeliveredUserComments`): a new
`reopenAgentClosedDone` option. In the same transaction that locks the
issue, re-checks the agent close and saves the receipt, it reopens the
task to `todo` through the issues service. It also logs `issue.updated`
(reopened from done, source `undelivered_comment_backstop`).
- `native-finalization-reconciler.ts` (`applyOwnershipReportBackstops`):
the lost-wake backstop allows `done` and passes `reopenAgentClosedDone`.
The receipt keeps its `wake_lost` / `reconciler_backstop` wait, so the
existing release path delivers it once.

## Verification

- `heartbeat-held-comment-wake.test.ts`, new test "delivers once a
comment whose wake a restart lost mid-run on a task the run then
closed". It fails on `master` and passes here. The violation is found,
the backstop saves and reopens, the comment is delivered exactly once,
and a second pass and backstop deliver nothing more. All 25 tests in the
file pass.
- `ownership-reconciliation-report.test.ts`, new test "finds a user
comment that a restart left without a wake on a task its agent then
closed". It fails on `master` and passes here: only the lost comment is
reported, not one steered into the run and not one on a task a person
closed. All 26 pass.
- The server package typecheck passes.

## Risks

- **Reopening a done task.** This only happens when the latest recorded
close was not made by a person, the comment is a direct human comment,
and no run received it or had it steered in. That's the same rule the
t72 stale-continuation path and the release drain already apply.
- **The steer check** joins the task's runs to their `item.started`
events by comment ID. It runs only for candidate comments within the
lookback window.

## 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
- [ ] 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>
2026-11-08 10:08:00 -06:00

168 lines
10 KiB
TypeScript

import { and, eq, inArray, isNotNull, isNull, sql } from "drizzle-orm";
import { agentWakeupRequests, issueComments, issues, type Db } from "@paperclipai/db";
import { notifyDeferredExecutionEligibility, notifyDeferredExecutionWork, notifyDeferredWakesForIssue } from "./deferred-execution-notifications.js";
import { withQueuedCommentIdsInWakePayload } from "./issue-queued-comment-queue.js";
import { deliveredQueuedCommentIds } from "./queued-comment-delivery.js";
import { notifyIssueRecovery } from "./issue-recovery-notifications.js";
import { notifyRunDispatch } from "./run-dispatch-notifications.js";
import { persistActivity, publishActivity, type ActivityPublication } from "./activity-log.js";
/**
* Work-intent outbox (durable ownership plan, section E; invariant 4).
*
* An accepted work intent (assignment, comment, Retry, recovery) ends in one
* of a closed set of terminal outcomes. Every other refusal is temporary: the
* intent is saved as `deferred_issue_execution` with an `executionWait` that
* names the refusal and the hold-release event that clears it. Each such event
* calls `promoteDeferredWorkIntents`, and the ownership reconciler calls it as
* a backstop for a missed event. Promotion re-runs normal admission; it never
* bypasses a gate, and the existing compare-and-swap promotion plus the
* per-comment consumption check keep delivery exactly once.
*/
/** The only terminal outcomes of an accepted work intent. */
export const WORK_INTENT_TERMINAL_OUTCOMES = ["delivered", "consumed", "task_cancelled", "hard_stop"] as const;
export type WorkIntentTerminalOutcome = (typeof WORK_INTENT_TERMINAL_OUTCOMES)[number];
/** Hold-release events. Each promotes the intents its hold deferred. */
export const WORK_INTENT_HOLD_TRIGGERS = [
"accounting_settled", "lock_released", "pause_lifted", "tree_hold_released", "scheduling_resumed", "reconciler_backstop",
] as const;
export type WorkIntentHoldTrigger = (typeof WORK_INTENT_HOLD_TRIGGERS)[number];
/** Temporary refusals of an accepted intent, and the event that clears each.
* A refusal not listed here is either a terminal outcome (a reached hard
* stop, a cancelled or reassigned task) or a signal that was never accepted
* work (a disabled timer, no actionable work). */
export const TEMPORARY_REFUSAL_TRIGGERS: Readonly<Record<string, WorkIntentHoldTrigger>> = {
"budget.blocked": "pause_lifted",
"company.inactive": "pause_lifted",
"agent.not_invokable": "pause_lifted",
"issue_tree_hold_active": "tree_hold_released",
"heartbeat.scheduling_suppressed": "scheduling_resumed",
/** A user message whose wake was lost (the reconciler saved it). */
wake_lost: "reconciler_backstop",
};
export function temporaryRefusalTrigger(reason: string): WorkIntentHoldTrigger | null {
return TEMPORARY_REFUSAL_TRIGGERS[reason] ?? null;
}
/** The `executionWait` saved on a deferred intent. */
export function deferredWorkIntentWait(reason: string, message: string, details: Record<string, unknown> = {}) {
return { ...details, reason, trigger: temporaryRefusalTrigger(reason), message };
}
/** Saved intents back off 30 s after each refusal (deferred-receipt-work.ts).
* A hold-release event makes the intents it may clear due at once. */
const DEFERRED_RETRY_BACKOFF = sql`interval '30 seconds'`;
/**
* The one promotion path for deferred work intents. Call it inside a
* transaction, normally the one that releases a hold (accounting settled, a lock released, a
* pause lifted). It wakes queued-run dispatch, the saved-receipt worker and
* stranded-work recovery for the company, and hints each saved intent of the
* issue by id. All notifications publish on commit.
*/
export async function promoteDeferredWorkIntents(tx: object, input: {
companyId: string; trigger: WorkIntentHoldTrigger; issueId?: string | null;
}) {
// Register the work signals before the write they announce.
await notifyRunDispatch(tx, { companyId: input.companyId });
await notifyDeferredExecutionEligibility(tx, { companyId: input.companyId });
await notifyIssueRecovery(tx, { companyId: input.companyId });
if (input.issueId) await notifyDeferredWakesForIssue(tx, { companyId: input.companyId, issueId: input.issueId });
const db = tx as Pick<Db, "update">;
await db.update(agentWakeupRequests).set({
updatedAt: sql`least(${agentWakeupRequests.updatedAt}, now() - ${DEFERRED_RETRY_BACKOFF})`,
}).where(and(
eq(agentWakeupRequests.companyId, input.companyId),
eq(agentWakeupRequests.status, "deferred_issue_execution"),
sql`${agentWakeupRequests.payload}->'executionWait' is not null`,
sql`${agentWakeupRequests.updatedAt} > now() - ${DEFERRED_RETRY_BACKOFF}`,
input.issueId ? sql`${agentWakeupRequests.payload}->>'issueId' = ${input.issueId}` : undefined,
));
}
const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext";
/** SQL: the latest recorded `done` transition of the task was not made by a
* person. A task a person closed (or with no recorded close) is never
* reopened for an earlier message. */
export function taskClosedByAgentSql(companyId: unknown, issueId: unknown) {
return sql`coalesce((select closed.actor_type from activity_log closed
where closed.company_id = ${companyId} and closed.entity_type = 'issue' and closed.entity_id = ${issueId}::text
and closed.action = 'issue.updated'
and (closed.details->>'toStatus' = 'done' or closed.details->>'status' = 'done')
order by closed.created_at desc limit 1), 'user') <> 'user'`;
}
/**
* Record the missing outbox entry for user messages whose wake was lost or
* ended without delivering them: one deferred receipt of the user that
* carries the undelivered comments. Only the task's assigned agent, and only
* live user comments that no completed run received, are saved. Statuses the
* caller allows (for example `done`, which the release drain reopens for a
* user's message) gate it. Normal admission and the release drain deliver
* the receipt once. Returns the receipt id, or null when nothing is owed.
*/
export async function saveUndeliveredUserComments(db: Db, input: {
companyId: string; issueId: string; agentId: string; commentIds: string[];
allowedStatuses: readonly string[]; requestedByActorId?: string | null;
source?: string | null; triggerDetail?: string | null;
/** Names why the receipt waits and what promotes it. */
executionWait?: Record<string, unknown>;
reopenAgentClosedDone?: boolean;
}): Promise<string | null> {
if (input.commentIds.length === 0) return null;
let publication: ActivityPublication | undefined;
const savedId = await db.transaction(async tx => {
const [issue] = await tx.select({ status: issues.status, assigneeAgentId: issues.assigneeAgentId,
conversationAgentId: issues.conversationAgentId }).from(issues)
.where(and(eq(issues.companyId, input.companyId), eq(issues.id, input.issueId))).for("update");
// A reassigned or cancelled task ends the intent; a conversation keeps
// its own inbound authorization path.
if (!issue || issue.assigneeAgentId !== input.agentId || issue.conversationAgentId ||
!input.allowedStatuses.includes(issue.status)) return null;
if (issue.status === "done") {
const [closed] = await tx.execute<{ byAgent: boolean }>(sql`select ${taskClosedByAgentSql(input.companyId, input.issueId)} as "byAgent"`);
if (closed?.byAgent !== true) return null;
}
const live = await tx.select({ id: issueComments.id, authorUserId: issueComments.authorUserId }).from(issueComments).where(and(
eq(issueComments.companyId, input.companyId), eq(issueComments.issueId, input.issueId),
inArray(issueComments.id, input.commentIds), isNotNull(issueComments.authorUserId), isNull(issueComments.deletedAt),
// A direct human comment: no agent author and no creating run.
isNull(issueComments.authorAgentId), isNull(issueComments.createdByRunId)));
const delivered = await deliveredQueuedCommentIds(tx as unknown as Db, { companyId: input.companyId,
issueId: input.issueId, agentId: input.agentId, commentIds: live.map(row => row.id) });
const remaining = input.commentIds.filter(id => live.some(row => row.id === id) && !delivered.has(id));
if (remaining.length === 0) return null;
const latest = remaining.at(-1)!;
await notifyDeferredExecutionWork(tx, { companyId: input.companyId });
await notifyDeferredWakesForIssue(tx, { companyId: input.companyId, issueId: input.issueId });
const [saved] = await tx.insert(agentWakeupRequests).values({
companyId: input.companyId, agentId: input.agentId, source: input.source ?? "automation",
triggerDetail: input.triggerDetail ?? "system", reason: "issue_commented", status: "deferred_issue_execution",
requestedByActorType: "user",
requestedByActorId: input.requestedByActorId ?? live.find(row => row.id === latest)?.authorUserId ?? null,
payload: withQueuedCommentIdsInWakePayload({ issueId: input.issueId, mutation: "comment", commentId: latest,
...(input.executionWait ? { executionWait: input.executionWait } : {}),
[DEFERRED_WAKE_CONTEXT_KEY]: { issueId: input.issueId, taskId: input.issueId, wakeReason: "issue_commented",
source: "issue.comment", wakeCommentIds: remaining, wakeCommentId: latest } }, remaining),
}).returning({ id: agentWakeupRequests.id });
if (saved && issue.status === "done" && input.reopenAgentClosedDone) {
const { issueService } = await import("./issues.js");
const updated = await issueService(tx as unknown as Db).updateForCompany(input.issueId, input.companyId,
{ status: "todo", executionState: null }, tx);
if (!updated) throw new Error("undelivered_comment_task_changed");
publication = (await persistActivity(tx as unknown as Db, { companyId: input.companyId, actorType: "system",
actorId: "ownership-reconciler", agentId: input.agentId, action: "issue.updated", entityType: "issue",
entityId: input.issueId,
details: { status: "todo", reopened: true, reopenedFrom: "done", source: "undelivered_comment_backstop" } })).publication;
}
return saved?.id ?? null;
});
if (publication) publishActivity(publication);
return savedId;
}