refactor(server): move the release half of the deferred wake state machine into a wake-queue module (#13132)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The heartbeat service releases deferred issue wakes and promotes the
next run
> - The release logic sat in a large service function, which made its
decisions and database effects hard to test
> - A promotion race could reopen an issue without creating the promoted
run
> - Company checks did not protect every read and write, and some
readers used a second transaction connection
> - This pull request moves the release logic into a layered wake-queue
module and closes these race and company-scope defects
> - The benefit is clearer decisions, safer writes, and focused tests
while existing callers keep the same entry point

## Linked Issues or Issue Description

Refs: #10195

## What Changed

- Move the release half of deferred issue execution from `heartbeat.ts`
into `server/src/modules/wake-queue/`.
- Add pure policy decisions with table-driven tests.
- Claim a wake before the reopen write and advance to the next wake when
the claim fails.
- Add company predicates to guarded reads and writes.
- Pass the transaction-scoped issue snapshot to reader ports.
- Keep `releaseIssueExecutionAndPromote` as the public wrapper.

## Verification

- Run `pnpm check:module-boundaries`.
- Run `pnpm exec tsc --noEmit` inside `server/` and compare the result
with the known baseline.
- Run the wake-queue unit and adapter tests in continuous integration.
- Check the promotion-claim ordering, guarded writes, transaction-scoped
reads, and cross-company outcomes.
- Search the pull request diff for internal issue identifiers.

## Risks

- The refactor changes the transaction path for deferred wake release.
- A stale or lost wake claim now skips that wake and continues with the
next queued wake.
- The public wrapper keeps its name and signature, which limits caller
risk.
- Continuous integration must confirm the full server test suite and
build.

## Model Used

OpenAI Codex, GPT-5, exact runtime model version not exposed, context
window not exposed, with shell, Git, and GitHub tool use.

## 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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky Leach 2026-09-09 21:01:26 -07:00 committed by GitHub
parent 018ca5daaf
commit 6dd48cad43
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 3896 additions and 1574 deletions

View File

@ -619,152 +619,6 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
}
}, 120_000);
it("cancels an empty deferred comment wake instead of promoting deleted input", async () => {
const gateway = await createControlledGatewayServer();
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
const heartbeat = heartbeatService(db);
try {
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Gateway Agent",
role: "engineer",
status: "idle",
adapterType: "openclaw_gateway",
adapterConfig: {
url: gateway.url,
headers: { "x-openclaw-token": "gateway-token" },
payloadTemplate: { message: "wake now" },
waitTimeoutMs: 2_000,
},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Discard deferred follow-up",
status: "todo",
priority: "medium",
responsibleUserId: "responsible-user",
assigneeAgentId: agentId,
issueNumber: 1,
identifier: `${issuePrefix}-1`,
});
const firstComment = await db
.insert(issueComments)
.values({
companyId,
issueId,
authorUserId: "user-1",
body: "First comment",
})
.returning()
.then((rows) => rows[0]);
const firstRun = await heartbeat.wakeup(agentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
payload: { issueId, commentId: firstComment.id },
contextSnapshot: {
issueId,
taskId: issueId,
commentId: firstComment.id,
wakeReason: "issue_commented",
},
requestedByActorType: "user",
requestedByActorId: "user-1",
});
expect(firstRun).not.toBeNull();
await waitFor(async () => {
const current = await db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, firstRun!.id))
.then((rows) => rows[0] ?? null);
return current?.status === "running";
});
const discardedComment = await db
.insert(issueComments)
.values({
companyId,
issueId,
authorUserId: "user-1",
body: "Delete this before the current turn finishes",
})
.returning()
.then((rows) => rows[0]);
expect(await heartbeat.wakeup(agentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
payload: { issueId, commentId: discardedComment.id },
contextSnapshot: {
issueId,
taskId: issueId,
commentId: discardedComment.id,
wakeReason: "issue_commented",
},
requestedByActorType: "user",
requestedByActorId: "user-1",
})).toBeNull();
await waitFor(async () => db
.select({ id: agentWakeupRequests.id })
.from(agentWakeupRequests)
.where(and(
eq(agentWakeupRequests.companyId, companyId),
eq(agentWakeupRequests.agentId, agentId),
eq(agentWakeupRequests.status, "deferred_issue_execution"),
))
.then((rows) => Boolean(rows[0])));
const deferredWake = await db
.select()
.from(agentWakeupRequests)
.where(and(
eq(agentWakeupRequests.companyId, companyId),
eq(agentWakeupRequests.agentId, agentId),
eq(agentWakeupRequests.status, "deferred_issue_execution"),
))
.then((rows) => rows[0]);
if (!deferredWake) throw new Error("Expected a deferred comment wake");
await db.delete(issueComments).where(eq(issueComments.id, discardedComment.id));
gateway.releaseFirstWait();
await waitFor(async () => {
const wake = await db
.select({ status: agentWakeupRequests.status })
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.id, deferredWake.id))
.then((rows) => rows[0] ?? null);
return wake?.status === "cancelled";
}, 90_000);
await heartbeat.drainActiveRunExecutions();
expect(gateway.getAgentPayloads()).toHaveLength(1);
const runs = await db
.select({ id: heartbeatRuns.id })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, agentId));
expect(runs.map((run) => run.id)).toEqual([firstRun!.id]);
} finally {
gateway.releaseFirstWait();
await heartbeat.drainActiveRunExecutions();
await gateway.close();
}
}, 120_000);
it("retains deferred comments for reconciliation after cancelling an unknown provider outcome", async () => {
const gateway = await createControlledGatewayServer();
const companyId = randomUUID();
@ -1312,363 +1166,6 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
}
}, 120_000);
it("cancels a deferred wake containing only a comment authored by the closing run", async () => {
const gateway = await createControlledGatewayServer();
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
const heartbeat = heartbeatService(db);
try {
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Local CLI Agent",
role: "engineer",
status: "idle",
adapterType: "openclaw_gateway",
adapterConfig: {
url: gateway.url,
headers: {
"x-openclaw-token": "gateway-token",
},
payloadTemplate: {
message: "wake now",
},
waitTimeoutMs: 2_000,
},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Self-comment must not reopen",
status: "todo",
priority: "medium",
responsibleUserId: "responsible-user",
assigneeAgentId: agentId,
issueNumber: 1,
identifier: `${issuePrefix}-1`,
});
const firstRun = await heartbeat.wakeup(agentId, {
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
payload: { issueId },
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: "issue_assigned",
},
requestedByActorType: "system",
requestedByActorId: null,
});
expect(firstRun).not.toBeNull();
await waitFor(async () => {
const run = await db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, firstRun!.id))
.then((rows) => rows[0] ?? null);
return run?.status === "running";
});
await waitFor(() => gateway.getAgentPayloads().length === 1);
// Local-CLI agents post comments under user auth, but stamp the heartbeat
// run id on each comment via createdByRunId. Simulate that here: a "user"
// comment that was actually authored by the run that is about to close
// the issue. Without the Path A guard this would trigger a reopen.
const selfComment = await db
.insert(issueComments)
.values({
companyId,
issueId,
authorUserId: "local-cli-user",
createdByRunId: firstRun?.id ?? null,
body: "Closing comment from the same run",
})
.returning()
.then((rows) => rows[0]);
const deferredRun = await heartbeat.wakeup(agentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
payload: { issueId, commentId: selfComment.id },
contextSnapshot: {
issueId,
taskId: issueId,
commentId: selfComment.id,
wakeCommentId: selfComment.id,
wakeReason: "issue_commented",
},
requestedByActorType: "user",
requestedByActorId: "local-cli-user",
});
expect(deferredRun).toBeNull();
await waitFor(async () => {
const deferred = await db
.select()
.from(agentWakeupRequests)
.where(
and(
eq(agentWakeupRequests.companyId, companyId),
eq(agentWakeupRequests.agentId, agentId),
eq(agentWakeupRequests.status, "deferred_issue_execution"),
),
)
.then((rows) => rows[0] ?? null);
return Boolean(deferred);
});
// Running records admission. Wait for provider acceptance before
// simulating completion by that provider, or startup correctly rejects
// the already-closed task before this scenario reaches its follow-up.
await waitFor(() => gateway.getAgentPayloads().length >= 1);
await db
.update(issues)
.set({
status: "done",
completedAt: new Date(),
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
updatedAt: new Date(),
})
.where(eq(issues.id, issueId));
gateway.releaseFirstWait();
await waitFor(async () => {
const run = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, firstRun!.id))
.then((rows) => rows[0] ?? null);
const deferred = await db
.select()
.from(agentWakeupRequests)
.where(
and(
eq(agentWakeupRequests.companyId, companyId),
eq(agentWakeupRequests.agentId, agentId),
),
)
.then((rows) => rows.find((request) => request.status === "cancelled") ?? null);
return (
run?.status === "succeeded" &&
deferred?.error ===
"Deferred wake contained only comments authored by the finishing run"
);
}, 90_000);
expect(gateway.getAgentPayloads()).toHaveLength(1);
const runs = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, agentId));
expect(runs).toHaveLength(1);
const issueAfterPromotion = await db
.select({
status: issues.status,
completedAt: issues.completedAt,
})
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
expect(issueAfterPromotion).toMatchObject({
status: "done",
});
expect(issueAfterPromotion?.completedAt).not.toBeNull();
} finally {
gateway.releaseFirstWait();
await gateway.close();
}
}, 120_000);
it("promotes an interaction continuation after removing a coalesced self-authored comment", async () => {
const gateway = await createControlledGatewayServer();
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const interactionId = randomUUID();
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
const heartbeat = heartbeatService(db);
try {
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Local CLI Agent",
role: "engineer",
status: "idle",
adapterType: "openclaw_gateway",
adapterConfig: {
url: gateway.url,
headers: {
"x-openclaw-token": "gateway-token",
},
payloadTemplate: {
message: "wake now",
},
waitTimeoutMs: 2_000,
},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Interaction continuation survives self-comment filtering",
status: "todo",
priority: "medium",
responsibleUserId: "responsible-user",
assigneeAgentId: agentId,
issueNumber: 1,
identifier: `${issuePrefix}-1`,
});
const firstRun = await heartbeat.wakeup(agentId, {
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
payload: { issueId },
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: "issue_assigned",
},
requestedByActorType: "system",
requestedByActorId: null,
});
expect(firstRun).not.toBeNull();
await waitFor(async () => {
const run = await db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, firstRun!.id))
.then((rows) => rows[0] ?? null);
return run?.status === "running";
});
const selfComment = await db
.insert(issueComments)
.values({
companyId,
issueId,
authorUserId: "local-cli-user",
createdByRunId: firstRun!.id,
body: "Completion note from the source run",
})
.returning()
.then((rows) => rows[0]);
expect(await heartbeat.wakeup(agentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
payload: { issueId, commentId: selfComment.id },
contextSnapshot: {
issueId,
taskId: issueId,
commentId: selfComment.id,
wakeCommentId: selfComment.id,
wakeReason: "issue_commented",
},
requestedByActorType: "user",
requestedByActorId: "local-cli-user",
})).toBeNull();
expect(await heartbeat.wakeup(agentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
payload: {
issueId,
interactionId,
interactionKind: "request_confirmation",
interactionStatus: "accepted",
mutation: "interaction",
},
contextSnapshot: {
issueId,
taskId: issueId,
interactionId,
interactionKind: "request_confirmation",
interactionStatus: "accepted",
wakeReason: "issue_commented",
source: "issue.interaction.respond",
},
requestedByActorType: "user",
requestedByActorId: "user-1",
})).toBeNull();
gateway.releaseFirstWait();
await waitFor(async () => {
const runs = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, agentId))
.orderBy(asc(heartbeatRuns.createdAt));
return (
runs.length === 2 &&
runs[0]?.status === "succeeded" &&
runs[1]?.status === "succeeded"
);
}, 90_000);
const promotedRun = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, agentId))
.orderBy(asc(heartbeatRuns.createdAt))
.then((runs) => runs[1] ?? null);
expect(promotedRun?.contextSnapshot).toMatchObject({
interactionId,
interactionKind: "request_confirmation",
interactionStatus: "accepted",
});
expect(promotedRun?.contextSnapshot).not.toMatchObject({
wakeCommentIds: expect.anything(),
});
expect(promotedRun?.contextSnapshot).not.toMatchObject({
commentId: selfComment.id,
});
expect(gateway.getAgentPayloads()).toHaveLength(2);
} finally {
gateway.releaseFirstWait();
await gateway.close();
}
}, 120_000);
it("still reopens a finished issue when a deferred batch mixes self-authored and human comments", async () => {
const gateway = await createControlledGatewayServer();
const companyId = randomUUID();
@ -1886,6 +1383,377 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
}
}, 120_000);
it("cancels a deferred comment wake when its only queued comment is deleted before promotion", async () => {
const gateway = await createControlledGatewayServer();
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
const heartbeat = heartbeatService(db);
try {
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Gateway Agent",
role: "engineer",
status: "idle",
adapterType: "openclaw_gateway",
adapterConfig: {
url: gateway.url,
headers: {
"x-openclaw-token": "gateway-token",
},
payloadTemplate: {
message: "wake now",
},
waitTimeoutMs: 2_000,
},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Deleted follow-up must not reopen",
status: "todo",
priority: "medium",
responsibleUserId: "responsible-user",
assigneeAgentId: agentId,
issueNumber: 1,
identifier: `${issuePrefix}-1`,
});
const firstRun = await heartbeat.wakeup(agentId, {
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
payload: { issueId },
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: "issue_assigned",
},
requestedByActorType: "system",
requestedByActorId: null,
});
expect(firstRun).not.toBeNull();
await waitFor(async () => {
const run = await db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, firstRun!.id))
.then((rows) => rows[0] ?? null);
return run?.status === "running";
});
await waitFor(() => gateway.getAgentPayloads().length === 1);
const queuedComment = await db
.insert(issueComments)
.values({
companyId,
issueId,
authorUserId: "user-1",
body: "Please look at this once you finish",
})
.returning()
.then((rows) => rows[0]);
const deferredRun = await heartbeat.wakeup(agentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
payload: { issueId, commentId: queuedComment.id },
contextSnapshot: {
issueId,
taskId: issueId,
commentId: queuedComment.id,
wakeCommentId: queuedComment.id,
wakeReason: "issue_commented",
},
requestedByActorType: "user",
requestedByActorId: "user-1",
});
expect(deferredRun).toBeNull();
await waitFor(async () => {
const deferred = await db
.select()
.from(agentWakeupRequests)
.where(
and(
eq(agentWakeupRequests.companyId, companyId),
eq(agentWakeupRequests.agentId, agentId),
eq(agentWakeupRequests.status, "deferred_issue_execution"),
),
)
.then((rows) => rows[0] ?? null);
return Boolean(deferred);
});
const deferredWake = await db
.select({ id: agentWakeupRequests.id })
.from(agentWakeupRequests)
.where(
and(
eq(agentWakeupRequests.companyId, companyId),
eq(agentWakeupRequests.agentId, agentId),
eq(agentWakeupRequests.status, "deferred_issue_execution"),
),
)
.then((rows) => rows[0] ?? null);
const deferredWakeId = deferredWake!.id;
// The author retracts the comment before the first run finishes, so the
// real comment-liveness query must find zero live comments left in the
// queued batch.
await db.update(issueComments).set({ deletedAt: new Date() }).where(eq(issueComments.id, queuedComment.id));
// Running records admission. Wait for provider acceptance before
// simulating completion by that provider, or startup correctly rejects
// the already-closed task before this scenario reaches its follow-up.
await waitFor(() => gateway.getAgentPayloads().length >= 1);
await db
.update(issues)
.set({
status: "done",
completedAt: new Date(),
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
updatedAt: new Date(),
})
.where(eq(issues.id, issueId));
gateway.releaseFirstWait();
await waitFor(async () => {
const [wake] = await db
.select({ status: agentWakeupRequests.status })
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.id, deferredWakeId));
return wake?.status === "cancelled";
}, 90_000);
const [cancelledWake] = await db
.select({ status: agentWakeupRequests.status, error: agentWakeupRequests.error })
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.id, deferredWakeId));
expect(cancelledWake).toMatchObject({
status: "cancelled",
error: "Queued messages were discarded before promotion",
});
// No live comment remained, so the queue must not promote a second run
// and the issue must stay in the state the first run's completion left it in.
expect(gateway.getAgentPayloads()).toHaveLength(1);
const issueAfterCompletion = await db
.select({ status: issues.status, completedAt: issues.completedAt })
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
expect(issueAfterCompletion).toMatchObject({ status: "done" });
expect(issueAfterCompletion?.completedAt).not.toBeNull();
} finally {
gateway.releaseFirstWait();
await gateway.close();
}
}, 120_000);
it("cancels a deferred comment wake when its only queued comment was authored by the finishing run", async () => {
const gateway = await createControlledGatewayServer();
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
const heartbeat = heartbeatService(db);
try {
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Local CLI Agent",
role: "engineer",
status: "idle",
adapterType: "openclaw_gateway",
adapterConfig: {
url: gateway.url,
headers: {
"x-openclaw-token": "gateway-token",
},
payloadTemplate: {
message: "wake now",
},
waitTimeoutMs: 2_000,
},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Self-authored note must not reopen",
status: "todo",
priority: "medium",
responsibleUserId: "responsible-user",
assigneeAgentId: agentId,
issueNumber: 1,
identifier: `${issuePrefix}-1`,
});
const firstRun = await heartbeat.wakeup(agentId, {
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
payload: { issueId },
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: "issue_assigned",
},
requestedByActorType: "system",
requestedByActorId: null,
});
expect(firstRun).not.toBeNull();
await waitFor(async () => {
const run = await db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, firstRun!.id))
.then((rows) => rows[0] ?? null);
return run?.status === "running";
});
const selfComment = await db
.insert(issueComments)
.values({
companyId,
issueId,
authorUserId: "local-cli-user",
createdByRunId: firstRun?.id ?? null,
body: "Closing note from the same run",
})
.returning()
.then((rows) => rows[0]);
const deferredRun = await heartbeat.wakeup(agentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
payload: { issueId, commentId: selfComment.id },
contextSnapshot: {
issueId,
taskId: issueId,
commentId: selfComment.id,
wakeCommentId: selfComment.id,
wakeReason: "issue_commented",
},
requestedByActorType: "user",
requestedByActorId: "local-cli-user",
});
expect(deferredRun).toBeNull();
await waitFor(async () => {
const deferred = await db
.select()
.from(agentWakeupRequests)
.where(
and(
eq(agentWakeupRequests.companyId, companyId),
eq(agentWakeupRequests.agentId, agentId),
eq(agentWakeupRequests.status, "deferred_issue_execution"),
),
)
.then((rows) => rows[0] ?? null);
return Boolean(deferred);
});
const queuedWake = await db
.select({ id: agentWakeupRequests.id })
.from(agentWakeupRequests)
.where(
and(
eq(agentWakeupRequests.companyId, companyId),
eq(agentWakeupRequests.agentId, agentId),
eq(agentWakeupRequests.status, "deferred_issue_execution"),
),
)
.then((rows) => rows[0] ?? null);
// Running records admission. Wait for provider acceptance before
// simulating completion by that provider, or startup correctly rejects
// the already-closed task before this scenario reaches its follow-up.
await waitFor(() => gateway.getAgentPayloads().length >= 1);
await db
.update(issues)
.set({
status: "done",
completedAt: new Date(),
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
updatedAt: new Date(),
})
.where(eq(issues.id, issueId));
gateway.releaseFirstWait();
await waitFor(async () => {
const [wake] = await db
.select({ status: agentWakeupRequests.status })
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.id, queuedWake!.id));
return wake?.status === "cancelled";
}, 90_000);
const [cancelledWake] = await db
.select({ status: agentWakeupRequests.status, error: agentWakeupRequests.error })
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.id, queuedWake!.id));
expect(cancelledWake).toMatchObject({
status: "cancelled",
error: "Deferred wake contained only comments authored by the finishing run",
});
// The only queued comment came from the run that just finished, so the
// queue must not promote a second run for the same agent to re-read its
// own note, and the issue must stay in the state the run's completion left it in.
expect(gateway.getAgentPayloads()).toHaveLength(1);
const issueAfterCompletion = await db
.select({ status: issues.status, completedAt: issues.completedAt })
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
expect(issueAfterCompletion).toMatchObject({ status: "done" });
expect(issueAfterCompletion?.completedAt).not.toBeNull();
} finally {
gateway.releaseFirstWait();
await gateway.close();
}
}, 120_000);
it("queues exactly one follow-up run when an issue-bound run exits without a comment", async () => {
const gateway = await createControlledGatewayServer();
const companyId = randomUUID();

View File

@ -0,0 +1,439 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
agentWakeupRequests,
agents,
companies,
createDb,
heartbeatRuns,
issueComments,
issues,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "../../../__tests__/helpers/embedded-postgres.js";
import { createPostgresWakeQueueAdapter } from "./postgres.js";
import type { WakeQueuePostgresAdapterDeps } from "./postgres.js";
// Proves the atomicity and company-scope properties the security review
// requires: every mutation names `companyId` in its own SQL `WHERE` clause,
// a foreign-company row is invisible to a read, and a deferred-status
// compare-and-set that affects no row leaves no other trace. The decision
// branching itself is proven against plain facts in `domain/policy.test.ts`.
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres wake-queue adapter tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("wake-queue postgres adapter", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
const stubDeps: WakeQueuePostgresAdapterDeps = {
resolveResponsibleUserId: async () => "responsible-user",
getRoutineEnv: async () => ({ routineId: null, env: null, responsibleUserId: null }),
resolveSessionBeforeForWakeup: async () => null,
};
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-wake-queue-postgres-adapter-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(issueComments);
// `heartbeat_runs.wakeup_request_id` references `agent_wakeup_requests.id`,
// so the run row must go first.
await db.delete(heartbeatRuns);
await db.delete(agentWakeupRequests);
await db.delete(issues);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedCompany(): Promise<string> {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
defaultResponsibleUserId: "responsible-user",
});
return companyId;
}
async function seedAgent(input: { companyId: string; name?: string }): Promise<string> {
const agentId = randomUUID();
await db.insert(agents).values({
id: agentId,
companyId: input.companyId,
name: input.name ?? "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } },
permissions: {},
});
return agentId;
}
async function seedIssue(input: {
companyId: string;
issueId?: string;
status?: string;
assigneeAgentId?: string | null;
executionRunId?: string | null;
checkoutRunId?: string | null;
}): Promise<string> {
const issueId = input.issueId ?? randomUUID();
await db.insert(issues).values({
id: issueId,
companyId: input.companyId,
title: "Wake-queue adapter fixture issue",
status: input.status ?? "in_progress",
priority: "medium",
assigneeAgentId: input.assigneeAgentId ?? null,
executionRunId: input.executionRunId ?? null,
checkoutRunId: input.checkoutRunId ?? null,
});
return issueId;
}
async function seedRun(input: {
companyId: string;
agentId: string;
status?: string;
contextSnapshot?: Record<string, unknown>;
errorCode?: string | null;
runtimeMode?: string;
}): Promise<string> {
const runId = randomUUID();
await db.insert(heartbeatRuns).values({
id: runId,
companyId: input.companyId,
agentId: input.agentId,
invocationSource: "on_demand",
status: input.status ?? "failed",
contextSnapshot: input.contextSnapshot ?? {},
errorCode: input.errorCode ?? null,
});
return runId;
}
async function seedDeferredWake(input: {
companyId: string;
agentId: string;
issueId: string;
requestedByActorType?: string;
requestedByActorId?: string | null;
payload?: Record<string, unknown>;
}): Promise<string> {
const id = randomUUID();
await db.insert(agentWakeupRequests).values({
id,
companyId: input.companyId,
agentId: input.agentId,
source: "automation",
reason: "issue_commented",
status: "deferred_issue_execution",
requestedByActorType: input.requestedByActorType ?? "user",
requestedByActorId: input.requestedByActorId ?? null,
payload: { issueId: input.issueId, ...(input.payload ?? {}) },
});
return id;
}
// Review test (a): a foreign-company agent id produces the current failed
// wake status and the current error text, and creates no run.
it("fails a deferred wake whose agent belongs to a different company, without creating a run", async () => {
const companyId = await seedCompany();
const otherCompanyId = await seedCompany();
const foreignAgentId = await seedAgent({ companyId: otherCompanyId });
const finishingAgentId = await seedAgent({ companyId });
const issueId = await seedIssue({ companyId, assigneeAgentId: finishingAgentId, status: "in_progress" });
// A finishing run status other than the legacy-reconciliation set (failed,
// timed_out, interrupted, cancelled) reaches the module's own drain logic.
const runId = await seedRun({ companyId, agentId: finishingAgentId, contextSnapshot: { issueId }, status: "succeeded" });
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
const wakeId = await seedDeferredWake({ companyId, agentId: foreignAgentId, issueId });
const adapter = createPostgresWakeQueueAdapter(db, stubDeps);
const result = await adapter.withIssueExecutionLock({ companyId, runId, now: new Date() }, async (locked, ports) => {
const candidate = await ports.writer.claimNextDeferredWake({ companyId, issueId: locked.primaryIssue.id });
expect(candidate?.id).toBe(wakeId);
const agent = await ports.reader.findInvokableAgent({ companyId, agentId: foreignAgentId });
expect(agent).toBeNull();
const failed = await ports.writer.failDeferredWake({ companyId, wakeId: candidate!.id, now: new Date() });
expect(failed).toBe(true);
return { outcome: { kind: "released" as const }, postCommitEffects: [] };
});
expect(result.outcome.kind).toBe("released");
const wakeRow = (await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, wakeId)))[0];
expect(wakeRow?.status).toBe("failed");
expect(wakeRow?.error).toBe("Deferred wake could not be promoted: agent is not invokable");
expect(wakeRow?.runId).toBeNull();
const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId));
expect(runs).toHaveLength(1);
expect(runs[0]!.id).toBe(runId);
});
// Review test (b): each release adapter mutation with a foreign company
// affects no row.
it("scopes every release mutation to its own company and affects no row across a company boundary", async () => {
const companyId = await seedCompany();
const otherCompanyId = await seedCompany();
const agentId = await seedAgent({ companyId });
const issueId = await seedIssue({ companyId, assigneeAgentId: agentId, status: "blocked" });
await db.update(issues).set({ executionState: { phase: "running" } }).where(eq(issues.id, issueId));
const wakeId = await seedDeferredWake({ companyId, agentId, issueId });
const runId = await seedRun({ companyId, agentId, contextSnapshot: { issueId }, status: "succeeded" });
const adapter = createPostgresWakeQueueAdapter(db, stubDeps);
await adapter.withIssueExecutionLock(
{ companyId, runId, now: new Date() },
async (_locked, ports) => {
const cancelledUnderWrongCompany = await ports.writer.cancelDeferredWake({
companyId: otherCompanyId,
wakeId,
reason: "cross-company cancel attempt",
now: new Date(),
});
expect(cancelledUnderWrongCompany).toBe(false);
const failedUnderWrongCompany = await ports.writer.failDeferredWake({
companyId: otherCompanyId,
wakeId,
now: new Date(),
});
expect(failedUnderWrongCompany).toBe(false);
const normalizedUnderWrongCompany = await ports.writer.normalizeDeferredWakeCommentIds({
companyId: otherCompanyId,
wakeId,
payload: { issueId },
liveCommentIds: ["c1"],
now: new Date(),
});
expect(normalizedUnderWrongCompany).toBeNull();
const reopenedUnderWrongCompany = await ports.writer.reopenIssue({
companyId: otherCompanyId,
issueId,
runId,
});
expect(reopenedUnderWrongCompany).toBeNull();
return { outcome: { kind: "released" as const }, postCommitEffects: [] };
},
);
const wakeRow = (await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, wakeId)))[0];
expect(wakeRow?.status).toBe("deferred_issue_execution");
expect(wakeRow?.error).toBeNull();
const issueRow = (await db.select().from(issues).where(eq(issues.id, issueId)))[0];
expect(issueRow?.status).toBe("blocked");
expect(issueRow?.executionState).toEqual({ phase: "running" });
});
// Review defect: the reopen path must carry the company into every read,
// lock, and write. A check before the write is not a boundary, because
// `issues.company_id` can change between that check and the write.
it("refuses to reopen an issue for a company that does not own it, and leaves the issue untouched", async () => {
const companyId = await seedCompany();
const otherCompanyId = await seedCompany();
const agentId = await seedAgent({ companyId });
const issueId = await seedIssue({ companyId, assigneeAgentId: agentId, status: "blocked" });
await db.update(issues).set({ executionState: { phase: "running" } }).where(eq(issues.id, issueId));
const runId = await seedRun({ companyId, agentId, contextSnapshot: { issueId }, status: "succeeded" });
const adapter = createPostgresWakeQueueAdapter(db, stubDeps);
const captured: { reopened: { status: string; executionState: Record<string, unknown> | null } | null } = {
reopened: null,
};
await adapter.withIssueExecutionLock({ companyId, runId, now: new Date() }, async (_locked, ports) => {
captured.reopened = await ports.writer.reopenIssue({ companyId: otherCompanyId, issueId, runId });
return { outcome: { kind: "released" as const }, postCommitEffects: [] };
});
expect(captured.reopened).toBeNull();
const issueRow = (await db.select().from(issues).where(eq(issues.id, issueId)))[0];
expect(issueRow?.status).toBe("blocked");
expect(issueRow?.executionState).toEqual({ phase: "running" });
});
it("reopens an issue for the company that owns it, and clears the execution state", async () => {
const companyId = await seedCompany();
const agentId = await seedAgent({ companyId });
const issueId = await seedIssue({ companyId, assigneeAgentId: agentId, status: "blocked" });
await db.update(issues).set({ executionState: { phase: "running" } }).where(eq(issues.id, issueId));
const runId = await seedRun({ companyId, agentId, contextSnapshot: { issueId }, status: "succeeded" });
const adapter = createPostgresWakeQueueAdapter(db, stubDeps);
const captured: { reopened: { status: string; executionState: Record<string, unknown> | null } | null } = {
reopened: null,
};
await adapter.withIssueExecutionLock({ companyId, runId, now: new Date() }, async (_locked, ports) => {
captured.reopened = await ports.writer.reopenIssue({ companyId, issueId, runId });
return { outcome: { kind: "released" as const }, postCommitEffects: [] };
});
expect(captured.reopened?.status).toBe("todo");
expect(captured.reopened?.executionState).toBeNull();
const issueRow = (await db.select().from(issues).where(eq(issues.id, issueId)))[0];
expect(issueRow?.status).toBe("todo");
expect(issueRow?.executionState).toBeNull();
});
// Review test (c): a deferred-status compare-and-set that affects no row
// claims nothing, and no other write in the promotion path ever runs.
it("fails the promotion claim when the deferred-status compare-and-set loses the race, before any other write", async () => {
const companyId = await seedCompany();
const agentId = await seedAgent({ companyId });
const issueId = await seedIssue({ companyId, assigneeAgentId: agentId });
const wakeId = await seedDeferredWake({ companyId, agentId, issueId });
// A concurrent finalization already claimed this wake before the promotion claim runs.
await db.update(agentWakeupRequests).set({ status: "cancelled" }).where(eq(agentWakeupRequests.id, wakeId));
const adapter = createPostgresWakeQueueAdapter(db, stubDeps);
const runId = await seedRun({ companyId, agentId, contextSnapshot: { issueId }, status: "succeeded" });
const result = await adapter.withIssueExecutionLock({ companyId, runId, now: new Date() }, async (_locked, ports) => {
const claimed = await ports.writer.claimDeferredWakeForPromotion({ companyId, wakeId, now: new Date() });
expect(claimed).toBe(false);
return { outcome: { kind: "released" as const }, postCommitEffects: [] };
});
expect(result.outcome.kind).toBe("released");
const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId));
expect(runs).toHaveLength(1);
expect(runs[0]!.id).toBe(runId);
const issueRow = (await db.select().from(issues).where(eq(issues.id, issueId)))[0];
expect(issueRow?.executionRunId).toBeNull();
const wakeRow = (await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, wakeId)))[0];
expect(wakeRow?.status).toBe("cancelled");
});
// `finalizePromotedWake`'s own writes guard against clobbering state a
// concurrent write already changed: it never takes the issue's execution
// lock away from a run that already holds it, and it never overwrites a
// `runId` a wake row already carries. Both guards only matter as defense
// in depth today (the release drain calls this at most once per
// transaction), so this drives the port directly to prove the SQL itself,
// independent of that call pattern.
it("guards finalizePromotedWake's own writes against clobbering an already-set execution lock or runId", async () => {
const companyId = await seedCompany();
const agentId = await seedAgent({ companyId });
const issueId = await seedIssue({ companyId, assigneeAgentId: agentId });
const wakeIdA = await seedDeferredWake({ companyId, agentId, issueId });
const wakeIdB = await seedDeferredWake({ companyId, agentId, issueId });
const runId = await seedRun({ companyId, agentId, contextSnapshot: { issueId }, status: "succeeded" });
const deferredAgent = { id: agentId, companyId, name: "CodexCoder", invokable: true };
const finalizedRunIds: string[] = [];
const adapter = createPostgresWakeQueueAdapter(db, stubDeps);
await adapter.withIssueExecutionLock({ companyId, runId, now: new Date() }, async (locked, ports) => {
const finalize = async (wakeId: string) => {
const promoted = await ports.writer.finalizePromotedWake({
companyId,
wakeId,
deferredAgent,
issue: locked.primaryIssue,
finishingRun: locked.run,
contextSnapshot: { issueId },
reason: "issue_execution_promoted",
source: "automation",
triggerDetail: null,
payload: {},
responsibleUserId: "responsible-user",
sessionBefore: null,
now: new Date(),
});
finalizedRunIds.push(promoted.id);
};
// The issue's execution lock is free; this call takes it.
await finalize(wakeIdA);
// The issue's execution lock is already held by the first call's run,
// so this call's issue-lock write must no-op even though a run is
// still created.
await finalize(wakeIdB);
// Repeating the same wake must not overwrite its now-set `runId`.
await finalize(wakeIdA);
return { outcome: { kind: "released" as const }, postCommitEffects: [] };
});
const [runA, runB, runC] = finalizedRunIds;
const wakeRowA = (await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, wakeIdA)))[0];
const wakeRowB = (await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, wakeIdB)))[0];
expect(wakeRowA?.runId).toBe(runA);
expect(wakeRowB?.runId).toBe(runB);
const issueRow = (await db.select().from(issues).where(eq(issues.id, issueId)))[0];
expect(issueRow?.executionRunId).toBe(runA);
expect(issueRow?.executionRunId).not.toBe(runB);
expect(issueRow?.executionRunId).not.toBe(runC);
const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId));
// All three finalize calls each still insert their own run row.
expect(runs.map((run) => run.id).sort()).toEqual([runId, runA, runB, runC].sort());
});
it("locks the context issue and every sibling issue in id order, and two concurrent releases do not deadlock", async () => {
const companyId = await seedCompany();
const agentId = await seedAgent({ companyId });
const issueA = await seedIssue({ companyId, assigneeAgentId: agentId });
const issueB = await seedIssue({ companyId, assigneeAgentId: agentId });
const runA = await seedRun({ companyId, agentId, contextSnapshot: { issueId: issueA } });
const runB = await seedRun({ companyId, agentId, contextSnapshot: { issueId: issueB } });
await db.update(issues).set({ executionRunId: runA, checkoutRunId: runB }).where(eq(issues.id, issueA));
await db.update(issues).set({ executionRunId: runB, checkoutRunId: runA }).where(eq(issues.id, issueB));
const adapterA = createPostgresWakeQueueAdapter(db, stubDeps);
const adapterB = createPostgresWakeQueueAdapter(db, stubDeps);
const releaseA = adapterA.withIssueExecutionLock({ companyId, runId: runA, now: new Date() }, async (_locked, _ports) => {
await new Promise((resolve) => setTimeout(resolve, 25));
return { outcome: { kind: "released" as const }, postCommitEffects: [] };
});
const releaseB = adapterB.withIssueExecutionLock({ companyId, runId: runB, now: new Date() }, async (_locked, _ports) => {
await new Promise((resolve) => setTimeout(resolve, 25));
return { outcome: { kind: "released" as const }, postCommitEffects: [] };
});
await expect(Promise.all([releaseA, releaseB])).resolves.toBeDefined();
const rows = await db.select().from(issues).where(eq(issues.companyId, companyId));
for (const row of rows) {
expect(row.executionRunId).toBeNull();
expect(row.checkoutRunId).toBeNull();
}
});
it("clears both lock columns on every sibling and keeps a transferred executionRunId", async () => {
const companyId = await seedCompany();
const agentId = await seedAgent({ companyId });
const finishingRunId = await seedRun({ companyId, agentId, contextSnapshot: {} });
const retryRunId = await seedRun({ companyId, agentId, contextSnapshot: {}, status: "queued" });
const issueId = await seedIssue({ companyId, assigneeAgentId: agentId, executionRunId: retryRunId, checkoutRunId: finishingRunId });
const adapter = createPostgresWakeQueueAdapter(db, stubDeps);
const result = await adapter.withIssueExecutionLock({ companyId, runId: finishingRunId, now: new Date() }, async () => ({
outcome: { kind: "released" as const },
postCommitEffects: [],
}));
expect(result.outcome.kind).toBe("released");
const issueRow = (await db.select().from(issues).where(eq(issues.id, issueId)))[0];
// executionRunId already pointed at the retry, not the finishing run, so it must survive.
expect(issueRow?.executionRunId).toBe(retryRunId);
expect(issueRow?.checkoutRunId).toBeNull();
});
});

View File

@ -0,0 +1,817 @@
import { and, asc, eq, inArray, isNull, notInArray, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agentWakeupRequests,
agents,
heartbeatRuns,
issueComments,
issueRecoveryActions,
issueRelations,
issues,
nativeRunFinalizations,
} from "@paperclipai/db";
import { legacyExecutionNeedsReconciliation } from "../../../services/legacy-execution-recovery.js";
import { evaluateAgentInvokability } from "../../../services/agent-invokability.js";
import { issueTreeControlService, isVerifiedIssueTreeControlInteractionWake } from "../../../services/issue-tree-control.js";
import { isAutomaticRecoverySuppressedByPauseHold } from "../../../services/recovery/pause-hold-guard.js";
import { issueService } from "../../../services/issues.js";
import { issueRecoveryActionService } from "../../../services/issue-recovery-actions.js";
import { readContinuationAttempt } from "../../../services/recovery/run-liveness-continuations.js";
import { withRecoveryContext } from "../../../services/recovery/status-only-context.js";
import { parseIssueExecutionState } from "../../../services/issue-execution-policy.js";
import {
buildConfigurationIncompleteRecoveryNoticeSeed,
buildExecutionReviewParticipantRecoveryNoticeSeed,
buildImmediateExecutionPathRecoveryNoticeSeed,
buildWorkspaceValidationRecoveryNoticeSeed,
} from "../../../services/recovery/stranded-notice.js";
import {
queuedCommentIdsFromWakePayload,
withQueuedCommentIdsInWakePayload,
} from "../../../services/issue-queued-comment-queue.js";
import { extractWakeCommentIds } from "../../run-dispatch/index.js";
import { hasInteractionContinuationWakeContext } from "../domain/context.js";
import type {
DeferredWakeCandidate,
InvokableAgentSnapshot,
IssueLockWriter,
IssueSnapshot,
LockedIssueExecution,
ReleaseTransactionResult,
RunSnapshot,
WakeQueueReader,
WakeQueueWriter,
} from "../application/ports.js";
import type { RunSummary } from "../application/types.js";
import { WakeQueueApplicationError } from "../application/types.js";
const DEFERRED_WAKE_STATUS = "deferred_issue_execution";
const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext";
const WORKSPACE_VALIDATION_FAILURE_CODE = "workspace_validation_failed";
const CONFIGURATION_INCOMPLETE_FAILURE_CODE = "configuration_incomplete";
const WORKSPACE_VALIDATION_RECOVERY_CAUSE = "workspace_validation_failed";
const CONFIGURATION_INCOMPLETE_RECOVERY_CAUSE = "configuration_incomplete";
const EXECUTION_REVIEW_PARTICIPANT_RECOVERY_CAUSE = "execution_review_participant_recovery";
const EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON = "execution_review_participant_recovery";
const EXECUTION_REVIEW_PARTICIPANT_RECOVERY_RETRY_REASON = "execution_review_participant_recovery";
const EXECUTION_PATH_HEARTBEAT_RUN_STATUSES = ["queued", "running", "scheduled_retry"] as const;
type HeartbeatRunRow = typeof heartbeatRuns.$inferSelect;
type IssueRow = typeof issues.$inferSelect;
function parseObject(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
function normalizeAgentNameKey(value: string | null | undefined): string | null {
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
return normalized.length > 0 ? normalized : null;
}
function isWorkspaceValidationFailedRun(run: Pick<HeartbeatRunRow, "errorCode">): boolean {
return run.errorCode === WORKSPACE_VALIDATION_FAILURE_CODE;
}
function isConfigurationIncompleteFailedRun(run: Pick<HeartbeatRunRow, "errorCode">): boolean {
return run.errorCode === CONFIGURATION_INCOMPLETE_FAILURE_CODE || run.errorCode === "model_not_found";
}
function toRunSnapshot(row: HeartbeatRunRow): RunSnapshot {
const configurationIncompletePayload = parseObject(parseObject(row.resultJson).configurationIncomplete);
return {
id: row.id,
companyId: row.companyId,
agentId: row.agentId,
status: row.status,
runtimeMode: row.runtimeMode,
errorCode: row.errorCode,
responsibleUserId: row.responsibleUserId,
contextSnapshot: parseObject(row.contextSnapshot),
configurationIncompletePayload: Object.keys(configurationIncompletePayload).length > 0 ? configurationIncompletePayload : null,
};
}
function toIssueSnapshot(row: IssueRow): IssueSnapshot {
return {
id: row.id,
companyId: row.companyId,
identifier: row.identifier ?? "",
status: row.status,
assigneeAgentId: row.assigneeAgentId,
assigneeUserId: row.assigneeUserId,
hiddenAt: row.hiddenAt,
originKind: row.originKind,
monitorNextCheckAt: row.monitorNextCheckAt,
executionState: (row.executionState as Record<string, unknown> | null) ?? null,
responsibleUserId: row.responsibleUserId,
parentId: row.parentId,
originId: row.originId,
originRunId: row.originRunId,
};
}
function toRunSummary(row: HeartbeatRunRow): RunSummary {
return {
id: row.id,
companyId: row.companyId,
agentId: row.agentId,
invocationSource: row.invocationSource,
triggerDetail: row.triggerDetail,
wakeupRequestId: row.wakeupRequestId,
};
}
function toDeferredWakeCandidate(row: typeof agentWakeupRequests.$inferSelect): DeferredWakeCandidate {
const payload = parseObject(row.payload);
const queuedCommentIds = queuedCommentIdsFromWakePayload(payload);
const deferredContextSeed = parseObject(payload[DEFERRED_WAKE_CONTEXT_KEY]);
const deferredCommentIds = extractWakeCommentIds(deferredContextSeed);
const wakeReason = readNonEmptyString(deferredContextSeed.wakeReason);
const queuedReason = wakeReason ?? readNonEmptyString(row.reason);
const queuedWakeIsCommentOnly =
!queuedReason ||
queuedReason === "issue_commented" ||
queuedReason === "issue_reopened_via_comment" ||
queuedReason === "issue_comment_mentioned";
const preservesIndependentContinuation =
hasInteractionContinuationWakeContext(deferredContextSeed) ||
deferredContextSeed.resumeIntent === true ||
!queuedWakeIsCommentOnly;
return {
id: row.id,
companyId: row.companyId,
agentId: row.agentId,
reason: row.reason,
source: row.source,
triggerDetail: row.triggerDetail,
requestedByActorType: row.requestedByActorType,
requestedByActorId: row.requestedByActorId,
payload,
queuedCommentIds,
preservesIndependentContinuation,
deferredContextSeed,
deferredCommentIds,
wakeReason,
};
}
export type WakeQueuePostgresAdapterDeps = {
resolveResponsibleUserId: WakeQueueReader["resolveResponsibleUserId"];
getRoutineEnv: WakeQueueReader["getRoutineEnv"];
resolveSessionBeforeForWakeup: WakeQueueReader["resolveSessionBeforeForWakeup"];
};
function buildReader(tx: Db, deps: WakeQueuePostgresAdapterDeps): WakeQueueReader {
return {
async findInvokableAgent({ companyId, agentId }): Promise<InvokableAgentSnapshot | null> {
const agent = await tx
.select()
.from(agents)
.where(and(eq(agents.id, agentId), eq(agents.companyId, companyId)))
.then((rows) => rows[0] ?? null);
if (!agent) return null;
const companyAgents = await tx
.select({ id: agents.id, companyId: agents.companyId, name: agents.name, reportsTo: agents.reportsTo, status: agents.status })
.from(agents)
.where(eq(agents.companyId, companyId));
const invokability = evaluateAgentInvokability(agent, companyAgents);
return { id: agent.id, companyId: agent.companyId, name: agent.name, invokable: invokability.invokable };
},
resolveResponsibleUserId: deps.resolveResponsibleUserId,
getRoutineEnv: deps.getRoutineEnv,
resolveSessionBeforeForWakeup: deps.resolveSessionBeforeForWakeup,
};
}
function buildWriter(tx: Db, deps: WakeQueuePostgresAdapterDeps): WakeQueueWriter {
const treeControlSvc = issueTreeControlService(tx);
const issuesSvc = issueService(tx);
return {
async claimNextDeferredWake({ companyId, issueId }) {
const row = await tx
.select()
.from(agentWakeupRequests)
.where(
and(
eq(agentWakeupRequests.companyId, companyId),
eq(agentWakeupRequests.status, DEFERRED_WAKE_STATUS),
sql`${agentWakeupRequests.payload} ->> 'issueId' = ${issueId}`,
),
)
.orderBy(asc(agentWakeupRequests.requestedAt))
.limit(1)
.then((rows) => rows[0] ?? null);
return row ? toDeferredWakeCandidate(row) : null;
},
async getQueuedCommentLiveness({ companyId, issueId, wakeAgentId, finishingRunId, finishingRunAgentId, queuedCommentIds }) {
const rows = await tx
.select({ id: issueComments.id, deletedAt: issueComments.deletedAt, createdByRunId: issueComments.createdByRunId })
.from(issueComments)
.where(and(eq(issueComments.companyId, companyId), eq(issueComments.issueId, issueId), inArray(issueComments.id, queuedCommentIds)));
const targetsFinishingRunAgent = wakeAgentId === finishingRunAgentId;
const liveNonSelfCommentIds = queuedCommentIds.filter((commentId) => {
const row = rows.find((candidate) => candidate.id === commentId);
return Boolean(row && !row.deletedAt && (!targetsFinishingRunAgent || row.createdByRunId !== finishingRunId));
});
const containedSelfAuthoredComment = rows.some(
(row) => targetsFinishingRunAgent && !row.deletedAt && row.createdByRunId === finishingRunId,
);
return { liveNonSelfCommentIds, containedSelfAuthoredComment };
},
async cancelDeferredWake({ companyId, wakeId, reason, now }) {
const rows = await tx
.update(agentWakeupRequests)
.set({ status: "cancelled", finishedAt: now, error: reason, updatedAt: now })
.where(
and(
eq(agentWakeupRequests.id, wakeId),
eq(agentWakeupRequests.companyId, companyId),
eq(agentWakeupRequests.status, DEFERRED_WAKE_STATUS),
),
)
.returning({ id: agentWakeupRequests.id });
return rows.length > 0;
},
async normalizeDeferredWakeCommentIds({ companyId, wakeId, payload, liveCommentIds, now }) {
const rows = await tx
.update(agentWakeupRequests)
.set({ payload: withQueuedCommentIdsInWakePayload(payload, liveCommentIds), updatedAt: now })
.where(
and(
eq(agentWakeupRequests.id, wakeId),
eq(agentWakeupRequests.companyId, companyId),
eq(agentWakeupRequests.status, DEFERRED_WAKE_STATUS),
),
)
.returning();
const row = rows[0];
return row ? toDeferredWakeCandidate(row) : null;
},
async failDeferredWake({ companyId, wakeId, now }) {
const rows = await tx
.update(agentWakeupRequests)
.set({
status: "failed",
finishedAt: now,
error: "Deferred wake could not be promoted: agent is not invokable",
updatedAt: now,
})
.where(
and(
eq(agentWakeupRequests.id, wakeId),
eq(agentWakeupRequests.companyId, companyId),
eq(agentWakeupRequests.status, DEFERRED_WAKE_STATUS),
),
)
.returning({ id: agentWakeupRequests.id });
return rows.length > 0;
},
async getPauseHoldFacts({ companyId, issueId, wakeAgentId, deferredContextSeed, requestedByActorType, requestedByActorId }) {
const activePauseHold = await treeControlSvc.getActivePauseHoldGate(companyId, issueId);
if (!activePauseHold) {
return {
activePauseHold: false,
treeHoldInteractionWake: false,
holdId: null,
rootIssueId: null,
mode: null,
reason: null,
releasePolicy: null,
};
}
const treeHoldInteractionWake = await isVerifiedIssueTreeControlInteractionWake(tx, {
companyId,
issueId,
agentId: wakeAgentId,
contextSnapshot: deferredContextSeed,
requestedByActorType,
requestedByActorId,
});
return {
activePauseHold: true,
treeHoldInteractionWake,
holdId: activePauseHold.holdId,
rootIssueId: activePauseHold.rootIssueId,
mode: activePauseHold.mode,
reason: activePauseHold.reason,
releasePolicy: activePauseHold.releasePolicy,
};
},
async getCommentSelfAuthorship({ companyId, issueId, finishingRunId, commentIds }) {
const rows = await tx
.select({ createdByRunId: issueComments.createdByRunId })
.from(issueComments)
.where(and(eq(issueComments.companyId, companyId), eq(issueComments.issueId, issueId), inArray(issueComments.id, commentIds)));
return { allSelfAuthored: rows.length > 0 && rows.every((row) => row.createdByRunId === finishingRunId) };
},
async reopenIssue({ companyId, issueId }) {
const updated = await issuesSvc.updateForCompany(issueId, companyId, { status: "todo", executionState: null }, tx);
return updated ? toIssueSnapshot(updated as unknown as IssueRow) : null;
},
async claimDeferredWakeForPromotion({ companyId, wakeId, now }) {
const claimed = await tx
.update(agentWakeupRequests)
.set({
status: "queued",
reason: "issue_execution_promoted",
claimedAt: null,
finishedAt: null,
error: null,
updatedAt: now,
})
.where(
and(
eq(agentWakeupRequests.id, wakeId),
eq(agentWakeupRequests.companyId, companyId),
eq(agentWakeupRequests.status, DEFERRED_WAKE_STATUS),
),
)
.returning({ id: agentWakeupRequests.id });
return claimed.length > 0;
},
async finalizePromotedWake(input) {
const newRun = await tx
.insert(heartbeatRuns)
.values({
companyId: input.deferredAgent.companyId,
agentId: input.deferredAgent.id,
invocationSource: input.source,
triggerDetail: input.triggerDetail,
status: "queued",
wakeupRequestId: input.wakeId,
contextSnapshot: input.contextSnapshot,
responsibleUserId: input.responsibleUserId,
sessionIdBefore: input.sessionBefore,
continuationAttempt: readContinuationAttempt(input.contextSnapshot.livenessContinuationAttempt),
})
.returning()
.then((rows) => rows[0]);
// `claimDeferredWakeForPromotion` already moved this row off
// `deferred_issue_execution` inside this same transaction, so no
// concurrent claimer can still match that guard; this extra `runId is
// null` guard only protects against writing the link twice.
await tx
.update(agentWakeupRequests)
.set({ runId: newRun.id, updatedAt: input.now })
.where(
and(
eq(agentWakeupRequests.id, input.wakeId),
eq(agentWakeupRequests.companyId, input.companyId),
isNull(agentWakeupRequests.runId),
),
);
// Promoted mention wakes are issue-scoped, not issue ownership
// transfers. The lock-clearing step earlier in this transaction
// already set `executionRunId` to null for this issue, so the `is
// null` guard only protects against taking the lock twice.
await tx
.update(issues)
.set({
executionRunId: newRun.id,
executionAgentNameKey: normalizeAgentNameKey(input.deferredAgent.name),
executionLockedAt: input.now,
updatedAt: input.now,
})
.where(
and(
eq(issues.id, input.issue.id),
eq(issues.companyId, input.companyId),
eq(issues.assigneeAgentId, input.deferredAgent.id),
isNull(issues.executionRunId),
),
);
return toRunSummary(newRun);
},
async hasExistingExecutionPath({ companyId, issueId, excludeRunId, agentId }) {
const row = await tx
.select({ id: heartbeatRuns.id })
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.companyId, companyId),
inArray(heartbeatRuns.status, [...EXECUTION_PATH_HEARTBEAT_RUN_STATUSES]),
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`,
sql`${heartbeatRuns.id} <> ${excludeRunId}`,
agentId ? eq(heartbeatRuns.agentId, agentId) : sql`true`,
),
)
.limit(1)
.then((rows) => rows[0] ?? null);
return row !== null;
},
async hasExplicitBlockerPath({ companyId, issueId }) {
const row = await tx
.select({ issueId: issueRelations.issueId })
.from(issueRelations)
.innerJoin(issues, eq(issueRelations.issueId, issues.id))
.where(
and(
eq(issueRelations.companyId, companyId),
eq(issueRelations.relatedIssueId, issueId),
eq(issueRelations.type, "blocks"),
eq(issues.companyId, companyId),
notInArray(issues.status, ["done", "cancelled"]),
isNull(issues.hiddenAt),
),
)
.limit(1)
.then((rows) => rows[0] ?? null);
return row !== null;
},
async isAutomaticRecoverySuppressedByPauseHold({ companyId, issueId }) {
return isAutomaticRecoverySuppressedByPauseHold(tx, companyId, issueId, treeControlSvc);
},
async buildBlockedRecoveryNotice({ noticeKind, issueStatus, finishingRun }) {
if (noticeKind === "workspace_validation") {
return { notice: buildWorkspaceValidationRecoveryNoticeSeed(), recoveryCause: WORKSPACE_VALIDATION_RECOVERY_CAUSE };
}
if (noticeKind === "configuration_incomplete") {
return {
notice: buildConfigurationIncompleteRecoveryNoticeSeed(finishingRun.configurationIncompletePayload),
recoveryCause: CONFIGURATION_INCOMPLETE_RECOVERY_CAUSE,
};
}
if (noticeKind === "execution_review_participant") {
return {
notice: buildExecutionReviewParticipantRecoveryNoticeSeed(),
recoveryCause: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_CAUSE,
};
}
return { notice: buildImmediateExecutionPathRecoveryNoticeSeed({ status: issueStatus }), recoveryCause: null };
},
async queueReviewParticipantRecoveryRun({ companyId, issue, finishingRun, recoveryAgent, sessionBefore, now }) {
const executionState = parseIssueExecutionState(issue.executionState);
const wakeupRequest = await tx
.insert(agentWakeupRequests)
.values({
companyId,
agentId: recoveryAgent.id,
source: "automation",
triggerDetail: "system",
reason: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON,
payload: withRecoveryContext(
{
issueId: issue.id,
retryOfRunId: finishingRun.id,
retryReason: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_RETRY_REASON,
currentStageId: executionState?.currentStageId ?? null,
currentStageType: executionState?.currentStageType ?? null,
},
"normal_model",
),
status: "queued",
requestedByActorType: "system",
requestedByActorId: null,
updatedAt: now,
})
.returning()
.then((rows) => rows[0]);
const queuedRun = await tx
.insert(heartbeatRuns)
.values({
companyId,
agentId: recoveryAgent.id,
invocationSource: "automation",
triggerDetail: "system",
status: "queued",
wakeupRequestId: wakeupRequest.id,
contextSnapshot: withRecoveryContext(
{
issueId: issue.id,
taskId: issue.id,
wakeReason: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON,
retryReason: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_RETRY_REASON,
source: "issue.execution_review_recovery",
retryOfRunId: finishingRun.id,
currentStageId: executionState?.currentStageId ?? null,
currentStageType: executionState?.currentStageType ?? null,
reviewRecoveryInstruction:
"The previous reviewer run ended while this execution-review stage was still pending. Submit the review decision now, or mark the issue blocked with the exact unblock action.",
},
"normal_model",
),
sessionIdBefore: sessionBefore,
retryOfRunId: finishingRun.id,
updatedAt: now,
})
.returning()
.then((rows) => rows[0]);
await tx
.update(agentWakeupRequests)
.set({ runId: queuedRun.id, updatedAt: now })
.where(and(eq(agentWakeupRequests.id, wakeupRequest.id), eq(agentWakeupRequests.companyId, companyId)));
await tx
.update(issues)
.set({
executionRunId: queuedRun.id,
executionAgentNameKey: normalizeAgentNameKey(recoveryAgent.name),
executionLockedAt: now,
updatedAt: now,
})
.where(and(eq(issues.id, issue.id), eq(issues.companyId, companyId)));
return toRunSummary(queuedRun);
},
async queueImmediateRecoveryRun({ companyId, issue, finishingRun, recoveryAgent, sessionBefore, now }) {
const retryReason = issue.status === "todo" ? "assignment_recovery" : "issue_continuation_needed";
const recoveryReason = issue.status === "todo" ? "issue_assignment_recovery" : "issue_continuation_needed";
const recoverySource = issue.status === "todo" ? "issue.assignment_recovery" : "issue.continuation_recovery";
const recoveryContextSnapshot = withRecoveryContext(
{
issueId: issue.id,
taskId: issue.id,
wakeReason: recoveryReason,
retryReason,
source: recoverySource,
retryOfRunId: finishingRun.id,
},
"normal_model",
);
const routineEnvContext = await deps.getRoutineEnv({ companyId, issue });
const responsibleUserId = await deps.resolveResponsibleUserId({
companyId,
contextSnapshot: recoveryContextSnapshot,
issue,
routineEnvContext,
requestedByActorType: "system",
requestedByActorId: null,
source: "automation",
triggerDetail: "system",
existingRunResponsibleUserId: finishingRun.responsibleUserId,
});
if (!responsibleUserId) {
throw new WakeQueueApplicationError(
"responsible_user_unresolved",
"Unable to resolve responsible user for recovery heartbeat run",
{
runId: finishingRun.id,
agentId: recoveryAgent.id,
companyId,
issueId: issue.id,
wakeReason: recoveryReason,
},
);
}
const wakeupRequest = await tx
.insert(agentWakeupRequests)
.values({
companyId,
agentId: recoveryAgent.id,
source: "automation",
triggerDetail: "system",
reason: recoveryReason,
payload: withRecoveryContext({ issueId: issue.id, retryOfRunId: finishingRun.id }, "normal_model"),
status: "queued",
requestedByActorType: "system",
requestedByActorId: null,
updatedAt: now,
})
.returning()
.then((rows) => rows[0]);
const queuedRun = await tx
.insert(heartbeatRuns)
.values({
companyId,
agentId: recoveryAgent.id,
invocationSource: "automation",
triggerDetail: "system",
status: "queued",
wakeupRequestId: wakeupRequest.id,
contextSnapshot: recoveryContextSnapshot,
responsibleUserId,
sessionIdBefore: sessionBefore,
retryOfRunId: finishingRun.id,
updatedAt: now,
})
.returning()
.then((rows) => rows[0]);
await tx
.update(agentWakeupRequests)
.set({ runId: queuedRun.id, updatedAt: now })
.where(and(eq(agentWakeupRequests.id, wakeupRequest.id), eq(agentWakeupRequests.companyId, companyId)));
await tx
.update(issues)
.set({
executionRunId: queuedRun.id,
executionAgentNameKey: normalizeAgentNameKey(recoveryAgent.name),
executionLockedAt: now,
updatedAt: now,
})
.where(and(eq(issues.id, issue.id), eq(issues.companyId, companyId)));
return toRunSummary(queuedRun);
},
};
}
async function recordNativeTerminalRecoveryIfNeeded(tx: Db, run: HeartbeatRunRow, issue: IssueRow, now: Date): Promise<boolean> {
const applies =
run.runtimeMode === "native" &&
["failed", "timed_out", "interrupted", "cancelled"].includes(run.status) &&
issue.assigneeAgentId === run.agentId &&
!["done", "cancelled"].includes(issue.status);
if (!applies) return false;
const existing = await tx
.select({ id: issueRecoveryActions.id })
.from(issueRecoveryActions)
.where(
and(
eq(issueRecoveryActions.companyId, issue.companyId),
eq(issueRecoveryActions.sourceIssueId, issue.id),
or(
inArray(issueRecoveryActions.status, ["active", "escalated"]),
sql`${issueRecoveryActions.evidence}->'automaticRecovery'->>'runId' = ${run.id}`,
),
),
)
.limit(1);
if (!existing.length) {
await tx
.update(nativeRunFinalizations)
.set({
phase: "terminal_failure",
leaseOwner: null,
leaseExpiresAt: null,
nextAttemptAt: null,
recoveryState: "blocked",
failureCode: "native_continuation_requires_reconciliation",
updatedAt: now,
})
.where(
and(
eq(nativeRunFinalizations.companyId, issue.companyId),
eq(nativeRunFinalizations.runId, run.id),
isNull(nativeRunFinalizations.resultId),
),
);
await issueRecoveryActionService(tx).upsertSourceScoped({
companyId: issue.companyId,
sourceIssueId: issue.id,
kind: "active_run_watchdog",
ownerType: "board",
returnOwnerAgentId: run.agentId,
cause: "native_continuation_requires_reconciliation",
fingerprint: `native-continuation:${run.id}`,
evidence: { runId: run.id, originalFailureCode: run.errorCode },
nextAction:
"Inspect the original failure and reconcile the previous execution before continuing. Automatic recovery cannot start another incident.",
maxAttempts: 3,
wakePolicy: null,
supersedeOnIdentityChange: true,
});
}
return true;
}
export function createPostgresWakeQueueAdapter(db: Db, deps: WakeQueuePostgresAdapterDeps): IssueLockWriter {
return {
async withIssueExecutionLock(input, fn): Promise<ReleaseTransactionResult & { run: RunSnapshot }> {
return db.transaction(async (rawTx) => {
const tx = rawTx as unknown as Db;
const run = await tx
.select()
.from(heartbeatRuns)
.where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId)))
.then((rows) => rows[0] ?? null);
if (!run) {
throw new Error(`wake-queue: run ${input.runId} was not found while releasing issue execution`);
}
const runSnapshot = toRunSnapshot(run);
const contextIssueId = readNonEmptyString(parseObject(run.contextSnapshot).issueId);
// Lock the context issue (if any) and every issue that still references this
// run, in id order, so two concurrent finalizations can never deadlock on
// each other's row-lock acquisition order.
await tx.execute(
contextIssueId
? sql`
select id from issues
where company_id = ${input.companyId}
and (
id = ${contextIssueId}
or execution_run_id = ${run.id}
or checkout_run_id = ${run.id}
)
order by id
for update
`
: sql`
select id from issues
where company_id = ${input.companyId}
and (execution_run_id = ${run.id} or checkout_run_id = ${run.id})
order by id
for update
`,
);
const candidateIssues = await tx
.select()
.from(issues)
.where(
and(
eq(issues.companyId, input.companyId),
contextIssueId
? or(eq(issues.id, contextIssueId), eq(issues.executionRunId, run.id), eq(issues.checkoutRunId, run.id))
: or(eq(issues.executionRunId, run.id), eq(issues.checkoutRunId, run.id)),
),
)
.orderBy(asc(issues.id));
// Two separate updates: a retry can move `executionRunId` to a new run
// while `checkoutRunId` still points at this one finishing.
await tx
.update(issues)
.set({ executionRunId: null, executionAgentNameKey: null, executionLockedAt: null, updatedAt: input.now })
.where(and(eq(issues.companyId, input.companyId), eq(issues.executionRunId, run.id)));
await tx
.update(issues)
.set({ checkoutRunId: null, updatedAt: input.now })
.where(and(eq(issues.companyId, input.companyId), eq(issues.checkoutRunId, run.id)));
const issueRow =
(contextIssueId ? candidateIssues.find((candidate) => candidate.id === contextIssueId) : candidateIssues[0]) ?? null;
if (!issueRow || (issueRow.executionRunId && issueRow.executionRunId !== run.id)) {
return { outcome: { kind: "released" }, postCommitEffects: [], run: runSnapshot };
}
if (
(isWorkspaceValidationFailedRun(run) || isConfigurationIncompleteFailedRun(run)) &&
(issueRow.status === "todo" || issueRow.status === "in_progress") &&
!issueRow.assigneeUserId &&
issueRow.assigneeAgentId === run.agentId
) {
const configurationIncomplete = isConfigurationIncompleteFailedRun(run);
const notice = configurationIncomplete
? buildConfigurationIncompleteRecoveryNoticeSeed(runSnapshot.configurationIncompletePayload)
: buildWorkspaceValidationRecoveryNoticeSeed();
return {
outcome: {
kind: "blocked",
issue: toIssueSnapshot(issueRow),
previousStatus: issueRow.status as "todo" | "in_progress",
notice,
recoveryCause: configurationIncomplete ? CONFIGURATION_INCOMPLETE_RECOVERY_CAUSE : WORKSPACE_VALIDATION_RECOVERY_CAUSE,
},
postCommitEffects: [],
run: runSnapshot,
};
}
if (legacyExecutionNeedsReconciliation(run)) {
return { outcome: { kind: "released" }, postCommitEffects: [], run: runSnapshot };
}
// An operator stop never promotes old queued work by itself. The next
// explicit wake adopts those messages atomically when it queues a run.
if (run.status === "cancelled" && parseObject(run.resultJson?.executionCancellation).state === "acknowledged") {
return { outcome: { kind: "released" }, postCommitEffects: [], run: runSnapshot };
}
if (await recordNativeTerminalRecoveryIfNeeded(tx, run, issueRow, input.now)) {
return { outcome: { kind: "released" }, postCommitEffects: [], run: runSnapshot };
}
const locked: LockedIssueExecution = { primaryIssue: toIssueSnapshot(issueRow), run: runSnapshot };
const result = await fn(locked, { reader: buildReader(tx, deps), writer: buildWriter(tx, deps) });
return { ...result, run: runSnapshot };
});
},
};
}

View File

@ -0,0 +1,244 @@
import type { ReleaseRecoveryBlockedNoticeKind } from "../domain/policy.js";
import type {
InvokableAgentSnapshot,
IssueSnapshot,
PostCommitEffect,
ReleaseOutcome,
RunSnapshot,
RunSummary,
} from "./types.js";
export type { InvokableAgentSnapshot, IssueSnapshot, RunSnapshot, RunSummary };
/** The primary issue a locked release resolves to, plus the finishing run the lock step already loaded. */
export type LockedIssueExecution = {
primaryIssue: IssueSnapshot;
run: RunSnapshot;
};
export type ReleaseTransactionResult = {
outcome: ReleaseOutcome;
postCommitEffects: PostCommitEffect[];
};
/** Read-only lookups the release use case needs, each scoped to a company. */
export interface WakeQueueReader {
findInvokableAgent(input: { companyId: string; agentId: string }): Promise<InvokableAgentSnapshot | null>;
/**
* Takes the transaction-scoped issue snapshot, not an issue id, so this
* port never re-reads the issue on a separate connection while the
* module's own transaction is open.
*/
resolveResponsibleUserId(input: {
companyId: string;
contextSnapshot: Record<string, unknown>;
issue: IssueSnapshot;
/** From a prior `getRoutineEnv` call against the same issue; pass `{ routineId: null, env: null, responsibleUserId: null }` when the issue is not a routine execution. */
routineEnvContext: { routineId: string | null; env: unknown; responsibleUserId: string | null };
requestedByActorType: "user" | "agent" | "system" | null;
requestedByActorId: string | null;
source: string;
triggerDetail: string | null;
existingRunResponsibleUserId: string | null;
}): Promise<string | null>;
/**
* Takes the transaction-scoped issue snapshot, not an issue id, so this
* port never re-reads the issue on a separate connection while the
* module's own transaction is open.
*/
getRoutineEnv(input: {
companyId: string;
issue: IssueSnapshot;
}): Promise<{ routineId: string | null; env: unknown; responsibleUserId: string | null }>;
resolveSessionBeforeForWakeup(input: {
companyId: string;
agentId: string;
taskKey: string | null;
}): Promise<string | null>;
}
export type DeferredWakeCandidate = {
id: string;
companyId: string;
agentId: string;
reason: string | null;
source: string | null;
triggerDetail: string | null;
requestedByActorType: string | null;
requestedByActorId: string | null;
payload: Record<string, unknown>;
/** The queued comment ids the wake's queued-comment context carries, already extracted from the payload. */
queuedCommentIds: string[];
/** True when the wake carries an independent reason to continue even with no live queued comments. */
preservesIndependentContinuation: boolean;
/** `payload._paperclipWakeContext`, already parsed to a plain object. */
deferredContextSeed: Record<string, unknown>;
/** The comment ids the wake's context snapshot carries (a separate set from queuedCommentIds), used for the reopen check. */
deferredCommentIds: string[];
wakeReason: string | null;
};
export type PromoteDeferredWakeInput = {
companyId: string;
wakeId: string;
deferredAgent: InvokableAgentSnapshot;
issue: IssueSnapshot;
finishingRun: RunSnapshot;
contextSnapshot: Record<string, unknown>;
reason: string;
source: string;
triggerDetail: string | null;
payload: Record<string, unknown>;
responsibleUserId: string;
sessionBefore: string | null;
now: Date;
};
/** The transaction-scoped write operations that drain and resolve the deferred-wake queue. */
export interface WakeQueueWriter {
claimNextDeferredWake(input: { companyId: string; issueId: string }): Promise<DeferredWakeCandidate | null>;
getQueuedCommentLiveness(input: {
companyId: string;
issueId: string;
wakeAgentId: string;
finishingRunId: string;
finishingRunAgentId: string;
queuedCommentIds: string[];
}): Promise<{ liveNonSelfCommentIds: string[]; containedSelfAuthoredComment: boolean }>;
/** Cancels the wake with `status = 'deferred_issue_execution'` as an atomic compare-and-set guard. */
cancelDeferredWake(input: {
companyId: string;
wakeId: string;
reason: string;
now: Date;
}): Promise<boolean>;
normalizeDeferredWakeCommentIds(input: {
companyId: string;
wakeId: string;
/** The wake's current payload, as already read by `claimNextDeferredWake`, used as the rewrite base. */
payload: Record<string, unknown>;
liveCommentIds: string[];
now: Date;
}): Promise<DeferredWakeCandidate | null>;
/** Sets `status = 'failed'` guarded by the current `deferred_issue_execution` status. */
failDeferredWake(input: { companyId: string; wakeId: string; now: Date }): Promise<boolean>;
getPauseHoldFacts(input: {
companyId: string;
issueId: string;
wakeAgentId: string;
deferredContextSeed: Record<string, unknown>;
requestedByActorType: string | null;
requestedByActorId: string | null;
}): Promise<{
activePauseHold: boolean;
treeHoldInteractionWake: boolean;
holdId: string | null;
rootIssueId: string | null;
mode: string | null;
reason: string | null;
releasePolicy: unknown;
}>;
getCommentSelfAuthorship(input: {
companyId: string;
issueId: string;
finishingRunId: string;
commentIds: string[];
}): Promise<{ allSelfAuthored: boolean }>;
reopenIssue(input: { companyId: string; issueId: string; runId: string }): Promise<IssueSnapshot | null>;
/**
* Atomically claims the wake for promotion, guarded on its current
* `deferred_issue_execution` status. Call this before any other write in
* the promotion path (including a reopen), so a lost race here can never
* leave another write committed underneath it. Returns `false` when a
* concurrent writer already changed the wake's status.
*/
claimDeferredWakeForPromotion(input: { companyId: string; wakeId: string; now: Date }): Promise<boolean>;
/**
* Finalizes a wake that `claimDeferredWakeForPromotion` already claimed:
* inserts the queued run, links it back onto the wake row, and takes the
* issue's execution lock. Call only after that claim returns `true`.
*/
finalizePromotedWake(input: PromoteDeferredWakeInput): Promise<RunSummary>;
/** An open run already on this issue (optionally scoped to one agent) that would race a new recovery run. */
hasExistingExecutionPath(input: {
companyId: string;
issueId: string;
excludeRunId: string;
agentId: string | null;
}): Promise<boolean>;
/** An open, non-hidden issue that still lists this issue as a `blocks` predecessor. */
hasExplicitBlockerPath(input: { companyId: string; issueId: string }): Promise<boolean>;
isAutomaticRecoverySuppressedByPauseHold(input: { companyId: string; issueId: string }): Promise<boolean>;
/** Builds the stranded-recovery notice content for a `blocked` outcome; pure formatting, kept behind the writer so `services/recovery/stranded-notice` stays out of the application layer. */
buildBlockedRecoveryNotice(input: {
noticeKind: ReleaseRecoveryBlockedNoticeKind;
issueStatus: "todo" | "in_progress";
finishingRun: RunSnapshot;
}): Promise<{ notice: Record<string, unknown>; recoveryCause: string | null }>;
queueReviewParticipantRecoveryRun(input: {
companyId: string;
issue: IssueSnapshot;
finishingRun: RunSnapshot;
recoveryAgent: InvokableAgentSnapshot;
sessionBefore: string | null;
now: Date;
}): Promise<RunSummary>;
/**
* Builds the recovery context snapshot, resolves the responsible user
* from it, and queues the run. Throws `WakeQueueApplicationError` with
* code `responsible_user_unresolved` when no responsible user resolves,
* without queuing anything.
*/
queueImmediateRecoveryRun(input: {
companyId: string;
issue: IssueSnapshot;
finishingRun: RunSnapshot;
recoveryAgent: InvokableAgentSnapshot;
sessionBefore: string | null;
now: Date;
}): Promise<RunSummary>;
}
/**
* Owns the module's own transaction: loads the finishing run, locks the
* context issue and every sibling issue in id order, clears the two
* release-lock columns, and picks the primary issue. When the primary
* issue is missing, already reclaimed, or resolved by an early exit
* (workspace-validation block, legacy reconciliation, a native-runtime
* terminal failure), the adapter returns that outcome directly without
* calling `fn`. Otherwise it calls `fn` with the locked issue and run, and
* with `reader`/`writer` ports bound to the same transaction, so every
* call `fn` makes through them participates in the one transaction this
* method owns.
*/
export interface IssueLockWriter {
withIssueExecutionLock(
input: { companyId: string; runId: string; now: Date },
fn: (
locked: LockedIssueExecution,
ports: { reader: WakeQueueReader; writer: WakeQueueWriter },
) => Promise<ReleaseTransactionResult>,
): Promise<ReleaseTransactionResult & { run: RunSnapshot }>;
}
export type StrandedAssignedIssueEscalationInput = {
issue: IssueSnapshot;
previousStatus: "todo" | "in_progress" | "in_review";
latestRun: RunSnapshot;
notice: Record<string, unknown>;
recoveryCause: string | null;
};
export type StrandedRecoveryInPlaceEscalationInput = {
issue: IssueSnapshot;
previousStatus: "todo" | "in_progress" | "in_review";
latestRun: RunSnapshot;
};
/** Wraps `services/recovery`'s stranded-issue escalation, called only after the release transaction commits. */
export interface RecoveryEscalationPort {
escalateStrandedAssignedIssue(input: StrandedAssignedIssueEscalationInput): Promise<void>;
escalateStrandedRecoveryIssueInPlace(input: StrandedRecoveryInPlaceEscalationInput): Promise<void>;
}
export type { PostCommitEffect, ReleaseOutcome };

View File

@ -0,0 +1,99 @@
export type RunSummary = {
id: string;
companyId: string;
agentId: string;
invocationSource: string;
triggerDetail: string | null;
wakeupRequestId: string | null;
};
export type RunSnapshot = {
id: string;
companyId: string;
agentId: string;
status: string;
runtimeMode: string | null;
errorCode: string | null;
responsibleUserId: string | null;
/** The run's own context snapshot, kept as plain JSON so ports carry no drizzle types. */
contextSnapshot: Record<string, unknown>;
/** `resultJson.configurationIncomplete`, already parsed; non-null only on a configuration-incomplete failed run. */
configurationIncompletePayload: Record<string, unknown> | null;
};
export type IssueSnapshot = {
id: string;
companyId: string;
identifier: string;
status: string;
assigneeAgentId: string | null;
assigneeUserId: string | null;
hiddenAt: Date | null;
originKind: string | null;
monitorNextCheckAt: Date | null;
executionState: Record<string, unknown> | null;
/** Carried so the routine-env and responsible-user reader ports can use this
* transaction-scoped snapshot instead of reading the issue again. */
responsibleUserId: string | null;
parentId: string | null;
originId: string | null;
originRunId: string | null;
};
export type InvokableAgentSnapshot = {
id: string;
companyId: string;
name: string | null;
invokable: boolean;
};
/** A new heartbeat run reached the queued state and should be published and dispatched. */
export type RunQueuedEffect = {
kind: "run_queued";
run: RunSummary;
};
/** An issue reopened from done/cancelled because a live deferred comment wake promoted on it. */
export type IssueReopenedEffect = {
kind: "issue_reopened";
companyId: string;
agentId: string;
runId: string;
issueId: string;
identifier: string;
reopenedFrom: string;
};
/** Explicit post-commit work a caller applies only after the release transaction commits. */
export type PostCommitEffect = RunQueuedEffect | IssueReopenedEffect;
export type ReleaseOutcome =
| { kind: "released" }
| { kind: "promoted"; run: RunSummary }
| { kind: "queued_review_participant_recovery"; run: RunSummary }
| { kind: "queued_recovery"; run: RunSummary }
| {
kind: "blocked";
issue: IssueSnapshot;
previousStatus: "todo" | "in_progress" | "in_review";
notice: Record<string, unknown>;
recoveryCause: string | null;
}
| {
kind: "blocked_recovery_in_place";
issue: IssueSnapshot;
previousStatus: "todo" | "in_progress" | "in_review";
};
export type WakeQueueApplicationErrorCode = "responsible_user_unresolved";
export class WakeQueueApplicationError extends Error {
constructor(
readonly code: WakeQueueApplicationErrorCode,
message: string,
readonly details: Record<string, unknown> = {},
) {
super(message);
this.name = "WakeQueueApplicationError";
}
}

View File

@ -0,0 +1,331 @@
import { describe, expect, it, vi } from "vitest";
import { createReleaseIssueExecution } from "./use-cases.js";
import { WakeQueueApplicationError } from "./types.js";
import type {
DeferredWakeCandidate,
InvokableAgentSnapshot,
IssueLockWriter,
IssueSnapshot,
PromoteDeferredWakeInput,
RecoveryEscalationPort,
RunSnapshot,
RunSummary,
WakeQueueReader,
WakeQueueWriter,
} from "./ports.js";
const RUN: RunSnapshot = {
id: "run-1",
companyId: "company-1",
agentId: "finishing-agent",
status: "failed",
runtimeMode: "process",
errorCode: null,
responsibleUserId: "user-1",
contextSnapshot: {},
configurationIncompletePayload: null,
};
const ISSUE: IssueSnapshot = {
id: "issue-1",
companyId: "company-1",
identifier: "PAP-1",
status: "in_progress",
assigneeAgentId: "finishing-agent",
assigneeUserId: null,
hiddenAt: null,
originKind: null,
monitorNextCheckAt: null,
executionState: null,
responsibleUserId: null,
parentId: null,
originId: null,
originRunId: null,
};
const AGENT: InvokableAgentSnapshot = {
id: "deferred-agent",
companyId: "company-1",
name: "Deferred Agent",
invokable: true,
};
function wakeCandidate(overrides: Partial<DeferredWakeCandidate> = {}): DeferredWakeCandidate {
return {
id: overrides.id ?? "wake-1",
companyId: "company-1",
agentId: "deferred-agent",
reason: "issue_commented",
source: "automation",
triggerDetail: null,
requestedByActorType: "user",
requestedByActorId: "actor-1",
payload: {},
queuedCommentIds: [],
preservesIndependentContinuation: false,
deferredContextSeed: {},
deferredCommentIds: [],
wakeReason: "issue_commented",
...overrides,
};
}
function runSummary(id: string): RunSummary {
return {
id,
companyId: "company-1",
agentId: "deferred-agent",
invocationSource: "automation",
triggerDetail: null,
wakeupRequestId: `wakeup-${id}`,
};
}
function createFakeReader(overrides: Partial<WakeQueueReader> = {}): WakeQueueReader {
return {
findInvokableAgent: vi.fn(async () => AGENT),
resolveResponsibleUserId: vi.fn(async () => "user-1"),
getRoutineEnv: vi.fn(async () => ({ routineId: null, env: null, responsibleUserId: null })),
resolveSessionBeforeForWakeup: vi.fn(async () => null),
...overrides,
};
}
function createFakeWriter(overrides: Partial<WakeQueueWriter> = {}): WakeQueueWriter {
return {
claimNextDeferredWake: vi.fn(async () => null),
getQueuedCommentLiveness: vi.fn(async () => ({ liveNonSelfCommentIds: [], containedSelfAuthoredComment: false })),
cancelDeferredWake: vi.fn(async () => true),
normalizeDeferredWakeCommentIds: vi.fn(async (input) => wakeCandidate({ id: input.wakeId, queuedCommentIds: input.liveCommentIds })),
failDeferredWake: vi.fn(async () => true),
getPauseHoldFacts: vi.fn(async () => ({
activePauseHold: false,
treeHoldInteractionWake: false,
holdId: null,
rootIssueId: null,
mode: null,
reason: null,
releasePolicy: null,
})),
getCommentSelfAuthorship: vi.fn(async () => ({ allSelfAuthored: false })),
reopenIssue: vi.fn(async () => null),
claimDeferredWakeForPromotion: vi.fn(async () => true),
finalizePromotedWake: vi.fn(async (input) => runSummary(input.wakeId)),
hasExistingExecutionPath: vi.fn(async () => false),
hasExplicitBlockerPath: vi.fn(async () => false),
isAutomaticRecoverySuppressedByPauseHold: vi.fn(async () => false),
buildBlockedRecoveryNotice: vi.fn(async () => ({ notice: {}, recoveryCause: null })),
queueReviewParticipantRecoveryRun: vi.fn(async () => runSummary("review-recovery")),
queueImmediateRecoveryRun: vi.fn(async () => runSummary("immediate-recovery")),
...overrides,
};
}
function createFakeIssueLock(reader: WakeQueueReader, writer: WakeQueueWriter): IssueLockWriter {
return {
withIssueExecutionLock: vi.fn(async (_input, fn) => {
const result = await fn({ primaryIssue: ISSUE, run: RUN }, { reader, writer });
return { ...result, run: RUN };
}),
};
}
function createFakeRecovery(): RecoveryEscalationPort {
return {
escalateStrandedAssignedIssue: vi.fn(async () => {}),
escalateStrandedRecoveryIssueInPlace: vi.fn(async () => {}),
};
}
describe("releaseIssueExecution", () => {
it("processes the deferred wakes in requestedAt order", async () => {
const claimOrder: string[] = [];
const queue = [wakeCandidate({ id: "wake-earliest" }), wakeCandidate({ id: "wake-latest" })];
const writer = createFakeWriter({
claimNextDeferredWake: vi.fn(async () => {
const next = queue.shift() ?? null;
if (next) claimOrder.push(next.id);
return next;
}),
});
// Every wake fails invokability so the loop keeps draining without promoting.
const reader = createFakeReader({ findInvokableAgent: vi.fn(async () => null) });
const issueLock = createFakeIssueLock(reader, writer);
const releaseIssueExecution = createReleaseIssueExecution({ issueLock, recovery: createFakeRecovery() });
await releaseIssueExecution({ companyId: "company-1", runId: "run-1", now: new Date() });
expect(claimOrder).toEqual(["wake-earliest", "wake-latest"]);
expect(writer.failDeferredWake).toHaveBeenCalledTimes(2);
});
it("stops the loop after the first promotion", async () => {
const claimNextDeferredWake = vi.fn(async () => wakeCandidate({ id: "wake-promotes" }));
const writer = createFakeWriter({ claimNextDeferredWake });
const reader = createFakeReader();
const issueLock = createFakeIssueLock(reader, writer);
const releaseIssueExecution = createReleaseIssueExecution({ issueLock, recovery: createFakeRecovery() });
const result = await releaseIssueExecution({ companyId: "company-1", runId: "run-1", now: new Date() });
expect(result.outcome.kind).toBe("promoted");
expect(claimNextDeferredWake).toHaveBeenCalledTimes(1);
});
it("continues the loop after a cancel outcome, a fail outcome, and a normalize outcome, then promotes", async () => {
const queue = [
// cancel_empty: queued comments, none live, no independent continuation.
wakeCandidate({ id: "wake-cancel", queuedCommentIds: ["c1"] }),
// fail_not_invokable: agent lookup misses for this one wake only.
wakeCandidate({ id: "wake-fail", agentId: "uninvokable-agent" }),
// normalize: queued comments differ from the live set, then promotes.
wakeCandidate({ id: "wake-normalize", queuedCommentIds: ["c1", "c2"] }),
];
const claimNextDeferredWake = vi.fn(async () => queue.shift() ?? null);
const findInvokableAgent = vi.fn(async (input: { agentId: string }) =>
input.agentId === "deferred-agent" ? AGENT : null,
);
const getQueuedCommentLiveness = vi.fn(async (input: { queuedCommentIds: string[] }) =>
input.queuedCommentIds.length === 1
? { liveNonSelfCommentIds: [], containedSelfAuthoredComment: false }
: { liveNonSelfCommentIds: ["c2"], containedSelfAuthoredComment: false },
);
const writer = createFakeWriter({ claimNextDeferredWake, getQueuedCommentLiveness });
const reader = createFakeReader({ findInvokableAgent });
const issueLock = createFakeIssueLock(reader, writer);
const releaseIssueExecution = createReleaseIssueExecution({ issueLock, recovery: createFakeRecovery() });
const result = await releaseIssueExecution({ companyId: "company-1", runId: "run-1", now: new Date() });
expect(writer.cancelDeferredWake).toHaveBeenCalledTimes(1);
expect(writer.failDeferredWake).toHaveBeenCalledTimes(1);
expect(writer.normalizeDeferredWakeCommentIds).toHaveBeenCalledTimes(1);
expect(claimNextDeferredWake).toHaveBeenCalledTimes(3);
expect(result.outcome.kind).toBe("promoted");
});
it("returns the post-commit effects as data without running them", async () => {
const writer = createFakeWriter({ claimNextDeferredWake: vi.fn(async () => wakeCandidate()) });
const reader = createFakeReader();
const issueLock = createFakeIssueLock(reader, writer);
const recovery = createFakeRecovery();
const releaseIssueExecution = createReleaseIssueExecution({ issueLock, recovery });
const result = await releaseIssueExecution({ companyId: "company-1", runId: "run-1", now: new Date() });
expect(result.postCommitEffects).toEqual([{ kind: "run_queued", run: runSummary("wake-1") }]);
expect(recovery.escalateStrandedAssignedIssue).not.toHaveBeenCalled();
expect(recovery.escalateStrandedRecoveryIssueInPlace).not.toHaveBeenCalled();
});
it("carries the deferred wake's raw issue, interaction, execution-stage, and accepted-plan context onto the promoted run, and clears only the rendered text projections", async () => {
const finalizePromotedWake = vi.fn(async (input: PromoteDeferredWakeInput) => runSummary(input.wakeId));
const writer = createFakeWriter({
claimNextDeferredWake: vi.fn(async () =>
wakeCandidate({
deferredContextSeed: {
issueId: ISSUE.id,
wakeCommentIds: ["comment-1"],
// A queue-time render from a prior coalesced run. Promotion must
// not persist this alongside the current (unrelated) comment id.
paperclipTaskMarkdown: "queue-time markdown",
paperclipTaskMarkdownCompact: "queue-time compact markdown",
paperclipWake: { commentId: "comment-1" },
executionStage: { stage: "review" },
planReviewInteraction: { acceptedTargetRevision: { revisionId: "revision-1" } },
acceptedPlanWakeRouting: { targetAgentId: "agent-1" },
},
}),
),
finalizePromotedWake,
});
const reader = createFakeReader();
const issueLock = createFakeIssueLock(reader, writer);
const releaseIssueExecution = createReleaseIssueExecution({ issueLock, recovery: createFakeRecovery() });
const result = await releaseIssueExecution({ companyId: "company-1", runId: "run-1", now: new Date() });
expect(result.outcome.kind).toBe("promoted");
expect(finalizePromotedWake).toHaveBeenCalledTimes(1);
const promotedContextSnapshot = finalizePromotedWake.mock.calls[0]![0].contextSnapshot;
// The rendered text is cleared; `executeRun` rebuilds it, with proper
// trust-based redaction, from the current issue and comment rows before
// the run dispatches.
expect(promotedContextSnapshot.paperclipTaskMarkdown).toBeUndefined();
expect(promotedContextSnapshot.paperclipTaskMarkdownCompact).toBeUndefined();
expect(promotedContextSnapshot.paperclipWake).toBeUndefined();
// The raw fields that render depends on are not dropped.
expect(promotedContextSnapshot.issueId).toBe(ISSUE.id);
expect(promotedContextSnapshot.executionStage).toEqual({ stage: "review" });
expect(promotedContextSnapshot.planReviewInteraction).toEqual({
acceptedTargetRevision: { revisionId: "revision-1" },
});
expect(promotedContextSnapshot.acceptedPlanWakeRouting).toEqual({ targetAgentId: "agent-1" });
});
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 = [
// Carries a comment that would reopen the done issue, but the
// promotion claim below loses the race before that reopen can run.
wakeCandidate({
id: "wake-lost-race",
deferredCommentIds: ["c1"],
requestedByActorType: "user",
}),
wakeCandidate({ id: "wake-promotes" }),
];
const claimNextDeferredWake = vi.fn(async () => queue.shift() ?? null);
const claimDeferredWakeForPromotion = vi.fn(async ({ wakeId }: { wakeId: string }) => wakeId !== "wake-lost-race");
const reopenIssue = vi.fn(async () => null);
const writer = createFakeWriter({ claimNextDeferredWake, claimDeferredWakeForPromotion, reopenIssue });
const reader = createFakeReader();
const issueLock: IssueLockWriter = {
withIssueExecutionLock: vi.fn(async (_input, fn) => {
const result = await fn({ primaryIssue: doneIssue, run: RUN }, { reader, writer });
return { ...result, run: RUN };
}),
};
const releaseIssueExecution = createReleaseIssueExecution({ issueLock, recovery: createFakeRecovery() });
const result = await releaseIssueExecution({ companyId: "company-1", runId: "run-1", now: new Date() });
expect(reopenIssue).not.toHaveBeenCalled();
expect(claimDeferredWakeForPromotion).toHaveBeenCalledTimes(2);
expect(result.outcome.kind).toBe("promoted");
expect(result.postCommitEffects).toEqual([{ kind: "run_queued", run: runSummary("wake-promotes") }]);
});
it("throws WakeQueueApplicationError with code responsible_user_unresolved when the responsible user cannot resolve", async () => {
const writer = createFakeWriter({ claimNextDeferredWake: vi.fn(async () => wakeCandidate()) });
const reader = createFakeReader({ resolveResponsibleUserId: vi.fn(async () => null) });
const issueLock = createFakeIssueLock(reader, writer);
const releaseIssueExecution = createReleaseIssueExecution({ issueLock, recovery: createFakeRecovery() });
await expect(
releaseIssueExecution({ companyId: "company-1", runId: "run-1", now: new Date() }),
).rejects.toMatchObject({
constructor: WakeQueueApplicationError,
code: "responsible_user_unresolved",
});
});
it("escalates through the recovery port for a blocked outcome, after the transaction resolves", async () => {
const writer = createFakeWriter({
claimNextDeferredWake: vi.fn(async () => null),
hasExistingExecutionPath: vi.fn(async () => false),
isAutomaticRecoverySuppressedByPauseHold: vi.fn(async () => false),
buildBlockedRecoveryNotice: vi.fn(async () => ({ notice: { kind: "immediate_execution_path" }, recoveryCause: "immediate_execution_path" })),
});
// The recovery agent (the finishing run's own agent) is not invokable, which forces "blocked".
const reader = createFakeReader({ findInvokableAgent: vi.fn(async () => null) });
const issueLock = createFakeIssueLock(reader, writer);
const recovery = createFakeRecovery();
const releaseIssueExecution = createReleaseIssueExecution({ issueLock, recovery });
const result = await releaseIssueExecution({ companyId: "company-1", runId: "run-1", now: new Date() });
expect(result.outcome.kind).toBe("blocked");
expect(recovery.escalateStrandedAssignedIssue).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,503 @@
import { enrichPromotedWakeContext } from "../domain/context.js";
import { decideDeferredWake, decideReleaseRecovery } from "../domain/policy.js";
import type {
IssueLockWriter,
IssueSnapshot,
LockedIssueExecution,
RecoveryEscalationPort,
ReleaseTransactionResult,
RunSnapshot,
WakeQueueReader,
WakeQueueWriter,
} from "./ports.js";
import type { PostCommitEffect, ReleaseOutcome } from "./types.js";
import { WakeQueueApplicationError } from "./types.js";
const ISSUE_DISPOSITION_REPAIR_RETRY_REASON = "issue_disposition_repair";
const EXECUTION_REVIEW_PARTICIPANT_RECOVERY_RETRY_REASON = "execution_review_participant_recovery";
const EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASONS = new Set([
"execution_review_requested",
"execution_approval_requested",
]);
const HEARTBEAT_RUN_TERMINAL_STATUSES = new Set([
"succeeded",
"failed",
"timed_out",
"cancelled",
]);
const UNSUCCESSFUL_HEARTBEAT_RUN_TERMINAL_STATUSES = new Set([
"failed",
"timed_out",
"cancelled",
]);
const STRANDED_ISSUE_RECOVERY_ORIGIN_KIND = "stranded_issue_recovery";
const WORKSPACE_VALIDATION_FAILURE_CODE = "workspace_validation_failed";
const CONFIGURATION_INCOMPLETE_FAILURE_CODE = "configuration_incomplete";
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
function isWorkspaceValidationFailedRun(run: Pick<RunSnapshot, "errorCode">): boolean {
return run.errorCode === WORKSPACE_VALIDATION_FAILURE_CODE;
}
function isConfigurationIncompleteFailedRun(run: Pick<RunSnapshot, "errorCode">): boolean {
return run.errorCode === CONFIGURATION_INCOMPLETE_FAILURE_CODE || run.errorCode === "model_not_found";
}
function isExecutionReviewParticipantRecoveryRun(run: Pick<RunSnapshot, "contextSnapshot">): boolean {
return readNonEmptyString(run.contextSnapshot.retryReason) === EXECUTION_REVIEW_PARTICIPANT_RECOVERY_RETRY_REASON;
}
function isExecutionReviewParticipantRecoveryEligibleRun(run: Pick<RunSnapshot, "contextSnapshot">): boolean {
const wakeReason = readNonEmptyString(run.contextSnapshot.wakeReason);
return (wakeReason !== null && EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASONS.has(wakeReason))
|| isExecutionReviewParticipantRecoveryRun(run);
}
function didAutomaticRecoveryFail(
run: Pick<RunSnapshot, "status" | "contextSnapshot">,
expectedRetryReason: "assignment_recovery" | "issue_continuation_needed",
): boolean {
const latestRetryReason = readNonEmptyString(run.contextSnapshot.retryReason);
return latestRetryReason === expectedRetryReason && UNSUCCESSFUL_HEARTBEAT_RUN_TERMINAL_STATUSES.has(run.status);
}
function currentAgentParticipant(issue: IssueSnapshot): { agentId: string } | null {
const executionState = issue.executionState;
if (!executionState || executionState.status !== "pending") return null;
const participant = executionState.currentParticipant as Record<string, unknown> | null | undefined;
if (!participant || participant.type !== "agent") return null;
const agentId = readNonEmptyString(participant.agentId);
return agentId ? { agentId } : null;
}
export type ReleaseIssueExecutionInput = {
companyId: string;
runId: string;
now: Date;
suppressImmediateRecovery?: boolean;
};
/**
* Drains the deferred-wake queue for the issue a run just released, in
* `requestedAt` order, promoting at most one wake. When the queue empties
* without a promotion, decides the release-recovery outcome. Every read and
* write happens through `ports`, already bound to the module's own
* transaction by the caller.
*/
async function runReleaseDrain(
locked: LockedIssueExecution,
ports: { reader: WakeQueueReader; writer: WakeQueueWriter },
input: ReleaseIssueExecutionInput,
): Promise<ReleaseTransactionResult> {
const { run } = locked;
let issue = locked.primaryIssue;
const postCommitEffects: PostCommitEffect[] = [];
while (true) {
const candidate = await ports.writer.claimNextDeferredWake({ companyId: run.companyId, issueId: issue.id });
if (!candidate) break;
let liveness = { liveNonSelfCommentIds: candidate.queuedCommentIds, containedSelfAuthoredComment: false };
if (candidate.queuedCommentIds.length > 0) {
liveness = await ports.writer.getQueuedCommentLiveness({
companyId: run.companyId,
issueId: issue.id,
wakeAgentId: candidate.agentId,
finishingRunId: run.id,
finishingRunAgentId: run.agentId,
queuedCommentIds: candidate.queuedCommentIds,
});
}
const liveCommentIdsChanged =
liveness.liveNonSelfCommentIds.length !== candidate.queuedCommentIds.length ||
liveness.liveNonSelfCommentIds.some((id, index) => id !== candidate.queuedCommentIds[index]);
const deferredAgent = await ports.reader.findInvokableAgent({ companyId: run.companyId, agentId: candidate.agentId });
const pauseHold = await ports.writer.getPauseHoldFacts({
companyId: run.companyId,
issueId: issue.id,
wakeAgentId: candidate.agentId,
deferredContextSeed: candidate.deferredContextSeed,
requestedByActorType: candidate.requestedByActorType,
requestedByActorId: candidate.requestedByActorId,
});
let decision = decideDeferredWake({
queuedComment: {
hasQueuedCommentIds: candidate.queuedCommentIds.length > 0,
liveNonSelfCommentIdsLength: liveness.liveNonSelfCommentIds.length,
queuedCommentIdsLength: candidate.queuedCommentIds.length,
liveCommentIdsChanged,
containedSelfAuthoredComment: liveness.containedSelfAuthoredComment,
preservesIndependentContinuation: candidate.preservesIndependentContinuation,
},
agent: { agentFound: deferredAgent !== null, invokable: deferredAgent?.invokable ?? false },
pauseHold: { activePauseHold: pauseHold.activePauseHold, treeHoldInteractionWake: pauseHold.treeHoldInteractionWake },
});
if (decision.kind === "cancel_empty") {
await ports.writer.cancelDeferredWake({
companyId: run.companyId,
wakeId: candidate.id,
reason: decision.selfAuthored
? "Deferred wake contained only comments authored by the finishing run"
: "Queued messages were discarded before promotion",
now: input.now,
});
continue;
}
let workingCandidate = candidate;
if (decision.kind === "normalize") {
const normalized = await ports.writer.normalizeDeferredWakeCommentIds({
companyId: run.companyId,
wakeId: candidate.id,
payload: candidate.payload,
liveCommentIds: liveness.liveNonSelfCommentIds,
now: input.now,
});
if (!normalized) continue;
workingCandidate = normalized;
// Re-decide with the same agent/pause-hold facts already fetched above; the
// comment-id set now matches, so only fail/cancel-pause-hold/promote can result.
decision = decideDeferredWake({
queuedComment: {
hasQueuedCommentIds: workingCandidate.queuedCommentIds.length > 0,
liveNonSelfCommentIdsLength: liveness.liveNonSelfCommentIds.length,
queuedCommentIdsLength: liveness.liveNonSelfCommentIds.length,
liveCommentIdsChanged: false,
containedSelfAuthoredComment: liveness.containedSelfAuthoredComment,
preservesIndependentContinuation: workingCandidate.preservesIndependentContinuation,
},
agent: { agentFound: deferredAgent !== null, invokable: deferredAgent?.invokable ?? false },
pauseHold: { activePauseHold: pauseHold.activePauseHold, treeHoldInteractionWake: pauseHold.treeHoldInteractionWake },
});
}
if (decision.kind === "fail_not_invokable") {
await ports.writer.failDeferredWake({ companyId: run.companyId, wakeId: workingCandidate.id, now: input.now });
continue;
}
if (decision.kind === "cancel_pause_hold") {
await ports.writer.cancelDeferredWake({
companyId: run.companyId,
wakeId: workingCandidate.id,
reason: "Deferred wake suppressed by active subtree pause hold",
now: input.now,
});
continue;
}
// decision.kind === "promote"
const invokableAgent = deferredAgent!;
// Claim the wake for promotion before any other write in this branch
// (design choice: claim first, then reopen). A reopen write, or its
// `issue_reopened` post-commit effect, must never survive a lost race on
// this compare-and-set. When the claim fails, a concurrent writer already
// changed the wake's status, so this candidate is gone; move on to the
// next one instead of ending the drain.
const claimedForPromotion = await ports.writer.claimDeferredWakeForPromotion({
companyId: run.companyId,
wakeId: workingCandidate.id,
now: input.now,
});
if (!claimedForPromotion) continue;
let currentIssue = issue;
if (workingCandidate.deferredCommentIds.length > 0 && (currentIssue.status === "done" || currentIssue.status === "cancelled")) {
const selfAuthorship = await ports.writer.getCommentSelfAuthorship({
companyId: run.companyId,
issueId: currentIssue.id,
finishingRunId: run.id,
commentIds: workingCandidate.deferredCommentIds,
});
const shouldReopen =
!selfAuthorship.allSelfAuthored &&
(workingCandidate.requestedByActorType === "user" || workingCandidate.wakeReason === "issue_reopened_via_comment");
if (shouldReopen) {
const reopened = await ports.writer.reopenIssue({ companyId: run.companyId, issueId: currentIssue.id, runId: run.id });
if (reopened) {
postCommitEffects.push({
kind: "issue_reopened",
companyId: reopened.companyId,
agentId: invokableAgent.id,
runId: run.id,
issueId: reopened.id,
identifier: reopened.identifier,
reopenedFrom: currentIssue.status,
});
currentIssue = reopened;
issue = reopened;
}
}
}
const promotedReason = workingCandidate.reason ?? "issue_execution_promoted";
const promotedSource = workingCandidate.source ?? "automation";
const promotedTriggerDetail = workingCandidate.triggerDetail ?? null;
const promotedPayload = { ...workingCandidate.payload };
delete promotedPayload["_paperclipWakeContext"];
const promotedContextSeed: Record<string, unknown> = { ...workingCandidate.deferredContextSeed };
if (pauseHold.activePauseHold) {
promotedContextSeed.treeHoldInteraction = true;
promotedContextSeed.activeTreeHold = {
holdId: pauseHold.holdId,
rootIssueId: pauseHold.rootIssueId,
mode: pauseHold.mode,
reason: pauseHold.reason,
releasePolicy: pauseHold.releasePolicy,
interaction: true,
};
}
const { contextSnapshot: promotedContextSnapshot, taskKey: promotedTaskKey } = enrichPromotedWakeContext({
contextSnapshot: promotedContextSeed,
reason: promotedReason,
source: promotedSource,
triggerDetail: promotedTriggerDetail,
payload: promotedPayload,
});
const sessionBefore =
readNonEmptyString(promotedContextSnapshot.resumeSessionDisplayId) ??
(await ports.reader.resolveSessionBeforeForWakeup({
companyId: run.companyId,
agentId: invokableAgent.id,
taskKey: promotedTaskKey,
}));
const promotedRoutineEnvContext = await ports.reader.getRoutineEnv({
companyId: invokableAgent.companyId,
issue: currentIssue,
});
const responsibleUserId = await ports.reader.resolveResponsibleUserId({
companyId: invokableAgent.companyId,
contextSnapshot: promotedContextSnapshot,
issue: currentIssue,
routineEnvContext: promotedRoutineEnvContext,
requestedByActorType: workingCandidate.requestedByActorType as "user" | "agent" | "system" | null,
requestedByActorId: workingCandidate.requestedByActorId,
source: promotedSource,
triggerDetail: promotedTriggerDetail,
existingRunResponsibleUserId: run.responsibleUserId,
});
if (!responsibleUserId) {
throw new WakeQueueApplicationError(
"responsible_user_unresolved",
"Unable to resolve responsible user for promoted heartbeat run",
{
runId: run.id,
agentId: invokableAgent.id,
companyId: invokableAgent.companyId,
issueId: currentIssue.id,
wakeReason: readNonEmptyString(promotedContextSnapshot.wakeReason),
},
);
}
const promotedRun = await ports.writer.finalizePromotedWake({
companyId: run.companyId,
wakeId: workingCandidate.id,
deferredAgent: invokableAgent,
issue: currentIssue,
finishingRun: run,
contextSnapshot: promotedContextSnapshot,
reason: promotedReason,
source: promotedSource,
triggerDetail: promotedTriggerDetail,
payload: promotedPayload,
responsibleUserId,
sessionBefore,
now: input.now,
});
postCommitEffects.push({ kind: "run_queued", run: promotedRun });
return { outcome: { kind: "promoted", run: promotedRun }, postCommitEffects };
}
return runReleaseRecoveryTail(issue, run, ports.reader, ports.writer, input, postCommitEffects);
}
async function runReleaseRecoveryTail(
issue: IssueSnapshot,
run: RunSnapshot,
reader: WakeQueueReader,
writer: WakeQueueWriter,
input: ReleaseIssueExecutionInput,
postCommitEffects: PostCommitEffect[],
): Promise<ReleaseTransactionResult> {
const suppressImmediateRecovery = input.suppressImmediateRecovery ?? false;
const isStrandedRecoveryOrigin = issue.originKind === STRANDED_ISSUE_RECOVERY_ORIGIN_KIND;
const recoveryAgent = await reader.findInvokableAgent({ companyId: issue.companyId, agentId: run.agentId });
const currentParticipant = currentAgentParticipant(issue);
const reviewParticipantApplies =
issue.status === "in_review" &&
!issue.assigneeUserId &&
currentParticipant !== null &&
currentParticipant.agentId === run.agentId &&
isExecutionReviewParticipantRecoveryEligibleRun(run) &&
HEARTBEAT_RUN_TERMINAL_STATUSES.has(run.status);
const immediateApplies =
(issue.status === "todo" || issue.status === "in_progress") &&
!issue.assigneeUserId &&
!issue.hiddenAt &&
issue.assigneeAgentId === run.agentId &&
(run.status === "failed" || run.status === "timed_out" || run.status === "cancelled");
const suppressedByPauseHold = (reviewParticipantApplies || immediateApplies)
? await writer.isAutomaticRecoverySuppressedByPauseHold({ companyId: issue.companyId, issueId: issue.id })
: false;
const hasExistingExecutionPath = reviewParticipantApplies
? await writer.hasExistingExecutionPath({
companyId: issue.companyId,
issueId: issue.id,
excludeRunId: run.id,
agentId: currentParticipant?.agentId ?? null,
})
: immediateApplies
? await writer.hasExistingExecutionPath({ companyId: issue.companyId, issueId: issue.id, excludeRunId: run.id, agentId: null })
: false;
const hasExplicitBlockerPath = immediateApplies && !reviewParticipantApplies
? await writer.hasExplicitBlockerPath({ companyId: issue.companyId, issueId: issue.id })
: false;
const expectedRetryReason: "assignment_recovery" | "issue_continuation_needed" =
issue.status === "todo" ? "assignment_recovery" : "issue_continuation_needed";
const decision = decideReleaseRecovery({
suppressImmediateRecovery,
reviewParticipant: {
applies: reviewParticipantApplies,
hasExistingExecutionPath,
hasPersistedMonitor: Boolean(issue.monitorNextCheckAt),
suppressedByPauseHold,
isStrandedRecoveryOrigin,
recoveryAgentPresent: recoveryAgent !== null,
recoveryAgentInvokable: recoveryAgent?.invokable ?? false,
isExecutionReviewParticipantRecoveryRun: isExecutionReviewParticipantRecoveryRun(run),
},
immediate: {
applies: immediateApplies,
isDispositionRepairRetry: readNonEmptyString(run.contextSnapshot.retryReason) === ISSUE_DISPOSITION_REPAIR_RETRY_REASON,
hasExistingExecutionPath,
hasPersistedMonitor: Boolean(issue.monitorNextCheckAt),
hasExplicitBlockerPath,
suppressedByPauseHold,
isStrandedRecoveryOrigin,
recoveryAgentPresent: recoveryAgent !== null,
recoveryAgentInvokable: recoveryAgent?.invokable ?? false,
isWorkspaceValidationFailedRun: isWorkspaceValidationFailedRun(run),
isConfigurationIncompleteFailedRun: isConfigurationIncompleteFailedRun(run),
automaticRecoveryAlreadyFailed: didAutomaticRecoveryFail(run, expectedRetryReason),
},
});
if (decision.kind === "released") {
return { outcome: { kind: "released" }, postCommitEffects };
}
if (decision.kind === "blocked_recovery_in_place") {
return {
outcome: { kind: "blocked_recovery_in_place", issue, previousStatus: statusForBlock(issue) },
postCommitEffects,
};
}
if (decision.kind === "blocked") {
const { notice, recoveryCause } = await writer.buildBlockedRecoveryNotice({
noticeKind: decision.notice,
issueStatus: issue.status === "todo" ? "todo" : "in_progress",
finishingRun: run,
});
return {
outcome: {
kind: "blocked",
issue,
previousStatus: statusForBlock(issue),
notice,
recoveryCause,
},
postCommitEffects,
};
}
const sessionBefore = await reader.resolveSessionBeforeForWakeup({
companyId: issue.companyId,
agentId: recoveryAgent!.id,
taskKey: readNonEmptyString(run.contextSnapshot.taskKey) ?? readNonEmptyString(run.contextSnapshot.issueId),
});
if (decision.kind === "queue_review_participant_recovery") {
const queuedRun = await writer.queueReviewParticipantRecoveryRun({
companyId: issue.companyId,
issue,
finishingRun: run,
recoveryAgent: recoveryAgent!,
sessionBefore,
now: input.now,
});
postCommitEffects.push({ kind: "run_queued", run: queuedRun });
return { outcome: { kind: "queued_review_participant_recovery", run: queuedRun }, postCommitEffects };
}
// decision.kind === "queue_recovery"; the adapter builds the recovery
// context snapshot and resolves the responsible user from it, throwing
// WakeQueueApplicationError when no responsible user resolves.
const queuedRun = await writer.queueImmediateRecoveryRun({
companyId: issue.companyId,
issue,
finishingRun: run,
recoveryAgent: recoveryAgent!,
sessionBefore,
now: input.now,
});
postCommitEffects.push({ kind: "run_queued", run: queuedRun });
return { outcome: { kind: "queued_recovery", run: queuedRun }, postCommitEffects };
}
function statusForBlock(issue: IssueSnapshot): "todo" | "in_progress" | "in_review" {
return issue.status === "todo" || issue.status === "in_review" ? issue.status : "in_progress";
}
export function createReleaseIssueExecution(deps: {
issueLock: IssueLockWriter;
recovery: RecoveryEscalationPort;
}) {
return async function releaseIssueExecution(
input: ReleaseIssueExecutionInput,
): Promise<{ outcome: ReleaseOutcome; postCommitEffects: PostCommitEffect[] }> {
const result = await deps.issueLock.withIssueExecutionLock(
{ companyId: input.companyId, runId: input.runId, now: input.now },
(locked, ports) => runReleaseDrain(locked, ports, input),
);
if (result.outcome.kind === "blocked") {
await deps.recovery.escalateStrandedAssignedIssue({
issue: result.outcome.issue,
previousStatus: result.outcome.previousStatus,
latestRun: result.run,
notice: result.outcome.notice,
recoveryCause: result.outcome.recoveryCause,
});
} else if (result.outcome.kind === "blocked_recovery_in_place") {
await deps.recovery.escalateStrandedRecoveryIssueInPlace({
issue: result.outcome.issue,
previousStatus: result.outcome.previousStatus,
latestRun: result.run,
});
}
return { outcome: result.outcome, postCommitEffects: result.postCommitEffects };
};
}

View File

@ -0,0 +1,124 @@
import { describe, expect, it } from "vitest";
import { enrichPromotedWakeContext } from "./context.js";
describe("enrichPromotedWakeContext", () => {
it("clears stale derived comment projections when the canonical id list is empty, but keeps an independent interaction continuation", () => {
const contextSnapshot: Record<string, unknown> = {
// Normalization already dropped the canonical wake comment ids upstream
// of this function; no wakeCommentIds field remains on the snapshot.
paperclipWake: { commentId: "comment-2" },
paperclipWakeComment: { id: "comment-2", body: "stale body" },
paperclipTaskMarkdown: "stale markdown",
paperclipTaskMarkdownCompact: "stale compact markdown",
// Independent interaction continuation the promoted run must keep.
interactionId: "interaction-1",
interactionKind: "request_confirmation",
interactionStatus: "accepted",
};
const result = enrichPromotedWakeContext({
contextSnapshot,
reason: "issue_execution_promoted",
source: "automation",
triggerDetail: null,
payload: { mutation: "interaction" },
});
expect(result.contextSnapshot.wakeCommentIds).toBeUndefined();
expect(result.contextSnapshot.commentId).toBeUndefined();
expect(result.contextSnapshot.wakeCommentId).toBeUndefined();
expect(result.contextSnapshot.paperclipWake).toBeUndefined();
expect(result.contextSnapshot.paperclipWakeComment).toBeUndefined();
expect(result.contextSnapshot.paperclipTaskMarkdown).toBeUndefined();
expect(result.contextSnapshot.paperclipTaskMarkdownCompact).toBeUndefined();
// The independent interaction continuation survives the clear.
expect(result.contextSnapshot.interactionId).toBe("interaction-1");
expect(result.contextSnapshot.interactionStatus).toBe("accepted");
});
it("sets the canonical id and latest-id fields when the recomputed list has entries, and still clears the stale projections", () => {
const contextSnapshot: Record<string, unknown> = {
wakeCommentIds: ["comment-1"],
paperclipWake: { commentId: "comment-1" },
paperclipWakeComment: { id: "comment-1", body: "will be rebuilt" },
paperclipTaskMarkdown: "will be rebuilt",
paperclipTaskMarkdownCompact: "will be rebuilt",
};
const result = enrichPromotedWakeContext({
contextSnapshot,
reason: "issue_execution_promoted",
source: "automation",
triggerDetail: null,
payload: {},
});
expect(result.contextSnapshot.wakeCommentIds).toEqual(["comment-1"]);
expect(result.contextSnapshot.commentId).toBe("comment-1");
expect(result.contextSnapshot.wakeCommentId).toBe("comment-1");
// The derived projections are cleared so the run rebuilds them from the
// canonical ids instead of carrying forward a queue-time snapshot.
expect(result.contextSnapshot.paperclipWake).toBeUndefined();
expect(result.contextSnapshot.paperclipWakeComment).toBeUndefined();
expect(result.contextSnapshot.paperclipTaskMarkdown).toBeUndefined();
expect(result.contextSnapshot.paperclipTaskMarkdownCompact).toBeUndefined();
});
it("adds a new id from the payload to the canonical list and clears the stale projections", () => {
const contextSnapshot: Record<string, unknown> = {
wakeCommentIds: ["comment-1"],
paperclipWakeComment: { id: "comment-1", body: "stale body" },
};
const result = enrichPromotedWakeContext({
contextSnapshot,
reason: "issue_execution_promoted",
source: "automation",
triggerDetail: null,
payload: { commentId: "comment-2" },
});
expect(result.contextSnapshot.wakeCommentIds).toEqual(["comment-1", "comment-2"]);
expect(result.contextSnapshot.commentId).toBe("comment-2");
expect(result.contextSnapshot.wakeCommentId).toBe("comment-2");
expect(result.contextSnapshot.paperclipWakeComment).toBeUndefined();
});
it("keeps the raw issue, execution-stage, and accepted-plan fields a dispatch rebuild needs, alongside the cleared text projections", () => {
const contextSnapshot: Record<string, unknown> = {
wakeCommentIds: ["comment-1"],
paperclipWake: { commentId: "comment-1" },
paperclipTaskMarkdown: "stale markdown",
paperclipTaskMarkdownCompact: "stale compact markdown",
// Raw context a dispatch-time rebuild reads directly off the promoted
// run; none of it is derived from the comment ids above, so none of it
// should be cleared alongside the stale text projections.
issueId: "issue-1",
executionStage: { stage: "review" },
planReviewInteraction: { acceptedTargetRevision: { revisionId: "revision-1" } },
acceptedPlanWakeRouting: { targetAgentId: "agent-1" },
workspaceRefreshReason: "accepted_plan_confirmation",
};
const result = enrichPromotedWakeContext({
contextSnapshot,
reason: "issue_execution_promoted",
source: "automation",
triggerDetail: null,
payload: {},
});
// The rendered text is cleared; dispatch rebuilds it from the raw fields.
expect(result.contextSnapshot.paperclipWake).toBeUndefined();
expect(result.contextSnapshot.paperclipTaskMarkdown).toBeUndefined();
expect(result.contextSnapshot.paperclipTaskMarkdownCompact).toBeUndefined();
// The raw fields the rebuild needs are untouched.
expect(result.contextSnapshot.issueId).toBe("issue-1");
expect(result.contextSnapshot.executionStage).toEqual({ stage: "review" });
expect(result.contextSnapshot.planReviewInteraction).toEqual({
acceptedTargetRevision: { revisionId: "revision-1" },
});
expect(result.contextSnapshot.acceptedPlanWakeRouting).toEqual({ targetAgentId: "agent-1" });
expect(result.contextSnapshot.workspaceRefreshReason).toBe("accepted_plan_confirmation");
});
});

View File

@ -0,0 +1,165 @@
// Pure helpers that build the context snapshot for a promoted deferred wake.
// `extractWakeCommentIds`, `deriveCommentId`, and `WAKE_COMMENT_IDS_KEY` come
// from the run-dispatch module so the two modules read one wake context
// shape; this file holds only the small pieces that are specific to
// building the release half's promoted-run snapshot.
import { extractWakeCommentIds, WAKE_COMMENT_IDS_KEY } from "../../run-dispatch/index.js";
const PAPERCLIP_WAKE_PAYLOAD_KEY = "paperclipWake";
const PAPERCLIP_WAKE_COMMENT_KEY = "paperclipWakeComment";
const PAPERCLIP_TASK_MARKDOWN_KEY = "paperclipTaskMarkdown";
const PAPERCLIP_TASK_MARKDOWN_COMPACT_KEY = "paperclipTaskMarkdownCompact";
const INTERACTION_CONTINUATION_CONTEXT_KEYS = [
"interactionId",
"interactionKind",
"interactionStatus",
"continuationPolicy",
"checkboxSelection",
"itemVerdicts",
"newlyResolvedItemIds",
] as const;
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
function deriveTaskKey(
contextSnapshot: Record<string, unknown> | null | undefined,
payload: Record<string, unknown> | null | undefined,
): string | null {
return (
readNonEmptyString(contextSnapshot?.taskKey) ??
readNonEmptyString(contextSnapshot?.taskId) ??
readNonEmptyString(contextSnapshot?.issueId) ??
readNonEmptyString(payload?.taskKey) ??
readNonEmptyString(payload?.taskId) ??
readNonEmptyString(payload?.issueId) ??
null
);
}
function mergeWakeCommentIds(...values: Array<unknown>): string[] {
const merged: string[] = [];
const append = (value: unknown) => {
const normalized = readNonEmptyString(value);
if (!normalized || merged.includes(normalized)) return;
merged.push(normalized);
};
for (const value of values) {
if (Array.isArray(value)) {
for (const entry of value) append(entry);
continue;
}
if (typeof value === "object" && value !== null) {
const candidate = value as Record<string, unknown>;
const batched = extractWakeCommentIds(candidate);
if (batched.length > 0) {
for (const entry of batched) append(entry);
continue;
}
append(candidate.wakeCommentId);
append(candidate.commentId);
continue;
}
append(value);
}
return merged;
}
export function hasInteractionContinuationWakeContext(contextSnapshot: Record<string, unknown>): boolean {
return INTERACTION_CONTINUATION_CONTEXT_KEYS.some((key) => readNonEmptyString(contextSnapshot[key]));
}
function isInteractionResolutionWakePayload(payload: Record<string, unknown> | null | undefined): boolean {
return readNonEmptyString(payload?.mutation) === "interaction";
}
function normalizeInteractionContinuationWakeContext(
contextSnapshot: Record<string, unknown>,
payload: Record<string, unknown> | null | undefined,
): void {
if (isInteractionResolutionWakePayload(payload)) return;
for (const key of INTERACTION_CONTINUATION_CONTEXT_KEYS) {
delete contextSnapshot[key];
}
}
export type EnrichPromotedWakeContextInput = {
contextSnapshot: Record<string, unknown>;
reason: string | null;
source: string | null;
triggerDetail: string | null;
payload: Record<string, unknown> | null;
};
export type EnrichPromotedWakeContextResult = {
contextSnapshot: Record<string, unknown>;
taskKey: string | null;
};
/**
* Fills the promoted run's context snapshot with the fields the original
* wake enrichment always fills, without overwriting a field the deferred
* wake already carried. The deferred wake's own context snapshot was
* already enriched once when it was first queued, so most calls only
* normalize the interaction-continuation keys.
*/
export function enrichPromotedWakeContext(
input: EnrichPromotedWakeContextInput,
): EnrichPromotedWakeContextResult {
const contextSnapshot = { ...input.contextSnapshot };
const { reason, source, triggerDetail, payload } = input;
const commentIdFromPayload = readNonEmptyString(payload?.["commentId"]);
const taskKey = deriveTaskKey(contextSnapshot, payload);
const wakeCommentIds = mergeWakeCommentIds(contextSnapshot, commentIdFromPayload);
if (!readNonEmptyString(contextSnapshot["wakeReason"]) && reason) {
contextSnapshot.wakeReason = reason;
}
if (!readNonEmptyString(contextSnapshot["taskKey"]) && taskKey) {
contextSnapshot.taskKey = taskKey;
}
if (!readNonEmptyString(contextSnapshot["commentId"]) && commentIdFromPayload) {
contextSnapshot.commentId = commentIdFromPayload;
}
// The wake payload, resolved comment, and task-markdown snapshots below are
// rendered text built from the canonical comment ids and the issue state at
// queue time. This function recomputes the canonical ids on every call
// (`wakeCommentIds`), so it must not let a rendered snapshot from a stale
// id list carry forward: clear all four, then restore only the canonical
// id and latest-id fields, and only when the recomputed list actually has
// entries.
//
// This function does not render a replacement in its place. The
// promoted run still carries every raw field the render depends on
// (`issueId`, `wakeReason`, interaction and accepted-plan fields, and the
// canonical comment ids set below): `heartbeatService`'s `executeRun`
// rebuilds the rendered text from those raw fields and the current issue
// and comment rows, with the trust-based redaction that rendering needs,
// before it persists the run and dispatches it to the agent. Rendering a
// second, simplified copy here would either skip that redaction (an unsafe
// shortcut for quarantined comment content) or duplicate it outside the
// one place it is proven correct, so this function leaves the render to
// dispatch and only guarantees the raw inputs survive.
delete contextSnapshot[PAPERCLIP_WAKE_PAYLOAD_KEY];
delete contextSnapshot[PAPERCLIP_WAKE_COMMENT_KEY];
delete contextSnapshot[PAPERCLIP_TASK_MARKDOWN_KEY];
delete contextSnapshot[PAPERCLIP_TASK_MARKDOWN_COMPACT_KEY];
if (wakeCommentIds.length > 0) {
const latestCommentId = wakeCommentIds[wakeCommentIds.length - 1];
contextSnapshot[WAKE_COMMENT_IDS_KEY] = wakeCommentIds;
contextSnapshot.commentId = latestCommentId;
contextSnapshot.wakeCommentId = latestCommentId;
}
if (!readNonEmptyString(contextSnapshot["wakeSource"]) && source) {
contextSnapshot.wakeSource = source;
}
if (!readNonEmptyString(contextSnapshot["wakeTriggerDetail"]) && triggerDetail) {
contextSnapshot.wakeTriggerDetail = triggerDetail;
}
normalizeInteractionContinuationWakeContext(contextSnapshot, payload);
return { contextSnapshot, taskKey };
}

View File

@ -0,0 +1,353 @@
import { describe, expect, it } from "vitest";
import {
decideDeferredWake,
decideReleaseRecovery,
type DeferredWakeFacts,
type ReleaseRecoveryFacts,
} from "./policy.js";
const baseDeferredWakeFacts: DeferredWakeFacts = {
queuedComment: {
hasQueuedCommentIds: false,
liveNonSelfCommentIdsLength: 0,
queuedCommentIdsLength: 0,
liveCommentIdsChanged: false,
containedSelfAuthoredComment: false,
preservesIndependentContinuation: false,
},
agent: { agentFound: true, invokable: true },
pauseHold: { activePauseHold: false, treeHoldInteractionWake: false },
};
describe("decideDeferredWake", () => {
const cases: Array<{
name: string;
facts: DeferredWakeFacts;
expected: ReturnType<typeof decideDeferredWake>;
}> = [
{
name: "cancel_empty: all queued comments discarded and no independent continuation",
facts: {
...baseDeferredWakeFacts,
queuedComment: {
hasQueuedCommentIds: true,
liveNonSelfCommentIdsLength: 0,
queuedCommentIdsLength: 2,
liveCommentIdsChanged: true,
containedSelfAuthoredComment: false,
preservesIndependentContinuation: false,
},
},
expected: { kind: "cancel_empty", selfAuthored: false },
},
{
name: "cancel_empty: self-authored comments discarded, error text reflects self-authorship",
facts: {
...baseDeferredWakeFacts,
queuedComment: {
hasQueuedCommentIds: true,
liveNonSelfCommentIdsLength: 0,
queuedCommentIdsLength: 1,
liveCommentIdsChanged: true,
containedSelfAuthoredComment: true,
preservesIndependentContinuation: false,
},
},
expected: { kind: "cancel_empty", selfAuthored: true },
},
{
name: "normalize: no live comments, but an independent continuation reason still rewrites the queued id list",
facts: {
...baseDeferredWakeFacts,
queuedComment: {
hasQueuedCommentIds: true,
liveNonSelfCommentIdsLength: 0,
queuedCommentIdsLength: 1,
liveCommentIdsChanged: true,
containedSelfAuthoredComment: false,
preservesIndependentContinuation: true,
},
},
expected: { kind: "normalize" },
},
{
name: "promote: an independent continuation reason keeps the wake alive with the live id set already matching",
facts: {
...baseDeferredWakeFacts,
queuedComment: {
hasQueuedCommentIds: true,
liveNonSelfCommentIdsLength: 0,
queuedCommentIdsLength: 0,
liveCommentIdsChanged: false,
containedSelfAuthoredComment: false,
preservesIndependentContinuation: true,
},
},
expected: { kind: "promote" },
},
{
name: "normalize: the live comment id set differs from the queued set",
facts: {
...baseDeferredWakeFacts,
queuedComment: {
hasQueuedCommentIds: true,
liveNonSelfCommentIdsLength: 1,
queuedCommentIdsLength: 2,
liveCommentIdsChanged: true,
containedSelfAuthoredComment: false,
preservesIndependentContinuation: false,
},
},
expected: { kind: "normalize" },
},
{
name: "fail_not_invokable: the agent lookup returns not-found",
facts: {
...baseDeferredWakeFacts,
agent: { agentFound: false, invokable: false },
},
expected: { kind: "fail_not_invokable" },
},
{
name: "fail_not_invokable: the agent is found but not invokable",
facts: {
...baseDeferredWakeFacts,
agent: { agentFound: true, invokable: false },
},
expected: { kind: "fail_not_invokable" },
},
{
name: "cancel_pause_hold: an active pause hold with no verified tree-hold interaction",
facts: {
...baseDeferredWakeFacts,
pauseHold: { activePauseHold: true, treeHoldInteractionWake: false },
},
expected: { kind: "cancel_pause_hold" },
},
{
name: "promote: an active pause hold but a verified tree-hold interaction wake survives it",
facts: {
...baseDeferredWakeFacts,
pauseHold: { activePauseHold: true, treeHoldInteractionWake: true },
},
expected: { kind: "promote" },
},
{
name: "promote: no queued comments, an invokable agent, and no pause hold",
facts: baseDeferredWakeFacts,
expected: { kind: "promote" },
},
];
for (const testCase of cases) {
it(testCase.name, () => {
expect(decideDeferredWake(testCase.facts)).toEqual(testCase.expected);
});
}
});
const baseReleaseRecoveryFacts: ReleaseRecoveryFacts = {
suppressImmediateRecovery: false,
reviewParticipant: {
applies: false,
hasExistingExecutionPath: false,
hasPersistedMonitor: false,
suppressedByPauseHold: false,
isStrandedRecoveryOrigin: false,
recoveryAgentPresent: true,
recoveryAgentInvokable: true,
isExecutionReviewParticipantRecoveryRun: false,
},
immediate: {
applies: false,
isDispositionRepairRetry: false,
hasExistingExecutionPath: false,
hasPersistedMonitor: false,
hasExplicitBlockerPath: false,
suppressedByPauseHold: false,
isStrandedRecoveryOrigin: false,
recoveryAgentPresent: true,
recoveryAgentInvokable: true,
isWorkspaceValidationFailedRun: false,
isConfigurationIncompleteFailedRun: false,
automaticRecoveryAlreadyFailed: false,
},
};
describe("decideReleaseRecovery", () => {
const cases: Array<{
name: string;
facts: ReleaseRecoveryFacts;
expected: ReturnType<typeof decideReleaseRecovery>;
}> = [
{
name: "released: neither the review-participant nor the immediate-recovery branch applies",
facts: baseReleaseRecoveryFacts,
expected: { kind: "released" },
},
{
name: "released: immediate recovery applies but the caller asked to suppress it",
facts: {
...baseReleaseRecoveryFacts,
suppressImmediateRecovery: true,
immediate: { ...baseReleaseRecoveryFacts.immediate, applies: true },
},
expected: { kind: "released" },
},
{
name: "released: immediate recovery applies but an existing execution path already covers it",
facts: {
...baseReleaseRecoveryFacts,
immediate: {
...baseReleaseRecoveryFacts.immediate,
applies: true,
hasExistingExecutionPath: true,
},
},
expected: { kind: "released" },
},
{
name: "released: immediate recovery applies but the finishing run carried the disposition-repair retry reason",
facts: {
...baseReleaseRecoveryFacts,
immediate: {
...baseReleaseRecoveryFacts.immediate,
applies: true,
isDispositionRepairRetry: true,
},
},
expected: { kind: "released" },
},
{
name: "released: immediate recovery applies but an explicit blocker path exists",
facts: {
...baseReleaseRecoveryFacts,
immediate: {
...baseReleaseRecoveryFacts.immediate,
applies: true,
hasExplicitBlockerPath: true,
},
},
expected: { kind: "released" },
},
{
name: "blocked_recovery_in_place: immediate recovery applies on a stranded-issue-recovery origin",
facts: {
...baseReleaseRecoveryFacts,
immediate: {
...baseReleaseRecoveryFacts.immediate,
applies: true,
isStrandedRecoveryOrigin: true,
},
},
expected: { kind: "blocked_recovery_in_place" },
},
{
name: "blocked: immediate recovery applies but the recovery agent is not invokable",
facts: {
...baseReleaseRecoveryFacts,
immediate: {
...baseReleaseRecoveryFacts.immediate,
applies: true,
recoveryAgentInvokable: false,
},
},
expected: { kind: "blocked", notice: "immediate_execution_path" },
},
{
name: "blocked: immediate recovery applies and the run failed workspace validation",
facts: {
...baseReleaseRecoveryFacts,
immediate: {
...baseReleaseRecoveryFacts.immediate,
applies: true,
isWorkspaceValidationFailedRun: true,
},
},
expected: { kind: "blocked", notice: "workspace_validation" },
},
{
name: "blocked: immediate recovery applies and the run failed on incomplete configuration",
facts: {
...baseReleaseRecoveryFacts,
immediate: {
...baseReleaseRecoveryFacts.immediate,
applies: true,
isConfigurationIncompleteFailedRun: true,
},
},
expected: { kind: "blocked", notice: "configuration_incomplete" },
},
{
name: "queue_recovery: immediate recovery applies and no suppression or block condition fires",
facts: {
...baseReleaseRecoveryFacts,
immediate: { ...baseReleaseRecoveryFacts.immediate, applies: true },
},
expected: { kind: "queue_recovery" },
},
{
name: "released: review-participant recovery applies but a persisted monitor already covers it",
facts: {
...baseReleaseRecoveryFacts,
reviewParticipant: {
...baseReleaseRecoveryFacts.reviewParticipant,
applies: true,
hasPersistedMonitor: true,
},
},
expected: { kind: "released" },
},
{
name: "blocked_recovery_in_place: review-participant recovery applies on a stranded-issue-recovery origin",
facts: {
...baseReleaseRecoveryFacts,
reviewParticipant: {
...baseReleaseRecoveryFacts.reviewParticipant,
applies: true,
isStrandedRecoveryOrigin: true,
},
},
expected: { kind: "blocked_recovery_in_place" },
},
{
name: "blocked: review-participant recovery applies but the finishing run was itself that recovery retry",
facts: {
...baseReleaseRecoveryFacts,
reviewParticipant: {
...baseReleaseRecoveryFacts.reviewParticipant,
applies: true,
isExecutionReviewParticipantRecoveryRun: true,
},
},
expected: { kind: "blocked", notice: "execution_review_participant" },
},
{
name: "queue_review_participant_recovery: review-participant recovery applies and no suppression or block condition fires",
facts: {
...baseReleaseRecoveryFacts,
reviewParticipant: { ...baseReleaseRecoveryFacts.reviewParticipant, applies: true },
},
expected: { kind: "queue_review_participant_recovery" },
},
{
name: "review-participant recovery is evaluated before immediate recovery when both apply",
facts: {
...baseReleaseRecoveryFacts,
reviewParticipant: { ...baseReleaseRecoveryFacts.reviewParticipant, applies: true },
immediate: {
...baseReleaseRecoveryFacts.immediate,
applies: true,
isStrandedRecoveryOrigin: true,
},
},
expected: { kind: "queue_review_participant_recovery" },
},
];
for (const testCase of cases) {
it(testCase.name, () => {
expect(decideReleaseRecovery(testCase.facts)).toEqual(testCase.expected);
});
}
});

View File

@ -0,0 +1,200 @@
// Pure decision rules for the release half of the deferred issue-execution
// wake state machine:
// - the per-wake decision (decideDeferredWake), applied to the earliest
// deferred wake queued against the issue a run just released
// - the release-recovery decision (decideReleaseRecovery), applied once
// the deferred-wake queue is empty and no wake was promoted
// The caller reads the database and packs the result into a facts object.
// This file only branches on that facts object; it never queries a
// database, reads the clock, or reads the wake context payload directly.
export type DeferredWakeQueuedCommentFacts = {
/** True when the wake carries one or more queued comment ids to check. */
hasQueuedCommentIds: boolean;
/** Count of queued comment ids that are still live and not self-authored by the finishing run. */
liveNonSelfCommentIdsLength: number;
/** Count of queued comment ids the wake originally carried. */
queuedCommentIdsLength: number;
/** True when the live, non-self comment id list differs from the queued list. */
liveCommentIdsChanged: boolean;
/** True when every discarded comment id was authored by the finishing run. */
containedSelfAuthoredComment: boolean;
/** True when the wake carries an independent reason to continue even with no live comments. */
preservesIndependentContinuation: boolean;
};
export type DeferredWakeAgentFacts = {
/** True when the wake's agent exists in the issue's own company. */
agentFound: boolean;
/** True when the agent is invokable (status, org chain). Meaningless when agentFound is false. */
invokable: boolean;
};
export type DeferredWakePauseHoldFacts = {
/** True when an active subtree pause hold covers the issue. */
activePauseHold: boolean;
/** True when the wake is a verified issue-tree-control interaction wake that survives a pause hold. */
treeHoldInteractionWake: boolean;
};
export type DeferredWakeFacts = {
queuedComment: DeferredWakeQueuedCommentFacts;
agent: DeferredWakeAgentFacts;
pauseHold: DeferredWakePauseHoldFacts;
};
export type DeferredWakeDecision =
| { kind: "cancel_empty"; selfAuthored: boolean }
| { kind: "normalize" }
| { kind: "fail_not_invokable" }
| { kind: "cancel_pause_hold" }
| { kind: "promote" };
/**
* Decides what to do with the earliest deferred wake queued against the
* issue a run just released. The caller applies a "normalize" decision (a
* queued-comment-id rewrite) and calls this function again with facts that
* reflect the rewrite, so a single wake can normalize and then also fail,
* cancel, or promote in the same drain step matching the order the
* original state machine always evaluated them in.
*/
export function decideDeferredWake(facts: DeferredWakeFacts): DeferredWakeDecision {
const { queuedComment, agent, pauseHold } = facts;
if (
queuedComment.hasQueuedCommentIds &&
queuedComment.liveNonSelfCommentIdsLength === 0 &&
!queuedComment.preservesIndependentContinuation
) {
return { kind: "cancel_empty", selfAuthored: queuedComment.containedSelfAuthoredComment };
}
if (queuedComment.hasQueuedCommentIds && queuedComment.liveCommentIdsChanged) {
return { kind: "normalize" };
}
if (!agent.agentFound || !agent.invokable) {
return { kind: "fail_not_invokable" };
}
if (pauseHold.activePauseHold && !pauseHold.treeHoldInteractionWake) {
return { kind: "cancel_pause_hold" };
}
return { kind: "promote" };
}
export type ReleaseRecoveryReviewParticipantFacts = {
/** True when the issue is in_review, unassigned to a user, and waiting on the finishing run as the current agent participant. */
applies: boolean;
hasExistingExecutionPath: boolean;
hasPersistedMonitor: boolean;
suppressedByPauseHold: boolean;
isStrandedRecoveryOrigin: boolean;
recoveryAgentPresent: boolean;
recoveryAgentInvokable: boolean;
/** True when the finishing run was itself a review-participant-recovery retry. */
isExecutionReviewParticipantRecoveryRun: boolean;
};
export type ReleaseRecoveryImmediateFacts = {
/** True when the issue is todo/in_progress, unassigned to a user, not hidden, still assigned to the finishing run's agent, and the run ended failed/timed_out/cancelled. */
applies: boolean;
/** True when the finishing run itself carried the disposition-repair retry reason. */
isDispositionRepairRetry: boolean;
hasExistingExecutionPath: boolean;
hasPersistedMonitor: boolean;
hasExplicitBlockerPath: boolean;
suppressedByPauseHold: boolean;
isStrandedRecoveryOrigin: boolean;
recoveryAgentPresent: boolean;
recoveryAgentInvokable: boolean;
isWorkspaceValidationFailedRun: boolean;
isConfigurationIncompleteFailedRun: boolean;
/** didAutomaticRecoveryFail(run, expectedRetryReason) for the issue's own status branch. */
automaticRecoveryAlreadyFailed: boolean;
};
export type ReleaseRecoveryFacts = {
/** options.suppressImmediateRecovery on the caller's release request. */
suppressImmediateRecovery: boolean;
reviewParticipant: ReleaseRecoveryReviewParticipantFacts;
immediate: ReleaseRecoveryImmediateFacts;
};
export type ReleaseRecoveryBlockedNoticeKind =
| "workspace_validation"
| "configuration_incomplete"
| "execution_review_participant"
| "immediate_execution_path";
export type ReleaseRecoveryDecision =
| { kind: "released" }
| { kind: "blocked_recovery_in_place" }
| { kind: "blocked"; notice: ReleaseRecoveryBlockedNoticeKind }
| { kind: "queue_review_participant_recovery" }
| { kind: "queue_recovery" };
/**
* Decides the release-recovery outcome once the deferred-wake queue is
* empty and no wake was promoted. The review-participant branch and the
* assignment-and-continuation branch are the two ways a released issue can
* still need automatic recovery; this function evaluates both as one
* decision, review-participant first, matching the original evaluation
* order.
*/
export function decideReleaseRecovery(facts: ReleaseRecoveryFacts): ReleaseRecoveryDecision {
const { reviewParticipant, immediate } = facts;
if (reviewParticipant.applies) {
if (
facts.suppressImmediateRecovery ||
reviewParticipant.hasExistingExecutionPath ||
reviewParticipant.hasPersistedMonitor ||
reviewParticipant.suppressedByPauseHold
) {
return { kind: "released" };
}
if (reviewParticipant.isStrandedRecoveryOrigin) {
return { kind: "blocked_recovery_in_place" };
}
const shouldBlock =
!reviewParticipant.recoveryAgentInvokable ||
!reviewParticipant.recoveryAgentPresent ||
reviewParticipant.isExecutionReviewParticipantRecoveryRun;
if (shouldBlock) {
return { kind: "blocked", notice: "execution_review_participant" };
}
return { kind: "queue_review_participant_recovery" };
}
if (immediate.isDispositionRepairRetry) return { kind: "released" };
if (!immediate.applies) return { kind: "released" };
if (facts.suppressImmediateRecovery) return { kind: "released" };
if (
immediate.hasExistingExecutionPath ||
immediate.hasPersistedMonitor ||
immediate.hasExplicitBlockerPath
) {
return { kind: "released" };
}
if (immediate.suppressedByPauseHold) return { kind: "released" };
if (immediate.isStrandedRecoveryOrigin) return { kind: "blocked_recovery_in_place" };
const shouldBlockImmediately =
!immediate.recoveryAgentInvokable ||
!immediate.recoveryAgentPresent ||
immediate.isWorkspaceValidationFailedRun ||
immediate.isConfigurationIncompleteFailedRun ||
immediate.automaticRecoveryAlreadyFailed;
if (shouldBlockImmediately) {
const notice: ReleaseRecoveryBlockedNoticeKind = immediate.isWorkspaceValidationFailedRun
? "workspace_validation"
: immediate.isConfigurationIncompleteFailedRun
? "configuration_incomplete"
: "immediate_execution_path";
return { kind: "blocked", notice };
}
return { kind: "queue_recovery" };
}

View File

@ -0,0 +1,50 @@
import type { Db } from "@paperclipai/db";
import { createPostgresWakeQueueAdapter } from "./adapters/postgres.js";
import { createReleaseIssueExecution } from "./application/use-cases.js";
import type {
IssueSnapshot,
RecoveryEscalationPort,
RunSnapshot,
WakeQueueReader,
} from "./application/ports.js";
export type {
PostCommitEffect,
ReleaseOutcome,
RunSummary,
} from "./application/types.js";
export { WakeQueueApplicationError } from "./application/types.js";
export type { IssueSnapshot, RunSnapshot, RecoveryEscalationPort } from "./application/ports.js";
export type { ReleaseIssueExecutionInput } from "./application/use-cases.js";
export type WakeQueueDeps = {
/** Stays in `heartbeat.ts`; resolves the responsible user for a promoted or recovery run seed. */
resolveResponsibleUserId: WakeQueueReader["resolveResponsibleUserId"];
/** Stays in `heartbeat.ts`; reads the routine environment context for an execution issue. */
getRoutineEnv: WakeQueueReader["getRoutineEnv"];
/** Stays in `heartbeat.ts`; resolves the session-before display id for a wakeup. */
resolveSessionBeforeForWakeup: WakeQueueReader["resolveSessionBeforeForWakeup"];
/** `services/recovery`'s stranded-issue escalation, called only after the release transaction commits. */
recovery: RecoveryEscalationPort;
};
/**
* Composes the wake-queue module: the Postgres adapter (which owns the
* release transaction) and the release use case. `heartbeat.ts` holds the
* only caller: it builds one instance per process next to
* `createRunDispatch(db)` and delegates `releaseIssueExecutionAndPromote`'s
* body to `releaseIssueExecution`.
*/
export function createWakeQueue(db: Db, deps: WakeQueueDeps) {
const issueLock = createPostgresWakeQueueAdapter(db, {
resolveResponsibleUserId: deps.resolveResponsibleUserId,
getRoutineEnv: deps.getRoutineEnv,
resolveSessionBeforeForWakeup: deps.resolveSessionBeforeForWakeup,
});
return {
releaseIssueExecution: createReleaseIssueExecution({ issueLock, recovery: deps.recovery }),
};
}
export type WakeQueue = ReturnType<typeof createWakeQueue>;

File diff suppressed because it is too large Load Diff

View File

@ -5638,7 +5638,7 @@ export function issueService(db: Db) {
return row;
}
return {
const service = {
clearExecutionRunIfTerminal,
clearCheckoutRunIfTerminal,
addStopRelayCommentIfNeeded,
@ -7783,6 +7783,7 @@ export function issueService(db: Db) {
blockedByIssueIds?: string[];
actorAgentId?: string | null;
actorUserId?: string | null;
companyGuard?: string;
},
dbOrTx: any = db,
postCommitActivityPublications?: ActivityPublication[],
@ -7792,10 +7793,18 @@ export function issueService(db: Db) {
const activityPublications = postCommitActivityPublications ?? ownedActivityPublications;
const ownedPostCommitActions: IssuePostCommitAction[] = [];
const queuedPostCommitActions = postCommitActions ?? ownedPostCommitActions;
// A caller that supplies `companyGuard` gets the company added to
// every read, lock, and write predicate below. A check before this
// call is not a boundary: `issues.company_id` can change between
// that check and this write, so the predicate must carry the
// company itself.
const idPredicate = data.companyGuard !== undefined
? and(eq(issues.id, id), eq(issues.companyId, data.companyGuard))
: eq(issues.id, id);
const existing = await dbOrTx
.select()
.from(issues)
.where(eq(issues.id, id))
.where(idPredicate)
.then((rows: Array<typeof issues.$inferSelect>) => rows[0] ?? null);
if (!existing) return null;
@ -7804,6 +7813,7 @@ export function issueService(db: Db) {
blockedByIssueIds,
actorAgentId,
actorUserId,
companyGuard,
...issueData
} = data;
const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces;
@ -7955,7 +7965,7 @@ export function issueService(db: Db) {
const receiptExisting = await tx
.select()
.from(issues)
.where(eq(issues.id, id))
.where(idPredicate)
.for("update")
.then((rows: Array<typeof issues.$inferSelect>) => rows[0] ?? null);
if (!receiptExisting) return null;
@ -7994,7 +8004,7 @@ export function issueService(db: Db) {
const updated = await tx
.update(issues)
.set(patch)
.where(eq(issues.id, id))
.where(idPredicate)
.returning()
.then((rows: Array<typeof issues.$inferSelect>) => rows[0] ?? null);
if (!updated) return null;
@ -9535,4 +9545,38 @@ export function issueService(db: Db) {
}));
},
};
type IssueServiceApi = typeof service & {
updateForCompany: (
id: string,
companyId: string,
data: Parameters<typeof service.update>[1],
dbOrTx?: any,
postCommitActivityPublications?: ActivityPublication[],
postCommitActions?: IssuePostCommitAction[],
) => ReturnType<typeof service.update>;
};
const serviceApi = service as IssueServiceApi;
// A company-scoped wrapper around `update`. It passes the company as a
// guard on every read, lock, and write predicate, so a caller with only
// a company id and an issue id cannot update an issue in another company.
serviceApi.updateForCompany = async (
id,
companyId,
data,
dbOrTx = db,
postCommitActivityPublications,
postCommitActions,
) => {
return service.update(
id,
{ ...data, companyGuard: companyId },
dbOrTx,
postCommitActivityPublications,
postCommitActions,
);
};
return serviceApi;
}