refactor(server): simplify the wake-queue module (#13139)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The server uses a deferred wake queue to release work at the correct
time
> - The wake queue had repeated decisions, helpers, queries, and
recovery data
> - This repetition made the module harder to read and left a drain
invariant implicit
> - This pull request moves pure decisions into the policy layer and
simplifies the queue flow
> - The benefit is a smaller, clearer module with the same behavior and
direct test coverage

## Linked Issues or Issue Description

Refs #13136

**Problem:** The deferred wake queue carried repeated logic across the
application and database layers.

**Expected behavior:** The queue keeps the same wake, release, recovery,
and escalation behavior after the refactor.

**Solution:** Move pure decisions into the policy layer, share repeated
data and predicates, and state the drain invariant in the application
layer.

## What Changed

- Remove the unused database port method and carry the blocked release
notice kind as a typed field.
- Move four pre-drain decisions into pure policy functions with
table-driven tests.
- Resolve the responsible user once in the application layer.
- Use the canonical helper for agent invokability checks.
- Share string helpers and run predicates across the module.
- Split the deferred-wake decision flow and share recovery facts with a
discriminator.
- Bound the drain loop and throw when it processes a wake identifier
twice.
- Rename queue ports to describe their behavior.
- Share row-loading code between stranded-issue escalation adapters.
- Restore the interaction-continuation integration test case.

## Verification

- Run the three wake-queue module test files. They pass 61 cases
locally.
- Run `pnpm check:module-boundaries`. It passes locally.
- Run the `server/` type-check and confirm that no error names the
changed wake-queue module or `heartbeat.ts`.
- Run continuous integration and confirm that `promotes an interaction
continuation after removing a coalesced self-authored comment` passes.

## Risks

The change refactors queue control flow and database adapter boundaries.
The main risk is a behavior change in deferred wake release or recovery.
The new policy tests and the restored integration case cover these
paths. The local environment cannot run the integration test because the
same dependency failure occurs on the base branch.

## Model Used

OpenAI Codex, GPT-5, context window not exposed by the runtime, with
reasoning, tool use, and code execution.

## 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-10 00:03:22 -07:00
1 parent 6dd48cad43
commit 0bff1d5cb6
13 files changed
+1225 -667

No files matched your search

@@ -5,6 +5,7 @@
// building the release half's promoted-run snapshot.
import { extractWakeCommentIds, WAKE_COMMENT_IDS_KEY } from "../../run-dispatch/index.js";
import { readNonEmptyString } from "./values.js";
const PAPERCLIP_WAKE_PAYLOAD_KEY = "paperclipWake";
const PAPERCLIP_WAKE_COMMENT_KEY = "paperclipWakeComment";
@@ -21,10 +22,6 @@ const INTERACTION_CONTINUATION_CONTEXT_KEYS = [
"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,
@@ -1,147 +1,269 @@
import { describe, expect, it } from "vitest";
import {
decideDeferredWake,
decidePreDrain,
decideQueuedCommentAction,
decideReleaseRecovery,
type DeferredWakeFacts,
decideWakeOutcome,
deriveImmediateRecoveryContextLabels,
type DeferredWakeOutcomeFacts,
type DeferredWakeQueuedCommentFacts,
type ImmediateRecoveryContextLabels,
type PreDrainFacts,
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 },
const basePreDrainFacts: PreDrainFacts = {
issueRowPresent: true,
executionRunIdMatchesRun: true,
isWorkspaceValidationFailedRun: false,
isConfigurationIncompleteFailedRun: false,
issueStatus: "in_progress",
hasAssigneeUser: false,
assigneeAgentMatchesRunAgent: true,
legacyExecutionNeedsReconciliation: false,
executionCancellationAcknowledged: false,
};
describe("decideDeferredWake", () => {
describe("decidePreDrain", () => {
const cases: Array<{
name: string;
facts: DeferredWakeFacts;
expected: ReturnType<typeof decideDeferredWake>;
facts: PreDrainFacts;
expected: ReturnType<typeof decidePreDrain>;
}> = [
{
name: "released: the issue row is missing",
facts: { ...basePreDrainFacts, issueRowPresent: false },
expected: { kind: "released" },
},
{
name: "released: another run already holds executionRunId",
facts: { ...basePreDrainFacts, executionRunIdMatchesRun: false },
expected: { kind: "released" },
},
{
name: "blocked: a workspace-validation failure on an eligible todo issue",
facts: { ...basePreDrainFacts, isWorkspaceValidationFailedRun: true, issueStatus: "todo" },
expected: { kind: "blocked", noticeKind: "workspace_validation" },
},
{
name: "blocked: a configuration-incomplete failure on an eligible in_progress issue",
facts: { ...basePreDrainFacts, isConfigurationIncompleteFailedRun: true, issueStatus: "in_progress" },
expected: { kind: "blocked", noticeKind: "configuration_incomplete" },
},
{
name: "proceed: a workspace-validation failure on an issue that is not todo or in_progress",
facts: { ...basePreDrainFacts, isWorkspaceValidationFailedRun: true, issueStatus: "in_review" },
expected: { kind: "proceed" },
},
{
name: "proceed: a workspace-validation failure but the issue already has an assigned user",
facts: {
...basePreDrainFacts,
isWorkspaceValidationFailedRun: true,
issueStatus: "todo",
hasAssigneeUser: true,
},
expected: { kind: "proceed" },
},
{
name: "proceed: a workspace-validation failure but the assigned agent does not match the finishing run's agent",
facts: {
...basePreDrainFacts,
isWorkspaceValidationFailedRun: true,
issueStatus: "todo",
assigneeAgentMatchesRunAgent: false,
},
expected: { kind: "proceed" },
},
{
name: "released: legacy execution needs reconciliation",
facts: { ...basePreDrainFacts, legacyExecutionNeedsReconciliation: true },
expected: { kind: "released" },
},
{
name: "released: an acknowledged execution cancellation",
facts: { ...basePreDrainFacts, executionCancellationAcknowledged: true },
expected: { kind: "released" },
},
{
name: "proceed: none of the pre-drain conditions apply",
facts: basePreDrainFacts,
expected: { kind: "proceed" },
},
{
name: "the blocked-notice check is evaluated before legacy-execution reconciliation",
facts: {
...basePreDrainFacts,
isWorkspaceValidationFailedRun: true,
issueStatus: "todo",
// If reconciliation were checked first, this would force a "released"
// outcome; the expected "blocked" here proves the blocked-notice
// check, evaluated first, decides the outcome.
legacyExecutionNeedsReconciliation: true,
},
expected: { kind: "blocked", noticeKind: "workspace_validation" },
},
{
name: "the blocked-notice check is evaluated before an acknowledged execution cancellation",
facts: {
...basePreDrainFacts,
isConfigurationIncompleteFailedRun: true,
issueStatus: "todo",
// If the cancellation check were checked first, this would force a
// "released" outcome; the expected "blocked" here proves the
// blocked-notice check, evaluated first, decides the outcome.
executionCancellationAcknowledged: true,
},
expected: { kind: "blocked", noticeKind: "configuration_incomplete" },
},
];
for (const testCase of cases) {
it(testCase.name, () => {
expect(decidePreDrain(testCase.facts)).toEqual(testCase.expected);
});
}
});
const baseQueuedCommentFacts: DeferredWakeQueuedCommentFacts = {
hasQueuedCommentIds: false,
liveNonSelfCommentIdsLength: 0,
liveCommentIdsDiffer: false,
containedSelfAuthoredComment: false,
preservesIndependentContinuation: false,
};
describe("decideQueuedCommentAction", () => {
const cases: Array<{
name: string;
facts: DeferredWakeQueuedCommentFacts;
expected: ReturnType<typeof decideQueuedCommentAction>;
}> = [
{
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,
},
...baseQueuedCommentFacts,
hasQueuedCommentIds: true,
liveNonSelfCommentIdsLength: 0,
liveCommentIdsDiffer: 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,
},
...baseQueuedCommentFacts,
hasQueuedCommentIds: true,
liveNonSelfCommentIdsLength: 0,
liveCommentIdsDiffer: 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,
},
...baseQueuedCommentFacts,
hasQueuedCommentIds: true,
liveNonSelfCommentIdsLength: 0,
liveCommentIdsDiffer: 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",
name: "proceed: 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,
},
...baseQueuedCommentFacts,
hasQueuedCommentIds: true,
liveNonSelfCommentIdsLength: 0,
liveCommentIdsDiffer: false,
containedSelfAuthoredComment: false,
preservesIndependentContinuation: true,
},
expected: { kind: "promote" },
expected: { kind: "proceed" },
},
{
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,
},
...baseQueuedCommentFacts,
hasQueuedCommentIds: true,
liveNonSelfCommentIdsLength: 1,
liveCommentIdsDiffer: true,
containedSelfAuthoredComment: false,
preservesIndependentContinuation: false,
},
expected: { kind: "normalize" },
},
{
name: "fail_not_invokable: the agent lookup returns not-found",
name: "proceed: no queued comments",
facts: baseQueuedCommentFacts,
expected: { kind: "proceed" },
},
{
name: "proceed: queued comments are all still live, matching the queued set",
facts: {
...baseDeferredWakeFacts,
agent: { agentFound: false, invokable: false },
...baseQueuedCommentFacts,
hasQueuedCommentIds: true,
liveNonSelfCommentIdsLength: 1,
liveCommentIdsDiffer: false,
},
expected: { kind: "proceed" },
},
];
for (const testCase of cases) {
it(testCase.name, () => {
expect(decideQueuedCommentAction(testCase.facts)).toEqual(testCase.expected);
});
}
});
const baseWakeOutcomeFacts: DeferredWakeOutcomeFacts = {
agent: { agentFound: true, invokable: true },
pauseHold: { activePauseHold: false, treeHoldInteractionWake: false },
};
describe("decideWakeOutcome", () => {
const cases: Array<{
name: string;
facts: DeferredWakeOutcomeFacts;
expected: ReturnType<typeof decideWakeOutcome>;
}> = [
{
name: "fail_not_invokable: the agent lookup returns not-found",
facts: { ...baseWakeOutcomeFacts, 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 },
},
facts: { ...baseWakeOutcomeFacts, 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 },
},
facts: { ...baseWakeOutcomeFacts, 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 },
},
facts: { ...baseWakeOutcomeFacts, pauseHold: { activePauseHold: true, treeHoldInteractionWake: true } },
expected: { kind: "promote" },
},
{
name: "promote: no queued comments, an invokable agent, and no pause hold",
facts: baseDeferredWakeFacts,
name: "promote: an invokable agent and no pause hold",
facts: baseWakeOutcomeFacts,
expected: { kind: "promote" },
},
];
for (const testCase of cases) {
it(testCase.name, () => {
expect(decideDeferredWake(testCase.facts)).toEqual(testCase.expected);
expect(decideWakeOutcome(testCase.facts)).toEqual(testCase.expected);
});
}
});
@@ -150,27 +272,23 @@ 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,
hasExplicitBlockerPath: false,
isWorkspaceValidationFailedRun: false,
isConfigurationIncompleteFailedRun: false,
automaticRecoveryAlreadyFailed: false,
},
shared: {
hasExistingExecutionPath: false,
hasPersistedMonitor: false,
hasExplicitBlockerPath: false,
suppressedByPauseHold: false,
isStrandedRecoveryOrigin: false,
recoveryAgentPresent: true,
recoveryAgentInvokable: true,
isWorkspaceValidationFailedRun: false,
isConfigurationIncompleteFailedRun: false,
automaticRecoveryAlreadyFailed: false,
},
};
@@ -198,11 +316,8 @@ describe("decideReleaseRecovery", () => {
name: "released: immediate recovery applies but an existing execution path already covers it",
facts: {
...baseReleaseRecoveryFacts,
immediate: {
...baseReleaseRecoveryFacts.immediate,
applies: true,
hasExistingExecutionPath: true,
},
immediate: { ...baseReleaseRecoveryFacts.immediate, applies: true },
shared: { ...baseReleaseRecoveryFacts.shared, hasExistingExecutionPath: true },
},
expected: { kind: "released" },
},
@@ -234,11 +349,8 @@ describe("decideReleaseRecovery", () => {
name: "blocked_recovery_in_place: immediate recovery applies on a stranded-issue-recovery origin",
facts: {
...baseReleaseRecoveryFacts,
immediate: {
...baseReleaseRecoveryFacts.immediate,
applies: true,
isStrandedRecoveryOrigin: true,
},
immediate: { ...baseReleaseRecoveryFacts.immediate, applies: true },
shared: { ...baseReleaseRecoveryFacts.shared, isStrandedRecoveryOrigin: true },
},
expected: { kind: "blocked_recovery_in_place" },
},
@@ -246,11 +358,8 @@ describe("decideReleaseRecovery", () => {
name: "blocked: immediate recovery applies but the recovery agent is not invokable",
facts: {
...baseReleaseRecoveryFacts,
immediate: {
...baseReleaseRecoveryFacts.immediate,
applies: true,
recoveryAgentInvokable: false,
},
immediate: { ...baseReleaseRecoveryFacts.immediate, applies: true },
shared: { ...baseReleaseRecoveryFacts.shared, recoveryAgentInvokable: false },
},
expected: { kind: "blocked", notice: "immediate_execution_path" },
},
@@ -290,11 +399,8 @@ describe("decideReleaseRecovery", () => {
name: "released: review-participant recovery applies but a persisted monitor already covers it",
facts: {
...baseReleaseRecoveryFacts,
reviewParticipant: {
...baseReleaseRecoveryFacts.reviewParticipant,
applies: true,
hasPersistedMonitor: true,
},
reviewParticipant: { ...baseReleaseRecoveryFacts.reviewParticipant, applies: true },
shared: { ...baseReleaseRecoveryFacts.shared, hasPersistedMonitor: true },
},
expected: { kind: "released" },
},
@@ -302,11 +408,8 @@ describe("decideReleaseRecovery", () => {
name: "blocked_recovery_in_place: review-participant recovery applies on a stranded-issue-recovery origin",
facts: {
...baseReleaseRecoveryFacts,
reviewParticipant: {
...baseReleaseRecoveryFacts.reviewParticipant,
applies: true,
isStrandedRecoveryOrigin: true,
},
reviewParticipant: { ...baseReleaseRecoveryFacts.reviewParticipant, applies: true },
shared: { ...baseReleaseRecoveryFacts.shared, isStrandedRecoveryOrigin: true },
},
expected: { kind: "blocked_recovery_in_place" },
},
@@ -335,11 +438,10 @@ describe("decideReleaseRecovery", () => {
facts: {
...baseReleaseRecoveryFacts,
reviewParticipant: { ...baseReleaseRecoveryFacts.reviewParticipant, applies: true },
immediate: {
...baseReleaseRecoveryFacts.immediate,
applies: true,
isStrandedRecoveryOrigin: true,
},
// If the immediate branch were evaluated instead, this flag would force a
// "blocked" outcome; the expected "queue_review_participant_recovery" here
// proves the review-participant branch, checked first, decides the outcome.
immediate: { ...baseReleaseRecoveryFacts.immediate, applies: true, isWorkspaceValidationFailedRun: true },
},
expected: { kind: "queue_review_participant_recovery" },
},
@@ -351,3 +453,45 @@ describe("decideReleaseRecovery", () => {
});
}
});
describe("deriveImmediateRecoveryContextLabels", () => {
const cases: Array<{
name: string;
issueStatus: string;
expected: ImmediateRecoveryContextLabels;
}> = [
{
name: "todo: the issue lost its assignment",
issueStatus: "todo",
expected: {
retryReason: "assignment_recovery",
recoveryReason: "issue_assignment_recovery",
recoverySource: "issue.assignment_recovery",
},
},
{
name: "not todo: an in_progress issue is a stalled continuation",
issueStatus: "in_progress",
expected: {
retryReason: "issue_continuation_needed",
recoveryReason: "issue_continuation_needed",
recoverySource: "issue.continuation_recovery",
},
},
{
name: "not todo: any other status also derives the stalled-continuation labels",
issueStatus: "in_review",
expected: {
retryReason: "issue_continuation_needed",
recoveryReason: "issue_continuation_needed",
recoverySource: "issue.continuation_recovery",
},
},
];
for (const testCase of cases) {
it(testCase.name, () => {
expect(deriveImmediateRecoveryContextLabels(testCase.issueStatus)).toEqual(testCase.expected);
});
}
});
+134 -51
View File
@@ -1,28 +1,93 @@
// 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 pre-drain decision (decidePreDrain), applied once per lock
// acquisition, before the caller touches the deferred-wake queue at all
// - the per-wake decision, split into decideQueuedCommentAction and
// decideWakeOutcome, 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 PreDrainFacts = {
/** True when the transaction found the issue row the lock is for. */
issueRowPresent: boolean;
/** True when the issue carries no `executionRunId`, or carries the finishing run's own id. False when a different run already holds it. */
executionRunIdMatchesRun: boolean;
isWorkspaceValidationFailedRun: boolean;
isConfigurationIncompleteFailedRun: boolean;
/** The issue's current status. Only "todo" and "in_progress" can lead to the blocked-notice outcome. */
issueStatus: string;
hasAssigneeUser: boolean;
assigneeAgentMatchesRunAgent: boolean;
legacyExecutionNeedsReconciliation: boolean;
/** True when the finishing run is cancelled and its stored result carries an acknowledged execution cancellation. */
executionCancellationAcknowledged: boolean;
};
export type PreDrainDecision =
| { kind: "released" }
| { kind: "blocked"; noticeKind: ReleaseRecoveryBlockedNoticeKind }
| { kind: "proceed" };
/**
* Decides the pre-drain release outcome for one lock acquisition, before the
* caller runs its own lock function. Check order is fixed: the issue-row
* check runs first, then the blocked-notice check, then legacy-execution
* reconciliation, then acknowledged execution cancellation. Each check
* returns as soon as it applies, so an earlier true condition can hide a
* later one when both hold at the same time. "proceed" means none of the
* four checks applied; the caller then runs its own write-carrying check
* and, if that also clears, calls its lock function.
*/
export function decidePreDrain(facts: PreDrainFacts): PreDrainDecision {
if (!facts.issueRowPresent || !facts.executionRunIdMatchesRun) {
return { kind: "released" };
}
if (
(facts.isWorkspaceValidationFailedRun || facts.isConfigurationIncompleteFailedRun) &&
(facts.issueStatus === "todo" || facts.issueStatus === "in_progress") &&
!facts.hasAssigneeUser &&
facts.assigneeAgentMatchesRunAgent
) {
return {
kind: "blocked",
noticeKind: facts.isConfigurationIncompleteFailedRun ? "configuration_incomplete" : "workspace_validation",
};
}
if (facts.legacyExecutionNeedsReconciliation) {
return { kind: "released" };
}
if (facts.executionCancellationAcknowledged) {
return { kind: "released" };
}
return { kind: "proceed" };
}
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;
liveCommentIdsDiffer: 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 DeferredWakeQueuedCommentDecision =
| { kind: "cancel_empty"; selfAuthored: boolean }
| { kind: "normalize" }
| { kind: "proceed" };
export type DeferredWakeAgentFacts = {
/** True when the wake's agent exists in the issue's own company. */
agentFound: boolean;
@@ -37,42 +102,39 @@ export type DeferredWakePauseHoldFacts = {
treeHoldInteractionWake: boolean;
};
export type DeferredWakeFacts = {
queuedComment: DeferredWakeQueuedCommentFacts;
export type DeferredWakeOutcomeFacts = {
agent: DeferredWakeAgentFacts;
pauseHold: DeferredWakePauseHoldFacts;
};
export type DeferredWakeDecision =
| { kind: "cancel_empty"; selfAuthored: boolean }
| { kind: "normalize" }
export type DeferredWakeOutcomeDecision =
| { 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;
/** Decides what to do with a deferred wake's queued comment ids. On "normalize", the caller rewrites the ids and then calls `decideWakeOutcome` directly; the rewrite cannot change the agent or pause-hold facts. */
export function decideQueuedCommentAction(
facts: DeferredWakeQueuedCommentFacts,
): DeferredWakeQueuedCommentDecision {
if (
queuedComment.hasQueuedCommentIds &&
queuedComment.liveNonSelfCommentIdsLength === 0 &&
!queuedComment.preservesIndependentContinuation
facts.hasQueuedCommentIds &&
facts.liveNonSelfCommentIdsLength === 0 &&
!facts.preservesIndependentContinuation
) {
return { kind: "cancel_empty", selfAuthored: queuedComment.containedSelfAuthoredComment };
return { kind: "cancel_empty", selfAuthored: facts.containedSelfAuthoredComment };
}
if (queuedComment.hasQueuedCommentIds && queuedComment.liveCommentIdsChanged) {
if (facts.hasQueuedCommentIds && facts.liveCommentIdsDiffer) {
return { kind: "normalize" };
}
return { kind: "proceed" };
}
/** Decides the outcome for a deferred wake whose queued-comment action resolved to "proceed" (or finished its rewrite): fail, cancel, or promote. */
export function decideWakeOutcome(facts: DeferredWakeOutcomeFacts): DeferredWakeOutcomeDecision {
const { agent, pauseHold } = facts;
if (!agent.agentFound || !agent.invokable) {
return { kind: "fail_not_invokable" };
}
@@ -84,15 +146,19 @@ export function decideDeferredWake(facts: DeferredWakeFacts): DeferredWakeDecisi
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;
/** Shared between the review-participant and immediate branches; the caller derives every field from the same expression regardless of which branch applies. */
export type ReleaseRecoverySharedFacts = {
hasExistingExecutionPath: boolean;
hasPersistedMonitor: boolean;
suppressedByPauseHold: boolean;
isStrandedRecoveryOrigin: boolean;
recoveryAgentPresent: boolean;
recoveryAgentInvokable: boolean;
};
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;
/** True when the finishing run was itself a review-participant-recovery retry. */
isExecutionReviewParticipantRecoveryRun: boolean;
};
@@ -102,13 +168,7 @@ export type ReleaseRecoveryImmediateFacts = {
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. */
@@ -120,6 +180,7 @@ export type ReleaseRecoveryFacts = {
suppressImmediateRecovery: boolean;
reviewParticipant: ReleaseRecoveryReviewParticipantFacts;
immediate: ReleaseRecoveryImmediateFacts;
shared: ReleaseRecoverySharedFacts;
};
export type ReleaseRecoveryBlockedNoticeKind =
@@ -135,6 +196,32 @@ export type ReleaseRecoveryDecision =
| { kind: "queue_review_participant_recovery" }
| { kind: "queue_recovery" };
export type ImmediateRecoveryContextLabels = {
retryReason: "assignment_recovery" | "issue_continuation_needed";
recoveryReason: "issue_assignment_recovery" | "issue_continuation_needed";
recoverySource: "issue.assignment_recovery" | "issue.continuation_recovery";
};
/**
* Derives the three labels an immediate-recovery heartbeat run's context
* snapshot carries, from the issue's status. A `todo` issue lost its
* assignment; every other status this decision reaches is a stalled
* continuation.
*/
export function deriveImmediateRecoveryContextLabels(issueStatus: string): ImmediateRecoveryContextLabels {
return issueStatus === "todo"
? {
retryReason: "assignment_recovery",
recoveryReason: "issue_assignment_recovery",
recoverySource: "issue.assignment_recovery",
}
: {
retryReason: "issue_continuation_needed",
recoveryReason: "issue_continuation_needed",
recoverySource: "issue.continuation_recovery",
};
}
/**
* Decides the release-recovery outcome once the deferred-wake queue is
* empty and no wake was promoted. The review-participant branch and the
@@ -144,23 +231,23 @@ export type ReleaseRecoveryDecision =
* order.
*/
export function decideReleaseRecovery(facts: ReleaseRecoveryFacts): ReleaseRecoveryDecision {
const { reviewParticipant, immediate } = facts;
const { reviewParticipant, immediate, shared } = facts;
if (reviewParticipant.applies) {
if (
facts.suppressImmediateRecovery ||
reviewParticipant.hasExistingExecutionPath ||
reviewParticipant.hasPersistedMonitor ||
reviewParticipant.suppressedByPauseHold
shared.hasExistingExecutionPath ||
shared.hasPersistedMonitor ||
shared.suppressedByPauseHold
) {
return { kind: "released" };
}
if (reviewParticipant.isStrandedRecoveryOrigin) {
if (shared.isStrandedRecoveryOrigin) {
return { kind: "blocked_recovery_in_place" };
}
const shouldBlock =
!reviewParticipant.recoveryAgentInvokable ||
!reviewParticipant.recoveryAgentPresent ||
!shared.recoveryAgentInvokable ||
!shared.recoveryAgentPresent ||
reviewParticipant.isExecutionReviewParticipantRecoveryRun;
if (shouldBlock) {
return { kind: "blocked", notice: "execution_review_participant" };
@@ -171,19 +258,15 @@ export function decideReleaseRecovery(facts: ReleaseRecoveryFacts): ReleaseRecov
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
) {
if (shared.hasExistingExecutionPath || shared.hasPersistedMonitor || immediate.hasExplicitBlockerPath) {
return { kind: "released" };
}
if (immediate.suppressedByPauseHold) return { kind: "released" };
if (immediate.isStrandedRecoveryOrigin) return { kind: "blocked_recovery_in_place" };
if (shared.suppressedByPauseHold) return { kind: "released" };
if (shared.isStrandedRecoveryOrigin) return { kind: "blocked_recovery_in_place" };
const shouldBlockImmediately =
!immediate.recoveryAgentInvokable ||
!immediate.recoveryAgentPresent ||
!shared.recoveryAgentInvokable ||
!shared.recoveryAgentPresent ||
immediate.isWorkspaceValidationFailedRun ||
immediate.isConfigurationIncompleteFailedRun ||
immediate.automaticRecoveryAlreadyFailed;
@@ -0,0 +1,26 @@
// Small, shared domain values for the wake-queue module: string readers, the
// two failed-run codes, and the recovery retry reason they gate. The
// application and adapter layers both need these, so they live here once
// instead of twice.
export function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
export function parseObject(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
export const WORKSPACE_VALIDATION_FAILURE_CODE = "workspace_validation_failed";
export const CONFIGURATION_INCOMPLETE_FAILURE_CODE = "configuration_incomplete";
export const EXECUTION_REVIEW_PARTICIPANT_RECOVERY_RETRY_REASON = "execution_review_participant_recovery";
export function isWorkspaceValidationFailedRun(run: { errorCode: string | null }): boolean {
return run.errorCode === WORKSPACE_VALIDATION_FAILURE_CODE;
}
export function isConfigurationIncompleteFailedRun(run: { errorCode: string | null }): boolean {
return run.errorCode === CONFIGURATION_INCOMPLETE_FAILURE_CODE || run.errorCode === "model_not_found";
}