mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 23:39:21 +02:00
## 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>
247 lines
9.5 KiB
TypeScript
247 lines
9.5 KiB
TypeScript
import type { ReleaseRecoveryBlockedNoticeKind } from "../domain/policy.js";
|
|
import type {
|
|
InvokableAgentSnapshot,
|
|
IssueSnapshot,
|
|
PostCommitEffect,
|
|
ReleaseOutcome,
|
|
RunSnapshot,
|
|
RunSummary,
|
|
} from "./types.js";
|
|
|
|
export type { InvokableAgentSnapshot, IssueSnapshot, ReleaseRecoveryBlockedNoticeKind, RunSnapshot, RunSummary };
|
|
|
|
/** The primary issue a locked release resolves to, plus the finishing run the lock step already loaded. */
|
|
export type LockedIssueExecution = {
|
|
primaryIssue: IssueSnapshot;
|
|
run: RunSnapshot;
|
|
};
|
|
|
|
export type ReleaseTransactionResult = {
|
|
outcome: ReleaseOutcome;
|
|
postCommitEffects: PostCommitEffect[];
|
|
};
|
|
|
|
/**
|
|
* The three host callbacks the release use case needs. These members do
|
|
* not run on the module's own transaction, which is why two of them take
|
|
* the transaction-scoped issue snapshot instead of an issue id.
|
|
*/
|
|
export interface WakeQueueHost {
|
|
/**
|
|
* Takes the transaction-scoped issue snapshot, not an issue id, so this
|
|
* port never re-reads the issue on a separate connection while the
|
|
* module's own transaction is open.
|
|
*/
|
|
resolveResponsibleUserId(input: {
|
|
companyId: string;
|
|
contextSnapshot: Record<string, unknown>;
|
|
issue: IssueSnapshot;
|
|
/** From a prior `getRoutineEnv` call against the same issue; pass `{ routineId: null, env: null, responsibleUserId: null }` when the issue is not a routine execution. */
|
|
routineEnvContext: { routineId: string | null; env: unknown; responsibleUserId: string | null };
|
|
requestedByActorType: "user" | "agent" | "system" | null;
|
|
requestedByActorId: string | null;
|
|
source: string;
|
|
triggerDetail: string | null;
|
|
existingRunResponsibleUserId: string | null;
|
|
}): Promise<string | null>;
|
|
/**
|
|
* Takes the transaction-scoped issue snapshot, not an issue id, so this
|
|
* port never re-reads the issue on a separate connection while the
|
|
* module's own transaction is open.
|
|
*/
|
|
getRoutineEnv(input: {
|
|
companyId: string;
|
|
issue: IssueSnapshot;
|
|
}): Promise<{ routineId: string | null; env: unknown; responsibleUserId: string | null }>;
|
|
resolveSessionBeforeForWakeup(input: {
|
|
companyId: string;
|
|
agentId: string;
|
|
taskKey: string | null;
|
|
}): Promise<string | null>;
|
|
}
|
|
|
|
export type DeferredWakeCandidate = {
|
|
id: string;
|
|
companyId: string;
|
|
agentId: string;
|
|
reason: string | null;
|
|
source: string | null;
|
|
triggerDetail: string | null;
|
|
requestedByActorType: "user" | "agent" | "system" | null;
|
|
requestedByActorId: string | null;
|
|
payload: Record<string, unknown>;
|
|
/** The queued comment ids the wake's queued-comment context carries, already extracted from the payload. */
|
|
queuedCommentIds: string[];
|
|
/** True when the wake carries an independent reason to continue even with no live queued comments. */
|
|
preservesIndependentContinuation: boolean;
|
|
/** `payload._paperclipWakeContext`, already parsed to a plain object. */
|
|
deferredContextSeed: Record<string, unknown>;
|
|
/** The comment ids the wake's context snapshot carries (a separate set from queuedCommentIds), used for the reopen check. */
|
|
deferredCommentIds: string[];
|
|
wakeReason: string | null;
|
|
};
|
|
|
|
export type PromoteDeferredWakeInput = {
|
|
companyId: string;
|
|
wakeId: string;
|
|
deferredAgent: InvokableAgentSnapshot;
|
|
issue: IssueSnapshot;
|
|
finishingRun: RunSnapshot;
|
|
contextSnapshot: Record<string, unknown>;
|
|
reason: string;
|
|
source: string;
|
|
triggerDetail: string | null;
|
|
payload: Record<string, unknown>;
|
|
responsibleUserId: string;
|
|
sessionBefore: string | null;
|
|
now: Date;
|
|
};
|
|
|
|
/**
|
|
* Every member is bound to the one transaction that `withIssueExecutionLock`
|
|
* owns. The interface holds both reads and writes that drain and resolve
|
|
* the deferred-wake queue.
|
|
*/
|
|
export interface WakeQueueTransaction {
|
|
findInvokableAgent(input: { companyId: string; agentId: string }): Promise<InvokableAgentSnapshot | null>;
|
|
findNextDeferredWake(input: { companyId: string; issueId: string }): Promise<DeferredWakeCandidate | null>;
|
|
getQueuedCommentLiveness(input: {
|
|
companyId: string;
|
|
issueId: string;
|
|
wakeAgentId: string;
|
|
finishingRunId: string;
|
|
finishingRunAgentId: string;
|
|
queuedCommentIds: string[];
|
|
}): Promise<{ liveNonSelfCommentIds: string[]; containedSelfAuthoredComment: boolean }>;
|
|
/** Cancels the wake with `status = 'deferred_issue_execution'` as an atomic compare-and-set guard. */
|
|
cancelDeferredWake(input: {
|
|
companyId: string;
|
|
wakeId: string;
|
|
reason: string;
|
|
now: Date;
|
|
}): Promise<boolean>;
|
|
normalizeDeferredWakeCommentIds(input: {
|
|
companyId: string;
|
|
wakeId: string;
|
|
/** The wake's current payload, as already read by `findNextDeferredWake`, used as the rewrite base. */
|
|
payload: Record<string, unknown>;
|
|
liveCommentIds: string[];
|
|
now: Date;
|
|
}): Promise<DeferredWakeCandidate | null>;
|
|
/** Sets `status = 'failed'` guarded by the current `deferred_issue_execution` status. */
|
|
failDeferredWake(input: { companyId: string; wakeId: string; now: Date }): Promise<boolean>;
|
|
getPauseHoldFacts(input: {
|
|
companyId: string;
|
|
issueId: string;
|
|
wakeAgentId: string;
|
|
deferredContextSeed: Record<string, unknown>;
|
|
requestedByActorType: string | null;
|
|
requestedByActorId: string | null;
|
|
}): Promise<{
|
|
activePauseHold: boolean;
|
|
treeHoldInteractionWake: boolean;
|
|
holdId: string | null;
|
|
rootIssueId: string | null;
|
|
mode: string | null;
|
|
reason: string | null;
|
|
releasePolicy: unknown;
|
|
}>;
|
|
getCommentSelfAuthorship(input: {
|
|
companyId: string;
|
|
issueId: string;
|
|
finishingRunId: string;
|
|
commentIds: string[];
|
|
}): Promise<{ allSelfAuthored: boolean }>;
|
|
reopenIssue(input: { companyId: string; issueId: string; runId: string }): Promise<IssueSnapshot | null>;
|
|
/**
|
|
* Atomically claims the wake for promotion, guarded on its current
|
|
* `deferred_issue_execution` status. Call this before any other write in
|
|
* the promotion path (including a reopen), so a lost race here can never
|
|
* leave another write committed underneath it. Returns `false` when a
|
|
* concurrent writer already changed the wake's status.
|
|
*/
|
|
claimDeferredWakeForPromotion(input: { companyId: string; wakeId: string; now: Date }): Promise<boolean>;
|
|
/**
|
|
* Finalizes a wake that `claimDeferredWakeForPromotion` already claimed:
|
|
* inserts the queued run, links it back onto the wake row, and takes the
|
|
* issue's execution lock. Call only after that claim returns `true`.
|
|
*/
|
|
finalizePromotedWake(input: PromoteDeferredWakeInput): Promise<RunSummary>;
|
|
/** An open run already on this issue (optionally scoped to one agent) that would race a new recovery run. */
|
|
hasExistingExecutionPath(input: {
|
|
companyId: string;
|
|
issueId: string;
|
|
excludeRunId: string;
|
|
agentId: string | null;
|
|
}): Promise<boolean>;
|
|
/** An open, non-hidden issue that still lists this issue as a `blocks` predecessor. */
|
|
hasExplicitBlockerPath(input: { companyId: string; issueId: string }): Promise<boolean>;
|
|
isAutomaticRecoverySuppressedByPauseHold(input: { companyId: string; issueId: string }): Promise<boolean>;
|
|
queueReviewParticipantRecoveryRun(input: {
|
|
companyId: string;
|
|
issue: IssueSnapshot;
|
|
finishingRun: RunSnapshot;
|
|
recoveryAgent: InvokableAgentSnapshot;
|
|
sessionBefore: string | null;
|
|
now: Date;
|
|
}): Promise<RunSummary>;
|
|
/**
|
|
* Queues the run with the context snapshot and the responsible user the
|
|
* caller already resolved.
|
|
*/
|
|
queueImmediateRecoveryRun(input: {
|
|
companyId: string;
|
|
issue: IssueSnapshot;
|
|
finishingRun: RunSnapshot;
|
|
recoveryAgent: InvokableAgentSnapshot;
|
|
/** The wakeup request's reason and the run's context-snapshot wakeReason; the caller derives it from the issue status. */
|
|
reason: string;
|
|
contextSnapshot: Record<string, unknown>;
|
|
responsibleUserId: string;
|
|
sessionBefore: string | null;
|
|
now: Date;
|
|
}): Promise<RunSummary>;
|
|
}
|
|
|
|
/**
|
|
* Owns the module's own transaction: loads the finishing run, locks the
|
|
* context issue and every sibling issue in id order, clears the two
|
|
* release-lock columns, and picks the primary issue. When the primary
|
|
* issue is missing, already reclaimed, or resolved by an early exit
|
|
* (workspace-validation block, legacy reconciliation, a native-runtime
|
|
* terminal failure), the adapter returns that outcome directly without
|
|
* calling `fn`. Otherwise it calls `fn` with the locked issue and run, and
|
|
* with `host`/`transaction` ports, so every call `fn` makes through the
|
|
* transaction port participates in the one transaction this method owns.
|
|
*/
|
|
export interface IssueLockWriter {
|
|
withIssueExecutionLock(
|
|
input: { companyId: string; runId: string; now: Date },
|
|
fn: (
|
|
locked: LockedIssueExecution,
|
|
ports: { host: WakeQueueHost; transaction: WakeQueueTransaction },
|
|
) => Promise<ReleaseTransactionResult>,
|
|
): Promise<ReleaseTransactionResult & { run: RunSnapshot }>;
|
|
}
|
|
|
|
export type StrandedAssignedIssueEscalationInput = {
|
|
issue: IssueSnapshot;
|
|
previousStatus: "todo" | "in_progress" | "in_review";
|
|
latestRun: RunSnapshot;
|
|
noticeKind: ReleaseRecoveryBlockedNoticeKind;
|
|
};
|
|
|
|
export type StrandedRecoveryInPlaceEscalationInput = {
|
|
issue: IssueSnapshot;
|
|
previousStatus: "todo" | "in_progress" | "in_review";
|
|
latestRun: RunSnapshot;
|
|
};
|
|
|
|
/** Wraps `services/recovery`'s stranded-issue escalation, called only after the release transaction commits. */
|
|
export interface RecoveryEscalationPort {
|
|
escalateStrandedAssignedIssue(input: StrandedAssignedIssueEscalationInput): Promise<void>;
|
|
escalateStrandedRecoveryIssueInPlace(input: StrandedRecoveryInPlaceEscalationInput): Promise<void>;
|
|
}
|
|
|
|
export type { PostCommitEffect, ReleaseOutcome };
|