mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:26:28 +01:00
fix(wake-queue): keep a task done for its delegator's comment posted during the closing run (#21032)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A lead agent delegates a task, and the assignee works on it in a run > - When someone comments while that run is active, the comment waits as a deferred wake for the next run > - If the run marks the task done, an agent comment with resume intent reopened the task. This started another assignee run and more lead wakes > - When the comment came from the lead that delegated the task, during the run that closed it, the lead gets the completed result anyway > - This pull request keeps the task done in that case. Other agents' feedback and any person's message still reopen it > - The benefit is no repeated work after a task is finished, and no lost person messages ## Linked Issues or Issue Description **What happened?** In the paid chat eval `secrets.least-scope-delivery`, the CEO created Morgan's task. While Morgan's run was active, the CEO posted a redundant instruction comment on it. Morgan's run completed the task. The deferred agent comment then reopened the done task. This caused a second Morgan run and extra CEO wakes, and the eval's run bound was exceeded. #20996 covers a person's owed answers. This change covers agent comments with resume intent. **Steps to reproduce** 1. Let agent A create a task assigned to agent B. 2. While B's run is active, let A comment on the task with resume intent. 3. Let B's run mark the task done. 4. The deferred comment reopens the task, and B runs again. **Expected behavior** The task stays done. A receives the completed result and can follow up. A person's message still reopens the task. ## What Changed - `server/src/modules/wake-queue/application/use-cases.ts`: the "agent feedback after completion" reopen rule now also requires that the comments are not watcher comments posted during the finishing run. Person messages, `issue_reopened_via_comment`, and other agents' feedback do not change. - `server/src/modules/wake-queue/application/ports.ts` and `adapters/postgres.ts`: `isWatcherCommentDuringFinishingRun` is true only when every comment exists, has an agent author and no person author, comes from the task's creator agent or its parent's assignee (not the finishing agent), and was created after the finishing run started. - When this is true, the deferred wake is cancelled with the existing reason "Deferred execution wake no longer applies to a terminal task". The comment stays on the task. - `server/src/__tests__/heartbeat-comment-wake-batching.test.ts`: a new heartbeat case. It is red on master (the task reopens and the assignee runs again) and green here (the task stays done, one run). The existing case, where a non-delegating agent's feedback reopens the task, still passes. - `use-cases.test.ts`: a unit scenario for the new rule. ## Verification - `cd server && npx vitest run src/__tests__/heartbeat-comment-wake-batching.test.ts src/modules/wake-queue` (369 passed; the new case is red on master) - `cd server && npx tsc --noEmit` passes. - Paid E2E run on a combined branch: https://github.com/paperclipai/paperclip/actions/runs/43161593494 (results in a comment below). ## Risks - Behavior change: if a delegating agent asks for new work in a comment during the closing run, the assignee does not get it automatically. The delegator receives the completed task and can comment again or create follow-up work. A person's message is never dropped by this rule. - Comments with no author type (older rows) count as agent comments only when they have an agent author and no person author. - No schema change. ## 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>
This commit is contained in:
1 parent
68b7852cd1
commit
a372271a40
5 files changed
+69
-12
No files matched your search
@@ -472,6 +472,33 @@ function buildTransaction(tx: Db, deps: WakeQueuePostgresAdapterDeps, db: Db, ru
|
||||
return { allSelfAuthored: rows.length > 0 && rows.every((row) => row.createdByRunId === finishingRunId) };
|
||||
},
|
||||
|
||||
async isWatcherCommentDuringFinishingRun({ companyId, issueId, finishingRunId, commentIds }) {
|
||||
if (run.companyId !== companyId || run.id !== finishingRunId || !run.startedAt) return false;
|
||||
const uniqueCommentIds = [...new Set(commentIds)];
|
||||
if (uniqueCommentIds.length === 0) return false;
|
||||
const [task] = await tx.select({ createdByAgentId: issues.createdByAgentId, parentId: issues.parentId })
|
||||
.from(issues).where(and(eq(issues.companyId, companyId), eq(issues.id, issueId)));
|
||||
if (!task) return false;
|
||||
const watchers = new Set<string>(task.createdByAgentId ? [task.createdByAgentId] : []);
|
||||
if (task.parentId) {
|
||||
const [parent] = await tx.select({ assigneeAgentId: issues.assigneeAgentId }).from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), eq(issues.id, task.parentId)));
|
||||
if (parent?.assigneeAgentId) watchers.add(parent.assigneeAgentId);
|
||||
}
|
||||
watchers.delete(run.agentId);
|
||||
if (watchers.size === 0) return false;
|
||||
const comments = await tx.select({ authorType: issueComments.authorType, authorAgentId: issueComments.authorAgentId,
|
||||
authorUserId: issueComments.authorUserId, createdAt: issueComments.createdAt })
|
||||
.from(issueComments).where(and(eq(issueComments.companyId, companyId), eq(issueComments.issueId, issueId),
|
||||
inArray(issueComments.id, uniqueCommentIds), isNull(issueComments.deletedAt)));
|
||||
const startedAt = run.startedAt.getTime();
|
||||
return comments.length === uniqueCommentIds.length && comments.every((comment) =>
|
||||
// Older rows carry no author type; an agent author and no person is the same fact.
|
||||
(comment.authorType === "agent" || comment.authorType === null) &&
|
||||
comment.authorUserId === null && comment.authorAgentId !== null &&
|
||||
watchers.has(comment.authorAgentId) && comment.createdAt.getTime() >= startedAt);
|
||||
},
|
||||
|
||||
async isCompletedDelegationMention({ companyId, issueId, finishingRunId, wakeAgentId, commentIds }) {
|
||||
if (
|
||||
run.companyId !== companyId || run.id !== finishingRunId ||
|
||||
|
||||
@@ -185,6 +185,18 @@ export interface WakeQueueTransaction {
|
||||
wakeAgentId: string;
|
||||
commentIds: string[];
|
||||
}): Promise<boolean>;
|
||||
/**
|
||||
* True when every comment was posted by the agent watching the task (its
|
||||
* creator or its parent's assignee) while the finishing run was active. That
|
||||
* agent receives the completed result and can follow up; its comment does
|
||||
* not need to reopen the task the run just closed.
|
||||
*/
|
||||
isWatcherCommentDuringFinishingRun(input: {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
finishingRunId: string;
|
||||
commentIds: string[];
|
||||
}): Promise<boolean>;
|
||||
/** Reopens to todo, or straight to in_progress for an owed answer, which continues the assignee's own execution. */
|
||||
reopenIssue(input: { companyId: string; issueId: string; runId: string; status?: "todo" | "in_progress" }): Promise<IssueSnapshot | null>;
|
||||
/** Verifies a Done onboarding parent's completion wake against its own completed children. */
|
||||
|
||||
@@ -115,6 +115,7 @@ function createFakeTransaction(overrides: Partial<WakeQueueTransaction> = {}): W
|
||||
releasePolicy: null,
|
||||
})),
|
||||
getCommentSelfAuthorship: vi.fn(async () => ({ allSelfAuthored: false })),
|
||||
isWatcherCommentDuringFinishingRun: vi.fn(async () => false),
|
||||
owedInteractionAnswer: vi.fn(async (): Promise<"deliver" | "blocked" | null> => null),
|
||||
isCompletedDelegationMention: vi.fn(async () => false),
|
||||
reopenIssue: vi.fn(async () => null),
|
||||
@@ -643,7 +644,7 @@ describe("releaseIssueExecution", () => {
|
||||
expect(transaction.cancelDeferredWake).toHaveBeenCalledWith(expect.objectContaining({ wakeId: "next" }));
|
||||
});
|
||||
|
||||
it.each(["done_live", "cancelled_live", "done_missing", "done_self", "done_no_resume", "done_untracked_comment"])("handles explicit agent feedback after completion: %s", async (scenario) => {
|
||||
it.each(["done_live", "cancelled_live", "done_missing", "done_self", "done_no_resume", "done_untracked_comment", "done_watcher_during_run"])("handles explicit agent feedback after completion: %s", async (scenario) => {
|
||||
const commentIds = ["accepted-agent-feedback"];
|
||||
const queue = [wakeCandidate({
|
||||
agentId: ISSUE.assigneeAgentId!,
|
||||
@@ -661,6 +662,7 @@ describe("releaseIssueExecution", () => {
|
||||
containedSelfAuthoredComment: scenario === "done_self",
|
||||
})),
|
||||
getCommentSelfAuthorship: vi.fn(async () => ({ allSelfAuthored: scenario === "done_self" })),
|
||||
isWatcherCommentDuringFinishingRun: vi.fn(async () => scenario === "done_watcher_during_run"),
|
||||
reopenIssue: vi.fn(async () => ({ ...ISSUE, status: "todo" })),
|
||||
});
|
||||
const release = createReleaseIssueExecution({
|
||||
|
||||
@@ -363,15 +363,25 @@ async function promoteDeferredWake(
|
||||
finishingRunId: run.id,
|
||||
commentIds: workingCandidate.deferredCommentIds,
|
||||
});
|
||||
const agentFeedbackAfterCompletion =
|
||||
currentIssue.status === "done" &&
|
||||
workingCandidate.agentId === currentIssue.assigneeAgentId &&
|
||||
workingCandidate.requestedByActorType === "agent" &&
|
||||
workingCandidate.deferredContextSeed.resumeIntent === true &&
|
||||
workingCandidate.queuedCommentIds.length > 0;
|
||||
shouldReopen =
|
||||
!selfAuthorship.allSelfAuthored &&
|
||||
(workingCandidate.requestedByActorType === "user" ||
|
||||
workingCandidate.wakeReason === "issue_reopened_via_comment" ||
|
||||
(currentIssue.status === "done" &&
|
||||
workingCandidate.agentId === currentIssue.assigneeAgentId &&
|
||||
workingCandidate.requestedByActorType === "agent" &&
|
||||
workingCandidate.deferredContextSeed.resumeIntent === true &&
|
||||
workingCandidate.queuedCommentIds.length > 0));
|
||||
// The agent watching the task posted it while the run that closed the
|
||||
// task was active. That agent receives the completed result and can
|
||||
// follow up from it; reopening would only repeat finished work. A
|
||||
// person's message (handled above) always reopens.
|
||||
(agentFeedbackAfterCompletion && run.agentId === currentIssue.assigneeAgentId &&
|
||||
!await ports.transaction.isWatcherCommentDuringFinishingRun({
|
||||
companyId: run.companyId, issueId: currentIssue.id, finishingRunId: run.id,
|
||||
commentIds: workingCandidate.queuedCommentIds,
|
||||
})));
|
||||
}
|
||||
|
||||
// A person's answer saved while the task was still open is owed input. The
|
||||
|
||||
Reference in new issue
Block a user