mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 02:32:07 +01:00
Revert "PAP-2731 Implement sky explicit-wait UI surfaces"
This reverts commit 08a9e0caf0.
This commit is contained in:
1 parent
0a345e9648
commit
caf37a355d
43 files changed
+181
-4777
No files matched your search
@@ -7304,11 +7304,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
return wakeupIds.length;
|
||||
}
|
||||
|
||||
async function cancelRunInternal(
|
||||
runId: string,
|
||||
reason = "Cancelled by control plane",
|
||||
opts?: { startNextQueuedRun?: boolean },
|
||||
) {
|
||||
async function cancelRunInternal(runId: string, reason = "Cancelled by control plane") {
|
||||
const run = await getRun(runId);
|
||||
if (!run) throw notFound("Heartbeat run not found");
|
||||
if (!CANCELLABLE_HEARTBEAT_RUN_STATUSES.includes(run.status as (typeof CANCELLABLE_HEARTBEAT_RUN_STATUSES)[number])) return run;
|
||||
@@ -7358,90 +7354,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
|
||||
runningProcesses.delete(run.id);
|
||||
await finalizeAgentStatus(run.agentId, "cancelled");
|
||||
if (opts?.startNextQueuedRun !== false) {
|
||||
await startNextQueuedRunForAgent(run.agentId);
|
||||
}
|
||||
await startNextQueuedRunForAgent(run.agentId);
|
||||
return cancelled;
|
||||
}
|
||||
|
||||
async function cancelIssueRunsInternal(input: {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
reason?: string;
|
||||
}) {
|
||||
const reason = input.reason ?? "Cancelled because the issue was cancelled";
|
||||
const now = new Date();
|
||||
|
||||
const cancelledWakeups = await db
|
||||
.update(agentWakeupRequests)
|
||||
.set({
|
||||
status: "cancelled",
|
||||
finishedAt: now,
|
||||
error: reason,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(agentWakeupRequests.companyId, input.companyId),
|
||||
inArray(agentWakeupRequests.status, ["queued", "deferred_issue_execution"]),
|
||||
isNull(agentWakeupRequests.runId),
|
||||
sql`${agentWakeupRequests.payload} ->> 'issueId' = ${input.issueId}`,
|
||||
),
|
||||
)
|
||||
.returning({
|
||||
id: agentWakeupRequests.id,
|
||||
agentId: agentWakeupRequests.agentId,
|
||||
reason: agentWakeupRequests.reason,
|
||||
status: agentWakeupRequests.status,
|
||||
payload: agentWakeupRequests.payload,
|
||||
});
|
||||
|
||||
const issue = await db
|
||||
.select({
|
||||
executionRunId: issues.executionRunId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, input.companyId), eq(issues.id, input.issueId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
const contextIssueId = sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`;
|
||||
const runs = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.companyId, input.companyId),
|
||||
inArray(heartbeatRuns.status, [...CANCELLABLE_HEARTBEAT_RUN_STATUSES]),
|
||||
issue?.executionRunId
|
||||
? or(eq(heartbeatRuns.id, issue.executionRunId), sql`${contextIssueId} = ${input.issueId}`)
|
||||
: sql`${contextIssueId} = ${input.issueId}`,
|
||||
),
|
||||
)
|
||||
.orderBy(
|
||||
sql`case when ${heartbeatRuns.status} = 'running' then 0 when ${heartbeatRuns.status} = 'queued' then 1 else 2 end`,
|
||||
asc(heartbeatRuns.createdAt),
|
||||
);
|
||||
|
||||
const cancelledRuns: Array<typeof heartbeatRuns.$inferSelect> = [];
|
||||
const affectedAgentIds = new Set<string>();
|
||||
for (const run of runs) {
|
||||
const cancelled = await cancelRunInternal(run.id, reason, { startNextQueuedRun: false });
|
||||
affectedAgentIds.add(run.agentId);
|
||||
if (cancelled?.status === "cancelled") {
|
||||
cancelledRuns.push(cancelled);
|
||||
}
|
||||
}
|
||||
|
||||
for (const agentId of affectedAgentIds) {
|
||||
await startNextQueuedRunForAgent(agentId);
|
||||
}
|
||||
|
||||
return {
|
||||
runs: cancelledRuns,
|
||||
wakeups: cancelledWakeups,
|
||||
};
|
||||
}
|
||||
|
||||
async function cancelActiveForAgentInternal(agentId: string, reason = "Cancelled due to agent pause") {
|
||||
const agent = await getAgent(agentId);
|
||||
const runs = await db
|
||||
@@ -7816,9 +7732,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
return { checked, enqueued, skipped };
|
||||
},
|
||||
|
||||
cancelRun: (runId: string, reason?: string) => cancelRunInternal(runId, reason),
|
||||
|
||||
cancelIssueRuns: (input: { companyId: string; issueId: string; reason?: string }) => cancelIssueRunsInternal(input),
|
||||
cancelRun: (runId: string) => cancelRunInternal(runId),
|
||||
|
||||
cancelActiveForAgent: (agentId: string) => cancelActiveForAgentInternal(agentId),
|
||||
|
||||
|
||||
@@ -1,521 +0,0 @@
|
||||
import { and, desc, eq, inArray, isNull, ne, notInArray, sql } from "drizzle-orm";
|
||||
import {
|
||||
agentWakeupRequests,
|
||||
agents,
|
||||
approvals,
|
||||
heartbeatRuns,
|
||||
issueApprovals,
|
||||
issueRelations,
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
IssueExecutionDisposition,
|
||||
IssueOriginKind,
|
||||
IssueRelationIssueSummary,
|
||||
IssueStatus,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
isLiveExplicitApprovalWaitingPath,
|
||||
isLiveExplicitInteractionWaitingPath,
|
||||
} from "./recovery/explicit-waiting-paths.js";
|
||||
import { parseIssueGraphLivenessIncidentKey } from "./recovery/origins.js";
|
||||
import {
|
||||
classifyIssueExecutionDisposition,
|
||||
type IssueExecutionParticipantState,
|
||||
type IssueExecutionRunLivenessState,
|
||||
type IssueExecutionRunStatus,
|
||||
} from "./issue-execution-disposition.js";
|
||||
|
||||
const ACTIVE_RUN_STATUSES = ["queued", "running"] as const;
|
||||
const ACTIVE_WAKE_STATUSES = ["queued", "deferred_issue_execution"] as const;
|
||||
const PENDING_INTERACTION_STATUSES = ["pending"] as const;
|
||||
const WAITING_INTERACTION_KINDS = ["request_confirmation", "ask_user_questions", "suggest_tasks"] as const;
|
||||
const PENDING_APPROVAL_STATUSES = ["pending", "revision_requested"] as const;
|
||||
const STRANDED_RECOVERY_ORIGIN_KIND = "stranded_issue_recovery" as const;
|
||||
const HARNESS_LIVENESS_ESCALATION_ORIGIN_KIND = "harness_liveness_escalation" as const;
|
||||
const PRODUCTIVITY_REVIEW_ORIGIN_KIND = "issue_productivity_review" as const;
|
||||
const NON_TERMINAL_RECOVERY_STATUSES = ["done", "cancelled"] as const;
|
||||
const INVOKABLE_AGENT_STATUSES: ReadonlySet<string> = new Set(["active", "idle", "running", "error"]);
|
||||
const DEFAULT_MAX_CONTINUATION_ATTEMPTS = 2;
|
||||
const QUERY_CHUNK_SIZE = 500;
|
||||
|
||||
export interface IssueExecutionDispositionInputNode {
|
||||
id: string;
|
||||
companyId: string;
|
||||
status: IssueStatus | string;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
originKind: IssueOriginKind | string | null;
|
||||
executionRunId: string | null;
|
||||
executionState?: Record<string, unknown> | null;
|
||||
blockedBy?: IssueRelationIssueSummary[];
|
||||
}
|
||||
|
||||
interface ExplicitInteractionRow {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
status: string;
|
||||
createdByUserId: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface ExplicitApprovalRow {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
status: string;
|
||||
requestedByUserId: string | null;
|
||||
linkedByUserId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
linkedAt: Date;
|
||||
}
|
||||
|
||||
interface LatestRunRow {
|
||||
issueId: string;
|
||||
status: string;
|
||||
livenessState: string | null;
|
||||
continuationAttempt: number | null;
|
||||
contextSnapshot: unknown;
|
||||
}
|
||||
|
||||
function chunk<T>(items: T[], size: number): T[][] {
|
||||
if (items.length === 0) return [];
|
||||
const out: T[][] = [];
|
||||
for (let i = 0; i < items.length; i += size) {
|
||||
out.push(items.slice(i, i + size));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function readPrincipalAgentId(principal: unknown): string | null {
|
||||
if (!principal || typeof principal !== "object") return null;
|
||||
const value = principal as Record<string, unknown>;
|
||||
return value.type === "agent" && typeof value.agentId === "string" && value.agentId.length > 0
|
||||
? value.agentId
|
||||
: null;
|
||||
}
|
||||
|
||||
function principalIsResolvableUser(principal: unknown): boolean {
|
||||
if (!principal || typeof principal !== "object") return false;
|
||||
const value = principal as Record<string, unknown>;
|
||||
return value.type === "user" && typeof value.userId === "string" && value.userId.length > 0;
|
||||
}
|
||||
|
||||
function participantStateForExecutionState(
|
||||
executionState: Record<string, unknown> | null | undefined,
|
||||
agentStatusById: ReadonlyMap<string, string>,
|
||||
): IssueExecutionParticipantState {
|
||||
if (!executionState) return "none";
|
||||
const status = (executionState.status as string | undefined) ?? null;
|
||||
if (!status || status === "satisfied") return "none";
|
||||
const principal = executionState.currentParticipant ?? null;
|
||||
if (!principal) return "none";
|
||||
const agentId = readPrincipalAgentId(principal);
|
||||
if (agentId) {
|
||||
const agentStatus = agentStatusById.get(agentId);
|
||||
if (agentStatus && INVOKABLE_AGENT_STATUSES.has(agentStatus)) return "valid";
|
||||
return "invalid";
|
||||
}
|
||||
if (principalIsResolvableUser(principal)) return "valid";
|
||||
return "invalid";
|
||||
}
|
||||
|
||||
function readNextActionFlag(contextSnapshot: unknown): boolean {
|
||||
if (!contextSnapshot || typeof contextSnapshot !== "object") return false;
|
||||
const next = (contextSnapshot as Record<string, unknown>).nextAction;
|
||||
return typeof next === "string" && next.trim().length > 0;
|
||||
}
|
||||
|
||||
export async function listIssueExecutionDispositionsMap(
|
||||
dbOrTx: any,
|
||||
companyId: string,
|
||||
issueRows: IssueExecutionDispositionInputNode[],
|
||||
): Promise<Map<string, IssueExecutionDisposition>> {
|
||||
const result = new Map<string, IssueExecutionDisposition>();
|
||||
const sameCompanyRows = issueRows.filter((row) => row.companyId === companyId);
|
||||
if (sameCompanyRows.length === 0) return result;
|
||||
|
||||
const issueIds = sameCompanyRows.map((row) => row.id);
|
||||
|
||||
// The list select drops executionState for performance; rehydrate it from the DB
|
||||
// for the issues that actually need participant evidence (in_review).
|
||||
const reviewIdsMissingState = sameCompanyRows
|
||||
.filter((row) => row.status === "in_review" && row.executionState === undefined)
|
||||
.map((row) => row.id);
|
||||
const executionStateById = new Map<string, Record<string, unknown> | null>();
|
||||
for (const part of chunk(reviewIdsMissingState, QUERY_CHUNK_SIZE)) {
|
||||
const rows: Array<{ id: string; executionState: Record<string, unknown> | null }> = await dbOrTx
|
||||
.select({ id: issues.id, executionState: issues.executionState })
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), inArray(issues.id, part)));
|
||||
for (const r of rows) executionStateById.set(r.id, r.executionState ?? null);
|
||||
}
|
||||
const resolvedExecutionStateFor = (row: IssueExecutionDispositionInputNode) =>
|
||||
row.executionState !== undefined ? row.executionState : executionStateById.get(row.id) ?? null;
|
||||
|
||||
const agentIds = new Set<string>();
|
||||
for (const row of sameCompanyRows) {
|
||||
if (row.assigneeAgentId) agentIds.add(row.assigneeAgentId);
|
||||
const state = resolvedExecutionStateFor(row);
|
||||
const participant = state?.currentParticipant ?? null;
|
||||
const participantAgentId = readPrincipalAgentId(participant);
|
||||
if (participantAgentId) agentIds.add(participantAgentId);
|
||||
}
|
||||
|
||||
const activeRunIssueIds = new Set<string>();
|
||||
const queuedWakeIssueIds = new Set<string>();
|
||||
const explicitInteractionLive = new Set<string>();
|
||||
const explicitApprovalLive = new Set<string>();
|
||||
const openRecoveryIssueOrigins = new Set<string>();
|
||||
const openProductivityReviewOrigins = new Set<string>();
|
||||
const latestRunByIssueId = new Map<string, LatestRunRow>();
|
||||
|
||||
// Active runs: any heartbeatRun in queued/running referencing the issue via contextSnapshot.
|
||||
for (const part of chunk(issueIds, QUERY_CHUNK_SIZE)) {
|
||||
const rows: Array<{ issueId: string | null }> = await dbOrTx
|
||||
.select({
|
||||
issueId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.companyId, companyId),
|
||||
inArray(heartbeatRuns.status, [...ACTIVE_RUN_STATUSES]),
|
||||
inArray(sql<string>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`, part),
|
||||
),
|
||||
);
|
||||
for (const row of rows) {
|
||||
if (row.issueId) activeRunIssueIds.add(row.issueId);
|
||||
}
|
||||
}
|
||||
|
||||
// Active runs by executionRunId — covers cases where the run claimed the issue but the
|
||||
// contextSnapshot lookup misses (legacy snapshots).
|
||||
const executionRunIds = sameCompanyRows
|
||||
.map((row) => row.executionRunId)
|
||||
.filter((value): value is string => Boolean(value));
|
||||
for (const part of chunk(executionRunIds, QUERY_CHUNK_SIZE)) {
|
||||
const rows: Array<{ id: string }> = await dbOrTx
|
||||
.select({ id: heartbeatRuns.id })
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.companyId, companyId),
|
||||
inArray(heartbeatRuns.status, [...ACTIVE_RUN_STATUSES]),
|
||||
inArray(heartbeatRuns.id, part),
|
||||
),
|
||||
);
|
||||
if (rows.length === 0) continue;
|
||||
const liveExecutionRunIds = new Set(rows.map((row) => row.id));
|
||||
for (const row of sameCompanyRows) {
|
||||
if (row.executionRunId && liveExecutionRunIds.has(row.executionRunId)) {
|
||||
activeRunIssueIds.add(row.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Queued wakes: agent_wakeup_requests with issueId payload, no runId yet.
|
||||
for (const part of chunk(issueIds, QUERY_CHUNK_SIZE)) {
|
||||
const rows: Array<{ issueId: string | null }> = await dbOrTx
|
||||
.select({
|
||||
issueId: sql<string | null>`${agentWakeupRequests.payload} ->> 'issueId'`,
|
||||
})
|
||||
.from(agentWakeupRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(agentWakeupRequests.companyId, companyId),
|
||||
inArray(agentWakeupRequests.status, [...ACTIVE_WAKE_STATUSES]),
|
||||
sql`${agentWakeupRequests.runId} is null`,
|
||||
inArray(sql<string>`${agentWakeupRequests.payload} ->> 'issueId'`, part),
|
||||
),
|
||||
);
|
||||
for (const row of rows) {
|
||||
if (row.issueId) queuedWakeIssueIds.add(row.issueId);
|
||||
}
|
||||
}
|
||||
|
||||
// Pending interactions.
|
||||
const interactionsByIssueId = new Map<string, ExplicitInteractionRow[]>();
|
||||
for (const part of chunk(issueIds, QUERY_CHUNK_SIZE)) {
|
||||
const rows: ExplicitInteractionRow[] = await dbOrTx
|
||||
.select({
|
||||
companyId: issueThreadInteractions.companyId,
|
||||
issueId: issueThreadInteractions.issueId,
|
||||
status: issueThreadInteractions.status,
|
||||
createdByUserId: issueThreadInteractions.createdByUserId,
|
||||
createdAt: issueThreadInteractions.createdAt,
|
||||
})
|
||||
.from(issueThreadInteractions)
|
||||
.where(
|
||||
and(
|
||||
eq(issueThreadInteractions.companyId, companyId),
|
||||
inArray(issueThreadInteractions.status, [...PENDING_INTERACTION_STATUSES]),
|
||||
inArray(issueThreadInteractions.kind, [...WAITING_INTERACTION_KINDS]),
|
||||
inArray(issueThreadInteractions.issueId, part),
|
||||
),
|
||||
);
|
||||
for (const row of rows) {
|
||||
const list = interactionsByIssueId.get(row.issueId) ?? [];
|
||||
list.push(row);
|
||||
interactionsByIssueId.set(row.issueId, list);
|
||||
}
|
||||
}
|
||||
|
||||
// Pending approvals.
|
||||
const approvalsByIssueId = new Map<string, ExplicitApprovalRow[]>();
|
||||
for (const part of chunk(issueIds, QUERY_CHUNK_SIZE)) {
|
||||
const rows: ExplicitApprovalRow[] = await dbOrTx
|
||||
.select({
|
||||
companyId: issueApprovals.companyId,
|
||||
issueId: issueApprovals.issueId,
|
||||
status: approvals.status,
|
||||
requestedByUserId: approvals.requestedByUserId,
|
||||
linkedByUserId: issueApprovals.linkedByUserId,
|
||||
createdAt: approvals.createdAt,
|
||||
updatedAt: approvals.updatedAt,
|
||||
linkedAt: issueApprovals.createdAt,
|
||||
})
|
||||
.from(issueApprovals)
|
||||
.innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
|
||||
.where(
|
||||
and(
|
||||
eq(issueApprovals.companyId, companyId),
|
||||
inArray(approvals.status, [...PENDING_APPROVAL_STATUSES]),
|
||||
inArray(issueApprovals.issueId, part),
|
||||
),
|
||||
);
|
||||
for (const row of rows) {
|
||||
const list = approvalsByIssueId.get(row.issueId) ?? [];
|
||||
list.push(row);
|
||||
approvalsByIssueId.set(row.issueId, list);
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
for (const row of sameCompanyRows) {
|
||||
const issueShape = {
|
||||
companyId: row.companyId,
|
||||
id: row.id,
|
||||
assigneeUserId: row.assigneeUserId,
|
||||
executionState: resolvedExecutionStateFor(row),
|
||||
};
|
||||
const interactions = interactionsByIssueId.get(row.id) ?? [];
|
||||
if (interactions.some((entry) => isLiveExplicitInteractionWaitingPath(issueShape, entry, now))) {
|
||||
explicitInteractionLive.add(row.id);
|
||||
}
|
||||
const approvalsForIssue = approvalsByIssueId.get(row.id) ?? [];
|
||||
if (approvalsForIssue.some((entry) => isLiveExplicitApprovalWaitingPath(issueShape, entry, now))) {
|
||||
explicitApprovalLive.add(row.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Open recovery / harness escalation / productivity review issues whose origin points at our inputs.
|
||||
for (const part of chunk(issueIds, QUERY_CHUNK_SIZE)) {
|
||||
const rows: Array<{ originId: string | null }> = await dbOrTx
|
||||
.select({ originId: issues.originId })
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.originKind, STRANDED_RECOVERY_ORIGIN_KIND),
|
||||
isNull(issues.hiddenAt),
|
||||
inArray(issues.originId, part),
|
||||
notInArray(issues.status, [...NON_TERMINAL_RECOVERY_STATUSES]),
|
||||
),
|
||||
);
|
||||
for (const r of rows) {
|
||||
if (r.originId) openRecoveryIssueOrigins.add(r.originId);
|
||||
}
|
||||
}
|
||||
const livenessRecoveryRows: Array<{ originId: string | null }> = await dbOrTx
|
||||
.select({ originId: issues.originId })
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.originKind, HARNESS_LIVENESS_ESCALATION_ORIGIN_KIND),
|
||||
isNull(issues.hiddenAt),
|
||||
notInArray(issues.status, [...NON_TERMINAL_RECOVERY_STATUSES]),
|
||||
),
|
||||
);
|
||||
for (const r of livenessRecoveryRows) {
|
||||
const parsed = parseIssueGraphLivenessIncidentKey(r.originId);
|
||||
if (!parsed || parsed.companyId !== companyId) continue;
|
||||
openRecoveryIssueOrigins.add(parsed.issueId);
|
||||
openRecoveryIssueOrigins.add(parsed.leafIssueId);
|
||||
}
|
||||
for (const part of chunk(issueIds, QUERY_CHUNK_SIZE)) {
|
||||
const rows: Array<{ originId: string | null }> = await dbOrTx
|
||||
.select({ originId: issues.originId })
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND),
|
||||
isNull(issues.hiddenAt),
|
||||
inArray(issues.originId, part),
|
||||
notInArray(issues.status, [...NON_TERMINAL_RECOVERY_STATUSES]),
|
||||
),
|
||||
);
|
||||
for (const r of rows) {
|
||||
if (r.originId) openProductivityReviewOrigins.add(r.originId);
|
||||
}
|
||||
}
|
||||
|
||||
// Latest issue-linked run, used to populate run evidence.
|
||||
const recoveryCandidateIds = sameCompanyRows
|
||||
.filter((row) =>
|
||||
row.assigneeAgentId &&
|
||||
!activeRunIssueIds.has(row.id) &&
|
||||
!queuedWakeIssueIds.has(row.id) &&
|
||||
(row.status === "todo" || row.status === "in_progress"),
|
||||
)
|
||||
.map((row) => row.id);
|
||||
for (const part of chunk(recoveryCandidateIds, QUERY_CHUNK_SIZE)) {
|
||||
const rows: LatestRunRow[] = await dbOrTx
|
||||
.select({
|
||||
issueId: sql<string>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`,
|
||||
status: heartbeatRuns.status,
|
||||
livenessState: heartbeatRuns.livenessState,
|
||||
continuationAttempt: heartbeatRuns.continuationAttempt,
|
||||
contextSnapshot: heartbeatRuns.contextSnapshot,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.companyId, companyId),
|
||||
inArray(sql<string>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`, part),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id));
|
||||
for (const row of rows) {
|
||||
if (!row.issueId) continue;
|
||||
if (latestRunByIssueId.has(row.issueId)) continue;
|
||||
latestRunByIssueId.set(row.issueId, row);
|
||||
}
|
||||
}
|
||||
|
||||
// Agents.
|
||||
const agentStatusById = new Map<string, string>();
|
||||
if (agentIds.size > 0) {
|
||||
const rows: Array<{ id: string; status: string }> = await dbOrTx
|
||||
.select({ id: agents.id, status: agents.status })
|
||||
.from(agents)
|
||||
.where(and(eq(agents.companyId, companyId), inArray(agents.id, [...agentIds])));
|
||||
for (const r of rows) agentStatusById.set(r.id, r.status);
|
||||
}
|
||||
|
||||
for (const row of sameCompanyRows) {
|
||||
const agentStatus = row.assigneeAgentId ? agentStatusById.get(row.assigneeAgentId) ?? null : null;
|
||||
const latestRun = latestRunByIssueId.get(row.id) ?? null;
|
||||
const productivityReviewNeeded = openProductivityReviewOrigins.has(row.id);
|
||||
const openRecoveryIssue = openRecoveryIssueOrigins.has(row.id);
|
||||
const blockers = (row.blockedBy ?? []).map((blocker) => ({
|
||||
issue: {
|
||||
id: blocker.id,
|
||||
status: blocker.status as IssueStatus,
|
||||
assigneeAgentId: blocker.assigneeAgentId ?? null,
|
||||
assigneeUserId: blocker.assigneeUserId ?? null,
|
||||
},
|
||||
}));
|
||||
|
||||
const disposition = classifyIssueExecutionDisposition({
|
||||
issue: {
|
||||
id: row.id,
|
||||
status: row.status as IssueStatus,
|
||||
assigneeAgentId: row.assigneeAgentId,
|
||||
assigneeUserId: row.assigneeUserId,
|
||||
originKind: row.originKind ?? null,
|
||||
},
|
||||
agent: row.assigneeAgentId
|
||||
? { id: row.assigneeAgentId, status: agentStatus ?? "terminated" }
|
||||
: null,
|
||||
execution: {
|
||||
activeRun: activeRunIssueIds.has(row.id),
|
||||
queuedWake: queuedWakeIssueIds.has(row.id),
|
||||
},
|
||||
waits: {
|
||||
participant: participantStateForExecutionState(resolvedExecutionStateFor(row), agentStatusById),
|
||||
pendingInteraction: explicitInteractionLive.has(row.id) ? "live" : "none",
|
||||
pendingApproval: explicitApprovalLive.has(row.id) ? "live" : "none",
|
||||
openRecoveryIssue,
|
||||
openProductivityReviewIssue: productivityReviewNeeded,
|
||||
productivityReviewNeeded,
|
||||
},
|
||||
latestRun: latestRun
|
||||
? {
|
||||
latestRunStatus: latestRun.status as IssueExecutionRunStatus,
|
||||
livenessState: (latestRun.livenessState as IssueExecutionRunLivenessState | null) ?? null,
|
||||
nextAction: readNextActionFlag(latestRun.contextSnapshot) ? "runnable" : "none",
|
||||
continuationAttempt: latestRun.continuationAttempt,
|
||||
maxContinuationAttempts: DEFAULT_MAX_CONTINUATION_ATTEMPTS,
|
||||
}
|
||||
: null,
|
||||
blockers,
|
||||
});
|
||||
|
||||
result.set(row.id, disposition);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Helper: load blockedBy relation summaries for issues that don't already have them on the input.
|
||||
export async function backfillBlockedBySummaries(
|
||||
dbOrTx: any,
|
||||
companyId: string,
|
||||
rows: IssueExecutionDispositionInputNode[],
|
||||
): Promise<void> {
|
||||
const needsBackfill = rows.filter((row) => row.status === "blocked" && !row.blockedBy);
|
||||
if (needsBackfill.length === 0) return;
|
||||
const ids = needsBackfill.map((row) => row.id);
|
||||
const blockerRows: Array<{
|
||||
issueId: string;
|
||||
blockerIssueId: string;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
priority: string | null;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
}> = await dbOrTx
|
||||
.select({
|
||||
issueId: issueRelations.relatedIssueId,
|
||||
blockerIssueId: issues.id,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
priority: issues.priority,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
})
|
||||
.from(issueRelations)
|
||||
.innerJoin(issues, eq(issueRelations.issueId, issues.id))
|
||||
.where(
|
||||
and(
|
||||
eq(issueRelations.companyId, companyId),
|
||||
eq(issueRelations.type, "blocks"),
|
||||
inArray(issueRelations.relatedIssueId, ids),
|
||||
eq(issues.companyId, companyId),
|
||||
ne(issues.status, "done"),
|
||||
),
|
||||
);
|
||||
const blockersByIssueId = new Map<string, IssueRelationIssueSummary[]>();
|
||||
for (const row of blockerRows) {
|
||||
const list = blockersByIssueId.get(row.issueId) ?? [];
|
||||
list.push({
|
||||
id: row.blockerIssueId,
|
||||
identifier: row.identifier,
|
||||
title: row.title,
|
||||
status: row.status as IssueStatus,
|
||||
priority: (row.priority ?? "medium") as IssueRelationIssueSummary["priority"],
|
||||
assigneeAgentId: row.assigneeAgentId,
|
||||
assigneeUserId: row.assigneeUserId,
|
||||
});
|
||||
blockersByIssueId.set(row.issueId, list);
|
||||
}
|
||||
for (const row of needsBackfill) {
|
||||
row.blockedBy = blockersByIssueId.get(row.id) ?? [];
|
||||
}
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
import type {
|
||||
AgentStatus,
|
||||
IssueOriginKind,
|
||||
IssueStatus,
|
||||
RunLivenessState,
|
||||
} from "@paperclipai/shared";
|
||||
import type {
|
||||
IssueExecutionDisposition,
|
||||
IssueExecutionInvalidReason,
|
||||
IssueExecutionLivePath,
|
||||
IssueExecutionWaitingPath,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
export type IssueExecutionWaitSignalState = "none" | "live" | "stale";
|
||||
export type IssueExecutionParticipantState = "none" | "valid" | "invalid";
|
||||
export type IssueExecutionRunStatus =
|
||||
| "queued"
|
||||
| "running"
|
||||
| "succeeded"
|
||||
| "failed"
|
||||
| "timed_out"
|
||||
| "cancelled";
|
||||
export type IssueExecutionRunLivenessState = RunLivenessState | "unknown";
|
||||
export type IssueExecutionNextActionState =
|
||||
| "runnable"
|
||||
| "manager_review"
|
||||
| "approval_required"
|
||||
| "unknown"
|
||||
| "none";
|
||||
|
||||
export interface IssueExecutionDispositionIssueInput {
|
||||
id: string;
|
||||
status: IssueStatus;
|
||||
assigneeAgentId?: string | null;
|
||||
assigneeUserId?: string | null;
|
||||
originKind?: IssueOriginKind | string | null;
|
||||
}
|
||||
|
||||
export interface IssueExecutionDispositionAgentInput {
|
||||
id: string;
|
||||
status: AgentStatus | string;
|
||||
}
|
||||
|
||||
export interface IssueExecutionPathVector {
|
||||
activeRun?: boolean;
|
||||
queuedWake?: boolean;
|
||||
scheduledRetry?: boolean;
|
||||
deferredExecution?: boolean;
|
||||
}
|
||||
|
||||
export interface IssueExecutionWaitVector {
|
||||
participant?: IssueExecutionParticipantState;
|
||||
pendingInteraction?: IssueExecutionWaitSignalState;
|
||||
pendingApproval?: IssueExecutionWaitSignalState;
|
||||
pauseHold?: boolean;
|
||||
openRecoveryIssue?: boolean;
|
||||
openWatchdogIssue?: boolean;
|
||||
openProductivityReviewIssue?: boolean;
|
||||
productivityReviewNeeded?: boolean;
|
||||
externalOwnerAction?: boolean;
|
||||
}
|
||||
|
||||
export interface IssueExecutionRunEvidence {
|
||||
latestRunStatus?: IssueExecutionRunStatus | null;
|
||||
livenessState?: IssueExecutionRunLivenessState | null;
|
||||
nextAction?: IssueExecutionNextActionState | null;
|
||||
continuationAttempt?: number | null;
|
||||
maxContinuationAttempts?: number | null;
|
||||
recoveryAttemptRemaining?: boolean;
|
||||
}
|
||||
|
||||
export interface IssueExecutionBlockerVector {
|
||||
issue: Pick<IssueExecutionDispositionIssueInput, "id" | "status" | "assigneeAgentId" | "assigneeUserId">;
|
||||
disposition?: IssueExecutionDisposition;
|
||||
stateVector?: IssueExecutionStateVector;
|
||||
}
|
||||
|
||||
export interface IssueExecutionGateVector {
|
||||
budgetBlocked?: boolean;
|
||||
}
|
||||
|
||||
export interface IssueExecutionStateVector {
|
||||
issue: IssueExecutionDispositionIssueInput;
|
||||
agent?: IssueExecutionDispositionAgentInput | null;
|
||||
execution?: IssueExecutionPathVector;
|
||||
waits?: IssueExecutionWaitVector;
|
||||
latestRun?: IssueExecutionRunEvidence | null;
|
||||
blockers?: IssueExecutionBlockerVector[];
|
||||
gates?: IssueExecutionGateVector;
|
||||
}
|
||||
|
||||
const INVOKABLE_AGENT_STATUSES = new Set(["active", "idle", "running", "error"]);
|
||||
const RECOVERY_ORIGIN_KINDS = new Set(["stranded_issue_recovery", "harness_liveness_escalation"]);
|
||||
|
||||
function invalid(
|
||||
reason: IssueExecutionInvalidReason,
|
||||
suggestedCorrection: string,
|
||||
): IssueExecutionDisposition {
|
||||
return { kind: "invalid", reason, suggestedCorrection };
|
||||
}
|
||||
|
||||
function hasAgentOwner(issue: IssueExecutionDispositionIssueInput) {
|
||||
return Boolean(issue.assigneeAgentId);
|
||||
}
|
||||
|
||||
function hasHumanOwner(issue: IssueExecutionDispositionIssueInput) {
|
||||
return Boolean(issue.assigneeUserId);
|
||||
}
|
||||
|
||||
function agentIsInvokable(
|
||||
issue: IssueExecutionDispositionIssueInput,
|
||||
agent: IssueExecutionDispositionAgentInput | null | undefined,
|
||||
budgetBlocked: boolean,
|
||||
) {
|
||||
if (!issue.assigneeAgentId || !agent || agent.id !== issue.assigneeAgentId) return false;
|
||||
if (budgetBlocked) return false;
|
||||
return INVOKABLE_AGENT_STATUSES.has(agent.status);
|
||||
}
|
||||
|
||||
function livePath(execution: IssueExecutionPathVector | undefined): IssueExecutionLivePath | null {
|
||||
if (execution?.activeRun) return "active_run";
|
||||
if (execution?.queuedWake) return "queued_wake";
|
||||
if (execution?.scheduledRetry) return "scheduled_retry";
|
||||
if (execution?.deferredExecution) return "deferred_execution";
|
||||
return null;
|
||||
}
|
||||
|
||||
function waitingPath(
|
||||
issue: IssueExecutionDispositionIssueInput,
|
||||
waits: IssueExecutionWaitVector | undefined,
|
||||
): IssueExecutionWaitingPath | null {
|
||||
if (waits?.pauseHold) return "pause_hold";
|
||||
if (hasHumanOwner(issue)) return "human_owner";
|
||||
if (waits?.participant === "valid") return "participant";
|
||||
if (waits?.pendingInteraction === "live") return "interaction";
|
||||
if (waits?.pendingApproval === "live") return "approval";
|
||||
if (waits?.openRecoveryIssue || waits?.openWatchdogIssue || waits?.openProductivityReviewIssue) {
|
||||
return "review_artifact";
|
||||
}
|
||||
if (waits?.externalOwnerAction) return "external_owner_action";
|
||||
return null;
|
||||
}
|
||||
|
||||
function runFailed(run: IssueExecutionRunEvidence | null | undefined) {
|
||||
return (
|
||||
run?.latestRunStatus === "failed" ||
|
||||
run?.latestRunStatus === "timed_out" ||
|
||||
run?.latestRunStatus === "cancelled"
|
||||
);
|
||||
}
|
||||
|
||||
function hasRunnableContinuation(run: IssueExecutionRunEvidence | null | undefined) {
|
||||
const attempt = run?.continuationAttempt ?? 0;
|
||||
const maxAttempts = run?.maxContinuationAttempts ?? 2;
|
||||
const livenessState = run?.livenessState ?? null;
|
||||
const actionableLowProgressState = livenessState === "plan_only" || livenessState === "empty_response";
|
||||
const runnableFollowupState =
|
||||
(livenessState === "advanced" || livenessState === "needs_followup") &&
|
||||
run?.nextAction === "runnable";
|
||||
return (
|
||||
run?.latestRunStatus === "succeeded" &&
|
||||
(actionableLowProgressState || runnableFollowupState) &&
|
||||
attempt < maxAttempts
|
||||
);
|
||||
}
|
||||
|
||||
function hasExhaustedOrAmbiguousContinuation(run: IssueExecutionRunEvidence | null | undefined) {
|
||||
if (run?.latestRunStatus !== "succeeded") return false;
|
||||
if (run.livenessState === "completed") return true;
|
||||
if (run.livenessState === "blocked" || run.livenessState === "failed") return true;
|
||||
if (run.livenessState === "plan_only" || run.livenessState === "empty_response") {
|
||||
return (run.continuationAttempt ?? 0) >= (run.maxContinuationAttempts ?? 2);
|
||||
}
|
||||
if (run.livenessState !== "advanced" && run.livenessState !== "needs_followup") return true;
|
||||
if (run.nextAction !== "runnable") return true;
|
||||
return (run.continuationAttempt ?? 0) >= (run.maxContinuationAttempts ?? 2);
|
||||
}
|
||||
|
||||
function isRecoveryIssue(issue: IssueExecutionDispositionIssueInput) {
|
||||
return Boolean(issue.originKind && RECOVERY_ORIGIN_KINDS.has(issue.originKind));
|
||||
}
|
||||
|
||||
function classifyBlocker(
|
||||
blocker: IssueExecutionBlockerVector,
|
||||
seen: Set<string>,
|
||||
): IssueExecutionDisposition {
|
||||
if (blocker.disposition) return blocker.disposition;
|
||||
if (blocker.stateVector) return classifyIssueExecutionDisposition(blocker.stateVector, seen);
|
||||
return classifyIssueExecutionDisposition({ issue: blocker.issue as IssueExecutionDispositionIssueInput }, seen);
|
||||
}
|
||||
|
||||
function blockerInvalidReason(blocker: IssueExecutionBlockerVector): IssueExecutionInvalidReason {
|
||||
if (blocker.issue.status === "cancelled") return "blocked_by_cancelled_issue";
|
||||
if (!blocker.issue.assigneeAgentId && !blocker.issue.assigneeUserId) return "blocked_by_unassigned_issue";
|
||||
return "blocked_by_resting_issue";
|
||||
}
|
||||
|
||||
function classifyBlockedIssue(
|
||||
input: IssueExecutionStateVector,
|
||||
seen: Set<string>,
|
||||
): IssueExecutionDisposition {
|
||||
if (isRecoveryIssue(input.issue) && (hasAgentOwner(input.issue) || hasHumanOwner(input.issue))) {
|
||||
return { kind: "waiting", path: "external_owner_action" };
|
||||
}
|
||||
|
||||
const path = waitingPath(input.issue, input.waits);
|
||||
if (path) return { kind: "waiting", path };
|
||||
|
||||
const blockers = input.blockers ?? [];
|
||||
if (blockers.length > 0) {
|
||||
for (const blocker of blockers) {
|
||||
if (blocker.issue.status === "cancelled") {
|
||||
return invalid(
|
||||
"blocked_by_cancelled_issue",
|
||||
"Remove or replace the cancelled blocker before treating the dependency chain as covered.",
|
||||
);
|
||||
}
|
||||
|
||||
const disposition = classifyBlocker(blocker, seen);
|
||||
if (disposition.kind === "invalid") {
|
||||
return invalid(
|
||||
"blocked_by_invalid_issue",
|
||||
"Repair the invalid unresolved blocker or create an explicit recovery/escalation path.",
|
||||
);
|
||||
}
|
||||
if (disposition.kind === "resting" || disposition.kind === "terminal") {
|
||||
return invalid(
|
||||
blockerInvalidReason(blocker),
|
||||
"Give the blocker a lawful next action, remove the blocker, or escalate to a human owner.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "waiting", path: "blocker_chain" };
|
||||
}
|
||||
|
||||
return invalid(
|
||||
"blocked_without_action_path",
|
||||
"Blocked issues require first-class blockers, a live wait primitive, or a structured external owner/action.",
|
||||
);
|
||||
}
|
||||
|
||||
export function classifyIssueExecutionDisposition(
|
||||
input: IssueExecutionStateVector,
|
||||
seen = new Set<string>(),
|
||||
): IssueExecutionDisposition {
|
||||
const { issue } = input;
|
||||
const status = issue.status;
|
||||
const budgetBlocked = Boolean(input.gates?.budgetBlocked);
|
||||
const agentInvokable = agentIsInvokable(issue, input.agent, budgetBlocked);
|
||||
const path = livePath(input.execution);
|
||||
const wait = waitingPath(issue, input.waits);
|
||||
|
||||
if (status === "done" || status === "cancelled") return { kind: "terminal" };
|
||||
|
||||
if (seen.has(issue.id)) {
|
||||
return invalid(
|
||||
"blocked_by_invalid_issue",
|
||||
"Break the recursive blocker chain or create an explicit recovery issue.",
|
||||
);
|
||||
}
|
||||
seen.add(issue.id);
|
||||
|
||||
if (hasAgentOwner(issue) && hasHumanOwner(issue)) {
|
||||
return invalid("dual_assignee", "Keep exactly one issue assignee: agent or human user, not both.");
|
||||
}
|
||||
|
||||
if (status === "backlog") return { kind: "resting" };
|
||||
if (wait) return { kind: "waiting", path: wait };
|
||||
if (input.waits?.participant === "invalid") {
|
||||
return invalid(
|
||||
"invalid_review_participant",
|
||||
"Set a typed execution participant with a valid agentId/userId or replace it with another explicit wait path.",
|
||||
);
|
||||
}
|
||||
if (status === "blocked") return classifyBlockedIssue(input, seen);
|
||||
if (hasHumanOwner(issue)) return { kind: "waiting", path: "human_owner" };
|
||||
if (path) return { kind: "live", path };
|
||||
if (!hasAgentOwner(issue)) return { kind: "resting" };
|
||||
|
||||
if (status === "todo") {
|
||||
if (isRecoveryIssue(issue) && runFailed(input.latestRun)) {
|
||||
return { kind: "human_escalation_required", owner: "recovery_owner" };
|
||||
}
|
||||
if (!agentInvokable) {
|
||||
return { kind: "human_escalation_required", owner: budgetBlocked ? "board" : "manager" };
|
||||
}
|
||||
if (runFailed(input.latestRun) && input.latestRun?.recoveryAttemptRemaining !== false) {
|
||||
return { kind: "recoverable_by_control_plane", recovery: "dispatch" };
|
||||
}
|
||||
if (runFailed(input.latestRun)) {
|
||||
return { kind: "human_escalation_required", owner: "manager" };
|
||||
}
|
||||
if (input.waits?.productivityReviewNeeded) {
|
||||
return { kind: "human_escalation_required", owner: "manager" };
|
||||
}
|
||||
return { kind: "dispatchable", wakeTarget: issue.assigneeAgentId! };
|
||||
}
|
||||
|
||||
if (status === "in_progress") {
|
||||
if (!agentInvokable) {
|
||||
return { kind: "human_escalation_required", owner: budgetBlocked ? "board" : "manager" };
|
||||
}
|
||||
if (isRecoveryIssue(issue) && runFailed(input.latestRun)) {
|
||||
return { kind: "human_escalation_required", owner: "recovery_owner" };
|
||||
}
|
||||
if (runFailed(input.latestRun) && input.latestRun?.recoveryAttemptRemaining !== false) {
|
||||
return { kind: "recoverable_by_control_plane", recovery: "continuation" };
|
||||
}
|
||||
if (runFailed(input.latestRun)) {
|
||||
return { kind: "human_escalation_required", owner: "manager" };
|
||||
}
|
||||
if (input.waits?.productivityReviewNeeded) {
|
||||
return { kind: "human_escalation_required", owner: "manager" };
|
||||
}
|
||||
if (hasRunnableContinuation(input.latestRun)) {
|
||||
return {
|
||||
kind: "agent_continuable",
|
||||
continuationAttempt: input.latestRun?.continuationAttempt ?? 0,
|
||||
maxAttempts: input.latestRun?.maxContinuationAttempts ?? 2,
|
||||
};
|
||||
}
|
||||
if (hasExhaustedOrAmbiguousContinuation(input.latestRun)) {
|
||||
return { kind: "human_escalation_required", owner: "manager" };
|
||||
}
|
||||
return { kind: "dispatchable", wakeTarget: issue.assigneeAgentId! };
|
||||
}
|
||||
|
||||
if (status === "in_review") {
|
||||
return invalid(
|
||||
"in_review_without_action_path",
|
||||
"Agent-owned review requires a typed participant, pending interaction, approval, active/queued execution path, human owner, or explicit recovery issue.",
|
||||
);
|
||||
}
|
||||
|
||||
return { kind: "human_escalation_required", owner: "manager" };
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
import { and, desc, eq, inArray, isNull, notInArray, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
agentWakeupRequests,
|
||||
agents,
|
||||
approvals,
|
||||
heartbeatRuns,
|
||||
issueApprovals,
|
||||
issueRelations,
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import type { IssueExecutionDisposition } from "@paperclipai/shared";
|
||||
import { unprocessable } from "../errors.js";
|
||||
import {
|
||||
classifyIssueExecutionDisposition,
|
||||
type IssueExecutionParticipantState,
|
||||
type IssueExecutionStateVector,
|
||||
} from "./issue-execution-disposition.js";
|
||||
import { parseIssueExecutionState } from "./issue-execution-policy.js";
|
||||
import {
|
||||
isLiveExplicitApprovalWaitingPath,
|
||||
isLiveExplicitInteractionWaitingPath,
|
||||
} from "./recovery/explicit-waiting-paths.js";
|
||||
|
||||
const ACTIVE_RUN_STATUSES = ["queued", "running"] as const;
|
||||
const SCHEDULED_RETRY_RUN_STATUSES = ["scheduled_retry"] as const;
|
||||
const ACTIVE_WAKE_STATUSES = ["queued", "deferred_issue_execution"] as const;
|
||||
const PENDING_INTERACTION_STATUSES = ["pending"] as const;
|
||||
const WAITING_INTERACTION_KINDS = ["request_confirmation", "ask_user_questions"] as const;
|
||||
const PENDING_APPROVAL_STATUSES = ["pending", "revision_requested"] as const;
|
||||
const OPEN_RECOVERY_ORIGIN_KINDS = ["harness_liveness_escalation", "stranded_issue_recovery"] as const;
|
||||
const OPEN_RECOVERY_TERMINAL_STATUSES = ["done", "cancelled"] as const;
|
||||
|
||||
type DbReader = Pick<Db, "select">;
|
||||
type IssueRow = typeof issues.$inferSelect;
|
||||
|
||||
export type IssueTransitionGuardSource = "create" | "update";
|
||||
|
||||
function isValidParticipant(participant: unknown) {
|
||||
if (!participant || typeof participant !== "object") return false;
|
||||
const value = participant as Record<string, unknown>;
|
||||
if (value.type === "agent") return typeof value.agentId === "string" && value.agentId.length > 0;
|
||||
if (value.type === "user") return typeof value.userId === "string" && value.userId.length > 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isGuardedStatus(status: string | null | undefined) {
|
||||
return status === "in_progress" || status === "in_review" || status === "blocked";
|
||||
}
|
||||
|
||||
function invalidMessage(disposition: Extract<IssueExecutionDisposition, { kind: "invalid" }>) {
|
||||
return `Invalid issue transition: ${disposition.reason}`;
|
||||
}
|
||||
|
||||
async function buildIssueExecutionStateVector(
|
||||
dbOrTx: DbReader,
|
||||
issue: IssueRow,
|
||||
): Promise<IssueExecutionStateVector> {
|
||||
const [agent] = issue.assigneeAgentId
|
||||
? await dbOrTx
|
||||
.select({
|
||||
id: agents.id,
|
||||
status: agents.status,
|
||||
})
|
||||
.from(agents)
|
||||
.where(and(eq(agents.companyId, issue.companyId), eq(agents.id, issue.assigneeAgentId)))
|
||||
: [];
|
||||
|
||||
const [executionRun] = issue.executionRunId
|
||||
? await dbOrTx
|
||||
.select({ status: heartbeatRuns.status })
|
||||
.from(heartbeatRuns)
|
||||
.where(and(eq(heartbeatRuns.companyId, issue.companyId), eq(heartbeatRuns.id, issue.executionRunId)))
|
||||
: [];
|
||||
|
||||
const [queuedWake] = await dbOrTx
|
||||
.select({
|
||||
issueId: sql<string | null>`${agentWakeupRequests.payload} ->> 'issueId'`,
|
||||
status: agentWakeupRequests.status,
|
||||
})
|
||||
.from(agentWakeupRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(agentWakeupRequests.companyId, issue.companyId),
|
||||
inArray(agentWakeupRequests.status, [...ACTIVE_WAKE_STATUSES]),
|
||||
sql`${agentWakeupRequests.runId} is null`,
|
||||
eq(sql<string>`${agentWakeupRequests.payload} ->> 'issueId'`, issue.id),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(agentWakeupRequests.createdAt), desc(agentWakeupRequests.id))
|
||||
.limit(1);
|
||||
|
||||
const executionState = parseIssueExecutionState(issue.executionState);
|
||||
let participant: IssueExecutionParticipantState = "none";
|
||||
if (executionState?.status === "pending") {
|
||||
participant = isValidParticipant(executionState.currentParticipant) ? "valid" : "invalid";
|
||||
}
|
||||
|
||||
let pendingInteraction: "none" | "live" = "none";
|
||||
const interactionRows: Array<{
|
||||
issueId: string;
|
||||
companyId: string;
|
||||
status: string;
|
||||
kind: string;
|
||||
createdByUserId: string | null;
|
||||
createdAt: Date;
|
||||
}> = await dbOrTx
|
||||
.select({
|
||||
issueId: issueThreadInteractions.issueId,
|
||||
companyId: issueThreadInteractions.companyId,
|
||||
status: issueThreadInteractions.status,
|
||||
kind: issueThreadInteractions.kind,
|
||||
createdByUserId: issueThreadInteractions.createdByUserId,
|
||||
createdAt: issueThreadInteractions.createdAt,
|
||||
})
|
||||
.from(issueThreadInteractions)
|
||||
.where(
|
||||
and(
|
||||
eq(issueThreadInteractions.companyId, issue.companyId),
|
||||
eq(issueThreadInteractions.issueId, issue.id),
|
||||
inArray(issueThreadInteractions.status, [...PENDING_INTERACTION_STATUSES]),
|
||||
inArray(issueThreadInteractions.kind, [...WAITING_INTERACTION_KINDS]),
|
||||
),
|
||||
);
|
||||
for (const row of interactionRows) {
|
||||
if (isLiveExplicitInteractionWaitingPath(issue, row)) {
|
||||
pendingInteraction = "live";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let pendingApproval: "none" | "live" = "none";
|
||||
const approvalRows: Array<{
|
||||
issueId: string;
|
||||
companyId: string;
|
||||
status: string;
|
||||
requestedByUserId: string | null;
|
||||
linkedByUserId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
linkedAt: Date;
|
||||
}> = await dbOrTx
|
||||
.select({
|
||||
issueId: issueApprovals.issueId,
|
||||
companyId: issueApprovals.companyId,
|
||||
status: approvals.status,
|
||||
requestedByUserId: approvals.requestedByUserId,
|
||||
linkedByUserId: issueApprovals.linkedByUserId,
|
||||
createdAt: approvals.createdAt,
|
||||
updatedAt: approvals.updatedAt,
|
||||
linkedAt: issueApprovals.createdAt,
|
||||
})
|
||||
.from(issueApprovals)
|
||||
.innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
|
||||
.where(
|
||||
and(
|
||||
eq(issueApprovals.companyId, issue.companyId),
|
||||
eq(issueApprovals.issueId, issue.id),
|
||||
inArray(approvals.status, [...PENDING_APPROVAL_STATUSES]),
|
||||
),
|
||||
);
|
||||
for (const row of approvalRows) {
|
||||
if (isLiveExplicitApprovalWaitingPath(issue, row)) {
|
||||
pendingApproval = "live";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const recoveryRows: Array<{ id: string }> = await dbOrTx
|
||||
.select({ id: issues.id })
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, issue.companyId),
|
||||
inArray(issues.originKind, [...OPEN_RECOVERY_ORIGIN_KINDS]),
|
||||
eq(issues.originId, issue.id),
|
||||
isNull(issues.hiddenAt),
|
||||
notInArray(issues.status, [...OPEN_RECOVERY_TERMINAL_STATUSES]),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
const blockerRows: Array<{
|
||||
id: string;
|
||||
status: string;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
originKind: string | null;
|
||||
}> = await dbOrTx
|
||||
.select({
|
||||
id: issues.id,
|
||||
status: issues.status,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
originKind: issues.originKind,
|
||||
})
|
||||
.from(issueRelations)
|
||||
.innerJoin(issues, eq(issueRelations.issueId, issues.id))
|
||||
.where(
|
||||
and(
|
||||
eq(issueRelations.companyId, issue.companyId),
|
||||
eq(issueRelations.relatedIssueId, issue.id),
|
||||
eq(issueRelations.type, "blocks"),
|
||||
eq(issues.companyId, issue.companyId),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
issue: {
|
||||
id: issue.id,
|
||||
status: issue.status as IssueExecutionStateVector["issue"]["status"],
|
||||
assigneeAgentId: issue.assigneeAgentId,
|
||||
assigneeUserId: issue.assigneeUserId,
|
||||
originKind: issue.originKind,
|
||||
},
|
||||
agent: agent
|
||||
? {
|
||||
id: agent.id,
|
||||
status: agent.status,
|
||||
}
|
||||
: null,
|
||||
execution: {
|
||||
activeRun: executionRun ? (ACTIVE_RUN_STATUSES as readonly string[]).includes(executionRun.status) : false,
|
||||
scheduledRetry: executionRun
|
||||
? (SCHEDULED_RETRY_RUN_STATUSES as readonly string[]).includes(executionRun.status)
|
||||
: false,
|
||||
queuedWake: queuedWake?.status === "queued",
|
||||
deferredExecution: queuedWake?.status === "deferred_issue_execution",
|
||||
},
|
||||
waits: {
|
||||
participant,
|
||||
pendingInteraction,
|
||||
pendingApproval,
|
||||
openRecoveryIssue: recoveryRows.length > 0 ||
|
||||
blockerRows.some((blocker) =>
|
||||
(OPEN_RECOVERY_ORIGIN_KINDS as readonly string[]).includes(blocker.originKind ?? "")
|
||||
),
|
||||
},
|
||||
blockers: blockerRows.map((blocker) => ({
|
||||
issue: {
|
||||
id: blocker.id,
|
||||
status: blocker.status as IssueExecutionStateVector["issue"]["status"],
|
||||
assigneeAgentId: blocker.assigneeAgentId,
|
||||
assigneeUserId: blocker.assigneeUserId,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function assertIssueTransitionAllowed(input: {
|
||||
db: DbReader;
|
||||
issue: IssueRow;
|
||||
source: IssueTransitionGuardSource;
|
||||
statusTouched?: boolean;
|
||||
assigneeTouched?: boolean;
|
||||
blockerTouched?: boolean;
|
||||
executionStateTouched?: boolean;
|
||||
}) {
|
||||
const shouldGuard =
|
||||
isGuardedStatus(input.issue.status) &&
|
||||
(input.source === "create" ||
|
||||
input.statusTouched ||
|
||||
input.assigneeTouched ||
|
||||
input.blockerTouched ||
|
||||
input.executionStateTouched);
|
||||
if (!shouldGuard) return;
|
||||
|
||||
const stateVector = await buildIssueExecutionStateVector(input.db, input.issue);
|
||||
const disposition = classifyIssueExecutionDisposition(stateVector);
|
||||
if (disposition.kind !== "invalid") return;
|
||||
|
||||
throw unprocessable(invalidMessage(disposition), {
|
||||
disposition,
|
||||
suggestedCorrection: disposition.suggestedCorrection,
|
||||
});
|
||||
}
|
||||
+23
-120
@@ -33,7 +33,6 @@ import type {
|
||||
IssueBlockerAttentionState,
|
||||
IssueBlockerNextActionHint,
|
||||
IssueBlockerNextActionOwner,
|
||||
IssueStatus,
|
||||
IssueProductivityReview,
|
||||
IssueProductivityReviewTrigger,
|
||||
IssueRelationIssueSummary,
|
||||
@@ -60,17 +59,6 @@ import {
|
||||
isLiveExplicitApprovalWaitingPath,
|
||||
isLiveExplicitInteractionWaitingPath,
|
||||
} from "./recovery/explicit-waiting-paths.js";
|
||||
import { parseIssueGraphLivenessIncidentKey } from "./recovery/origins.js";
|
||||
import {
|
||||
classifyIssueExecutionDisposition,
|
||||
type IssueExecutionRunLivenessState,
|
||||
} from "./issue-execution-disposition.js";
|
||||
import {
|
||||
backfillBlockedBySummaries,
|
||||
listIssueExecutionDispositionsMap,
|
||||
type IssueExecutionDispositionInputNode,
|
||||
} from "./issue-execution-disposition-resolver.js";
|
||||
import { assertIssueTransitionAllowed } from "./issue-transition-guard.js";
|
||||
|
||||
const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"];
|
||||
const MAX_ISSUE_COMMENT_PAGE_LIMIT = 500;
|
||||
@@ -896,37 +884,6 @@ function classifyLeafRecovery(input: {
|
||||
// the existing "no owner" surface continues to apply.
|
||||
if (owner.type === "none") return null;
|
||||
|
||||
const disposition = classifyIssueExecutionDisposition({
|
||||
issue: {
|
||||
id: node.id,
|
||||
status: node.status as IssueStatus,
|
||||
assigneeAgentId: node.assigneeAgentId,
|
||||
assigneeUserId: node.assigneeUserId,
|
||||
},
|
||||
agent: node.assigneeAgentId
|
||||
? {
|
||||
id: node.assigneeAgentId,
|
||||
status: agentInvokable ? "idle" : "paused",
|
||||
}
|
||||
: null,
|
||||
execution: {
|
||||
activeRun: hasActivePath,
|
||||
},
|
||||
waits: {
|
||||
productivityReviewNeeded: hasProductivityReview,
|
||||
},
|
||||
latestRun: {
|
||||
latestRunStatus: "succeeded",
|
||||
livenessState: latestRun.livenessState as IssueExecutionRunLivenessState,
|
||||
nextAction: "runnable",
|
||||
continuationAttempt: latestRun.continuationAttempt,
|
||||
maxContinuationAttempts,
|
||||
},
|
||||
});
|
||||
if (disposition.kind !== "agent_continuable" && disposition.kind !== "human_escalation_required") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let reason: LeafRecoveryClassification["reason"];
|
||||
if (hasProductivityReview) {
|
||||
reason = "continuation_suppressed";
|
||||
@@ -1428,36 +1385,29 @@ async function listIssueBlockerAttentionMap(
|
||||
})));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const recoveryRows: Array<{
|
||||
id: string;
|
||||
originId: string | null;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
}> = await dbOrTx
|
||||
.select({
|
||||
id: issues.id,
|
||||
originId: issues.originId,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.originKind, BLOCKER_ATTENTION_OPEN_RECOVERY_ORIGIN_KIND),
|
||||
isNull(issues.hiddenAt),
|
||||
notInArray(issues.status, BLOCKER_ATTENTION_OPEN_RECOVERY_TERMINAL_STATUSES),
|
||||
),
|
||||
);
|
||||
for (const row of recoveryRows) {
|
||||
const parsed = parseIssueGraphLivenessIncidentKey(row.originId);
|
||||
if (!parsed || parsed.companyId !== companyId) continue;
|
||||
const waitingPath = explicitWaitingPathForOwner(ownerForExplicitWait(row));
|
||||
setExplicitWaitingPath(row.id, waitingPath);
|
||||
setExplicitWaitingPath(parsed.issueId, waitingPath);
|
||||
setExplicitWaitingPath(parsed.leafIssueId, waitingPath);
|
||||
const recoveryRows: Array<{
|
||||
originId: string | null;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
}> = await dbOrTx
|
||||
.select({
|
||||
originId: issues.originId,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.originKind, BLOCKER_ATTENTION_OPEN_RECOVERY_ORIGIN_KIND),
|
||||
isNull(issues.hiddenAt),
|
||||
inArray(issues.originId, chunk),
|
||||
notInArray(issues.status, BLOCKER_ATTENTION_OPEN_RECOVERY_TERMINAL_STATUSES),
|
||||
),
|
||||
);
|
||||
for (const row of recoveryRows) {
|
||||
setExplicitWaitingPath(row.originId, explicitWaitingPathForOwner(ownerForExplicitWait(row)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2716,23 +2666,6 @@ export function issueService(db: Db) {
|
||||
listIssueBlockerAttentionMap(db, companyId, withRuns),
|
||||
listIssueProductivityReviewMap(db, companyId, issueIds),
|
||||
]);
|
||||
const dispositionInputs: IssueExecutionDispositionInputNode[] = withRuns.map((row) => ({
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
status: row.status,
|
||||
assigneeAgentId: row.assigneeAgentId,
|
||||
assigneeUserId: row.assigneeUserId,
|
||||
originKind: row.originKind ?? null,
|
||||
executionRunId: row.executionRunId ?? null,
|
||||
// executionState is intentionally omitted from the list select; the resolver rehydrates it.
|
||||
blockedBy: includeBlockedBy ? blockedByMap.get(row.id) ?? [] : undefined,
|
||||
}));
|
||||
await backfillBlockedBySummaries(db, companyId, dispositionInputs);
|
||||
const dispositionByIssueId = await listIssueExecutionDispositionsMap(
|
||||
db,
|
||||
companyId,
|
||||
dispositionInputs,
|
||||
);
|
||||
|
||||
if (!contextUserId) {
|
||||
return withRuns.map((row) => {
|
||||
@@ -2747,7 +2680,6 @@ export function issueService(db: Db) {
|
||||
...(includeBlockedBy ? { blockedBy: blockedByMap.get(row.id) ?? [] } : {}),
|
||||
lastActivityAt,
|
||||
...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}),
|
||||
...(dispositionByIssueId.has(row.id) ? { executionDisposition: dispositionByIssueId.get(row.id) } : {}),
|
||||
...(productivityReviewByIssueId.has(row.id)
|
||||
? { productivityReview: productivityReviewByIssueId.get(row.id) }
|
||||
: {}),
|
||||
@@ -2769,7 +2701,6 @@ export function issueService(db: Db) {
|
||||
...(includeBlockedBy ? { blockedBy: blockedByMap.get(row.id) ?? [] } : {}),
|
||||
lastActivityAt,
|
||||
...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}),
|
||||
...(dispositionByIssueId.has(row.id) ? { executionDisposition: dispositionByIssueId.get(row.id) } : {}),
|
||||
...(productivityReviewByIssueId.has(row.id)
|
||||
? { productivityReview: productivityReviewByIssueId.get(row.id) }
|
||||
: {}),
|
||||
@@ -2924,16 +2855,6 @@ export function issueService(db: Db) {
|
||||
return listIssueBlockerAttentionMap(dbOrTx, companyId, issueRows);
|
||||
},
|
||||
|
||||
listExecutionDispositions: async (
|
||||
companyId: string,
|
||||
issueRows: IssueExecutionDispositionInputNode[],
|
||||
dbOrTx: any = db,
|
||||
) => {
|
||||
const inputs = issueRows.map((row) => ({ ...row }));
|
||||
await backfillBlockedBySummaries(dbOrTx, companyId, inputs);
|
||||
return listIssueExecutionDispositionsMap(dbOrTx, companyId, inputs);
|
||||
},
|
||||
|
||||
listProductivityReviews: async (
|
||||
companyId: string,
|
||||
sourceIssueIds: string[],
|
||||
@@ -3309,15 +3230,6 @@ export function issueService(db: Db) {
|
||||
tx,
|
||||
);
|
||||
}
|
||||
await assertIssueTransitionAllowed({
|
||||
db: tx,
|
||||
issue,
|
||||
source: "create",
|
||||
statusTouched: true,
|
||||
assigneeTouched: issueData.assigneeAgentId !== undefined || issueData.assigneeUserId !== undefined,
|
||||
blockerTouched: blockedByIssueIds !== undefined,
|
||||
executionStateTouched: issueData.executionState !== undefined,
|
||||
});
|
||||
const [enriched] = await withIssueLabels(tx, [issue]);
|
||||
return enriched;
|
||||
});
|
||||
@@ -3471,15 +3383,6 @@ export function issueService(db: Db) {
|
||||
tx,
|
||||
);
|
||||
}
|
||||
await assertIssueTransitionAllowed({
|
||||
db: tx,
|
||||
issue: updated,
|
||||
source: "update",
|
||||
statusTouched: issueData.status !== undefined,
|
||||
assigneeTouched: issueData.assigneeAgentId !== undefined || issueData.assigneeUserId !== undefined,
|
||||
blockerTouched: blockedByIssueIds !== undefined,
|
||||
executionStateTouched: issueData.executionState !== undefined,
|
||||
});
|
||||
const [enriched] = await withIssueLabels(tx, [updated]);
|
||||
return enriched;
|
||||
};
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { buildIssueGraphLivenessIncidentKey } from "./origins.js";
|
||||
import type { IssueStatus } from "@paperclipai/shared";
|
||||
import {
|
||||
isLiveExplicitApprovalWaitingPath,
|
||||
isLiveExplicitInteractionWaitingPath,
|
||||
} from "./explicit-waiting-paths.js";
|
||||
import {
|
||||
classifyIssueExecutionDisposition,
|
||||
type IssueExecutionDispositionAgentInput,
|
||||
type IssueExecutionParticipantState,
|
||||
} from "../issue-execution-disposition.js";
|
||||
|
||||
export type IssueLivenessSeverity = "warning" | "critical";
|
||||
|
||||
@@ -148,26 +142,6 @@ function hasActiveExecutionPath(
|
||||
);
|
||||
}
|
||||
|
||||
function hasActiveRunPath(
|
||||
companyId: string,
|
||||
issueId: string,
|
||||
activeRuns: IssueLivenessExecutionPathInput[],
|
||||
) {
|
||||
return activeRuns.some(
|
||||
(entry) => entry.companyId === companyId && entry.issueId === issueId,
|
||||
);
|
||||
}
|
||||
|
||||
function hasQueuedWakePath(
|
||||
companyId: string,
|
||||
issueId: string,
|
||||
queuedWakeRequests: IssueLivenessExecutionPathInput[],
|
||||
) {
|
||||
return queuedWakeRequests.some(
|
||||
(entry) => entry.companyId === companyId && entry.issueId === issueId,
|
||||
);
|
||||
}
|
||||
|
||||
function hasWaitingPath(
|
||||
issue: IssueLivenessIssueInput,
|
||||
waitingPaths: IssueLivenessWaitingPathInput[],
|
||||
@@ -195,23 +169,6 @@ function principalIsResolvableUser(principal: unknown): boolean {
|
||||
return value.type === "user" && typeof value.userId === "string" && value.userId.length > 0;
|
||||
}
|
||||
|
||||
function participantStateForIssue(
|
||||
issue: IssueLivenessIssueInput,
|
||||
agentsById: Map<string, IssueLivenessAgentInput>,
|
||||
): IssueExecutionParticipantState {
|
||||
if (!issue.executionState) return "none";
|
||||
const participant = issue.executionState.currentParticipant;
|
||||
const participantAgentId = readPrincipalAgentId(participant);
|
||||
if (participantAgentId) {
|
||||
const participantAgent = agentsById.get(participantAgentId);
|
||||
return participantAgent && participantAgent.companyId === issue.companyId && isInvokableAgent(participantAgent)
|
||||
? "valid"
|
||||
: "invalid";
|
||||
}
|
||||
if (principalIsResolvableUser(participant)) return "valid";
|
||||
return "invalid";
|
||||
}
|
||||
|
||||
function addOwnerCandidate(
|
||||
candidates: IssueLivenessOwnerCandidate[],
|
||||
seen: Set<string>,
|
||||
@@ -416,42 +373,13 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu
|
||||
openRecoveryIssues.some((entry) => entry.companyId === issue.companyId && entry.issueId === issue.id);
|
||||
}
|
||||
|
||||
function canonicalDisposition(issue: IssueLivenessIssueInput) {
|
||||
const agent: IssueExecutionDispositionAgentInput | null = issue.assigneeAgentId
|
||||
? agentsById.get(issue.assigneeAgentId) ?? null
|
||||
: null;
|
||||
|
||||
return classifyIssueExecutionDisposition({
|
||||
issue: {
|
||||
id: issue.id,
|
||||
status: issue.status as IssueStatus,
|
||||
assigneeAgentId: issue.assigneeAgentId,
|
||||
assigneeUserId: issue.assigneeUserId,
|
||||
},
|
||||
agent,
|
||||
execution: {
|
||||
activeRun: hasActiveRunPath(issue.companyId, issue.id, activeRuns),
|
||||
queuedWake: hasQueuedWakePath(issue.companyId, issue.id, queuedWakeRequests),
|
||||
},
|
||||
waits: {
|
||||
participant: participantStateForIssue(issue, agentsById),
|
||||
pendingInteraction: hasWaitingPath(issue, pendingInteractions, "interaction", now) ? "live" : "none",
|
||||
pendingApproval: hasWaitingPath(issue, pendingApprovals, "approval", now) ? "live" : "none",
|
||||
openRecoveryIssue: openRecoveryIssues.some((entry) =>
|
||||
entry.companyId === issue.companyId && entry.issueId === issue.id
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function reviewFinding(
|
||||
source: IssueLivenessIssueInput,
|
||||
reviewIssue: IssueLivenessIssueInput,
|
||||
dependencyPath: IssueLivenessIssueInput[],
|
||||
): IssueLivenessFinding | null {
|
||||
if (reviewIssue.status !== "in_review") return null;
|
||||
const disposition = canonicalDisposition(reviewIssue);
|
||||
if (disposition.kind !== "invalid") return null;
|
||||
if (hasExplicitWaitingPath(reviewIssue)) return null;
|
||||
|
||||
const ownerCandidates = ownerCandidatesForRecoveryIssue(reviewIssue, input.agents, agentsById, {
|
||||
includeStalledAssignee: true,
|
||||
@@ -459,8 +387,9 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu
|
||||
|
||||
const participant = reviewIssue.executionState?.currentParticipant;
|
||||
const participantAgentId = readPrincipalAgentId(participant);
|
||||
if (participantAgentId && disposition.reason === "invalid_review_participant") {
|
||||
if (participantAgentId) {
|
||||
const participantAgent = agentsById.get(participantAgentId);
|
||||
if (isInvokableAgent(participantAgent) && participantAgent?.companyId === reviewIssue.companyId) return null;
|
||||
|
||||
return finding({
|
||||
issue: source,
|
||||
@@ -478,7 +407,9 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu
|
||||
});
|
||||
}
|
||||
|
||||
if (disposition.reason === "invalid_review_participant") {
|
||||
if (principalIsResolvableUser(participant)) return null;
|
||||
|
||||
if (reviewIssue.executionState) {
|
||||
return finding({
|
||||
issue: source,
|
||||
state: "invalid_review_participant",
|
||||
@@ -492,7 +423,7 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu
|
||||
});
|
||||
}
|
||||
|
||||
if (disposition.reason !== "in_review_without_action_path") return null;
|
||||
if (!reviewIssue.assigneeAgentId || reviewIssue.assigneeUserId) return null;
|
||||
|
||||
return finding({
|
||||
issue: source,
|
||||
@@ -516,7 +447,6 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu
|
||||
const ownerCandidates = ownerCandidatesForRecoveryIssue(blocker, input.agents, agentsById, {
|
||||
includeStalledAssignee: true,
|
||||
});
|
||||
const disposition = canonicalDisposition(blocker);
|
||||
|
||||
if (blocker.status === "cancelled") {
|
||||
return finding({
|
||||
@@ -533,21 +463,13 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
disposition.kind === "waiting" ||
|
||||
disposition.kind === "live" ||
|
||||
disposition.kind === "dispatchable" ||
|
||||
disposition.kind === "agent_continuable" ||
|
||||
disposition.kind === "recoverable_by_control_plane"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (hasExplicitWaitingPath(blocker)) return null;
|
||||
|
||||
if (blocker.status === "in_review") {
|
||||
return reviewFinding(source, blocker, dependencyPath);
|
||||
}
|
||||
|
||||
if (disposition.kind === "resting" && !blocker.assigneeAgentId && !blocker.assigneeUserId) {
|
||||
if (!blocker.assigneeAgentId && !blocker.assigneeUserId) {
|
||||
return finding({
|
||||
issue: source,
|
||||
state: "blocked_by_unassigned_issue",
|
||||
@@ -562,12 +484,10 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu
|
||||
});
|
||||
}
|
||||
|
||||
const blockerAgent = blocker.assigneeAgentId ? agentsById.get(blocker.assigneeAgentId) : null;
|
||||
if (
|
||||
disposition.kind === "human_escalation_required" &&
|
||||
blocker.assigneeAgentId &&
|
||||
(!blockerAgent || blockerAgent.companyId !== source.companyId || BLOCKING_AGENT_STATUSES.has(blockerAgent.status))
|
||||
) {
|
||||
if (!blocker.assigneeAgentId) return null;
|
||||
|
||||
const blockerAgent = agentsById.get(blocker.assigneeAgentId);
|
||||
if (!blockerAgent || blockerAgent.companyId !== source.companyId || BLOCKING_AGENT_STATUSES.has(blockerAgent.status)) {
|
||||
return finding({
|
||||
issue: source,
|
||||
state: "blocked_by_uninvokable_assignee",
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agentWakeupRequests, agents, heartbeatRuns, issues } from "@paperclipai/db";
|
||||
import type { IssueStatus, RunLivenessState } from "@paperclipai/shared";
|
||||
import type { RunLivenessState } from "@paperclipai/shared";
|
||||
import { RECOVERY_REASON_KINDS } from "./origins.js";
|
||||
import {
|
||||
classifyIssueExecutionDisposition,
|
||||
type IssueExecutionRunLivenessState,
|
||||
type IssueExecutionNextActionState,
|
||||
} from "../issue-execution-disposition.js";
|
||||
|
||||
export const RUN_LIVENESS_CONTINUATION_REASON = RECOVERY_REASON_KINDS.runLivenessContinuation;
|
||||
export const DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS = 2;
|
||||
|
||||
const ACTIONABLE_LIVENESS_STATES = new Set<RunLivenessState>(["plan_only", "empty_response"]);
|
||||
const PROGRESS_WITH_FOLLOWUP_LIVENESS_STATES = new Set<RunLivenessState>(["advanced"]);
|
||||
const CONTINUATION_ACTIVE_ISSUE_STATUSES = new Set(["todo", "in_progress"]);
|
||||
// A prior adapter error should not permanently suppress bounded liveness
|
||||
// continuations; the max-attempt/idempotency guards prevent unbounded retries.
|
||||
const CONTINUATION_AGENT_STATUSES = new Set(["active", "idle", "running", "error"]);
|
||||
const IDEMPOTENT_WAKE_STATUSES = ["queued", "deferred_issue_execution", "completed"];
|
||||
|
||||
type HeartbeatRunRow = Pick<typeof heartbeatRuns.$inferSelect, "id" | "companyId" | "agentId" | "continuationAttempt">;
|
||||
@@ -61,14 +61,6 @@ export function buildRunLivenessContinuationIdempotencyKey(input: {
|
||||
].join(":");
|
||||
}
|
||||
|
||||
function nextActionStateForContinuation(nextAction: string | null): IssueExecutionNextActionState {
|
||||
return nextAction?.trim() ? "runnable" : "none";
|
||||
}
|
||||
|
||||
function livenessStateForDisposition(livenessState: RunLivenessState): IssueExecutionRunLivenessState {
|
||||
return livenessState;
|
||||
}
|
||||
|
||||
export async function findExistingRunLivenessContinuationWake(
|
||||
db: Db,
|
||||
input: {
|
||||
@@ -117,6 +109,13 @@ export function decideRunLivenessContinuation(input: {
|
||||
return { kind: "skip", reason: "liveness state is not actionable for continuation" };
|
||||
}
|
||||
|
||||
const hasExplicitRunnableFollowup = Boolean(nextAction?.trim());
|
||||
const livenessCanContinue =
|
||||
ACTIONABLE_LIVENESS_STATES.has(livenessState) ||
|
||||
PROGRESS_WITH_FOLLOWUP_LIVENESS_STATES.has(livenessState) && hasExplicitRunnableFollowup;
|
||||
if (!livenessCanContinue) {
|
||||
return { kind: "skip", reason: "liveness state is not actionable for continuation" };
|
||||
}
|
||||
if (!issue) return { kind: "skip", reason: "issue not found" };
|
||||
if (!agent) return { kind: "skip", reason: "agent not found" };
|
||||
if (issue.companyId !== run.companyId || agent.companyId !== run.companyId) {
|
||||
@@ -131,48 +130,14 @@ export function decideRunLivenessContinuation(input: {
|
||||
if (issue.executionState) {
|
||||
return { kind: "skip", reason: "issue is blocked by execution policy state" };
|
||||
}
|
||||
|
||||
const currentAttempt = readContinuationAttempt(run.continuationAttempt);
|
||||
const disposition = classifyIssueExecutionDisposition({
|
||||
issue: {
|
||||
id: issue.id,
|
||||
status: issue.status as IssueStatus,
|
||||
assigneeAgentId: issue.assigneeAgentId,
|
||||
assigneeUserId: null,
|
||||
},
|
||||
agent,
|
||||
latestRun: {
|
||||
latestRunStatus: "succeeded",
|
||||
livenessState: livenessStateForDisposition(livenessState),
|
||||
nextAction: nextActionStateForContinuation(nextAction),
|
||||
continuationAttempt: currentAttempt,
|
||||
maxContinuationAttempts: maxAttempts,
|
||||
},
|
||||
gates: { budgetBlocked },
|
||||
});
|
||||
|
||||
if (!CONTINUATION_AGENT_STATUSES.has(agent.status)) {
|
||||
return { kind: "skip", reason: `agent status ${agent.status} is not invokable` };
|
||||
}
|
||||
if (budgetBlocked) {
|
||||
return { kind: "skip", reason: "budget hard stop blocks continuation" };
|
||||
}
|
||||
if (disposition.kind === "human_escalation_required" && disposition.owner === "manager" && currentAttempt >= maxAttempts) {
|
||||
return {
|
||||
kind: "exhausted",
|
||||
attempt: currentAttempt,
|
||||
maxAttempts,
|
||||
comment: [
|
||||
"Bounded liveness continuation exhausted",
|
||||
"",
|
||||
`- Last liveness state: \`${livenessState}\``,
|
||||
`- Attempts used: ${currentAttempt}/${maxAttempts}`,
|
||||
`- Reason: ${livenessReason ?? "Run ended without concrete progress"}`,
|
||||
"- Next action: a human or manager should inspect the run and either clarify the task, mark it blocked, or assign a concrete follow-up.",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
if (disposition.kind !== "agent_continuable") {
|
||||
return { kind: "skip", reason: `canonical disposition ${disposition.kind} is not continuable` };
|
||||
}
|
||||
|
||||
const currentAttempt = readContinuationAttempt(run.continuationAttempt);
|
||||
if (currentAttempt >= maxAttempts) {
|
||||
return {
|
||||
kind: "exhausted",
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
MIN_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
|
||||
type IssueGraphLivenessAutoRecoveryPreview,
|
||||
type IssueGraphLivenessAutoRecoveryPreviewItem,
|
||||
type IssueStatus,
|
||||
type RunLivenessState,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
@@ -24,7 +23,7 @@ import {
|
||||
} from "@paperclipai/db";
|
||||
import { parseObject, asBoolean, asNumber } from "../../adapters/utils.js";
|
||||
import { runningProcesses } from "../../adapters/index.js";
|
||||
import { badRequest, conflict, forbidden, notFound } from "../../errors.js";
|
||||
import { forbidden, notFound } from "../../errors.js";
|
||||
import { logger } from "../../middleware/logger.js";
|
||||
import { redactCurrentUserText } from "../../log-redaction.js";
|
||||
import { redactSensitiveText } from "../../redaction.js";
|
||||
@@ -57,14 +56,8 @@ import {
|
||||
findExistingRunLivenessContinuationWake,
|
||||
readContinuationAttempt,
|
||||
} from "./run-liveness-continuations.js";
|
||||
import {
|
||||
classifyIssueExecutionDisposition,
|
||||
type IssueExecutionRunLivenessState,
|
||||
type IssueExecutionRunStatus,
|
||||
} from "../issue-execution-disposition.js";
|
||||
|
||||
const EXECUTION_PATH_HEARTBEAT_RUN_STATUSES = ["queued", "running", "scheduled_retry"] as const;
|
||||
const CANCELLABLE_ACTIVE_RUN_RECOVERY_STATUSES = ["queued", "running", "scheduled_retry"] as const;
|
||||
const UNSUCCESSFUL_HEARTBEAT_RUN_TERMINAL_STATUSES = ["failed", "cancelled", "timed_out"] as const;
|
||||
export const ACTIVE_RUN_OUTPUT_SUSPICION_THRESHOLD_MS = 60 * 60 * 1000;
|
||||
export const ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS = 4 * 60 * 60 * 1000;
|
||||
@@ -113,8 +106,6 @@ type WatchdogDecisionActor =
|
||||
| { type: "agent"; agentId?: string | null; runId?: string | null }
|
||||
| { type: "none" };
|
||||
|
||||
type WatchdogDecision = "snooze" | "continue" | "dismissed_false_positive" | "cancel_run";
|
||||
|
||||
export type RunOutputSilenceSummary = {
|
||||
lastOutputAt: Date | null;
|
||||
lastOutputSeq: number;
|
||||
@@ -488,47 +479,14 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
issue: typeof issues.$inferSelect,
|
||||
latestRun: LatestIssueRun,
|
||||
): Promise<PostRunIssueDisposition> {
|
||||
const [activePath, explicitWait, agent, budgetBlocked] = await Promise.all([
|
||||
hasActiveExecutionPath(issue.companyId, issue.id),
|
||||
hasExplicitWaitingPath(issue),
|
||||
issue.assigneeAgentId ? getAgent(issue.assigneeAgentId) : Promise.resolve(null),
|
||||
issue.assigneeAgentId ? isInvocationBudgetBlocked(issue, issue.assigneeAgentId) : Promise.resolve(false),
|
||||
]);
|
||||
const activeRunFromLatest = Boolean(
|
||||
latestRun &&
|
||||
EXECUTION_PATH_HEARTBEAT_RUN_STATUSES.includes(
|
||||
latestRun.status as (typeof EXECUTION_PATH_HEARTBEAT_RUN_STATUSES)[number],
|
||||
),
|
||||
);
|
||||
const disposition = classifyIssueExecutionDisposition({
|
||||
issue: {
|
||||
id: issue.id,
|
||||
status: issue.status as IssueStatus,
|
||||
assigneeAgentId: issue.assigneeAgentId,
|
||||
assigneeUserId: issue.assigneeUserId,
|
||||
originKind: issue.originKind,
|
||||
},
|
||||
agent,
|
||||
execution: {
|
||||
activeRun: activePath || activeRunFromLatest,
|
||||
},
|
||||
waits: {
|
||||
openRecoveryIssue: explicitWait,
|
||||
},
|
||||
latestRun: latestRun
|
||||
? {
|
||||
latestRunStatus: latestRun.status as IssueExecutionRunStatus,
|
||||
livenessState: latestRun.livenessState as IssueExecutionRunLivenessState | null,
|
||||
nextAction: latestRun.nextAction ? "runnable" : "none",
|
||||
continuationAttempt: latestRun.continuationAttempt,
|
||||
}
|
||||
: null,
|
||||
gates: { budgetBlocked },
|
||||
});
|
||||
|
||||
if (disposition.kind === "terminal") return "terminal";
|
||||
if (disposition.kind === "live") return "explicitly_live";
|
||||
if (disposition.kind === "waiting") return "explicitly_waiting";
|
||||
if (issue.status === "done" || issue.status === "cancelled") return "terminal";
|
||||
if (await hasActiveExecutionPath(issue.companyId, issue.id)) return "explicitly_live";
|
||||
if (await hasExplicitWaitingPath(issue)) return "explicitly_waiting";
|
||||
if (latestRun && EXECUTION_PATH_HEARTBEAT_RUN_STATUSES.includes(
|
||||
latestRun.status as (typeof EXECUTION_PATH_HEARTBEAT_RUN_STATUSES)[number],
|
||||
)) {
|
||||
return "explicitly_live";
|
||||
}
|
||||
return "invalid";
|
||||
}
|
||||
|
||||
@@ -1345,7 +1303,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
async function recordWatchdogDecision(input: {
|
||||
runId: string;
|
||||
actor: WatchdogDecisionActor;
|
||||
decision: WatchdogDecision;
|
||||
decision: "snooze" | "continue" | "dismissed_false_positive";
|
||||
evaluationIssueId?: string | null;
|
||||
reason?: string | null;
|
||||
snoozedUntil?: Date | null;
|
||||
@@ -1359,15 +1317,6 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
.limit(1);
|
||||
if (!run) throw notFound("Heartbeat run not found");
|
||||
|
||||
const normalizedReason = typeof input.reason === "string" ? input.reason.trim() : "";
|
||||
if (input.decision === "cancel_run") {
|
||||
if (!normalizedReason) throw badRequest("Cancellation reason is required");
|
||||
if (!input.evaluationIssueId) throw badRequest("evaluationIssueId is required for cancellation");
|
||||
if (!CANCELLABLE_ACTIVE_RUN_RECOVERY_STATUSES.includes(run.status as (typeof CANCELLABLE_ACTIVE_RUN_RECOVERY_STATUSES)[number])) {
|
||||
throw conflict("Heartbeat run is not cancellable", { status: run.status });
|
||||
}
|
||||
}
|
||||
|
||||
let evaluationIssue: {
|
||||
id: string;
|
||||
assigneeAgentId: string | null;
|
||||
@@ -1454,7 +1403,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
evaluationIssueId: input.evaluationIssueId ?? null,
|
||||
decision: input.decision,
|
||||
snoozedUntil: effectiveSnoozedUntil,
|
||||
reason: input.decision === "cancel_run" ? normalizedReason : input.reason ?? null,
|
||||
reason: input.reason ?? null,
|
||||
createdByAgentId: input.actor.type === "agent" ? input.actor.agentId ?? null : null,
|
||||
createdByUserId: input.actor.type === "board" ? input.actor.userId ?? null : null,
|
||||
createdByRunId,
|
||||
@@ -1471,21 +1420,15 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
: "unknown",
|
||||
agentId: input.actor.type === "agent" ? input.actor.agentId ?? null : null,
|
||||
runId: run.id,
|
||||
action: input.decision === "snooze"
|
||||
? "heartbeat.watchdog_snoozed"
|
||||
: input.decision === "cancel_run"
|
||||
? "heartbeat.watchdog_cancel_requested"
|
||||
: "heartbeat.watchdog_decision_recorded",
|
||||
action: input.decision === "snooze" ? "heartbeat.watchdog_snoozed" : "heartbeat.watchdog_decision_recorded",
|
||||
entityType: "heartbeat_run",
|
||||
entityId: run.id,
|
||||
details: {
|
||||
source: "recovery.record_watchdog_decision",
|
||||
decision: input.decision,
|
||||
evaluationIssueId: input.evaluationIssueId ?? null,
|
||||
targetRunId: run.id,
|
||||
snoozedUntil: effectiveSnoozedUntil?.toISOString() ?? null,
|
||||
reason: input.decision === "cancel_run" ? normalizedReason : input.reason ?? null,
|
||||
createdByRunId,
|
||||
reason: input.reason ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2414,36 +2357,31 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
return `${companyId}:${issueId}`;
|
||||
}
|
||||
|
||||
type LivenessDependencyTimestamps = {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
async function loadLivenessDependencyTimestampsByIssue(findings: IssueLivenessFinding[]) {
|
||||
async function loadLivenessDependencyUpdatedAtByIssue(findings: IssueLivenessFinding[]) {
|
||||
const issueIds = [
|
||||
...new Set(
|
||||
findings.flatMap((finding) => finding.dependencyPath.map((entry) => entry.issueId)),
|
||||
),
|
||||
];
|
||||
if (issueIds.length === 0) return new Map<string, LivenessDependencyTimestamps>();
|
||||
if (issueIds.length === 0) return new Map<string, Date>();
|
||||
const rows = await db
|
||||
.select({ id: issues.id, companyId: issues.companyId, createdAt: issues.createdAt, updatedAt: issues.updatedAt })
|
||||
.select({ id: issues.id, companyId: issues.companyId, updatedAt: issues.updatedAt })
|
||||
.from(issues)
|
||||
.where(inArray(issues.id, issueIds));
|
||||
return new Map(rows.map((row) => [
|
||||
livenessDependencyIssueKey(row.companyId, row.id),
|
||||
{ createdAt: row.createdAt, updatedAt: row.updatedAt },
|
||||
row.updatedAt,
|
||||
]));
|
||||
}
|
||||
|
||||
function latestDependencyUpdatedAtForLivenessFinding(
|
||||
finding: IssueLivenessFinding,
|
||||
timestampsByIssueKey: Map<string, LivenessDependencyTimestamps>,
|
||||
updatedAtByIssueKey: Map<string, Date>,
|
||||
) {
|
||||
const dependencyIssueIds = [...new Set(finding.dependencyPath.map((entry) => entry.issueId))];
|
||||
if (dependencyIssueIds.length === 0) return null;
|
||||
const timestamps = dependencyIssueIds.map((issueId) =>
|
||||
timestampsByIssueKey.get(livenessDependencyIssueKey(finding.companyId, issueId))?.updatedAt ?? null
|
||||
updatedAtByIssueKey.get(livenessDependencyIssueKey(finding.companyId, issueId)) ?? null
|
||||
);
|
||||
if (timestamps.some((timestamp) => !timestamp)) return null;
|
||||
const [firstTimestamp, ...remainingTimestamps] = timestamps as Date[];
|
||||
@@ -2452,22 +2390,13 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
firstTimestamp!);
|
||||
}
|
||||
|
||||
function recoveryIssueCreatedAtForLivenessFinding(
|
||||
finding: IssueLivenessFinding,
|
||||
timestampsByIssueKey: Map<string, LivenessDependencyTimestamps>,
|
||||
) {
|
||||
return timestampsByIssueKey.get(
|
||||
livenessDependencyIssueKey(finding.companyId, finding.recoveryIssueId),
|
||||
)?.createdAt ?? null;
|
||||
}
|
||||
|
||||
function isLivenessFindingInsideAutoRecoveryLookback(
|
||||
finding: IssueLivenessFinding,
|
||||
cutoff: Date,
|
||||
timestampsByIssueKey: Map<string, LivenessDependencyTimestamps>,
|
||||
updatedAtByIssueKey: Map<string, Date>,
|
||||
) {
|
||||
const recoveryIssueCreatedAt = recoveryIssueCreatedAtForLivenessFinding(finding, timestampsByIssueKey);
|
||||
return Boolean(recoveryIssueCreatedAt && recoveryIssueCreatedAt >= cutoff);
|
||||
const latestUpdatedAt = latestDependencyUpdatedAtForLivenessFinding(finding, updatedAtByIssueKey);
|
||||
return Boolean(latestUpdatedAt && latestUpdatedAt >= cutoff);
|
||||
}
|
||||
|
||||
async function buildIssueGraphLivenessAutoRecoveryPreview(
|
||||
@@ -2477,7 +2406,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
const lookbackHours = normalizeIssueGraphLivenessAutoRecoveryLookbackHours(opts?.lookbackHours);
|
||||
const cutoff = new Date(now.getTime() - lookbackHours * 60 * 60 * 1000);
|
||||
const findings = await collectIssueGraphLivenessFindings();
|
||||
const timestampsByIssueKey = await loadLivenessDependencyTimestampsByIssue(findings);
|
||||
const updatedAtByIssueKey = await loadLivenessDependencyUpdatedAtByIssue(findings);
|
||||
const issueIds = [...new Set(findings.map((finding) => finding.recoveryIssueId))];
|
||||
const recoveryRows = issueIds.length > 0
|
||||
? await db
|
||||
@@ -2492,12 +2421,9 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
for (const finding of findings) {
|
||||
const latestDependencyUpdatedAt = latestDependencyUpdatedAtForLivenessFinding(
|
||||
finding,
|
||||
timestampsByIssueKey,
|
||||
updatedAtByIssueKey,
|
||||
);
|
||||
if (
|
||||
!latestDependencyUpdatedAt ||
|
||||
!isLivenessFindingInsideAutoRecoveryLookback(finding, cutoff, timestampsByIssueKey)
|
||||
) {
|
||||
if (!latestDependencyUpdatedAt || latestDependencyUpdatedAt < cutoff) {
|
||||
skippedOutsideLookback += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -2807,7 +2733,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
const now = new Date();
|
||||
const cutoff = new Date(now.getTime() - lookbackHours * 60 * 60 * 1000);
|
||||
const obsoleteRecoveryCleanup = await retireObsoleteLivenessRecoveryIssues(findings);
|
||||
const timestampsByIssueKey = await loadLivenessDependencyTimestampsByIssue(findings);
|
||||
const updatedAtByIssueKey = await loadLivenessDependencyUpdatedAtByIssue(findings);
|
||||
const result = {
|
||||
findings: findings.length,
|
||||
autoRecoveryEnabled,
|
||||
@@ -2832,7 +2758,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
}
|
||||
|
||||
for (const finding of findings) {
|
||||
if (!isLivenessFindingInsideAutoRecoveryLookback(finding, cutoff, timestampsByIssueKey)) {
|
||||
if (!isLivenessFindingInsideAutoRecoveryLookback(finding, cutoff, updatedAtByIssueKey)) {
|
||||
result.skippedOutsideLookback += 1;
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
|
||||
Reference in new issue
Block a user