mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 16:58:52 +01:00
112 lines
4.6 KiB
TypeScript
112 lines
4.6 KiB
TypeScript
// Shared wake-reason and retry-reason constants, and the pure classifiers
|
|
// that read them off a run's context snapshot. `heartbeat.ts` and this
|
|
// module's own Postgres adapter both decide on the same context snapshot
|
|
// shape, so this file is their one shared source for it: a second,
|
|
// independently maintained copy in either file could silently drift out of
|
|
// sync with the other and change only one of the two gates that read it.
|
|
|
|
function parseObject(value: unknown): Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: {};
|
|
}
|
|
|
|
function readNonEmptyString(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
}
|
|
|
|
export const MAX_TURN_CONTINUATION_RETRY_REASON = "max_turns_continuation";
|
|
export const COMPUTER_ADMISSION_WAIT_RETRY_REASON = "computer_admission_wait";
|
|
export const WORKSPACE_BUSY_RETRY_REASON = "workspace_busy";
|
|
export const AI_CONNECTION_POOL_WAIT_RETRY_REASON = "ai_connection_pool_wait";
|
|
export const AI_CONNECTION_BUSY_RETRY_REASON = "ai_connection_busy";
|
|
export const INTERACTION_CONTINUATION_INFRA_RETRY_REASON = "interaction_continuation_infra_retry";
|
|
export const INTERACTION_CONTINUATION_INFRA_WAKE_REASON = "interaction_continuation_infra_retry";
|
|
export const WAKE_COMMENT_IDS_KEY = "wakeCommentIds";
|
|
export const RESOLVED_INTERACTION_CONTINUATION_STATUSES = new Set([
|
|
"accepted",
|
|
"answered",
|
|
"cancelled",
|
|
"rejected",
|
|
]);
|
|
|
|
/**
|
|
* True for a resource-wait retry whose original run did not
|
|
* execute under assignee-ship (a comment or review-participant wake). Such a
|
|
* retry has an expected assignee mismatch, so the scheduled-retry gate and
|
|
* the queued-run staleness check must not treat it as a reassignment.
|
|
*/
|
|
export function isNonAssigneeWorkspaceBusyRetry(
|
|
retryReason: string | null | undefined,
|
|
contextSnapshot: Record<string, unknown>,
|
|
): boolean {
|
|
return (
|
|
(retryReason === COMPUTER_ADMISSION_WAIT_RETRY_REASON &&
|
|
contextSnapshot.computerAdmissionDeferredWhileAssignee === false) ||
|
|
(retryReason === WORKSPACE_BUSY_RETRY_REASON &&
|
|
contextSnapshot.workspaceBusyDeferredWhileAssignee === false) ||
|
|
((retryReason === AI_CONNECTION_BUSY_RETRY_REASON || retryReason === AI_CONNECTION_POOL_WAIT_RETRY_REASON) &&
|
|
contextSnapshot.aiConnectionBusyDeferredWhileAssignee === false)
|
|
);
|
|
}
|
|
|
|
export function extractWakeCommentIds(
|
|
contextSnapshot: Record<string, unknown> | null | undefined,
|
|
): string[] {
|
|
const raw = contextSnapshot?.[WAKE_COMMENT_IDS_KEY];
|
|
if (!Array.isArray(raw)) return [];
|
|
const out: string[] = [];
|
|
for (const entry of raw) {
|
|
const value = readNonEmptyString(entry);
|
|
if (!value || out.includes(value)) continue;
|
|
out.push(value);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function deriveCommentId(
|
|
contextSnapshot: Record<string, unknown> | null | undefined,
|
|
payload?: Record<string, unknown> | null,
|
|
): string | null {
|
|
const batchedCommentId = extractWakeCommentIds(contextSnapshot).at(-1);
|
|
return (
|
|
batchedCommentId ??
|
|
readNonEmptyString(contextSnapshot?.wakeCommentId) ??
|
|
readNonEmptyString(contextSnapshot?.commentId) ??
|
|
readNonEmptyString(payload?.commentId) ??
|
|
null
|
|
);
|
|
}
|
|
|
|
/**
|
|
* `allowedWakeReasons` is the issue-tree-control module's own set of wake
|
|
* reasons that excuse an interaction wake. This file stays free of service
|
|
* imports, so the caller passes the set in rather than this function reading
|
|
* it from the service directly.
|
|
*/
|
|
export function allowsIssueInteractionWake(
|
|
contextSnapshot: Record<string, unknown> | null | undefined,
|
|
allowedWakeReasons: ReadonlySet<string>,
|
|
): boolean {
|
|
const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason);
|
|
if (!wakeReason || !allowedWakeReasons.has(wakeReason)) return false;
|
|
return Boolean(deriveCommentId(contextSnapshot));
|
|
}
|
|
|
|
export function isResolvedInteractionContinuationWakeContext(contextSnapshot: unknown): boolean {
|
|
const context = parseObject(contextSnapshot);
|
|
const interactionId = readNonEmptyString(context.interactionId);
|
|
const interactionStatus = readNonEmptyString(context.interactionStatus);
|
|
if (!interactionId || !interactionStatus) return false;
|
|
if (!RESOLVED_INTERACTION_CONTINUATION_STATUSES.has(interactionStatus)) return false;
|
|
|
|
const mutation = readNonEmptyString(context.mutation);
|
|
const wakeReason = readNonEmptyString(context.wakeReason);
|
|
const retryReason = readNonEmptyString(context.retryReason);
|
|
return (
|
|
(mutation === "interaction" && wakeReason === "issue_commented") ||
|
|
wakeReason === INTERACTION_CONTINUATION_INFRA_WAKE_REASON ||
|
|
retryReason === INTERACTION_CONTINUATION_INFRA_RETRY_REASON
|
|
);
|
|
}
|