mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
fix: make connection checks and task handoffs reliable (#13404)
Preserve connection-probe outcomes through cleanup, reduce unrelated startup work, and report selected Claude authentication accurately. Make artifact download actions match their labels. Route delegated feedback through its active child, retain accepted messages across completion, and avoid redundant worker runs for proven closing notes. Preserve explicit follow-ups, human input, company boundaries, source provenance, and mixed issue references. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
2d47a8058d
commit
f1d57863d2
20 files changed
+740
-65
No files matched your search
@@ -158,6 +158,14 @@ export interface WakeQueueTransaction {
|
||||
finishingRunId: string;
|
||||
commentIds: string[];
|
||||
}): Promise<{ allSelfAuthored: boolean }>;
|
||||
/** Proves all candidate comments only report completed child work in the finishing parent's own run. */
|
||||
isCompletedDelegationMention(input: {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
finishingRunId: string;
|
||||
wakeAgentId: string;
|
||||
commentIds: string[];
|
||||
}): Promise<boolean>;
|
||||
reopenIssue(input: { companyId: string; issueId: string; runId: string }): Promise<IssueSnapshot | null>;
|
||||
/**
|
||||
* Atomically claims the wake for promotion, guarded on its current
|
||||
|
||||
@@ -114,6 +114,7 @@ function createFakeTransaction(overrides: Partial<WakeQueueTransaction> = {}): W
|
||||
releasePolicy: null,
|
||||
})),
|
||||
getCommentSelfAuthorship: vi.fn(async () => ({ allSelfAuthored: false })),
|
||||
isCompletedDelegationMention: vi.fn(async () => false),
|
||||
reopenIssue: vi.fn(async () => null),
|
||||
claimDeferredWakeForPromotion: vi.fn(async () => true),
|
||||
finalizePromotedWake: vi.fn(async (input) => runSummary(input.wakeId)),
|
||||
@@ -127,11 +128,11 @@ function createFakeTransaction(overrides: Partial<WakeQueueTransaction> = {}): W
|
||||
};
|
||||
}
|
||||
|
||||
function createFakeIssueLock(host: WakeQueueHost, transaction: WakeQueueTransaction, issue = ISSUE): IssueLockWriter {
|
||||
function createFakeIssueLock(host: WakeQueueHost, transaction: WakeQueueTransaction, issue = ISSUE, run = RUN): IssueLockWriter {
|
||||
return {
|
||||
withIssueExecutionLock: vi.fn(async (_input, fn) => {
|
||||
const result = await fn({ primaryIssue: issue, run: RUN }, { host, transaction });
|
||||
return { ...result, run: RUN };
|
||||
const result = await fn({ primaryIssue: issue, run }, { host, transaction });
|
||||
return { ...result, run };
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -144,6 +145,49 @@ function createFakeRecovery(): RecoveryEscalationPort {
|
||||
}
|
||||
|
||||
describe("releaseIssueExecution", () => {
|
||||
it.each(["closing", "mixed_comments", "explicit_resume", "interaction", "other_source_task", "parent_open"])(
|
||||
"checks late completed-delegation mentions without dropping independent input (%s)", async (scenario) => {
|
||||
const originalCommentIds = ["closing-comment", "second-comment"];
|
||||
const candidate = wakeCandidate({
|
||||
reason: "issue_comment_mentioned", wakeReason: "issue_comment_mentioned",
|
||||
requestedByActorType: "agent", requestedByActorId: RUN.agentId,
|
||||
queuedCommentIds: [originalCommentIds[0]], deferredCommentIds: originalCommentIds,
|
||||
preservesIndependentContinuation: scenario === "explicit_resume",
|
||||
payload: scenario === "interaction" ? { mutation: "interaction" } : {},
|
||||
deferredContextSeed: scenario === "explicit_resume" ? { resumeIntent: true } : {},
|
||||
});
|
||||
const queue = [candidate];
|
||||
const transaction = createFakeTransaction({
|
||||
findNextDeferredWake: vi.fn(async () => queue.shift() ?? null),
|
||||
isCompletedDelegationMention: vi.fn(async () => scenario !== "mixed_comments"),
|
||||
getQueuedCommentLiveness: vi.fn(async () => ({ liveNonSelfCommentIds: [originalCommentIds[0]], containedSelfAuthoredComment: false })),
|
||||
});
|
||||
const issue = { ...ISSUE, status: scenario === "parent_open" ? "in_progress" : "done" };
|
||||
const run = { ...RUN, status: "succeeded", contextSnapshot: { issueId: scenario === "other_source_task" ? "other-issue" : ISSUE.id } };
|
||||
const release = createReleaseIssueExecution({
|
||||
issueLock: createFakeIssueLock(createFakeHost(), transaction, issue, run), recovery: createFakeRecovery(),
|
||||
});
|
||||
const result = await release({ companyId: RUN.companyId, runId: RUN.id, now: new Date() });
|
||||
if (scenario === "closing" || scenario === "mixed_comments") {
|
||||
expect(transaction.isCompletedDelegationMention).toHaveBeenCalledWith({
|
||||
companyId: RUN.companyId, issueId: ISSUE.id, finishingRunId: RUN.id,
|
||||
wakeAgentId: AGENT.id, commentIds: originalCommentIds,
|
||||
});
|
||||
} else {
|
||||
expect(transaction.isCompletedDelegationMention).not.toHaveBeenCalled();
|
||||
}
|
||||
if (scenario === "closing") {
|
||||
expect(result.outcome.kind).toBe("released");
|
||||
expect(transaction.cancelDeferredWake).toHaveBeenCalledTimes(1);
|
||||
expect(transaction.claimDeferredWakeForPromotion).not.toHaveBeenCalled();
|
||||
expect(transaction.finalizePromotedWake).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(result.outcome.kind).toBe("promoted");
|
||||
expect(transaction.cancelDeferredWake).not.toHaveBeenCalled();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves the former owner's queue for handoff adoption while draining the new owner's wake", async () => {
|
||||
const stale = wakeCandidate({ agentId: RUN.agentId, queuedCommentIds: ["saved-user-direction"] });
|
||||
const current = wakeCandidate({ id: "wake-new-owner", agentId: "new-agent" });
|
||||
@@ -489,6 +533,46 @@ describe("releaseIssueExecution", () => {
|
||||
expect(result.outcome.kind).toBe("promoted");
|
||||
});
|
||||
|
||||
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) => {
|
||||
const commentIds = ["accepted-agent-feedback"];
|
||||
const queue = [wakeCandidate({
|
||||
agentId: ISSUE.assigneeAgentId!,
|
||||
requestedByActorType: "agent",
|
||||
requestedByActorId: "delegating-agent",
|
||||
queuedCommentIds: scenario === "done_untracked_comment" ? [] : commentIds,
|
||||
deferredCommentIds: commentIds,
|
||||
payload: { resumeIntent: scenario !== "done_no_resume", followUpRequested: true },
|
||||
deferredContextSeed: { resumeIntent: scenario !== "done_no_resume", followUpRequested: true, wakeCommentIds: commentIds },
|
||||
})];
|
||||
const transaction = createFakeTransaction({
|
||||
findNextDeferredWake: vi.fn(async () => queue.shift() ?? null),
|
||||
getQueuedCommentLiveness: vi.fn(async () => ({
|
||||
liveNonSelfCommentIds: scenario === "done_missing" || scenario === "done_self" ? [] : commentIds,
|
||||
containedSelfAuthoredComment: scenario === "done_self",
|
||||
})),
|
||||
getCommentSelfAuthorship: vi.fn(async () => ({ allSelfAuthored: scenario === "done_self" })),
|
||||
reopenIssue: vi.fn(async () => ({ ...ISSUE, status: "todo" })),
|
||||
});
|
||||
const release = createReleaseIssueExecution({
|
||||
issueLock: createFakeIssueLock(createFakeHost(), transaction, { ...ISSUE, status: scenario === "cancelled_live" ? "cancelled" : "done" }),
|
||||
recovery: createFakeRecovery(),
|
||||
});
|
||||
|
||||
const result = await release({ companyId: RUN.companyId, runId: RUN.id, now: new Date() });
|
||||
|
||||
if (scenario === "done_live") {
|
||||
expect(transaction.cancelDeferredWake).not.toHaveBeenCalled();
|
||||
expect(transaction.reopenIssue).toHaveBeenCalledTimes(1);
|
||||
expect(transaction.finalizePromotedWake).toHaveBeenCalledTimes(1);
|
||||
expect(result.outcome.kind).toBe("promoted");
|
||||
} else {
|
||||
expect(transaction.cancelDeferredWake).toHaveBeenCalledTimes(1);
|
||||
expect(transaction.reopenIssue).not.toHaveBeenCalled();
|
||||
expect(transaction.finalizePromotedWake).not.toHaveBeenCalled();
|
||||
expect(result.outcome.kind).toBe("released");
|
||||
}
|
||||
});
|
||||
|
||||
it("never reopens the issue when the promotion claim loses the race, and moves on to the next wake", async () => {
|
||||
const doneIssue: IssueSnapshot = { ...ISSUE, status: "done" };
|
||||
const queue = [
|
||||
|
||||
@@ -167,6 +167,34 @@ async function runReleaseDrain(
|
||||
}
|
||||
processedWakeIds.add(candidate.id);
|
||||
|
||||
// A lead can post its closing comment before committing Done. Check
|
||||
// again when the source run releases its queue, using every original
|
||||
// comment ID so coalesced human or unrelated input is never hidden.
|
||||
if (
|
||||
issue.status === "done" &&
|
||||
run.agentId === issue.assigneeAgentId &&
|
||||
run.contextSnapshot?.issueId === issue.id &&
|
||||
!candidate.authorizedFailedChatRetry &&
|
||||
!candidate.preservesIndependentContinuation &&
|
||||
candidate.payload.mutation !== "interaction" &&
|
||||
(candidate.wakeReason ?? candidate.reason) === "issue_comment_mentioned" &&
|
||||
await ports.transaction.isCompletedDelegationMention({
|
||||
companyId: run.companyId,
|
||||
issueId: issue.id,
|
||||
finishingRunId: run.id,
|
||||
wakeAgentId: candidate.agentId,
|
||||
commentIds: [...new Set([...candidate.queuedCommentIds, ...candidate.deferredCommentIds])],
|
||||
})
|
||||
) {
|
||||
await ports.transaction.cancelDeferredWake({
|
||||
companyId: run.companyId,
|
||||
wakeId: candidate.id,
|
||||
reason: "Delegation closing note refers to completed child work",
|
||||
now: input.now,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const ordinaryTaskComment = !candidate.authorizedFailedChatRetry && candidate.payload.mutation !== "interaction" &&
|
||||
!candidate.preservesIndependentContinuation && candidate.queuedCommentIds.length > 0 &&
|
||||
["issue_commented", "issue_reopened_via_comment"].includes(candidate.wakeReason ?? candidate.reason ?? "");
|
||||
@@ -314,12 +342,18 @@ async function promoteDeferredWake(
|
||||
shouldReopen =
|
||||
!selfAuthorship.allSelfAuthored &&
|
||||
(workingCandidate.requestedByActorType === "user" ||
|
||||
workingCandidate.wakeReason === "issue_reopened_via_comment");
|
||||
workingCandidate.wakeReason === "issue_reopened_via_comment" ||
|
||||
(currentIssue.status === "done" &&
|
||||
workingCandidate.agentId === currentIssue.assigneeAgentId &&
|
||||
workingCandidate.requestedByActorType === "agent" &&
|
||||
workingCandidate.deferredContextSeed.resumeIntent === true &&
|
||||
workingCandidate.queuedCommentIds.length > 0));
|
||||
}
|
||||
|
||||
// Agent continuations can outlive the work they addressed. Only a human
|
||||
// reopen can revive assignee execution; other agents may still receive
|
||||
// notifications about the closed task. Cancel before claiming promotion so
|
||||
// Agent continuations can outlive the work they addressed. Live,
|
||||
// non-self feedback with an explicit resume intent must still be read
|
||||
// after completion; it cannot revive a cancelled task. Other stale
|
||||
// continuations cannot revive assignee execution. Cancel before claiming promotion so
|
||||
// the compare-and-set still sees the deferred wake.
|
||||
if (
|
||||
!shouldReopen &&
|
||||
|
||||
Reference in new issue
Block a user