mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 09:37:19 +01:00
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:
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" };
|
||||
}
|
||||
Reference in new issue
Block a user