mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 23:43:16 +01:00
Preserve native workspace recovery across Stop and sandbox reuse (#18332)
Land the thirteen-file native recovery fix independently from #18245. Retain verified Stop evidence and queued user comments. Retire stale preparation ownership atomically and require paired sandbox lease handoff receipts before workspace recovery. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
75666224d4
commit
f5e5b05a33
13 files changed
+1220
-28
No files matched your search
@@ -1001,6 +1001,40 @@ export function createWakeAdmissionWriter(): WakeAdmissionWriter {
|
||||
async mergeIntoExistingDeferredWake(scope, input) {
|
||||
const tx = requireAdmissionTx(scope, input.companyId);
|
||||
if (input.manualUserWakeActorId) await notifyChatInboundWakeNotices(tx, { companyId: input.companyId });
|
||||
const [prior] = await tx.select().from(agentWakeupRequests).where(and(
|
||||
eq(agentWakeupRequests.companyId, input.companyId), eq(agentWakeupRequests.id, input.existingDeferredWakeId),
|
||||
eq(agentWakeupRequests.status, DEFERRED_WAKE_STATUS),
|
||||
)).for("update");
|
||||
if (!prior) throw new Error("wake-queue: the deferred wake to merge into was not found for this company");
|
||||
const priorPayload = parseObject(prior.payload), issueId = readNonEmptyString(priorPayload.issueId);
|
||||
const priorCommentId = readNonEmptyString(priorPayload.commentId), priorActor = readNonEmptyString(prior.requestedByActorId);
|
||||
const [priorComment] = prior.requestedByActorType === "user" && priorActor && issueId && isUuidLike(issueId) &&
|
||||
priorCommentId && isUuidLike(priorCommentId) ? await tx.select({ id: issueComments.id, body: issueComments.body }).from(issueComments).where(and(
|
||||
eq(issueComments.companyId, input.companyId), eq(issueComments.issueId, issueId), eq(issueComments.id, priorCommentId),
|
||||
eq(issueComments.authorType, "user"), eq(issueComments.authorUserId, priorActor),
|
||||
isNull(issueComments.deletedAt), isNull(issueComments.createdByRunId),
|
||||
)) : [];
|
||||
if ((input.coalescedReceipt || priorComment?.body.trim()) &&
|
||||
(readNonEmptyString(input.mergedPayload.issueId) !== issueId || (input.coalescedReceipt &&
|
||||
(input.coalescedReceipt.agentId !== prior.agentId || readNonEmptyString(input.coalescedReceipt.payload.issueId) !== issueId))))
|
||||
throw new Error("wake-queue: merged receipt does not match the saved queue scope");
|
||||
if (priorComment?.body.trim()) {
|
||||
const [recorded] = await tx.select({ id: agentWakeupRequests.id }).from(agentWakeupRequests).where(and(
|
||||
eq(agentWakeupRequests.companyId, input.companyId), eq(agentWakeupRequests.agentId, prior.agentId),
|
||||
eq(agentWakeupRequests.status, "coalesced"), eq(agentWakeupRequests.requestedByActorType, "user"),
|
||||
eq(agentWakeupRequests.requestedByActorId, priorActor!),
|
||||
prior.runId ? eq(agentWakeupRequests.runId, prior.runId) : isNull(agentWakeupRequests.runId),
|
||||
sql`${agentWakeupRequests.payload}->>'issueId' = ${issueId}`,
|
||||
sql`${agentWakeupRequests.payload}->>'commentId' = ${priorComment.id}`,
|
||||
sql`${agentWakeupRequests.payload}->>'coalescedIntoWakeupRequestId' = ${prior.id}`,
|
||||
)).limit(1);
|
||||
if (!recorded) await tx.insert(agentWakeupRequests).values({
|
||||
companyId: input.companyId, agentId: prior.agentId, source: prior.source, triggerDetail: prior.triggerDetail,
|
||||
reason: prior.reason, payload: { issueId, commentId: priorComment.id, coalescedIntoWakeupRequestId: prior.id },
|
||||
status: "coalesced", coalescedCount: 1, requestedByActorType: "user", requestedByActorId: priorActor,
|
||||
requestedAt: prior.requestedAt, finishedAt: new Date(), runId: prior.runId,
|
||||
});
|
||||
}
|
||||
const rows = await tx
|
||||
.update(agentWakeupRequests)
|
||||
.set({
|
||||
@@ -1032,6 +1066,9 @@ export function createWakeAdmissionWriter(): WakeAdmissionWriter {
|
||||
await tx.insert(agentWakeupRequests).values({
|
||||
...input.coalescedReceipt,
|
||||
companyId: input.companyId,
|
||||
agentId: prior.agentId,
|
||||
runId: prior.runId,
|
||||
payload: { ...input.coalescedReceipt.payload, coalescedIntoWakeupRequestId: prior.id },
|
||||
status: "coalesced",
|
||||
coalescedCount: 1,
|
||||
finishedAt: new Date(),
|
||||
|
||||
@@ -322,7 +322,9 @@ export type DurableWakeAdmissionReceipt = {
|
||||
requestedAt: Date;
|
||||
};
|
||||
|
||||
export type CoalescedDeferredAdmissionReceipt = DurableWakeAdmissionReceipt & {
|
||||
export type CoalescedDeferredAdmissionReceipt =
|
||||
Omit<DurableWakeAdmissionReceipt, "id" | "requestedAt"> &
|
||||
Partial<Pick<DurableWakeAdmissionReceipt, "id" | "requestedAt">> & {
|
||||
agentId: string;
|
||||
source: string;
|
||||
triggerDetail: string | null;
|
||||
|
||||
@@ -1129,6 +1129,48 @@ describe("admitWakeBehindIssueExecution", () => {
|
||||
expect(writer.coalesceIntoActiveExecutionRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("records an ordinary merged comment under its own actor without requiring durable receipt identity", async () => {
|
||||
const writer = createFakeAdmissionWriter();
|
||||
const reader = createFakeAdmissionReader({ findExistingDeferredWake: vi.fn(async () => ({
|
||||
id: "existing-deferred-wake", runId: null, payload: { issueId: "issue-1" }, deferredContext: {}, coalescedCount: 1,
|
||||
})) });
|
||||
const admit = createAdmitWakeBehindIssueExecution({ reader, writer,
|
||||
helpers: createFakeAdmissionHelpers({ shouldDeferFollowupWakeForSameIssue: vi.fn(() => true) }) });
|
||||
await expect(admit(SCOPE, admissionInput({ wakeCommentId: "second-comment", requestedByActorId: "second-user",
|
||||
payload: { commentId: "second-comment", coalescedIntoWakeupRequestId: "forged-target" },
|
||||
}))).resolves.toEqual({ kind: "deferred" });
|
||||
const input = vi.mocked(writer.mergeIntoExistingDeferredWake).mock.calls[0][1];
|
||||
expect(input.coalescedReceipt).toEqual({ agentId: "wake-agent", source: "on_demand", triggerDetail: null,
|
||||
reason: null, payload: { issueId: "issue-1", commentId: "second-comment", coalescedIntoWakeupRequestId: "existing-deferred-wake" },
|
||||
requestedByActorType: "user", requestedByActorId: "second-user", idempotencyKey: null, runId: null });
|
||||
expect(input.coalescedReceipt).not.toHaveProperty("id");
|
||||
expect(input.coalescedReceipt).not.toHaveProperty("requestedAt");
|
||||
expect(reader.matchesActiveWakeActor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("binds authoritative issue scope when durable merged input omits it from payload", async () => {
|
||||
const writer = createFakeAdmissionWriter();
|
||||
const durableReceipt = { id: "own-request", requestedAt: new Date("2026-09-10T00:00:00Z") };
|
||||
const admit = createAdmitWakeBehindIssueExecution({ writer,
|
||||
reader: createFakeAdmissionReader({ findExistingDeferredWake: vi.fn(async () => ({
|
||||
id: "saved-wake", runId: null, payload: { issueId: "issue-1" }, deferredContext: {}, coalescedCount: 0,
|
||||
})) }), helpers: createFakeAdmissionHelpers({ shouldDeferFollowupWakeForSameIssue: vi.fn(() => true) }) });
|
||||
await admit(SCOPE, admissionInput({ durableReceipt, payload: { text: "saved input" } }));
|
||||
expect(vi.mocked(writer.mergeIntoExistingDeferredWake).mock.calls[0][1].coalescedReceipt).toEqual(expect.objectContaining({
|
||||
...durableReceipt, payload: { text: "saved input", issueId: "issue-1", coalescedIntoWakeupRequestId: "saved-wake" },
|
||||
}));
|
||||
});
|
||||
|
||||
it.each(["agent", "system"])("does not create a user-comment authority receipt for an ordinary %s merge", async actor => {
|
||||
const writer = createFakeAdmissionWriter();
|
||||
const admit = createAdmitWakeBehindIssueExecution({ writer,
|
||||
reader: createFakeAdmissionReader({ findExistingDeferredWake: vi.fn(async () => ({
|
||||
id: "existing-deferred-wake", runId: null, payload: { issueId: "issue-1" }, deferredContext: {}, coalescedCount: 1,
|
||||
})) }), helpers: createFakeAdmissionHelpers({ shouldDeferFollowupWakeForSameIssue: vi.fn(() => true) }) });
|
||||
await admit(SCOPE, admissionInput({ wakeCommentId: "second-comment", requestedByActorType: actor }));
|
||||
expect(vi.mocked(writer.mergeIntoExistingDeferredWake).mock.calls[0][1]).not.toHaveProperty("coalescedReceipt");
|
||||
});
|
||||
|
||||
it("gives manual input its own run boundary even when the active receipt has the same requester", async () => {
|
||||
const writer = createFakeAdmissionWriter();
|
||||
const admit = createAdmitWakeBehindIssueExecution({
|
||||
|
||||
@@ -923,7 +923,8 @@ export function createAdmitWakeBehindIssueExecution(deps: {
|
||||
mergedPayload,
|
||||
nextCoalescedCount: (existingDeferred.coalescedCount ?? 0) + 1,
|
||||
...(manualUserWakeActorId ? { manualUserWakeActorId } : {}),
|
||||
...(input.durableReceipt
|
||||
...(input.durableReceipt || (input.requestedByActorType === "user" &&
|
||||
readNonEmptyString(input.requestedByActorId) && readNonEmptyString(input.wakeCommentId))
|
||||
? {
|
||||
coalescedReceipt: {
|
||||
...input.durableReceipt,
|
||||
@@ -933,6 +934,7 @@ export function createAdmitWakeBehindIssueExecution(deps: {
|
||||
reason: input.reason ?? null,
|
||||
payload: {
|
||||
...(input.payload ?? {}),
|
||||
issueId: input.issueId,
|
||||
coalescedIntoWakeupRequestId: existingDeferred.id,
|
||||
},
|
||||
requestedByActorType: input.requestedByActorType,
|
||||
|
||||
Reference in new issue
Block a user