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 LeachandPaperclip authored and GitHub committed 2026-09-09 21:01:26 -07:00
1 parent 018ca5daaf
commit 6dd48cad43
14 files changed
+3896 -1574

No files matched your search

@@ -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");
});
});
@@ -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 };
}
@@ -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);
});
}
});
@@ -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" };
}