Files
PaperClipAI/ui/src/lib/issue-queued-comment-queue.ts
T
DottaandPaperclip f52da40f08 fix: resume queued turns after maintenance (#17967)
Keep an accepted turn's starting input in task history, show its admission wait, and resume existing queued runs promptly when maintenance ends or expires.

Verified with behavioral red/green regressions, real PostgreSQL and process execution, browser/native Runner acceptance with a deterministic provider, workspace typecheck, production build, and current-head CI/review.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-27 22:20:03 -05:00

187 lines
7.2 KiB
TypeScript

import type {
IssueComment,
IssueQueuedCommentProtocol,
IssueQueuedCommentQueue,
IssueQueuedCommentQueueState,
IssueQueuedCommentSteeringDisposition,
} from "@paperclipai/shared";
import type { RunForIssue } from "../api/activity";
/** Presentation only. Admission has accepted these inputs, but has not consumed them. */
export function startingQueuedCommentIds(
queue: IssueQueuedCommentQueue | null | undefined,
runs: readonly RunForIssue[],
): ReadonlySet<string> {
if (!queue?.queueId || queue.state !== "queued" || queue.targetRunId) return new Set();
const owners = runs.filter(run => run.contextIssueId === queue.issueId &&
run.wakeupRequestId === queue.queueId && run.status === "queued" &&
!run.startedAt && !run.retryOfRunId);
if (owners.length !== 1 || runs.some(run => run.contextIssueId === queue.issueId &&
run.runId !== owners[0]!.runId && run.status === "running")) return new Set();
const run = owners[0]!;
const inputs = new Set(run.wakeCommentIds?.length ? run.wakeCommentIds
: [run.wakeCommentId ?? run.contextCommentId]);
const createdAt = new Date(run.createdAt).getTime();
return new Set(queue.entries.flatMap(entry =>
!entry.source && !entry.comment.authorAgentId && !entry.comment.deletedAt &&
inputs.has(entry.comment.id) && new Date(entry.comment.createdAt).getTime() <= createdAt
? [entry.comment.id] : []));
}
function record(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
const STEERING_DISPOSITIONS = new Set<IssueQueuedCommentSteeringDisposition>([
"available",
"unsupported",
"temporarily_unavailable",
]);
const QUEUE_STATES = new Set<IssueQueuedCommentQueueState>([
"deferred",
"queued",
]);
/** Defensive boundary shared by IssueDetail and Storybook queue fixtures. */
export function normalizeIssueQueuedCommentQueue(
value: unknown,
fallbackIssueId: string,
): IssueQueuedCommentQueue {
const source = record(value);
const seen = new Set<string>();
const entries = (Array.isArray(source?.entries) ? source.entries : [])
.flatMap((candidate, sourcePosition) => {
const entry = record(candidate);
const comment = record(entry?.comment);
const id = typeof comment?.id === "string" ? comment.id : "";
const body = typeof comment?.body === "string" ? comment.body : null;
if (!id || body === null || seen.has(id)) return [];
seen.add(id);
const position =
typeof entry?.position === "number" && Number.isFinite(entry.position)
? entry.position
: sourcePosition;
const response = record(entry?.source);
return [
{
comment: comment as unknown as IssueComment,
position,
...(response?.kind === "interaction" && typeof response.interactionId === "string"
&& typeof response.interactionKind === "string" ? { source: {
kind: "interaction" as const, interactionId: response.interactionId,
interactionKind: response.interactionKind,
requiresFreshSession: response.requiresFreshSession === true,
} } : {}),
canEdit: entry?.canEdit === true,
canDiscard: entry?.canDiscard === true,
},
];
})
.sort((left, right) => left.position - right.position)
.map((entry, position) => ({ ...entry, position }));
const disposition = source?.steeringDisposition;
const state = source?.state;
const wait = record(source?.executionWait);
return {
issueId:
typeof source?.issueId === "string" ? source.issueId : fallbackIssueId,
queueId: typeof source?.queueId === "string" ? source.queueId : null,
state:
typeof state === "string" &&
QUEUE_STATES.has(state as IssueQueuedCommentQueueState)
? (state as IssueQueuedCommentQueueState)
: null,
targetRunId:
typeof source?.targetRunId === "string" ? source.targetRunId : null,
revision:
typeof source?.revision === "string" ? source.revision : "unavailable",
protocol:
source?.protocol === "paperclip_runner_v1"
? "paperclip_runner_v1"
: "legacy",
steeringDisposition:
typeof disposition === "string" &&
STEERING_DISPOSITIONS.has(
disposition as IssueQueuedCommentSteeringDisposition,
)
? (disposition as IssueQueuedCommentSteeringDisposition)
: "unsupported",
entries,
executionWait: typeof wait?.reason === "string" && typeof wait?.message === "string"
? { reason: wait.reason, message: wait.message }
: null,
};
}
export interface PendingIssueQueuedComment {
comment: IssueComment;
targetRunId: string | null;
}
/**
* Keeps a just-submitted follow-up in the composer queue while the comment and
* queue endpoints acknowledge it independently. If the server has not yet
* included every local entry, queue mutations are withheld by clearing the
* queue id; local discard remains available through the optimistic comment
* cancellation path.
*/
export function mergePendingIssueQueuedComments(params: {
issueId: string;
authoritativeQueue: IssueQueuedCommentQueue | null | undefined;
pendingComments: PendingIssueQueuedComment[];
fallbackProtocol: IssueQueuedCommentProtocol;
}): IssueQueuedCommentQueue | null {
const authoritativeEntries = params.authoritativeQueue?.entries ?? [];
const authoritativeIds = new Set(
authoritativeEntries.map((entry) => entry.comment.id),
);
const pendingEntries = params.pendingComments
.filter(({ comment }) => !authoritativeIds.has(comment.id))
.map(({ comment }, position) => ({
comment,
position: authoritativeEntries.length + position,
canEdit: false,
canDiscard: true,
}));
const entries = [...authoritativeEntries, ...pendingEntries].map(
(entry, position) => ({ ...entry, position }),
);
if (entries.length === 0) return null;
const queueAcknowledged = pendingEntries.length === 0;
const authoritativeOwnsQueue = Boolean(params.authoritativeQueue?.queueId);
const fallbackTargetRunId =
params.pendingComments.find((entry) => entry.targetRunId)?.targetRunId ??
null;
// An empty snapshot predating this submission does not own its runtime.
// Use the active run until the server acknowledges an actual queue.
const protocol = authoritativeOwnsQueue
? params.authoritativeQueue!.protocol
: params.fallbackProtocol;
const targetRunId = authoritativeOwnsQueue
? (params.authoritativeQueue?.targetRunId ?? null)
: fallbackTargetRunId;
return {
issueId: params.authoritativeQueue?.issueId ?? params.issueId,
queueId: queueAcknowledged
? (params.authoritativeQueue?.queueId ?? null)
: null,
state:
params.authoritativeQueue?.state ?? (targetRunId ? "deferred" : null),
targetRunId,
revision: params.authoritativeQueue?.revision ?? "awaiting-server",
protocol,
steeringDisposition:
(authoritativeOwnsQueue ? params.authoritativeQueue?.steeringDisposition : undefined) ??
(protocol === "paperclip_runner_v1" && targetRunId
? "temporarily_unavailable"
: "unsupported"),
entries,
executionWait: params.authoritativeQueue?.executionWait ?? null,
};
}